# Kino plugins: the complete authoring guide, for AI assistants Source: https://kinotvapp.github.io/kino-plugins/ (English pages, in site order). Every number here is the one the app enforces; contract.json and kino.d.ts at the end are the machine-readable truth. # AGENTS.md: building a Kino plugin with an AI assistant You are helping a person write a **Kino plugin**: a public GitHub repository with one JSON manifest (`kino-plugin.json`) and one JavaScript ES module (`plugin.js`) that Kino, an Android video app for phones and TVs, runs inside a QuickJS sandbox. The plugin turns one video source into search results, Home rows, episodes and playable streams (and, optionally, live TV channels). Your job: produce a plugin that **Kino accepts and that actually plays**, verified with the Node kit before anyone installs it. Kino is strict: every rule below is enforced by the app, not a style suggestion. When this file and your prior knowledge disagree, this file and the guide win. ## 1. Read first, in this order 1. This file, whole. 2. The complete guide as one text file: (English; the same pages in Spanish live at ). If you cannot fetch it, read at least these pages, in order: [The contract](https://kinotvapp.github.io/kino-plugins/en/contract/), [The manifest](https://kinotvapp.github.io/kino-plugins/en/manifest/), [The `kino` API](https://kinotvapp.github.io/kino-plugins/en/kino-api/), [Limits and engine quirks](https://kinotvapp.github.io/kino-plugins/en/engine-limits/), [Test it locally](https://kinotvapp.github.io/kino-plugins/en/test-locally/), [Publishing](https://kinotvapp.github.io/kino-plugins/en/publish/), [Signed plugins](https://kinotvapp.github.io/kino-plugins/en/signed/), [What's new](https://kinotvapp.github.io/kino-plugins/en/changelog/), and for live TV [Live channels](https://kinotvapp.github.io/kino-plugins/en/live-channels/). For apiVersion 6 features, the page of each one (and, for an overview of everything a plugin can change in how Kino shows it, [Customize your plugin](https://kinotvapp.github.io/kino-plugins/en/customize/)): [The settings form](https://kinotvapp.github.io/kino-plugins/en/settings-form/), [Signing every request](https://kinotvapp.github.io/kino-plugins/en/signed-streams/), [Moving saved titles](https://kinotvapp.github.io/kino-plugins/en/migrate/), [Section, categories and colors](https://kinotvapp.github.io/kino-plugins/en/section-theme/), [Logs and telemetry](https://kinotvapp.github.io/kino-plugins/en/diagnostics/), [Hidden browser](https://kinotvapp.github.io/kino-plugins/en/browser/). 3. The machine-readable contract: `contract.json` (every number and rule) and `kino.d.ts` (every shape and the whole `kino` API), at and , and also in the template. 4. The complete reference: `plugin.js` and `kino-plugin.json` in [kinotvapp/kino-plugin-own-server](https://github.com/kinotvapp/kino-plugin-own-server) -- raw at and -- use nearly every feature up to apiVersion 7 a plugin can have ("Tu servidor" 1.5.0): every setting type and the settings form, a session, `kino.storage`, downloads, copies, request signing, `live` items and `channels` in every shape, a section, `migrate`, `meta`, `subtitles`, `tracking` and `segments`. Study it whenever the plugin needs settings, auth, downloads or live channels. For a plain plugin with no settings or login, read `plugin.js` in [kinotvapp/kino-plugin-archive](https://github.com/kinotvapp/kino-plugin-archive) instead, the simpler reference for the five basic capabilities. Do not rely on memory of other plugin systems (Kodi, Stremio, Cloudstream…): the contract is different. ## 2. Before writing code, settle these with the person - **The source**: the site or API, and whether it needs a login, an API key or a server address the person types. Credentials always come from `settings` (type `password` for secrets), never from the code. - **The hosts**: every host your code calls **and** every host the video, subtitles, audio tracks, HLS/DASH segments and keys, and redirects land on. Look at real responses (`curl -sIL`) to find redirect targets and CDNs. - **What it offers**: movies, series (with `episodes`), Home rows (`home`, and `browse` for "Ver más"), live channels (`live` items at apiVersion 2, or the En vivo tab with `channels` at apiVersion 3). - **The lowest `apiVersion` that works**: `1` unless you need `download`, `drm`, `insecureHttp`, `"hosts": []`, or `live` items (`2`), `channels`/`liveStreamHosts` (`3`), or a `list` setting, `"streamHosts": "any"` or sealed `secrets` (`4`), or an author `signature` (`5`, Kino 0.9.45+), or anything in "apiVersion 6" in section 4 (`6`, **Kino 0.9.50+**; Kino 0.9.49 and older refuse the whole plugin with "Este plugin necesita una versión más nueva de Kino"). `apiVersion` 6 = Kino 0.9.50: never declare 6 "just in case". - **Whether to sign it** (optional, `apiVersion` 5): ask whether the person wants people to know every update comes from them. If yes, follow "Signing" in section 4. Never sign without telling them what it is and that the key must be kept and backed up. - Whether the person has the right to use the source. Do not help circumvent DRM: the only DRM path is a Widevine license the source itself hands out (the `drm` capability). - **Offline downloads**: declare `download` (apiVersion 2) when the source allows saving titles. Kino (phones only) saves a movie or episode that is a progressive file (`mp4`, `mkv`, `webm`, `ts`…) or an HLS VOD stream (AES-128 keys fine; discontinuities kept unless the video/audio codecs change at one, which is refused); a live stream, DASH, SAMPLE-AES or any DRM never downloads. A download never asks about a host: a refused server ends it for good (the person plays the title once to approve a server playback would ask about, then downloads again). - **Whether plain `kino.fetch` is enough** (it almost always is): fetch the real pages with `curl` and look for the video address in the HTML, JSON or a script. Only if a server's embed builds the address by running its own scripts does the plugin need the hidden browser (`"browser": true`, `kino.browser.capture`, apiVersion 6, a red consent line): see "The hidden browser" in section 4. If the site shows a captcha or "verify you are human", tell the person Kino cannot use that source that way: **never** try to get around it. - **Several servers or languages per title?** Plan labelled lazy copies (apiVersion 6): resolve one, list the rest as `{ label, ref }` (section 4). - **Optional apiVersion 6 extras to offer only when they fit**: a settings form with a status line and action buttons (an account to link and a "Cerrar sesión"), a section of its own and Categorías tiles, colors, `adult` marks for 18+ content, `telemetry` so its errors reach Kino's error tracker, `migrate` when the plugin replaces an older one. Each has a cost (approval prompts, more code): ask. - If the person wants a **Nuvio scraper**, stop: Kino installs Nuvio repositories directly ([Nuvio scrapers](https://kinotvapp.github.io/kino-plugins/en/nuvio/)); no plugin needs writing. - If the person wants a **Stremio addon** in Kino, stop too: Kino installs it from its `manifest.json` URL ([Stremio addons](https://kinotvapp.github.io/kino-plugins/en/stremio/)). Write a Kino plugin only for what an addon cannot do there (torrents and P2P are refused either way). **The person may not program.** Explain each step in plain Spanish, run the commands yourself (say which and why first), ask before anything irreversible (deleting files, pushing, publishing), and give exact clicks for what they must do on GitHub or in Kino. Ask for the full terminal output or the exact message Kino shows, never a summary. ## 3. Workflow 1. **Start from the template, not a fork** (Kino's community search leaves forks out). Use `kinotvapp/kino-plugin-archive` for a plain plugin (no settings, no login); use `kinotvapp/kino-plugin-own-server` instead when the plugin needs settings, auth, downloads or live channels: - `gh repo create my-plugin --public --template kinotvapp/kino-plugin-archive --clone` (swap the template name for `kinotvapp/kino-plugin-own-server` when that fits better), or - `git clone https://github.com/kinotvapp/kino-plugin-archive` (or `-own-server`) somewhere else, then `node kino-plugin-archive/sdk/init.mjs my-plugin --host example.com` and copy its `sdk/` and `contract.json` into `my-plugin/` (the kit looks for `contract.json` next to `sdk/`). `init.mjs` never overwrites a file. 2. **Write `kino-plugin.json`**: a new `id` (`^[a-z0-9][a-z0-9-]{1,39}$`, never a reserved one, never changed after release), `name`, `version` `0.1.0`, the lowest `apiVersion`, `entry`, `hosts`, `capabilities`, `settings` if needed, `description` in Spanish. 3. **Implement one named `export async function` per declared capability** (`search`, `home`, `browse`, `episodes`, `resolve`; `liveCategories` + `liveChannels` (+ optional `guide`, and optional `liveSearch` for a catalog too big to list whole: `liveSearch({ query })` returns channels like a `liveChannels` page, with the same `id`s; export it only if the source can really search) for `channels`; `migrate` for `migrate`; `meta` for `meta`; `subtitles` for `subtitles`). `download`, `drm` and `scopedSearch` are declarative: nothing to export. Other exports are required by a manifest field, not a capability: `section` (by `"section"`), `settingsStatus` (by a `status` setting), `action` (by an `action` setting), `sign` (by any stream with `signing: "request"`); `categories` (needs `browse`) and `validateSettings` are optional. Return plain JSON only. Kino loads exactly one file (`entry`), with no `require` and no module resolver, so do not split source across files that `plugin.js` imports at runtime. If the plugin is big enough to want more than one file for its own sake, write it split (e.g. `src/plugin.js` importing from `src/animeav1.js`) and bundle it to a single `plugin.js` before validating or publishing: `npx esbuild src/plugin.js --bundle --format=esm --outfile=plugin.js`. `plugin.js` is always the finished, single-file output — never hand-edit it if a `src/` exists. 4. **Validate the manifest and exports**: `node sdk/validate.mjs .` must exit 0. 5. **Run every function against the real source** and read what Kino would drop (stderr): ``` node sdk/run.mjs . search "algo" node sdk/run.mjs . home node sdk/run.mjs . browse node sdk/run.mjs . episodes '' node sdk/run.mjs . resolve '' node sdk/validate.mjs . --run search "algo" ``` For live channels: `node sdk/run.mjs . live categories`, `node sdk/run.mjs . live channels `, `node sdk/run.mjs . live guide `, `node sdk/run.mjs . live search ` (for `liveSearch`), `node sdk/run.mjs live playlist [--epg ]`, and `resolve --live` for a channel's ref. Settings: `--config key=value` (repeatable) or `sdk/config.json` (never committed). apiVersion 6: `node sdk/run.mjs . section [tab]`, `. categories`, `. theme`, `. settingsStatus`, `. action `, `. validateSettings ''`, `./plugin.js migrate '{"kind":"title","ref":"…"}'`, `--within '' ./plugin.js search "…"`, `./plugin.js sign '{"url":…,"kind":"segment","ref":…,"context":…}'`, `--retry conflict:1 ./plugin.js resolve ''`, and `node sdk/validate.mjs . --run liveSearch ` for the 18+ marks. Lines marked `[dropped by Kino]` are what the app would drop. 6. **Record fixtures and test offline**: `node sdk/run.mjs --record test/fixtures.json . search "algo"`, then `node --test test/plugin.test.mjs` (the scaffold's test: it validates the manifest and replays the fixtures offline). Name the file explicitly: a bare `node --test test/` does not work on every Node version, and a bare `node --test` would also run the kit's own suite. The kit's tests (`node --test sdk/test/kit.test.mjs`) are only for someone changing the kit (you should not). 7. **Check what Node hides** (the kit is more permissive than Kino): grep `plugin.js` for the missing globals (section 4) and for any `throw` before the first `await` of an async function. 8. **Publish**: public repository, `kino-plugin.json` and `plugin.js` at the root, `.gitignore` with `.kino-storage.json`, `.kino-cookies.json`, `.kino-secrets.json`, `sdk/config.json`; tag a release (`v1.0.0`); add the topic and a one-line GitHub description: `gh repo edit owner/repo --add-topic kino-plugin --description "…"` (or, on the repo page, About → ⚙ → Description + Topics → Save changes). Write a good Spanish `name` and `description` in the manifest: that is what Kino's cards show (see section 6). The person installs it from Kino (on a phone: menu ☰ → Plugins → the + button; on a TV: Ajustes → Plugins → Agregar), typing `owner/repo` → Agregar → Instalar, and must try it in the app (search, episodes, play, and download if declared). The same field ("Escribe usuario/repositorio de GitHub o pega la URL del manifest (kino-plugin.json)") also takes the URL of the `kino-plugin.json` (GitHub `blob`/`raw`, raw.githubusercontent.com or `cdn.jsdelivr.net/gh/owner/repo@/…`, which all become `owner/repo`; jsDelivr needs an exact ref such as `@main` or `@v1.0.0`, `@latest` is the default branch, a range like `@1` is refused). From the Kino version after 0.9.49 a plugin can also live **outside GitHub**, shared as the `https` URL of its `kino-plugin.json` on any public server (the file must be named exactly that; `entry`/`icon` are read next to it; no IPs, `localhost` or local names). Such a `url:` install cannot use sealed `secrets` (refused), always counts as unsigned, updates from that same URL, and is never listed in "De la comunidad" (discovery only finds GitHub repositories with the topic). Recommend GitHub + topic unless the person has a reason not to. 9. **Updates**: raise `version` every time (an equal or lower version never reaches anyone). Adding hosts, `permissions`, `download`, `drm`, `channels`, `migrate`, `telemetry` (or `true` → `"verbose"`), `liveStreamHosts`, `streamHosts`, `browser` (or `true` → `"pages"`), an `insecureHttp` host, or `secrets` to a plugin that had none makes the update wait for the person's approval. From Kino 0.9.50 updates are also checked at app start (at most every 12 h), a badge counts the waiting ones, and a failed call of a plugin whose update waits says "Hay una versión nueva de : actualízala en Ajustes ▸ Plugins". ## 4. Hard rules (with the exact numbers) **Network** - `kino.fetch` reaches only the manifest's `hosts` (and servers the person typed in a `url` setting), over `https`, checked on **every redirect hop**. `*.example.com` does **not** cover `example.com`: list both. No IPs, no `localhost`, no `.local`/`.lan`/`.internal`/`.localhost`/`.home.arpa`, no bare `*`, no scheme/port/path in `hosts`. At least 1 entry (`[]` only from apiVersion 2 with a `url` setting) and **no maximum from Kino 0.9.45**; Kino 0.9.44 and older refuse more than 20 (the kit warns "Más de 20 hosts: ..."), so with more than 20 hosts tell the person it needs 0.9.45+. During `resolve` and `episodes` only, a fetch to an undeclared `https` host asks the person (the call's clock stops meanwhile) -- at most 3 hosts per call, and none after the person rejects one; everywhere else it just fails as `host_not_allowed`. Declared names that resolve into the home network (including IPv6 prefixes embedding such an address) are refused, and plugin traffic never goes through a device proxy. Likewise, when the person opens a title, an undeclared `https` host of the returned `Stream` (video, subtitle, audio track, license) or one the player meets mid-playback is asked about once; a download or anything in the background is never asked. Never design around those questions: declare every host. - Kino strips `Accept-Encoding` (and `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Cookie2`) from your headers and always hands you decompressed bodies. - The `Stream` URL, subtitles, `audioTracks`, every HLS variant/segment/key, DASH `BaseURL`, and the `drm` `licenseUrl` must be on those same hosts. Images (`poster`, `backdrop`, `still`, `logo`) are the only exception: any `http` or `https` URL (a public IPv4 is fine), never a private address or local name. - Plain `http` only for a server the person typed, or a host declared `{ "host": "…", "insecureHttp": true }` (apiVersion 2, no wildcard; shown in red to the person). - **`"entry"` and `"icon"` never start with `./`.** Write `"plugin.js"`, not `"./plugin.js"`: Kino 0.9.45 and older refuse the `./` (`El campo "entry" debe ser una ruta relativa a un archivo .js`) and the plugin does not install (an AI-generated plugin did exactly this and failed for about 35 installs). Kino 0.9.46 and later tolerate it, but never rely on that. The kit's `validate.mjs` refuses it. - `"liveStreamHosts": "any"` (apiVersion 3 + `channels`) frees only live channel streams, never `kino.fetch`, playlists, subtitles, licenses, movies or images. - `"streamHosts": "any"` (apiVersion 4) lets what the plugin plays be on any public host: for a movie or episode the video, its manifest, redirects, `subtitles` and `audioTracks` (and its download); for a channel the `liveStreamHosts` rule. Never `kino.fetch`, images or DRM licenses, never the home network. Shown in red; prefer listing real domains. The person can grant the same rule themselves ("Permitir video de cualquier servidor", the broad video permission). - `"fetchHosts"` exists only for plugins Kino converts from Nuvio scrapers; in a hand-written plugin it does nothing (`sdk/validate.mjs` warns "fetchHosts solo tiene efecto en plugins convertidos desde Nuvio; en tu plugin se ignora"). Do not use it. From apiVersion 4 any value but `"any"` is refused. **The engine is QuickJS, not Node, not a browser.** Missing: `setTimeout`, `setInterval`, `setImmediate`, `queueMicrotask`, `Buffer`, `process`, `require`, `fetch`, `AbortController`, `structuredClone`, `performance`, `crypto`, `WeakRef`, `Intl`. Present: `URL` (no punycode), `URLSearchParams`, `atob`, `btoa`, `TextEncoder`, `TextDecoder` (UTF-8), `console` (`URL` has the usual setters: `url.hostname = …` works). The extra globals Nuvio-converted scrapers get (`setTimeout`, `AbortController`, `crypto.subtle`, `require`…) are **not** available to your plugin. Use `kino.fetch`, `kino.sleep`, `kino.crypto`, `kino.storage`, `kino.html.select` (only in the app; the Node kit's version throws). No `import`: one file; bundle anything else into it. `localeCompare` and `toLocaleString` do not localize. No network calls at the module's top level (install loads it offline). Never write a synchronous infinite loop: it cannot be interrupted. **Limits** (from `contract.json`) | What | Limit | | --- | --- | | Manifest / entry file / icon | 16 KB / 1 MB / 128 KB | | Memory / stack | 64 MB / 1 MB | | Time per call | `search` 15 s; `home`, `browse`, `episodes`, `resolve` 20 s (`resolve` 75 s for an approved `"browser": true` or `"pages"` plugin, and for one Kino generates from a Nuvio scraper or a Stremio addon); `liveCategories`, `liveChannels`, `guide` 20 s; `liveSearch` 15 s; `subtitles` 10 s; apiVersion 6: `section`, `categories`, `validateSettings` 20 s, `settingsStatus` 10 s, `action` 30 s, `migrate` 10 s, `sign` 1.5 s; `meta` 6 s per plugin (no answer after that); all fetches and sleeps count (not the time the person spends answering a host question) | | Module top level | 10 s | | Idle sandbox | closed after 5 minutes | | Timeouts | 3 in a row disable the plugin ("No responde") | | `kino.fetch` | 15 s default, 30 s max; body 5 MB; request 1,048,576 characters; 60 requests per call (every redirect hop counts, refused ones too); 6 in flight at once; 3 host questions per call; 10 redirects | | Cookies | 50 per domain, 64 KB total | | `kino.storage` | 256 KB; `ttlMs` 1..2,592,000,000 (30 days) | | `kino.sleep` | 0..5,000 ms | | `kino.crypto` | 5 MB data; PBKDF2 100,000 iterations, 64-byte keys; `randomBytes` 1,024 | | Log message | 2,000 characters; for a plugin with `"telemetry"` (apiVersion 6; no other plugin sends lines), a failed call's last 30 lines (300 chars each, scrubbed, 2 KB) go with the error report: log steps and statuses, never what the person typed, a secret or a setting value | | Return value | 2,000,000 characters of JSON | | Results | `search` 100; `home` 20 rows × 60; `browse` 100/page; `episodes` 5,000 (+50 `seasons`); `ref` 4,096 chars; `next` 2,048 chars; `id` `^[A-Za-z0-9._~-]{1,128}$` | | Live (apiVersion 3) | 200 categories; 500 channels/page, 10 pages at first then 5 more per scroll, up to 10,000 channels (200 pages)/category; `liveSearch` keeps 100 channels, asked from 2 characters; `guide` 50 channels, 24 h, 100 entries/channel; `number` 1..9999 | | Settings | 12 with a value, plus 16 `section`/`status`/`action` (apiVersion 6); `text` 500, `url` 2,048, `password` 500 characters; a `list` holds up to `max` entries (1..50, default 20), each of 1..4 `text`/`url` fields; `status` text 200, action `message` 300, `confirm` 120, `clearSettings` 12 keys | | `secrets` (apiVersion 4) | 16; names `^[A-Za-z][A-Za-z0-9_]{0,31}$`; values 1..4,096 bytes (1..8,192 at apiVersion 6); typed cipher keys 16/24/32 bytes (apiVersion 6) | | Stream (apiVersion 6) | `alternatives` 8 (any apiVersion; lazy and concrete together); `label` 48 chars; lazy `ref` 512 chars; `alternateHosts` 6; `signContext` 4,096 chars; 3 `resolve` retries | | Hidden browser (apiVersion 6) | one page at a time in the whole app; `capture` `timeoutMs` ≤ 25,000 (default 18,000), ≤ 8 media, 10 subtitles; `page` `timeoutMs` ≤ 25,000 (default 15,000; Kino cuts it to the call's remaining time minus 1.5 s; pass ~12,000 in `search`), never from `categories`, top document on your hosts, 20 reads a minute; automatic fallback waits ≤ 20 s per lazy copy | | Section / categories (apiVersion 6) | label 20; 8 tabs × 24 chars; hero text 300; 24 category tiles, titles 40 | | Error text | `kino.error` detail 200 characters; `userMessage` 160 (apiVersion 6) | **Data rules that silently drop things**: an item `id` outside `^[A-Za-z0-9._~-]{1,128}$` (derive a slug); a repeated `id`; `adult: true` below apiVersion 6 (from 6 it is kept behind the person's 18+ code); a `series` without the `episodes` capability; a `live` item at apiVersion 1, or in a `home` row below apiVersion 6; an episode `number` 0; images that are not `http`/`https` or point at a local address; in `search`, a `live` item whose name shares too few words with the query (so never answer a search with your whole channel list). `id` must be stable across calls (library and progress hang off it); `ref` may change but must keep working later (put a stable id in it and look fresh links up inside `resolve`). A `Stream` is all or nothing; `mime` must look like `video/mp4` or be omitted. **Errors**: `throw kino.error(code, detail)` with one of these codes; the person reads Kino's Spanish sentence, your detail (cut at 200 characters) goes to the log. | Code | The person sees | | --- | --- | | `auth_required` | "Configura {plugin} en Ajustes ▸ Plugins" (from Kino 0.9.50 "… Ajustes ▸ {plugin}" when it has settings: its own tab), with a button to Configurar | | `not_found` | "No se encontró en {plugin}" | | `geo_blocked` | "Este contenido no está disponible en tu región" | | `rate_limited` | "{plugin} está limitando las peticiones; intenta en unos minutos" | | `unavailable` | "{plugin} no está disponible ahora" | **`userMessage` (apiVersion 6)**: `throw kino.error("not_found", "E100006", { userMessage: "Este capítulo ya no está disponible." })` shows "Mensaje de : " **instead of** Kino's line, only for the five codes above and only if the sentence passes every safety rule; otherwise Kino's line shows and the sentence is dropped silently. The rules, in short: 1 to 160 characters of Spanish/Latin-1 letters, digits and plain punctuation (no emoji, `@`, other scripts or invisible characters); at least two words; no URL, domain, `www` or "punto com"; fewer than 6 digits in all, no digit glued to a letter; never spells "Kino"; never asks for money, credentials, codes or contact (`pag…`, `recarg…`, `transfer…`, `contraseñ…`, `clave…`, `token…`, `tarjeta`, `PIN`, Nequi, Daviplata, WhatsApp, Telegram, SMS/verification codes); never contains a password the person typed or a sealed value. The plugin name itself must be plain (no `:`, no digit glued to a letter). **A plugin that uses `userMessage` to ask for money, credentials or contact outside Kino breaks the rules for plugins.** Each author is responsible for their own plugin; Kino only lists community plugins (no recommendation or promotion), and removes one that breaks the rules (asking for money, passwords or contact data, malware, rights claims) from the community index through [`community-blocklist.json`](https://github.com/kinotvapp/kino-plugins/blob/main/community-blocklist.json); anyone can report it with the ["Reclamo / retiro de plugin"](https://github.com/kinotvapp/kino-plugins/issues/new?template=reclamo-retiro-plugin.yml) issue template. An installed copy stays installed, shows "Retirado del índice de la comunidad." and gets no more updates; a fork needs its own report. Build it where you throw, write it in Spanish, and never echo what the person typed. Full rules: [Your own sentence](https://kinotvapp.github.io/kino-plugins/en/contract/#user-message). `kino.fetch` does not throw on non-2xx (check `r.ok`); it throws with `e.code` one of `host_not_allowed`, `timeout`, `network`, `too_large`, `invalid_request`. `kino.crypto` errors carry `code: "crypto_error"`. **The unhandled-rejection trap**: in Kino 0.9.49 and older a `throw` inside an `async` function **before its first `await`** aborts the whole call even if the caller wraps it in `try`/`catch` (also `return Promise.reject(e)` and a `new Promise` rejected at once). Kino 0.9.50 catches it like Node; only a rejection nobody ever handles still fails the call. The Node kit does not show the old behaviour. Since people update late, keep `await`ing first (a fetch, or `await null;`) and validate after. **Language**: everything the person sees (the manifest's `name` and `description`, settings `label` and `hint`, Home row titles, error details, badges) is **Spanish from Bogotá, with tuteo** ("Configura", "Escribe tu usuario"), **never voseo** ("Configurá", "Escribí" are wrong). Code, identifiers and comments may be English. **Secrets**: never hardcode a password, token, API key or cookie in `plugin.js` or the repository; ask for it in a `settings` entry of type `password`, keep derived tokens in `kino.storage` keyed by user and server, and never log a setting. A `url` setting cannot have a `default` (use `hint`). A fixed key that belongs to the plugin's author (not the person) can be **sealed** instead: `node sdk/seal.mjs --repo owner/repo --name apiKey` prints `kino-sealed:v1:…` for the manifest's `"secrets"` (apiVersion 4); the code uses `kino.secret("apiKey")` (fine at module top level too), a marker Kino swaps for the value only inside `kino.fetch`, toward the manifest's `hosts` over https, and redacts from everything the code reads back. It is obfuscation, not secrecy; seals only open when the plugin is installed from its default branch (no `@ref`); the Node kit reads plain values from `.kino-secrets.json` (never commit it). Full rules: [Sealed secrets](https://kinotvapp.github.io/kino-plugins/en/manifest/#secrets). **Downloads are declarative.** `"download"` in `capabilities` (apiVersion 2) is a flag the app acts on: export nothing extra, there is no separate "resolve for download" call (Kino calls your `resolve(ref)` when the queued download actually runs). Progressive files and non-live HLS save; DASH, live, SAMPLE-AES and DRM never do. Phones only. **Signing (apiVersion 5, Kino 0.9.45+, optional).** The author signs `plugin.js` with their own Ed25519 key; the manifest carries `"signature": { "authorKey": <64 hex>, "value": <128 hex> }`; Kino checks it at install and at every update (never at runtime), pins the key at the first install (trust on first use) and refuses an update signed with another key or no longer signed. People see "Firmado por su autor" and a "Firmado" badge. It does not hide the code. Full page: [Signed plugins](https://kinotvapp.github.io/kino-plugins/en/signed/). When the person wants it: 1. Explain it in plain Spanish first. Then `node sdk/seal.mjs --keygen` **once** (writes `kino-author-key.pem`; refuses to overwrite). Add `*.pem` to `.gitignore` **before any commit** (the scaffold's `.gitignore` does not have it). Tell them to back the key up and never share it: there is no recovery, and a new key makes every existing install refuse the update (they must uninstall and reinstall). **Never put the key in the repository, the chat or a log.** 2. Set `"apiVersion": 5`, then `node sdk/seal.mjs --sign --repo owner/repo[/folder]` **after the last change** to `plugin.js` or `version`, and **again after every later change** (the signature covers the exact entry file, repo, `id` and `version`). 3. `node sdk/validate.mjs . --repo owner/repo` verifies it and fails if a `.pem` is tracked. 4. If it says "La firma del autor no es válida…", the code, `id`, `version` or repo changed after signing: sign again. If a person reports "firmada con otra clave de autor", the key changed. **apiVersion 6 (Kino 0.9.50+): every widening, when to use it, its limits.** Declare `"apiVersion": 6` only when you use one of these. - **Typed / larger sealed secrets.** Values up to 8,192 bytes; a key for `kino.crypto` can be sealed as `{ "seal": "kino-sealed:v1:…", "use": "cipher-key", "encoding": "hex"|"base64" }` (`seal.mjs --use cipher-key --encoding hex`), 16/24/32 bytes, usable as the whole key of any cipher (DES-EDE3 too) and **nothing else**: never HMAC/PBKDF2 input, never in `kino.fetch`. Use it when a site's own player ships a fixed cipher key. - **`migrate`** (capability + export, needs approval): Kino asks it about saved library titles, chapters and live favorites it can no longer open; answer what `search()` would return today, or `null`. 10 s each; avoid fetching; never return a `plg1:` ref. Use it only when the plugin replaces an older source or changes its ref format. - **Request-signed streams** (`signing: "request"` + export `sign`): only for an HLS origin that wants a fresh signature on every playlist/segment request. `sign()` runs in a separate **signing lane**: only `kino.crypto`, `kino.secret`, `kino.config`, `kino.html`, `kino.log`; no network, no storage, no cookies, no sleep, no memory shared with the main runtime; 1.5 s per call; three failures stop the video. Everything it needs travels in `signContext` (a string ≤ 4,096 chars, never a `kino.secret` marker: call `kino.secret()` inside `sign`). No `drm`, no `audioTracks`, not downloadable, not for an inline channel stream. `resolve(ref, { retry: { reason: "conflict"|"expired", attempt, status } })` is called again on 409 or repeated 401/403 (3 tries). `alternateHosts` (≤ 6, same host rules as `url`) are other hosts serving the same stream. Casting works through the phone, which must stay on the same Wi-Fi. [Signing every request](https://kinotvapp.github.io/kino-plugins/en/signed-streams/). - **Settings form**: `section` (heading), `status` (needs export `settingsStatus()` → `{ key: text }`, 10 s) and `action` (needs export `action(key)` → `{ message?, refresh?, clearSettings? }`, 30 s, optional `confirm`) hold no value; at most 16 on top of the 12 valued settings. `clearSettings` empties up to 12 of the plugin's own optional valued settings (never `required` ones); use it for "Cerrar sesión". Optional `validateSettings(values)` (20 s) returns `null` to accept, a text, or `{ key: "message" }` to refuse. Every plugin with settings gets its own Ajustes tab; settings sync between paired devices and arrive **without** `validateSettings`, so key any `kino.storage` session by account and never store a device identity in a setting. A `toggle` without `default` reads `false`, a `select` without one its first option, a `list` an array of `{ [field key]: string }`; `confirm` only on `action`, `fields`/`max` only on `list`, `options` only on `select`. There are no conditional fields: group with a `section` and check combinations in `validateSettings`. Every type and attribute: [The settings form](https://kinotvapp.github.io/kino-plugins/en/settings-form/#types). - **`debug: true`**: on-screen error panels and a "Registro" page (last 200 events); logcat tag `KinoPlugin/` (and `KinoPlay` for playback metrics). Development only: **remove before publishing** (`validate.mjs` warns). - **`telemetry: true | "verbose"`**: asks the person (consent line, and an update that adds it waits for approval) to share the failed call's scrubbed `kino.log` lines with Kino's error tracker; only plugins that declare it ever send lines (recommended or not), and for now there is no switch (a later Kino adds a per-device one). `"verbose"` adds playback metrics of a sample of good plays and edge cases (60 events per run). `kino.log.report("myplugin:area", "code", "count=2")` flags a degraded-but-working result (area: lowercase namespaced word with `_` or `:`, ≤ 24 chars; 1/hour per area, 3 per run). Log codes and counts, never values from a response, never what the person typed. [Logs and telemetry](https://kinotvapp.github.io/kino-plugins/en/diagnostics/). - **`section`** (`"section": { "label": "…" }` ≤ 20 chars + export `section({ tab })` → `{ tabs?, tab, hero?, rows }`, 20 s): a section of its own (TV sidebar, max 3 plugins; phone chip strip, max 8). **`categories()`** (needs `browse`, 20 s): up to 24 tiles in Categorías, each opening `browse(ref, null)`. **`theme`**: up to five `#RRGGBB` colors (`accent`, `onAccent`, `background`, `surface`, `highlight`); dark backgrounds only, contrast 3:1 / 4.5:1, nothing close to Kino's red `#E50914`; a failing color falls back to Kino's (accent+onAccent as a pair); errors are always Kino's red. Preview with `node sdk/run.mjs . theme`. - **`scopedSearch`** (capability, needs `search`): your `search` gets `query.within` (the browse `ref` of a "Ver más" page) and answers inside that category; `null` means "let Kino filter". Kino gives up on you after 6 s. Without it Kino filters the loaded titles itself. - **`adult: true`** on items, Categorías tiles, live categories and channels: kept and shown only while the person's 18+ code is unlocked (Ajustes ▸ Adultos). Below apiVersion 6 they are dropped. With an 18+ live category, mark **every** `liveSearch` hit with `adult` or its `categoryId` (unmarked hits count as 18+). Never try to detect or bypass the lock: always send the mark. - **Live channels in Home rows**: `kind: "live"` items now stay in `home` rows (a country's channels, for example); below 6 they are dropped from Home. - **`kino.crypto` key pairs**: `generateKeyPair` (`ec` P-256/P-384, `ed25519`, `x25519`), `sign`, `verify`, `importKey`, `deriveSharedSecret`; private keys are handles that live only in the sandbox that made them (not in `sign()`'s lane), at most 64. For a player that proves itself by signing a challenge. Map Node/WebCrypto calls with the table in [The kino API](https://kinotvapp.github.io/kino-plugins/en/kino-api/#key-pairs). - **`Stream.label` and labelled lazy copies**: `label` (≤ 48 chars, e.g. `"Latino · Servidor 1"`) names a copy in the player's **Servidor** menu; an alternative may be `{ label, ref }` (ref ≤ 512 chars) that Kino passes to `resolve(ref)` only when needed: the person picks it (whole `resolve` limit; if it fails they keep watching the copy they had), the automatic fallback reaches it (≤ 20 s, then the next copy), or a download's copy choice probes it (inside its 30 s budget). From that answer only `url`, `headers`, `mime`, `subtitles`, `expiresInSeconds` and `skip` are used; its own `alternatives` are ignored. Use it for **every** source with several servers or languages: never resolve all of them up front. [Labelled and lazy copies](https://kinotvapp.github.io/kino-plugins/en/contract/#lazy-copies). - **`"browser": true`** (`kino.browser.capture`) or **`"browser": "pages"`** (capture plus `kino.browser.page`): see "The hidden browser" below. - **`meta`** (capability + export `meta(query)`, no consent line, 6 s): fill a title's info page when TMDB/AniList have nothing (e.g. `kitsu:` anime); return `null` for titles you do not know. - **Kino 0.9.53, no new apiVersion, always behind `typeof kino. === "function"`**: `kino.meta(query)` asks Kino what it knows about a title (its own TMDB lookup, AniList, the person's other `meta` plugins; 30/min; `null` when unknown). `kino.tmdb(path, params)` is read-only TMDB v3 with **no key in your code**: Kino's own key first (behind its cache and limits: 20/10 s per plugin, 60/10 s for all), the person's key (Ajustes, or a Stremio addon's key they agreed to share) only when Kino's fails: never put a TMDB key in the code, and for a TMDB-based catalog prefer it over a `tmdbKey` setting (keep the setting only as the fallback for older Kino). Catch `no_tmdb_key` where an empty answer is better than an error (Home rows); uncaught, the person reads Kino's own sentence telling them where to add the key. [The `kino` API](https://kinotvapp.github.io/kino-plugins/en/kino-api/#tmdb). - **Any apiVersion**: `Stream.alternatives` (≤ 8 `{ url, mime?, headers? }`, other copies of the same video, best first; Kino switches when one cannot decode or is gone; ignored with `drm`, `signing` and for live). `Stream.skip` (`{ openingStartMs?, openingEndMs?, endingStartMs? }` for this exact file: "Saltar intro"/"Saltar outro"; wins over AniSkip, a hand correction wins over it). The `subtitles` export (10 s, background): tracks for any title Kino knows by IMDb/TMDB id, alongside your videos or as a pure subtitle provider (`"capabilities": ["subtitles"]` alone). The manifest's `categories` field (`movies`, `series`, `anime`, `live`, `radio`, `subtitles`, `utilities`, `adult`) only tags the plugin in the marketplace; it is not the `categories()` export. `settingsStatus()` is asked again after every `action` (no need for `refresh: true`). **The hidden browser (`"browser": true` or `"pages"`, apiVersion 6, Kino 0.9.50+).** Rules, in order: 1. **Prefer `kino.fetch`.** Use the browser only for a server whose embed builds the video address by running its own scripts, after you checked the HTML, JSON and scripts with `curl`. It costs the person a red consent line ("Puede abrir páginas web ocultas para encontrar el video"), 5-25 s per page, and it never runs in the Node kit (`browser_unavailable` there): keep a `kino.fetch` path wherever one exists. 2. **`kino.browser.capture(url, { timeoutMs, headers, match, autoplay })` only inside `resolve`**, and only a `resolve` the person started (play or a download); anywhere else `not_allowed`. The start `url` must be on your `hosts`, https, public. It returns `{ media: [{ url, mime?, headers }], subtitles, finalUrl }`: return `media[0].url` with **its `headers`** (Referer, User-Agent, cookies…) as the Stream's `headers`. Add `"streamHosts": "any"` because the video lives on hosts you cannot list. An approved plugin's `resolve` gets 75 s: plan for two or three servers, not all of them. 3. **`kino.browser.page(url, { waitFor, timeoutMs })`**, only with `"browser": "pages"` (its own red line, "Puede abrir páginas web ocultas para mostrar contenido y encontrar el video"; `true` is capture-only and gets `not_allowed`; an update from `true` to `"pages"` asks the person again), returns `{ html, finalUrl, status, truncated }` for a site whose plain fetch only gets its automatic check page ("Just a moment…"): only from `search`, `home`, `browse`, `episodes`, `section` or `resolve` while the person is using the app (never background calls; **never from `categories`**, which is always Kino's own call), 20 reads a minute, Kino never touches the page. The top document must stay on your hosts: every redirect or navigation hop is checked, and the first one off your hosts ends the read with `blocked` (no HTML). Kino cuts `timeoutMs` to the call's remaining time minus 1.5 s; still pass ~12000 in `search`. Try `kino.fetch` first and cache with `kino.storage`. 4. **Never try to defeat a captcha or bot protection.** Kino never solves, clicks or ticks a CAPTCHA, Turnstile, hCaptcha, reCAPTCHA or "verify you are human": the call ends with `blocked`. Do not add solver services, fingerprint spoofing, stealth tricks, or retry loops; on `blocked`, `timeout` or `busy` move to the next server or return nothing. The author is responsible for their own plugin; Kino only lists community plugins (community search), it does not recommend or promote them (see "Community takedowns" for what happens to a plugin that breaks the rules). 5. **One page at a time in the whole app** (`busy`); a device without WebView gets `browser_unavailable`. Each page starts with no cookies and is wiped after; it never reaches the home network. 6. **Combine it with labelled lazy copies**: capture the first server in `resolve`, list the other servers/languages as `{ label, ref }`, and capture each only when its `ref` comes back. ([Maratón](https://github.com/xuper-plugin/maraton), a signed community plugin, works this way; "Tu servidor" stays the complete reference for everything else.) Full page: [Hidden browser](https://kinotvapp.github.io/kino-plugins/en/browser/). What Kino 0.9.50 does for every plugin, with no field: plays a plugin title on the paired TV through the TV's own copy of the plugin (keep `id` and refs identical across devices), checks followed series for new chapters through `episodes(ref)` (keep series refs stable), saves "Para ti" picks by `ref`, and treats a **signed** plugin installed from two repos with the same `id` and author key as one plugin across devices. **Community takedowns**: each author is responsible for their own plugin; Kino only lists community plugins (no recommendation or promotion). A plugin that breaks the rules for plugins (a `userMessage` asking for money, passwords or contact data, malware, a rights claim) is removed from the community index through `community-blocklist.json` at the root of `kinotvapp/kino-plugins` (reasons `claim`, `malware`, `broken`, `rules`, `author_request`); anyone can report one with the "Reclamo / retiro de plugin" issue template. Installed copies stay installed, show "Retirado del índice de la comunidad." and stop updating; a fork needs its own report. Do not build anything that infringes rights or harms people. See [Claims and plugin takedowns](https://kinotvapp.github.io/kino-plugins/en/claims/). **Sending to the TV** needs nothing from the plugin; it casts best with a correct `mime` and no `headers` (see [What people see](https://kinotvapp.github.io/kino-plugins/en/what-people-see/#cast)). ## 5. Checklist before publishing - [ ] `node sdk/validate.mjs .` exits 0, and `node sdk/validate.mjs . --run …` passes for every declared capability. - [ ] `node --test test/plugin.test.mjs` passes offline from recorded fixtures. - [ ] Every host the code, the stream, its segments, subtitles and redirects touch is in `hosts`, with the bare domain next to its `*.` form. - [ ] No missing global (`setTimeout`, `fetch`, `Buffer`, `process`, `require`, `crypto`, `Intl`…) in `plugin.js`; no `import`. - [ ] No `throw` before the first `await` in any async function. - [ ] Every declared capability is an exported async function (except `download`/`drm`); nothing exported that the manifest does not declare is ever called. - [ ] `id`s are stable and match the pattern; `ref`s keep working when replayed later. - [ ] User-facing text in Spanish (Bogotá, tuteo); no secrets in the repository. - [ ] `version` raised; `apiVersion` is the lowest that works (6 only for an apiVersion 6 feature: it needs Kino 0.9.50+; 7 only for `tracking` or `segments`: it needs Kino 0.9.51+). - [ ] No `"debug": true` in the manifest unless the person asked for it (every plugin has a "Modo debug" switch in Kino's Ajustes; `true` only turns it on by default for everyone). If `telemetry` is declared, the person agreed and the logs hold codes and counts only. - [ ] Every `userMessage` passes the rules (Spanish, ≤ 160, no URL/digits/money/credentials/contact) and `run.mjs` shows it; 18+ content carries `adult: true`. - [ ] `"entry"` and `"icon"` have **no leading `./`** (`"plugin.js"`). - [ ] If `"browser": true`: every server that works with `kino.fetch` uses it; `kino.browser.capture` is only called from `resolve`; nothing tries to solve, click or bypass a captcha or bot check; `blocked`/`timeout`/`busy`/`browser_unavailable` move on to the next server or end cleanly; the captured `headers` are returned with the Stream; several servers are labelled lazy copies. - [ ] If signed: `"apiVersion": 5`, `signature` present, `node sdk/validate.mjs . --repo owner/repo` verified it after the last edit, `*.pem` in `.gitignore`, no `.pem` tracked, the person knows to back the key up. - [ ] Public repository, manifest at the root, topic `kino-plugin` on THAT repository (mandatory: without it the app never finds the plugin), not a fork. Manifest `description` written (the card shows it); the GitHub About description is optional and not read by the app. - [ ] The person installed it in Kino and it searched, listed episodes and played. ## 6. Being discovered ("De la comunidad") Kino lists community plugins itself; nobody approves them. The step-by-step for the person, with exact clicks and how to check it, is [Get listed in Kino](https://kinotvapp.github.io/kino-plugins/en/listed/). Every rule, as the app applies it (full detail: [Publishing › Get found](https://kinotvapp.github.io/kino-plugins/en/publish/#get-found)): 1. Public GitHub repository at `https://github.com//` (owner `^[A-Za-z0-9][A-Za-z0-9-]{0,38}$`, repo `^[A-Za-z0-9._-]{1,100}$`), **not a fork** (use the template's "Use this template"). 2. Topic exactly `kino-plugin`, **mandatory**, on the repository that holds `kino-plugin.json` (`gh repo edit owner/repo --add-topic kino-plugin`). Verify: `curl -s https://api.github.com/repos/owner/repo | tr -d ' \n' | grep -o '"topics":\[[^]]*\]'` must list it. The card shows the manifest `description`; the GitHub About description is optional and does not affect discovery. 3. `kino-plugin.json` at the repository **root on the default branch** (`https://raw.githubusercontent.com///HEAD/kino-plugin.json`), at most 16 KB, valid by the installer's rules, `apiVersion` not above the person's Kino (6 from Kino 0.9.50, 5 on 0.9.45 to 0.9.49; a signed plugin needs Kino 0.9.45+), and not `"discoverable": false`. 4. An `id` of your own: never a recommended plugin's id from another repo (today `internet-archive`, `own-server`), never the template's `archive-org`; the same id installed from another repo hides it on that device. 5. Stars: the app makes one unauthenticated request, `https://api.github.com/search/repositories?q=topic:kino-plugin+fork:false&sort=stars&order=desc&per_page=50`, keeps the first 30 well-formed results, then drops the ones whose manifest fails (they still used a slot). Below the top 30 a plugin is not listed. 6. Timing: each device searches when its plugins screen opens and its copy is older than 12 hours, or on "Actualizar" (never twice within 60 s); 403/429 means waiting `Retry-After` / `X-RateLimit-Reset` / 15 min (1 min to 24 h). GitHub indexes new topics on its own schedule; raw files are cached about 5 minutes. 7. If GitHub cannot answer, a backup list published by the Kino team is used; it is rebuilt from the same search, so nothing needs requesting. A repository in `community-blocklist.json` (a claim was upheld) never shows, in either list. 8. Nothing installs by itself: the person always sees the consent sheet ("Plugin no verificado…"). 9. Where to look in the app: Plugins (phone: menu ☰ → Plugins; TV: Ajustes → Plugins), tab "Recomendados" (only Internet Archive and Tu servidor), section "De la comunidad" ("Plugins de la comunidad — Kino no los revisa ni responde por su contenido.") with its "Actualizar" button; also the first-run "Elige tus fuentes". On the web: . To debug "it does not appear": open the search URL in a browser and find the repo in `items`; open the raw manifest URL; run `node sdk/validate.mjs .`; check the id; tap "Actualizar" after 60 s. ## 7. Common mistakes - Declaring `*.site.com` only, then failing on `site.com` (or the reverse after a redirect). - A CDN, a subtitle host or a license server missing from `hosts`: playback stops with an error. - Using `fetch`, `setTimeout` or `Buffer` because the Node kit ran fine. - Writing `plugin.js` with an `import`/`require` of a second local file: Kino only loads `entry`, there is nothing for it to resolve on-device. Bundle to one file first (section 3, step 3). - Validating arguments with `throw` before the first `await` in a helper wrapped in `try`/`catch`. - Returning ids with `/`, `:` or spaces (dropped), or ids that change between calls (the library loses the title). - Returning `next` or a row `ref` without declaring and exporting `browse` (they are dropped). - Putting expiring links in `ref` instead of resolving them fresh in `resolve`. - Network calls at the top level of the module (install fails). - Forgetting to raise `version`, so the fix never reaches anyone. - Writing `"entry": "./plugin.js"` (or `"icon": "./icon.png"`): refused by Kino 0.9.45 and older. - Signing, then editing `plugin.js` or `version` without signing again; committing the `.pem`; making a new key for an already published plugin (everyone must reinstall). - Declaring `apiVersion` 2 or 3 without needing it (older Kino builds cannot install it); declaring 6 for nothing (Kino 0.9.49 and older refuse it). - Publishing with `"debug": true` without being asked (it turns error panels on by default for everyone). - Calling `kino.fetch`, `kino.storage` or a private-key handle from `sign()`, or putting a `kino.secret()` marker in `signContext`. - A `userMessage` with a URL, a phone number, "WhatsApp", "paga"/"recarga" or the person's own input: dropped silently at best, the plugin removed at worst. - A `status` or `action` setting without exporting `settingsStatus` / `action`; `"section"` without exporting `section` (install fails). - `kino.html.select` "failing" under Node: it only exists in the app; test that part in Kino. - Forking the template: forks never appear in "De la comunidad". - Keeping the template's `"id": "archive-org"`: install refused ("Ya hay un plugin con ese id") and hidden from the community list wherever the Internet Archive plugin is installed. - English or voseo in what the person reads. - Copying a browser's `Accept-Encoding` or `Host` header and expecting it to be sent. - Pasting a secret into `kino-plugin.json` or `plugin.js` instead of sealing it (author keys) or asking for it in a `password` setting (the person's credentials); committing `.kino-secrets.json`. # Writing a Kino plugin A Kino plugin is a video source that anyone can publish as a small GitHub repository: one JSON manifest and one JavaScript file. A person types `owner/repo` in Kino, sees which sites the plugin will talk to, accepts, and from then on the plugin is one more source: its results show up in search and on Home, and its titles open, list episodes, play in Kino's player, keep progress and appear in "Continuar viendo" and the library like any other title. You can write, run and test a plugin on your computer with Node before you ever touch the app. This guide has everything you need: the file layout, the manifest, the contract your code must meet, the API Kino gives you, every limit, the quirks of the JavaScript engine, and how to publish. The complete API demo is [kinotvapp/kino-plugin-own-server](https://github.com/kinotvapp/kino-plugin-own-server) ("Tu servidor" 1.5.0: every feature up to apiVersion 7 working end to end); [kinotvapp/kino-plugin-archive](https://github.com/kinotvapp/kino-plugin-archive) (Internet Archive) is the simplest starting template. Both carry the `sdk/` folder, the Node kit. Two more files describe the contract for machines (both on the [Reference](reference/index.md) page): `contract.json` holds every number and rule the app enforces (the tables in this guide are generated from it, and the app's tests pin its own constants to it), and `kino.d.ts` declares the whole `kino` API for your editor (`/// ` at the top of `plugin.js`). ## The 5-minute path { #five-minutes } 1. **Start from the template.** Create your repository from [kinotvapp/kino-plugin-archive](https://github.com/kinotvapp/kino-plugin-archive) -- the simplest one -- or from [kinotvapp/kino-plugin-own-server](https://github.com/kinotvapp/kino-plugin-own-server) if you need settings, a session, downloads or live channels ("Use this template" on either one, or clone it and copy `sdk/`). Do not *fork* it: Kino's community search leaves forks out ([Get found](publish.md#get-found)). Or let the kit write a skeleton: `node sdk/init.mjs my-plugin --host example.com`. 2. **Declare what you need** in `kino-plugin.json`: an `id`, the `hosts` you will call and the `capabilities` you export ([The manifest](manifest.md)). 3. **Write the functions** in `plugin.js`: `search` and/or `home`, and `resolve` at least ([The contract](contract.md), [The `kino` API](kino-api.md)). 4. **Check it the way Kino does:** `node sdk/validate.mjs .`, then `node sdk/run.mjs . search "algo"` ([Test it locally](test-locally.md)). 5. **Publish** it as a public repository with the topic `kino-plugin` (mandatory: without it Kino cannot find it), and install it in Kino from Ajustes > Plugins by typing `owner/repo` or pasting the URL of its `kino-plugin.json` ([Publishing](publish.md)). 6. **Get Kino to show it by itself** in "De la comunidad": the topic, name and description, and how to check it, in [Get listed in Kino](listed.md). Using an AI assistant? Give it [the ready-made prompt](ai.md): it reads this whole guide from `llms-full.txt` and follows `AGENTS.md`. ## What a plugin is { #what-a-plugin-is } A public GitHub repository, or a folder inside one, with: ``` kino-plugin.json the manifest (required) plugin.js the code: a single ES module (required; its name is set by "entry") icon.png optional, square, at most 128 KB README.md for humans ``` Kino runs your code in a sandbox: no filesystem, no timers, no other plugins, no access to the person's data. The only way out is `kino.fetch`, which can reach only the hosts your manifest declares and the person approved on screen, plus the servers the person typed in your plugin's settings (see [The manifest](manifest.md)). Kino loads exactly one JavaScript file, so there is nothing for an `import` to resolve to. If you use a build step or a library, bundle everything into that single file -- see [Splitting your code across files](engine-limits.md#splitting-files) for a worked example. **How people install it.** In Kino, Ajustes > Plugins, they type the address of your repository: | They type | Kino reads | | --- | --- | | `owner/repo` | the repository root, default branch | | `owner/repo/sub/dir` | a folder inside the repository | | `owner/repo@v1.2.0` | a branch, tag or commit (the name cannot contain `/`); also works with a folder | | `https://github.com/owner/repo` or `.../tree//` | the same, pasted from the browser | | `https://raw.githubusercontent.com/owner/repo///kino-plugin.json` (or a `github.com/.../blob//.../kino-plugin.json`, also `/raw/`) | the folder that `.json` file is in, at that ref; any other kind of file is refused | | `https://cdn.jsdelivr.net/gh/owner/repo[@]//kino-plugin.json` (also `fastly`, `gcore`, `testingcf` and `quantil.jsdelivr.net`) | the same repository folder, read from GitHub: no `@ref` or `@latest` is the default branch; the ref must be an exact branch, tag or commit (`@main`, `@v1.2.0`), so a version range (`@1`, `@^1.2`, `@1.x`) is refused | | `https:////kino-plugin.json` | a plugin hosted outside GitHub: see [Installing from a manifest URL](#manifest-url) | The field in Kino says "Escribe usuario/repositorio de GitHub o pega la URL del manifest (kino-plugin.json)". A `?query` or `#fragment` in a pasted URL is ignored. A ref that only comes from a pasted URL (`tree`, `blob`, `raw`, `raw.githubusercontent.com` or jsDelivr's `@ref`) is not a pin: a plugin with [sealed secrets](manifest.md#secrets) pasted that way installs from the default branch. For a repository address, Kino downloads `kino-plugin.json`, your entry file and the icon from `raw.githubusercontent.com`, which is why the repository has to be public. A URL of a repository's `kino-plugin.json` (on GitHub, raw.githubusercontent.com or jsDelivr) always becomes that repository's address, so sealed secrets, signatures and community search keep working for it. ### Installing from a manifest URL { #manifest-url } Your plugin does not have to live on GitHub (new in the Kino version after 0.9.49). People can paste the `https` URL of its `kino-plugin.json` on any public server: your own site, GitHub Pages, a CDN such as jsDelivr's `npm/`. Kino stores it as the address `url:https://…/kino-plugin.json` (scheme and host lowercased, default port, query and fragment dropped): that URL is the plugin's identity, and it travels as is to the person's other devices with plugin sync. - `entry` and `icon` are read relative to the manifest URL: `"entry": "plugin.js"` next to `https://example.com/kino/kino-plugin.json` is `https://example.com/kino/plugin.js`. - `https` only (`http://` is refused with "Kino solo instala plugins desde direcciones https…"), on a public name: no IP addresses, no `localhost`, no single-label or `.local`/`.lan` names, no user:password in the URL, and the file has to be named exactly `kino-plugin.json`. A name that resolves to a private address, and a redirect off `https` or to such a host, are refused too. - **No sealed secrets**: seals are bound to a GitHub repository, so a manifest with `secrets` installed from a URL is refused ("Este plugin trae datos sellados, y esos solo funcionan si lo instalas desde su repositorio de GitHub…"). Publish such a plugin on GitHub. - **Unsigned**: a `signature` is bound to `owner/repo`, so it is not checked and the plugin installs (and shows) as unsigned. - Everything else is the same as a repository install: the consent sheet, `hosts` and every approval rule, and updates: Kino re-reads the same URL and applies a higher `version`, asking again when it needs more than was approved. The plugin is never listed by community search (that only finds GitHub repositories with the `kino-plugin` topic). **Stremio addons** are a Kino feature for people, not something you write: in the same field a person can paste the address of a Stremio addon's `manifest.json` (or a `stremio://` link) and Kino generates a plugin for it. Nothing in this guide changes for your plugin. ## The guide, page by page { #pages } | Page | What is in it | | --- | --- | | [A first plugin](first-plugin.md) | Two files that search and play, and how to run them | | [Signed plugins](signed.md) | **Sign your plugin with your own key** so people know every update is yours (apiVersion 5, Kino 0.9.45+) | | [What's new](changelog.md) | What changed for plugin authors, by Kino version | | [The manifest](manifest.md) | Every field and rule of `kino-plugin.json`, settings, `streamHosts`, sealed secrets, the person's own servers, `insecureHttp`, downloads | | [The contract](contract.md) | The functions you export, their arguments, what you return, host questions and the broad video permission, and the errors people understand | | [The `kino` API](kino-api.md) | `fetch`, cookies, `secret`, crypto, sleep, config, HTML, storage, log, rank | | [Live channels](live-channels.md) | `live` items, the En vivo tab, M3U/XMLTV playlists, guides, `liveStreamHosts`, three recipes | | [Customize your plugin](customize.md) | **Everything your plugin can change** in how Kino shows it, in one table: icon, color, settings, section, categories, colors, Home rows, the Servidor menu, your own sentences | | [The settings form](settings-form.md) | Every field type and its attributes, `section`, `status` and `action`, `clearSettings`, `validateSettings`, a complete example, the own tab and syncing | | [Signing every request](signed-streams.md) | HLS streams signed on every request: `signing`, `sign`, retries and `alternateHosts` (apiVersion 6) | | [Hidden browser](browser.md) | `"browser": true` / `"pages"`, `kino.browser.capture` and `kino.browser.page`: when to use them, the safety model, never a captcha, timeouts and failures (apiVersion 6) | | [Moving saved titles](migrate.md) | `migrate`: move what the person had saved to your plugin (apiVersion 6) | | [Section, categories and colors](section-theme.md) | `section`, `categories` and `theme` (apiVersion 6) | | [Logs and telemetry](diagnostics.md) | `debug`, the Registro page, `telemetry`, `kino.log.report`, logcat and playback metrics (apiVersion 6) | | [Limits and engine quirks](engine-limits.md) | Every number in one place, how your code lives, what QuickJS lacks, the rejection trap | | [Test it locally](test-locally.md) | The Node kit: `run.mjs`, `validate.mjs`, record and replay, live channels | | [Get listed in Kino](listed.md) | The five steps to show in "De la comunidad", how long it takes and how to check it | | [Publishing](publish.md) | Releases, updates and approvals, and appearing in "De la comunidad" | | [What people see](what-people-see.md) | The consent sheet, host dialogs, player messages, Configurar, statuses, disabling and uninstalling | | [Cookbook](cookbook.md) | An HTML site with a login, a JSON API with a token, the person's own server, Widevine, plain `http` | | [Nuvio scrapers](nuvio.md) | How people install Nuvio scrapers, what the conversion builds, and its limits | | [Stremio addons](stremio.md) | How people install a Stremio addon, how each resource maps, what is refused, and its limits | | [Example plugins](examples.md) | The two published examples, and how the reference plugin is built | | [Claims and plugin takedowns](claims.md) | How to ask for a community plugin to leave the index, what Kino does and how to appeal | | [Reference](reference/index.md) | `contract.json` and `kino.d.ts`, to read or download | # A first plugin Two files. `kino-plugin.json`: ```json { "id": "hello-archive", "name": "Hola Archive", "version": "0.1.0", "apiVersion": 1, "entry": "plugin.js", "description": "Películas de archive.org, en veinte líneas", "hosts": ["archive.org", "*.archive.org"], "capabilities": ["search", "resolve"] } ``` `plugin.js`: ```js const BASE = "https://archive.org"; export async function search(query) { const q = "title:(" + query.q + ") AND mediatype:(movies)"; const url = BASE + "/advancedsearch.php?q=" + encodeURIComponent(q) + "&fl%5B%5D=identifier&fl%5B%5D=title&rows=10&output=json"; const r = await kino.fetch(url); if (!r.ok) throw new Error("archive.org respondió " + r.status); return r.json().response.docs.map((d) => ({ id: d.identifier, ref: d.identifier, title: String(d.title), kind: "movie", poster: BASE + "/services/img/" + encodeURIComponent(d.identifier), })); } export async function resolve(ref) { const r = await kino.fetch(BASE + "/metadata/" + encodeURIComponent(ref)); const file = r.json().files.find((f) => f.name.endsWith(".mp4")); if (!file) throw new Error("este item no tiene un mp4"); const path = file.name.split("/").map(encodeURIComponent).join("/"); return { url: BASE + "/download/" + encodeURIComponent(ref) + "/" + path }; } ``` Run it (needs Node 18 or newer; see [Test it locally](test-locally.md)): ``` node sdk/run.mjs ./plugin.js search "metropolis" node sdk/run.mjs ./plugin.js resolve TheGiantOfMetropolis1961 ``` Or let the kit write the skeleton for you: `node sdk/init.mjs my-plugin --host example.com` creates `my-plugin/` with a manifest, a `plugin.js` with every function, a README and a replay-based test. This one is deliberately naive (a query with a `/` or a lone `AND` makes archive.org answer with an error, and nothing checks the shape of the reply). The reference plugin, [kinotvapp/kino-plugin-archive](https://github.com/kinotvapp/kino-plugin-archive), is the robust version of the same idea; read [how it is built](examples.md#reference-plugin) before you build on it. ## Where the `sdk/` comes from { #get-the-sdk } The **kit** is the `sdk/` folder inside the example plugins. Nothing to install with npm or anything else: they are `.mjs` files you run with Node 18 or newer. The easiest way to get it is to create your plugin from a template, so the kit is already inside. **Steps (recommended):** 1. Open the template closest to what you want to build: - [kinotvapp/kino-plugin-own-server](https://github.com/kinotvapp/kino-plugin-own-server): the complete example, "Tu servidor" 1.5.0 (settings, user and password, downloads, live channels, and every feature up to apiVersion 7). - [kinotvapp/kino-plugin-archive](https://github.com/kinotvapp/kino-plugin-archive): the simplest, for a site with search and videos. 2. Top right, press the green **Use this template → Create a new repository** button. Name your repository and keep it **public**. 3. Download it to your computer and check the kit works: ``` git clone https://github.com//.git cd node sdk/validate.mjs . ``` If you see `✓ Kino would accept this plugin`, you are ready to start changing `plugin.js` and `kino-plugin.json`. !!! warning "Use the template, not the Fork button" **Use this template** creates a new, independent repository that is yours. **Fork** creates a copy linked to the original, and Kino **does not show forks** in "De la comunidad" ([Get found](publish.md#get-found)). If you fork, your plugin will never show up in the app's search. **Already have your own repository?** Copy just the `sdk/` folder from either template into it (for example by downloading the ZIP from **Code → Download ZIP**). The commands are the same: `node sdk/validate.mjs .`, `node sdk/run.mjs …`. ## Next steps { #next } - [The manifest](manifest.md): every field and what the person approves. - [The contract](contract.md): the shapes you return and the rules Kino checks them with. - [Limits and engine quirks](engine-limits.md): read [the rejection trap](engine-limits.md#rejection-trap) before you write a helper (it matters on Kino 0.9.49 and older). - [Get listed in Kino](listed.md): once it works, the five steps for people to find it in the app without knowing its address. # Example plugins Two published plugins, both public, both installable in Kino, and both usable as a template. Start from **Internet Archive** for the simplest possible template; start from **Tu servidor**, the complete API demo, when your source is a server the person owns, or when you want to see every feature up to apiVersion 7 working end to end.
- ![](assets/own-server-icon.png){ .card-icon } **Tu servidor** · `kinotvapp/kino-plugin-own-server` --- **The complete API demo.** A media server at home (Jellyfin, Emby, a NAS…): the person types its address, user and password. Version 1.5.0, apiVersion 7: `"hosts": []`, every setting type and the full settings form, a session kept with `kino.storage`, `kino.rank`, seasons, `download`, copies, request signing, `channels` in every shape, a section and Categorías tiles, `migrate`, `meta`, `subtitles`, `tracking` and `segments`, plus a reference server (`server.mjs`) to run it against with nothing of your own. [:octicons-repo-template-16: Use as template](https://github.com/kinotvapp/kino-plugin-own-server/generate){ .md-button .md-button--primary } [:octicons-mark-github-16: View on GitHub](https://github.com/kinotvapp/kino-plugin-own-server){ .md-button } - ![](assets/archive-icon.png){ .card-icon } **Internet Archive** · `kinotvapp/kino-plugin-archive` --- Public-domain films and classic TV from archive.org. **The simplest template to start from**: one manifest, one JavaScript file, no build step, all five capabilities plus `download`, and one `list` setting for the person's own archive.org addresses (apiVersion 4), with the `sdk/` kit, `GUIDE.md`, `contract.json` and `kino.d.ts`. [:octicons-repo-template-16: Use as template](https://github.com/kinotvapp/kino-plugin-archive/generate){ .md-button .md-button--primary } [:octicons-mark-github-16: View on GitHub](https://github.com/kinotvapp/kino-plugin-archive){ .md-button }
To try either one in Kino, open Ajustes > Plugins and type `kinotvapp/kino-plugin-archive` or `kinotvapp/kino-plugin-own-server`. **Use the template, don't fork.** "Use as template" creates a fresh repository of your own with the same files. A fork would work as a plugin too, but Kino's community search leaves forks out ([Get found](publish.md#get-found)). Then change `id`, `name`, `homepage`, `hosts` and `capabilities` in `kino-plugin.json`, rewrite `plugin.js`, and keep `sdk/`. !!! note "A real-world example: a plugin that uses the hidden browser" [**Maratón**](https://github.com/xuper-plugin/maraton) (signed, `apiVersion` 6, `"browser": "pages"`) is a real-world plugin, by someone else, that finds its video with [`kino.browser.capture`](browser.md) and offers each episode's other servers and languages as [labelled lazy copies](contract.md#lazy-copies). It is an example of those two features only (Kino just lists community plugins: each author is responsible for theirs); "Tu servidor" stays the complete reference plugin. ## The reference plugin { #reference-plugin } For the basics -- `search`, `home`, `browse`, `episodes` and `resolve` over a public site, with no settings or session -- the reference is `kino-plugin.json` and `plugin.js` in [kinotvapp/kino-plugin-archive](https://github.com/kinotvapp/kino-plugin-archive), the Internet Archive plugin, with all five capabilities. It reads about like this: 1. It declares `archive.org` **and** `*.archive.org`: a download URL on `archive.org` redirects to a storage node such as `dn720705.ca.archive.org`, and the wildcard does not cover the bare domain. 2. `getJson` does the `await` first and throws afterwards (the rule of [the rejection trap](engine-limits.md#rejection-trap)). 3. `search` cleans what the person typed: archive.org answers 200 with an error body when the query has a stray `/`, `-`, `&` or `'` or a dangling `AND`/`OR`/`NOT`, so it keeps letters, digits and apostrophes inside words, drops the operator words, and asks both collections (films and classic TV) whatever `type` says, using it only to decide which group comes first; an item that is in both is listed once. 4. `home` builds three rows (films, classic TV, classic animation) and wraps each row in its own `try`/`catch`, so one failing row does not lose the others; it reports it with `kino.log`. Each row carries its own id as `ref`, and `browse(ref, cursor)` pages through the same query 50 at a time with the page number as the cursor (`"2"`, `"3"`, …), throwing `kino.error("not_found")` for a row it does not know. 5. `episodes` reads the item's file list, keeps the video originals in natural order (a small `natural()` comparator, because `localeCompare` cannot be trusted), numbers them from `S01E02` in the file name or 1, 2, 3, and uses `"|"` as each episode's `ref`. 6. `resolve` picks the best playable file (an mp4 derived from the original, or the mp4/webm itself), turns sibling `.vtt`/`.srt` files into `subtitles`, and sets `durationMs`. 7. Every URL it builds is `https` on a declared host; posters use `https://archive.org/services/img/` and are not host-checked. `README.md` in that repository says what it does not do (a collection is exposed as a single movie, episodes numbered 0 are dropped), so do not copy those as intended behavior. For everything else, up to apiVersion 7 (Kino 0.9.51) -- settings, a session, downloads, `live` items, `channels`, the apiVersion 6 set, `tracking` and `segments` -- the reference is [kinotvapp/kino-plugin-own-server](https://github.com/kinotvapp/kino-plugin-own-server) ("Tu servidor" 1.5.0): its [`kino-plugin.json`](https://github.com/kinotvapp/kino-plugin-own-server/blob/main/kino-plugin.json) and its [`plugin.js`](https://github.com/kinotvapp/kino-plugin-own-server/blob/main/plugin.js) use nearly everything that exists, and its [`README.md`](https://github.com/kinotvapp/kino-plugin-own-server/blob/main/README.md) maps every feature to the title of its test server that exercises it: | What it shows | Where in the code | Guide | | --- | --- | --- | | `"hosts": []`, the server the person types and its other addresses (a `list` of `url` fields) | `kino-plugin.json`; `base()`, `addresses()`, `reach()` | [The person's own servers](manifest.md#own-servers) | | Every setting type: `url`, `text`, `password`, `toggle`, `select`, `list`, `section` (a 300-character hint), `status`, `action` | `kino-plugin.json`: `settings` | [The settings form](settings-form.md#types) | | Status lines, buttons (`confirm`, `clearSettings`) and a check before saving | `settingsStatus()`, `action()`, `validateSettings()` | [The settings form](settings-form.md) | | A login and a token kept in `kino.storage`, retried once on a 401; `kino.sleep` on a short `Retry-After` | `token()`, `api()` | [`kino.storage`](kino-api.md#storage) | | Typed errors (`kino.error`) and your own sentence (`userMessage`) | `api()`, `resolveCopy()` | [Errors people understand](contract.md#errors) | | A cache with `ttlMs` the person picks, and the last copy when the server is down; `telemetry` + `kino.log.report` | `home()`, `reach()` | [`kino.storage`](kino-api.md#storage), [Telemetry](diagnostics.md#telemetry) | | Title search over a backend that matches any word; a `Page` with `next`; `scopedSearch` | `search()`, `kino.rank.*` | [`kino.rank`](kino-api.md#rank), [Searching inside "Ver más"](contract.md#scoped-search) | | Cursor paging, rows with `genre` | `browse()`, `refFilter()`, `ROWS` | [Paging](contract.md#paging) | | Seasons as separate titles | `episodes()` | [Seasons](contract.md#seasons) | | `ids.tmdb` + `ids.imdb`, item fields, `adult: true` entries | `item()`, `categories()` | [`ids.tmdb`](contract.md#tmdb), [18+ content](contract.md#adult) | | A section with tabs and a hero, Categorías tiles, `theme` | `section()`, `categories()`; `kino-plugin.json` | [Section, categories and colors](section-theme.md) | | Downloads (`download`) | `kino-plugin.json`; `resolve()` returns a progressive mp4 | [Downloads](manifest.md#downloads) | | `audioTracks`, `subtitles`, `durationMs` and `skip` on the Stream | `resolve()` | [The `Stream` rules](contract.md#stream) | | Labelled and lazy copies, the same file at the other addresses | `withCopies()`, `resolveCopy()`, `withAddresses()` | [Labelled and lazy copies](contract.md#lazy-copies) | | Signing every request (`signing`, `signContext`, `sign`, `alternateHosts`, `resolve(ref, { retry })`) | `signedStream()`, `sign()`, `resolve()` | [Signing every request](signed-streams.md) | | `live` items (apiVersion 2) | `item()`, `resolve()` | [Live channels (apiVersion 2)](live-channels.md#live-items) | | `channels`: a `ref`, an inline `stream`, an M3U list with an XMLTV guide, a `resolve: true` list, `liveSearch`, paging | `liveCategories()`, `channel()`, `liveChannels()`, `liveSearch()`, `resolveListEntry()` | [Channels in the En vivo tab](live-channels.md#en-vivo-tab), [Three recipes](live-channels.md#recipes) | | A User-Agent the channels insist on: `headers` on a Stream, `streamHeaders` on a playlist | `agentHeaders()` | [Channels in the En vivo tab](live-channels.md#en-vivo-tab) | | A guide for its own channels | `guide()` | [The channel functions](live-channels.md#live-contract) | | Moving saved titles from the server's older ids | `migrate()`, `movedTable()` | [Moving saved titles](migrate.md) | | `meta` with `logo`, `ratings` and `cast` (Kino 0.9.51) | `meta()` | [Describing other titles](contract.md#meta) | | `subtitles` with the `file` hint (Kino 0.9.51) | `subtitles()` | [Subtitles for any title](contract.md#subtitles) | | `tracking` (apiVersion 7): idempotency by `event.id`, `{ skipped: true }` | `track()` | [Telling a tracker](contract.md#tracking) | | `segments` (apiVersion 7) | `segments()` | [Where the intro and credits are](contract.md#segments) | Its core, line by line, is in the cookbook: [The person's own server](cookbook.md#own-server). ## Install it and see it work { #own-server-demo } [kinotvapp/kino-plugin-own-server](https://github.com/kinotvapp/kino-plugin-own-server) bundles a reference server with no dependencies (`node server.mjs [--port 8096] [--user ana] [--password s3cr3t] [--live-agent VLC]`) whose catalog exercises one feature per title, so you can install the plugin in Kino and watch every row of the table above work, with no real server of your own. A few powers are deliberately **not** in it, because a server at home never needs them: Widevine DRM ([recipe](cookbook.md#widevine)), a declared host over plain `http` ([recipe](cookbook.md#insecure-site)), streams on any server ([recipe](live-channels.md#recipe-m3u)), sealed secrets ([`kino.secret`](kino-api.md#secret)), the author's signature ([Signed plugins](signed.md)), the hidden browser and `kino.html.select` ([Hidden browser](browser.md)), and key pairs ([Key pairs](kino-api.md#key-pairs)). # Build a plugin with AI An AI coding assistant (Claude Code, Codex, Gemini CLI, Cursor, GitHub Copilot, Aider…) can write a Kino plugin for you if it reads the rules first, **even if you cannot program**: you say which source you want and try the result in Kino; the assistant writes the code, tests it with the kit and tells you what to do at each step. This site publishes the rules in the shapes those tools read best: | File | What it is | | --- | --- | | [`AGENTS.md`](https://kinotvapp.github.io/kino-plugins/AGENTS.md) | The instructions for the assistant: its role, what to read, the step-by-step workflow, the hard rules with their exact numbers, a checklist and the usual mistakes. Also in [the repository](https://github.com/kinotvapp/kino-plugins/blob/main/AGENTS.md). | | [`llms-full.txt`](https://kinotvapp.github.io/kino-plugins/llms-full.txt) | This whole guide as one text file (English), plus `AGENTS.md`, `contract.json` and `kino.d.ts`. Built with the site, so it is always the same version as these pages. | | [`llms.txt`](https://kinotvapp.github.io/kino-plugins/llms.txt) | The short index, in the [llms.txt](https://llmstxt.org/) format. | !!! tip "Want to use a Nuvio scraper?" You do not need this prompt: Kino installs the scrapers of a Nuvio repository directly, converting them on the device. See [Nuvio scrapers](nuvio.md). ## Before you start { #before } You need five things. All are free except, sometimes, the assistant. 1. **A GitHub account** ([github.com/signup](https://github.com/signup)). Your plugin lives in a public GitHub repository; Kino installs it from there. 2. **Node.js 18 or newer** ([nodejs.org](https://nodejs.org/), the "LTS" version). It runs the test kit. To check, in a terminal: `node --version` must answer `v18` or a higher number. 3. **Git** ([git-scm.com](https://git-scm.com/downloads)), to download and upload the repository. Optional but handy: [GitHub CLI](https://cli.github.com/) (`gh`), with which the assistant can create the repository and set its topic for you (the first time, `gh auth login`). 4. **An assistant that can use the terminal**, that is, one that runs commands and reads what they answer: - in the terminal: Claude Code, Codex CLI, Gemini CLI, Aider; - in an editor: Cursor, or VS Code with GitHub Copilot in agent mode. **How to open a terminal:** on Windows, Start menu → type "Terminal" (or "PowerShell"); on a Mac, Cmd + Space → type "Terminal"; on Linux, Ctrl + Alt + T. Create an empty folder, go into it and start the assistant there. A chat in the browser, with no terminal, works too, but then you copy each command, run it and paste what it answers back into the chat. 5. **Kino on a phone or a TV**, to try the plugin for real before sharing it. And one more thing: **the right to use the source.** A plugin can only reach what the person could watch anyway; do not ask an assistant to get around a paywall, someone else's login or DRM. ## The prompt { #prompt } Copy it (the copy button is at the top right of the block), change what is between `<<<` and `>>>` and paste it into your assistant. If you do not know what to put on a line, leave it as it is: the assistant will ask you. ```text You are going to write a Kino plugin: a public GitHub repository with kino-plugin.json and one JavaScript ES module (plugin.js) that the Kino video app runs in a QuickJS sandbox. I may not know how to program. Explain each step in plain words, run the commands yourself (tell me first which one and why), ask me before anything that cannot be undone (deleting, publishing, pushing to GitHub) and, when I have to do something on GitHub or in Kino, give me the exact clicks. Before writing anything: 1. Read https://kinotvapp.github.io/kino-plugins/AGENTS.md completely and follow it as your instructions. 2. Read https://kinotvapp.github.io/kino-plugins/llms-full.txt (the whole guide, contract.json and kino.d.ts). If you cannot open URLs, tell me and I will paste them. 3. Start from a template, with "Use this template" (never Fork): kinotvapp/kino-plugin-archive if my plugin is simple, kinotvapp/kino-plugin-own-server if it needs settings, a session, downloads or live channels (its plugin.js and kino-plugin.json, "Tu servidor" 1.5.0, are the complete API reference, up to apiVersion 7). The template's sdk/ folder is the Node test kit. What I want: - Source: <<< the site or API, e.g. https://example.com >>> - Content: <<< movies / series / anime / live TV; language and country >>> - Access: <<< none / my username and password on the site / an API key of mine (the developer's) that I do not want to publish / the address of a server each person types >>> - What Kino asks the person when setting it up: <<< nothing / their username and password / their region / their server's address >>> - Offline downloads: <<< yes / no >>> - Name in Kino and a short description: <<< e.g. "Mi fuente": "Películas de …, en español" >>> - Sign the plugin with my own key, so people know every update is mine: <<< yes / no >>> - My GitHub user: <<< e.g. my-user >>> Rules you cannot break (the detail and exact numbers are in AGENTS.md): - Declare in "hosts" every host you know: the API's and the video's, subtitles', audio's, segments' and redirects' (*.x does not cover x). If one is missing, Kino asks the person once when a title is opened or played; do not rely on it. If the video comes from changing CDNs, use "streamHosts": "any" (apiVersion 4; the person approves it at install); for live channels, "liveStreamHosts": "any" (apiVersion 3, needs the "channels" capability). Do not use "fetchHosts": it only works on plugins converted from Nuvio. There is no maximum number of hosts from Kino 0.9.45; Kino 0.9.44 and older refuse more than 20, so if you declare more than 20, tell me. - In kino-plugin.json write "entry": "plugin.js" and "icon": "icon.png", NEVER "./plugin.js": Kino 0.9.45 and older refuse a leading "./" and the plugin does not install. - It is neither Node nor a browser: no fetch, setTimeout, Buffer, process, require, crypto or Intl; use kino.fetch, kino.sleep, kino.crypto, kino.storage. URL, URLSearchParams, atob, btoa, TextEncoder, TextDecoder and console do exist. One file, no import. - Never throw before the first await of an async function (await first, validate after): Kino 0.9.50 catches it, but 0.9.49 and older abort the whole call, and people update late. - Use kino.fetch whenever it can find the video. Only if a server's embed builds the address by running its own scripts, use the hidden browser ("browser": true, apiVersion 6; it asks me in red) and call kino.browser.capture only inside resolve. Never try to solve or get around a captcha or a "verify you are human" check: on blocked, move to the next server. If a title has several servers or languages, resolve one and list the others as alternatives { label, ref }. - Limits: search 15 s and the other calls 20 s; 60 requests per call (every redirect hop counts, refused ones too) and at most 6 at once; 5 MB bodies; 256 KB of kino.storage; 100 search results; Home with 20 rows of 60. - Errors for the person: kino.error("auth_required" | "not_found" | "geo_blocked" | "rate_limited" | "unavailable"). With apiVersion 6 you may add a sentence of your own, kino.error(code, detail, { userMessage: "…" }): in Spanish, at most 160 characters, no URL or domain, no long numbers, never asking for money, passwords, codes or contact outside Kino, and never echoing what the person typed (Kino shows it as "Mensaje de : …" only if it passes all its rules; a plugin that uses it to ask for money or data breaks the rules and is taken out of the community index). - 18+ content: mark it with adult: true (apiVersion 6; Kino shows it only with the 18+ code unlocked). Never try to get around that lock. - Everything the person reads is in Spanish from Bogotá with tuteo, never voseo. - Nothing secret in the code or the repository. Each person's username and password go in a "password" setting. An API key of mine is sealed: "secrets" in the manifest, sealed with `node sdk/seal.mjs --repo USER/REPO --name name`, and kino.secret("name") in the code (apiVersion 4). - Downloads are declarative: if the source allows it, add "download" to "capabilities" (apiVersion 2) and export nothing extra; Kino calls resolve itself when the download runs. Movies and episodes are saved from a plain file (mp4, mkv…) or from HLS that is not live; live channels, DASH and anything with DRM, never. - Signing (only if I said yes): "apiVersion": 5 (needs Kino 0.9.45+). Tell me, in plain words, what it is for, and walk me through it: `node sdk/seal.mjs --keygen` ONCE (it writes kino-author-key.pem), add `*.pem` to .gitignore BEFORE any commit, and tell me to back the key up and never share it (if it is lost, everyone who installed the plugin must uninstall and reinstall it). Then `node sdk/seal.mjs --sign --repo USER/REPO` AFTER the last change to plugin.js or "version" and again after every later change, never commit the .pem. Never print the key's contents in the chat. - apiVersion: the lowest that works (3 for channels, 4 only for secrets, "streamHosts": "any" or a "list" setting, 5 only for a signed plugin, 6 only if you use an apiVersion 6 feature: status lines and buttons in the settings, a section of its own, colors, telemetry, migrate, request signing, userMessage, adult, channels in Home rows; 6 needs Kino 0.9.50 or newer; 7 only for "tracking" or "segments", and it needs Kino 0.9.51 or newer). A new id of my own (never "archive-org"). - No "debug": true in a published plugin unless I ask for it: every plugin already has a "Modo debug" switch in Kino's Ajustes, and "debug": true only turns it on by default for everyone. "telemetry" only if I agree that the plugin's errors reach Kino; log steps and counts, never what the person types. Work step by step: first explore the source with real requests, then the manifest, then each function. After each step run `node sdk/validate.mjs .` and `node sdk/run.mjs . …` and fix everything Kino would drop. Record fixtures with --record and make `node --test test/plugin.test.mjs` pass offline. Finish with the AGENTS.md checklist, and before you hand the plugin over run this self-check and tell me the result of each line: - `node sdk/validate.mjs .` exits 0 with no problems; - kino-plugin.json: "entry" and "icon" have no leading "./"; "version" raised; apiVersion is the lowest that works; no "debug": true unless I asked for it; - if signing: "signature" is in kino-plugin.json, `validate.mjs` verified it AFTER the last edit, and no .pem is tracked (`.gitignore` has *.pem); - every host (video, subtitles, segments, redirects) is in "hosts" or covered by an "any" field; - for discovery: the repository is public and not a fork, the topic kino-plugin is set, and the manifest has a Spanish "name" and "description". At the end, walk me through: 1. Publishing it: a public repository (never a fork) with the files at the root; the topic kino-plugin and a GitHub description (About → ⚙ → Description and Topics → Save changes, or `gh repo edit USER/REPO --add-topic kino-plugin --description "…"`); and a good "name" and "description", in Spanish, in kino-plugin.json, because that is what people see in Kino. 2. Installing it in Kino: on a phone, menu ☰ → Plugins → + button; on a TV, Ajustes → Plugins → Agregar. Type USER/REPO → Agregar → read the sheet → Instalar (and Configurar if it asks for data). 3. What to try by hand in Kino, and what to do if something fails. ``` ## A filled-in example { #example } This is the "What I want" part for a simple public source. Paste it instead of the prompt's if you want to rehearse the whole path before making your own: ```text What I want: - Source: https://archive.org, only the film noir collection (https://archive.org/details/Film_Noir) - Content: movies; in English - Access: none - What Kino asks the person when setting it up: nothing - Offline downloads: yes - Name in Kino and a short description: "Cine negro": "Películas clásicas de cine negro de archive.org, de dominio público. No pide cuenta." - My GitHub user: my-user ``` ## Getting Kino to find your plugin { #listed } Installing it by typing its address works from the start. For it to also show up by itself in Kino, under "De la comunidad" (Plugins → Recomendados), the repository must: 1. be **public** and **not a fork** (create it with "Use this template"); 2. have `kino-plugin.json` at its root, valid for `node sdk/validate.mjs .`, with a `name` and a `description` in Spanish (that is what the card shows); 3. carry the topic **`kino-plugin`** (About → ⚙ → Topics) and, better, a GitHub description; 4. be among the 30 with the most stars on the topic. Each device searches again every 12 hours, or when "Actualizar" is tapped. The exact clicks and how to check it: [Get listed in Kino](listed.md). ## Signed plugins { #signed } If you want people to know that every update comes from you, ask for a [signed plugin](signed.md) (`"apiVersion": 5`, Kino 0.9.45+): you make a key once, the assistant signs `plugin.js` with it after every change, and Kino checks the signature at install and at each update. The private key stays on your computer: it must **never** go to GitHub (`*.pem` in `.gitignore`), and you should back it up, because if it is lost, everyone who installed the plugin has to uninstall and reinstall it. It is optional: an unsigned plugin works the same. [Read the whole page](signed.md). ## If something fails { #troubleshooting } Paste **the full text** the terminal printed into the assistant (not a summary) and say what you expected. Some common cases: | What you see | What to do | | --- | --- | | `node: command not found`, "node is not recognized…" | Node is not installed, or you opened the terminal before installing it: install it and open a new terminal. | | `✗ kino-plugin.json: …` | The manifest breaks a rule; the message is the one Kino gives. Ask the assistant to fix it following AGENTS.md. | | `[dropped by Kino] …` | Kino would drop those results. Ask which rule of [the contract](contract.md) they break, instead of accepting a blind patch. | | `[host_not_allowed] …` | A host missing from `hosts`: have it declared. | | `El campo "entry" debe ser una ruta relativa a un archivo .js` (Kino) or `Quita el "./" del campo "entry"` (kit) | `"entry"` (or `"icon"`) starts with `./`. Write `"plugin.js"`: Kino 0.9.45 and older refuse the `./` ([why](manifest.md#entry-dot-slash)). | | `La firma del autor no es válida…` | The plugin is [signed](signed.md) and `plugin.js` or `version` changed after signing: run `node sdk/seal.mjs --sign --repo owner/repo` again. | | `[timeout] …` | The source is slow or there are too many requests: have it make fewer requests per call. | | Works in the kit but fails in Kino | The Node kit is more permissive than the app ([what it does not reproduce](test-locally.md#differences)): missing globals, a `throw` before the first `await` (on Kino 0.9.49 and older), `kino.html.select`. Give the assistant the exact message Kino shows (or a photo of the screen). | | Kino says "Configura … en Ajustes ▸ Plugins" | The plugin needs data: tap the message's Configurar button, or go to Plugins → Instalados → your plugin → Configurar. | | It does not show in "De la comunidad" | See [Get listed in Kino](listed.md). | **Kino's logs** (for someone with a computer connected to the phone with `adb`): `adb logcat -s KinoPlugin` shows what the plugin writes with `kino.log`. Never paste passwords or keys into the chat. ## Tips { #tips } - **Give the assistant a terminal.** The kit only helps if the assistant can run `node sdk/validate.mjs .` and `node sdk/run.mjs …` and read what they print. - **Try it in the app.** The Node kit is more permissive than Kino ([what it does not reproduce](test-locally.md#differences)): install the plugin and check that it searches, lists episodes, plays (and downloads, if you declared it) before sharing it. - **Ask for the reasons.** When the kit reports a dropped item, ask the assistant which rule of [the contract](contract.md) it broke, instead of accepting a patch. - **Raise the version on every change.** Kino only installs an update whose `version` is higher ([Publishing](publish.md#updates)). - **Mind the rights.** A plugin can only reach what the person could watch anyway; do not ask an assistant to get around a paywall, a login or DRM. # Get listed in Kino People get your plugin in two ways: - **By typing its address.** In Kino, on Plugins, the **+** button (on a TV, **Agregar**), then `owner/repo`. This always works as long as the repository is public. - **By finding it in the app, without knowing the address.** Kino shows a list, **"De la comunidad"**, of the plugins it finds on GitHub by itself. This page explains how to get into it. Nobody approves community plugins: the app searches GitHub itself, with the rules below. Follow the five steps and you are in. ## The five steps { #steps } ### 1. A public repository, created from the template (not a fork) { #public-not-fork } - Create the repository with the green **Use this template → Create a new repository** button of [kino-plugin-archive](https://github.com/kinotvapp/kino-plugin-archive) or [kino-plugin-own-server](https://github.com/kinotvapp/kino-plugin-own-server), and pick **Public**. **Never use the Fork button**: Kino leaves forks out of the list. A fork says "forked from …" under its name; if yours does, create a new one from the template and move your files over. - Created it private? In your repository: **Settings → General**, at the bottom **Danger Zone → Change visibility → Change to public**. A private repository can be neither found nor installed. ### 2. `kino-plugin.json` at the root, and valid { #valid-manifest } - Kino reads `https://raw.githubusercontent.com///HEAD/kino-plugin.json`: the file must be at the **root** of the **default branch** (a plugin in a subfolder can be installed by typing its address, but it is not listed). - Check it with the kit: `node sdk/validate.mjs .` must end in `✓ Kino would accept this plugin` and **without** the line "No aparecerá en la búsqueda de Kino" (printed when the manifest says `"discoverable": false`). - An `apiVersion` newer than the person's Kino hides it from them. Kino 0.9.50 goes up to `6` (0.9.45 to 0.9.49, up to `5`); use the lowest that works for you and older Kino builds see it too. - An `id` of your own: never the template's `archive-org`, a recommended plugin's (`internet-archive`, `own-server`). With someone else's id your plugin is hidden ([why](publish.md#discovery-hidden)). ### 3. A good name and description, in the manifest { #name-description } Your plugin's card in Kino shows the `name` and `description` **of `kino-plugin.json`**, not GitHub's. Write them in Spanish, for the person picking sources on their TV: - `name`: 1 to 40 characters, short and recognizable ("Cine clásico", not "my-plugin-v2"). - `description`: up to 300 characters. Say what is there (movies, series, anime, live channels), where it comes from, and whether it asks for anything (an account, a server address). Without a description the card has no text. ```json "name": "Cine clásico", "description": "Películas de dominio público de archive.org, con subtítulos en español cuando los hay. No pide cuenta." ``` Optional, but they make the card look right: `color` (`#RRGGBB`) and `icon` (a square PNG of at most 128 KB). ### 4. The `kino-plugin` topic and the GitHub description { #topic } The topic is **mandatory**: it is the only way Kino finds a plugin. Put it on **the same repository that holds `kino-plugin.json`**. **By clicking**, on your repository's GitHub page: 1. On the right, in the **About** box, click the **⚙** gear. 2. In **Description**, write one line saying what it is ("Kino plugin: classic films from archive.org"). 3. In **Topics**, type `kino-plugin` and press Enter (it becomes a blue tag). 4. Click **Save changes**. **From the terminal** (with [GitHub CLI](https://cli.github.com/)): ``` gh repo edit OWNER/REPO --add-topic kino-plugin --description "Kino plugin: classic films from archive.org" ``` Kino does not read the GitHub description, but set it anyway: it is what people see when they reach your repository from [github.com/topics/kino-plugin](https://github.com/topics/kino-plugin) or a GitHub search, and a clear line gets more people to try it and star it. ### 5. Check it { #check } 1. **Does GitHub see the topic?** Open `https://api.github.com/repos/OWNER/REPO` in a browser and find `"topics"`: it must say `"kino-plugin"`. It should also show on [github.com/topics/kino-plugin](https://github.com/topics/kino-plugin). 2. **Is it in Kino's search?** Open [this search](https://api.github.com/search/repositories?q=topic:kino-plugin+fork:false&sort=stars&order=desc&per_page=50) (exactly the one the app makes) and find your repository in `items`. 3. **In the app.** Open Plugins (on a phone: the **☰ → Plugins** menu; on a TV: **Ajustes → Plugins**), tab **Recomendados**, scroll down to **"De la comunidad"** and tap **Actualizar**. Your card shows your name, your description and "por ". The same list shows in "Elige tus fuentes", the screen Kino shows when there is no source yet. ## How long it takes { #timing } - **GitHub** indexes a new topic (or a repository just made public) on its own schedule: usually minutes, with no promised delay. - **Kino** keeps the list on each device and only asks GitHub again when that copy is older than **12 hours**, or when the person taps **Actualizar** (at most once every 60 seconds). - **Your repository's files** (`raw.githubusercontent.com`) are cached for about 5 minutes: a manifest change may take that long to show. ## The order { #ranking } Kino sorts by GitHub **stars** and keeps the **top 30** of a single search of 50 results; below that nothing is listed. Plugins that fail the rules still use up their slot, so the list may hold fewer than 30. Ask the people who use your plugin to give the repository a ⭐. A plugin the Kino team recommends shows under "Recomendados", not "De la comunidad". Installing any plugin from the list goes through the same consent sheet ("Plugin no verificado…"): nothing installs by itself. ## If it does not show { #not-showing } Go through [Why my plugin does not appear](publish.md#troubleshooting); every exact rule, with its numbers, is in [Get found](publish.md#get-found). # Signed plugins { #signed } A **signed plugin** carries your signature: a mark that only you can make, because it comes from a private key that only you have. Kino checks that mark before it installs your plugin and on every update. Signing is **optional**, it needs **Kino 0.9.45 or newer** and **`"apiVersion": 5` or higher** (6 works too, Kino 0.9.50), and it changes nothing for plugins that don't use it. !!! info "In one sentence" Your code stays plain, readable JavaScript. The signature only proves that this exact code comes from you, and that nobody swapped it on the way. ## What it is, in plain words { #what } Think of a wax seal on a letter. You make a **key pair** once: - a **private key**, a small file (`kino-author-key.pem`) that stays on your computer and that you never share, and - a **public key**, written inside your `kino-plugin.json` (`authorKey`), which anyone can read. Every time you publish, you run one command. It reads your `plugin.js` and writes a **signature** (`value`) into `kino-plugin.json`. Kino uses the public key to check that the signature really was made by the private key, over exactly that code. If one byte of `plugin.js` is different, the check fails and the plugin does not install. **Signing is not obfuscation.** Nothing is hidden or encrypted: anyone can still read your `plugin.js`. For keys and tokens keep using [sealed secrets](test-locally.md#secrets). ## Why it matters { #why } - **People know who it comes from, and that it was not altered.** When someone installs your plugin, the consent screen says **"Firmado por su autor"**. The catalog and community cards show a **"Firmado"** pill (an installed card says "Activo · Firmado"), and the plugin's details show **"Clave del autor: ABCD-EF01-2345-6789"** (phone: Gestionar; TV: the installed plugin's actions). - **Trust on first use: Kino remembers your key.** The first time someone installs your plugin, Kino pins your author key. From then on **every update must be signed with the same key**. An update signed with another key, or no longer signed, is refused. That protects your users if someone takes over your GitHub account or repository and tries to ship different code: without your private key their update does not install. - **It is optional and backward compatible.** Unsigned plugins keep working, on every apiVersion. A signed one simply earns more trust. An unsigned plugin that becomes signed asks the person to approve the update again. What it does **not** do: it does not make the *first* install trustworthy, it does not protect you if your private key leaks, and it does not hide your code. ## How to sign, step by step { #how } You need the Node kit that comes with the example plugins (the `sdk/` folder). If you started from [an example](examples.md) you already have it. 1. **Make your key, once.** From your plugin's folder: ```bash node sdk/seal.mjs --keygen ``` It writes `kino-author-key.pem` (readable only by you) and prints its fingerprint. If the file already exists it refuses to overwrite it. 2. **Keep the key out of git.** Add `*.pem` to your `.gitignore` **before** your next commit. The scaffold's `.gitignore` covers the kit's local files but not `*.pem`, and `--keygen` does not edit it. See [the key](#key) below. 3. **Set the apiVersion.** In `kino-plugin.json`: ```json "apiVersion": 5, "entry": "plugin.js" ``` 4. **Sign.** After the last change to `plugin.js` and to `version`: ```bash node sdk/seal.mjs --sign --repo owner/repo ``` It writes `"signature": { "authorKey": "<64 hex>", "value": "<128 hex>" }` into `kino-plugin.json`. For a plugin in a folder of the repo use `--repo owner/repo/folder`. The options `--manifest` and `--key` change which manifest and key file it uses (defaults: `kino-plugin.json`, `kino-author-key.pem`). 5. **Check.** ```bash node sdk/validate.mjs . --repo owner/repo ``` It verifies the signature, the exports, and that no `*.pem` is tracked by git. Without `--repo` it reads the repository from the folder's GitHub `origin`. 6. **Commit and push** `plugin.js` and `kino-plugin.json` (never the `.pem`). ### What the signature covers, and why you must sign again { #covers } The signature is made over this text: ``` kino-signed-entry:v1 ``` So it covers your **exact `plugin.js`**, the **repository** (and folder), the plugin **`id`** and the **`version`**. Two consequences: - **Sign again after any change** to `plugin.js` or to `version`, even a comment or a space. `validate.mjs` fails until you do. - The signature cannot be copied onto another repository, another plugin or another version. The branch or tag is *not* part of it: a signed plugin installs from any branch or tag. Kino checks the signature **when installing and updating**, never while the plugin runs, so it costs nothing at runtime. Kino 0.9.44 and older refuse an `apiVersion` 5 manifest ("Este plugin necesita una versión más nueva de Kino"). Below apiVersion 5 a `signature` field is ignored. ### The same plugin at two addresses { #two-addresses } A plugin is normally known by the exact address it was installed from: the same `id` from another repo is another plugin (a second install of that `id` is refused with "Ya hay un plugin con ese id"). A signed plugin is the exception (Kino 0.9.50): when the person has your plugin from one repo on their phone and from another repo on their TV, both installs are **the same plugin** when they have the same `id` and both pinned the **same author key**. Then switching it on or off, the approved hosts, the settings and passwords (still sealed end to end, per setting), and an uninstall sync between those devices both ways, exactly as for one address. Each device keeps the address it installed from and keeps updating from it; nothing is moved. - An unsigned install on either side, or another key, keeps the exact-address rule: never merged, and neither ever receives the other's settings or passwords. - Your manifest's own sealed `secrets` are bound to each repo and never travel between devices, so sign and seal each repo's manifest for that repo. - If you publish the same plugin at two addresses (a move, a mirror), sign both with the same key. - In the recommended list, an entry that names your key (`"signed": true, "authorKey": "<64 hex>"`) shows "Instalado" for someone who already has your plugin from your other repo; any other plugin with that `id` installed here makes the entry say "Ya tienes otro plugin con ese id" ("No disponible", nothing to install). ## Keeping your private key safe { #key } - **Never commit it, never share it.** Put `*.pem` in `.gitignore`. The kit's `validate.mjs` fails if a `.pem` file is tracked by git. If one ever leaks, treat the key as lost (below) and make a new one. - **Back it up** somewhere private (a password manager, an encrypted drive). The kit writes the key into the folder you run it from (`kino-author-key.pem` by default); that file exists only on your computer. - **There is no recovery.** Kino keeps no copy and nobody can regenerate it. `--keygen` refuses to overwrite an existing file, and a new key is a different identity. ### If you lose the key, or change it { #lost } Kino pinned the old key at each person's first install, so **a plugin signed with a new key is refused for everyone who already has it installed**: they see *"Esta versión está firmada con otra clave de autor, así que no se instala. Si confías en el cambio, desinstala el plugin y vuelve a instalarlo."* The only way through is for each person to **uninstall your plugin and install it again**, which pins the new key. New installs are not affected. An update that **drops the signature** is refused the same way (*"Esta versión ya no está firmada por su autor..."*), also until the person reinstalls. So once you sign, keep signing every version. !!! warning "Not documented upstream" Kino's documentation does not describe any other recovery path (for example rotating the key without a reinstall). Plan as if there is none. ## When something fails { #troubleshooting } | Message | What it means and what to do | | --- | --- | | `El campo "signature" debe ser { "authorKey": 64 caracteres hex, "value": 128 caracteres hex } (node sdk/seal.mjs --sign)` | The `signature` field is malformed: it must have exactly `authorKey` (64 hex characters) and `value` (128 hex characters). Don't write it by hand: run `node sdk/seal.mjs --sign --repo owner/repo`. | | `La firma del autor no es válida: el código no es el que firmó, o no es para este repositorio, este plugin o esta versión` | The signature does not match. Either `plugin.js` changed after you signed, or the `id` or `version` changed, or you signed for another repository (wrong `--repo`). Sign again with the right `--repo`; check with `validate.mjs`. | | `Esta versión está firmada con otra clave de autor, así que no se instala. Si confías en el cambio, desinstala el plugin y vuelve a instalarlo.` | Shown to a person who has your plugin installed when an update carries a different key than the pinned one. Use the original key; if it is lost, they must reinstall ([above](#lost)). | | `Esta versión ya no está firmada por su autor, así que no se instala. Si confías en el cambio, desinstala el plugin y vuelve a instalarlo.` | An update came without a signature although the installed version had one. Sign every version. | | `Este plugin necesita una versión más nueva de Kino` | The person's Kino is older than 0.9.45 and your manifest says `"apiVersion": 5`. | | ` is tracked by git: anyone can read your author key on GitHub...` (kit) | A `.pem` is committed. `git rm --cached `, add `*.pem` to `.gitignore`, and since it leaked, make a new key (everyone reinstalls). | | ` needs "apiVersion": 5 or newer to carry a signature` (kit) | `--sign` needs `"apiVersion": 5` in the manifest first. | | `no author key at kino-author-key.pem: create one once with node sdk/seal.mjs --keygen` (kit) | You haven't made the key yet (or `--key` points at the wrong file). | See also: [the manifest's `signature` field](manifest.md#signature), [publishing](publish.md#checklist) and [building with an AI](ai.md). # What's new for plugin authors { #changelog } What changed in Kino that matters when you write a plugin, by app version. Every number is in [the contract](contract.md) and [the reference files](reference/index.md). ## Kino 0.9.53: `kino.meta` and `kino.tmdb` { #v0953 } No new `apiVersion`: it is still 7, and nothing on this list makes you change your plugin. Both new calls exist only from Kino 0.9.53, so feature-detect them (`typeof kino.meta === "function"`, `typeof kino.tmdb === "function"`); `node sdk/validate.mjs` warns when your code calls one without that check. - **`kino.meta(query)`**: ask Kino what it knows about a title (`{ type, ids: { imdb, tmdb, tvdb, kitsu, mal, anilist }, lang }`) and get synopsis, year, poster, backdrop, logo, genres, runtime, episodes with their ids, the cross-reference of every id, ratings and cast, or `null`. Kino answers from its own TMDB lookup, AniList for anime, and the person's other `meta` plugins, merged the way its info page merges them; your plugin never touches a TMDB key, and it is never asked on its own behalf. 30 calls a minute, 8 s at most, cached 30 minutes. [The `kino` API](kino-api.md#meta). - **`kino.tmdb(path, params)`**: TMDB's read-only v3 API with **no key in your plugin**. Kino's own key goes first, behind Kino's TMDB cache (on disk, shared with its own screens, a copy up to 7 days old when TMDB is down) and limits of its own (20 calls per 10 s per plugin, 60 per 10 s for all plugins), so plugins cannot spend Kino's quota. Only when Kino's key fails (TMDB answers 401/403/429 for it, or one of those limits is spent) does the same request go again with the person's key: the one they type in Ajustes ("Tu llave de TMDB", optional, synced between their devices), else the one they configured in an installed Stremio addon, once they agree. A key your plugin keeps in its own settings is never used. Kino adds the key; your code never sees any, and TMDB needs no entry in your `hosts`. `no_tmdb_key` (with Kino's sentence for the person in `e.userMessage`: "Agrega tu llave de TMDB en Ajustes, o instala un addon de TMDB de Stremio configurado con tu llave.") is left for a Kino build without a key of its own and a person without one. Allowlisted read paths only, 40 calls per 10 s per plugin whichever key, cached 10 minutes. A TMDB-based catalog no longer needs its own key setting (keep it only as a fallback for older Kino). [The `kino` API](kino-api.md#tmdb), [a complete example](cookbook.md#tmdb-catalog). - **The Node kit** runs both: `KINO_META_FIXTURE`, `KINO_TMDB_KEY` (or `"tmdbKey"` in `sdk/config.json`) and `KINO_TMDB_FIXTURE`. [Test it locally](test-locally.md#kino-services). ## Kino 0.9.51: `apiVersion` 7 { #v0951 } **`apiVersion` 7 = Kino 0.9.51.** The contract (`contract.json`) now says `maxApiVersion` 7 and `kino.apiVersion` reports 7. A manifest with `"apiVersion": 7` is refused by Kino 0.9.50 and older ("Este plugin necesita una versión más nueva de Kino"), so declare 7 only if you use `tracking` or `segments`. Nothing else on this list makes you change your plugin. - **`tracking`** (apiVersion 7): your plugin exports `track(event)` and Kino tells it which movie or episode plays on that device, from any source: `start`, `progress`, `stop` and `watched`, with the episode's own ids and the show's apart. Approved in red ("Le contará a … qué ves y cuándo lo terminas"), with a "Enviar lo que veo" switch in your Ajustes tab; events wait in a queue that survives offline and a closed app (ordered retries, 200 per plugin, 7 days). Return `{ skipped: true }` for an event your service has no use for. [Telling a tracker what the person watches](contract.md#tracking). - **`segments`** (apiVersion 7): your plugin exports `segments(query)` and tells Kino where any movie's or episode's intro and credits are; the "Saltar intro" and "Saltar outro" buttons and auto-skip use them on phone and TV. No red approval. [Where the intro and credits are](contract.md#segments). - **Subtitles for the exact file**: `subtitles()` gets `file: { hash?, size?, name? }`, what Kino knows of the playing file (its OpenSubtitles hash, its size, its name, or a release-style one built from the title), to rank the exact release first. A Stremio addon gets it as the `videoHash`, `videoSize` and `filename` extras. No new `apiVersion`. [Subtitles for any title](contract.md#subtitles), [Stremio addons](stremio.md#subtitles). - **`meta` with a logo, ratings and cast**: a `meta` answer may carry `logo` (shown instead of the name on the info page), `ratings` (up to 6, from IMDb, Rotten Tomatoes, Letterboxd…) and `cast` (up to 20); an AIOMetadata-style Stremio addon gives them through its `logo`, `imdbRating` and `app_extras.cast`. No new `apiVersion`: older versions ignore them. [Describing other titles](contract.md#meta). - **`stremio:///detail/…` links open the title in Kino** (Seenr's "Open in Stremio" and the like) instead of being ignored. [Stremio addons](stremio.md#detail-links). - **"Tu servidor" 1.5.0**, the reference plugin, now shows everything a server of one's own can use up to apiVersion 7: `tracking`, `segments`, `subtitles` with `file`, `meta` with logo, ratings and cast, and the apiVersion 6 features (section, categories, request signing, copies, the full settings form, a `resolve: true` playlist, `liveSearch` and channel paging). [Example plugins](examples.md#reference-plugin). - **Nuvio compatibility v2**: Kino converts many more Nuvio scrapers. Multi-file scrapers (siblings read from the same repository, at most 16 files and 1 MiB), a Node subset (`path`, `url`, `util`, `events`, `querystring`, `timers`, `buffer` and `http`/`https`/`undici` over `kino.fetch`; `setInterval`, `queueMicrotask`), each scraper's `onSettings` as a settings form synced across devices, and every playable copy offered as a labelled alternative in the Servidor menu (embed pages last). A Kodi-style `url|User-Agent=…` address becomes headers. Peer-to-peer and debrid scrapers are refused ("No compatible"). None of it is for your own plugin: it is what a Nuvio scraper may rely on. [Nuvio scrapers](nuvio.md#runtime). - **Settings form**: a `section`'s `hint` may be up to 300 characters and wraps (`sectionHintMaxChars` in `contract.json`). Builds before 0.9.51 refuse one over 80, so keep it short if your plugin must install on them. [The settings form](settings-form.md#types). - **A `%` in an error's text no longer crashes the app.** Up to 0.9.50, an error from your plugin whose text carried a `%` (a percent-encoded URL such as `?q=Inception%20s` in a "fetch failed") could kill Kino outright. Now an error's text can be anything; if your plugin must run on 0.9.50 and older, keep encoded addresses out of its error messages. [Errors your code can catch](kino-api.md#catch). - **Recomendados** gains Stremio utility addons (subtitles such as OpenSubtitles v3 and Subtis, catalogs such as Cinemeta, TMDB and IMDb) and free, legal channels (Pluto TV, Radios). The list is no longer published on npm: Kino reads it from this repository on GitHub, then from archive.org, then from jsDelivr's copy of the same GitHub file. An addon that plays video is still never recommended. [Stremio addons](stremio.md#subtitles). - **A Stremio addon whose description denies torrents is no longer hidden** ("no incluye streams, torrents ni contenido P2P"); the id and name stay strict. [What Kino refuses](stremio.md#refused). - **Your plugin's card no longer wears a "Kino" badge** (it read as made by Kino); Stremio addons and Nuvio scrapers keep theirs. ## Kino 0.9.50: `apiVersion` 6 { #v0950 } **`apiVersion` 6 = Kino 0.9.50.** The contract (`contract.json`) now says `maxApiVersion` 6 and `kino.apiVersion` reports 6. A manifest with `"apiVersion": 6` is refused by Kino 0.9.49 and older ("Este plugin necesita una versión más nueva de Kino"), so declare 6 only if you use something on this list. Everything a plugin can now change in how Kino shows it is gathered on [Customize your plugin](customize.md). - **Larger and typed sealed secrets**: up to 8,192 bytes, and typed cipher keys (`use: "cipher-key"`) that also work for `des-ede3`. [The manifest](manifest.md#typed-keys). - **`migrate`**: move to your plugin what the person had saved and Kino can no longer open. [Moving saved titles](migrate.md). - **Request-signed streams**: `signing: "request"`, `signContext`, the `sign` export, `resolve(ref, { retry })` and `alternateHosts`. [Signing every request](signed-streams.md). - **The settings form**: the `section`, `status` and `action` types (up to 16, on top of the 12 valued ones), `settingsStatus`, `action` with `clearSettings`, `validateSettings`, the plugin's own tab in Ajustes and syncing across devices. [The settings form](settings-form.md). - **`debug`** and **`telemetry`** (`true` or `"verbose"`), `kino.log.report`, the Registro page, the `KinoPlugin/` and `KinoPlay` logcat tags, and playback metrics. [Logs and telemetry](diagnostics.md). - **Modo debug in every plugin**: every installed plugin (yours, a generated Stremio addon, a Nuvio scraper) has a "Modo debug" switch in its Ajustes tab, with no work on your side: on, its failures show on screen and its Registro can be copied or shared, so a person can send you a screenshot or their Registro. `"debug": true` now only makes the switch on by default; without it the switch starts off. The person's choice survives updates and syncs to their other devices. [Modo debug](diagnostics.md#debug). - **Early rejections are caught**: a `throw` in an `async` function before its first `await` is caught by the caller's `try`/`catch` (or `.catch()`, `Promise.all`), as in Node; only a rejection nobody ever handles still fails the call. Keep awaiting first if your plugin must run on 0.9.49 and older. [The rejection trap](engine-limits.md#rejection-trap). - **`section`, `categories` and `theme`**: a section of your own, a group in Categorías and your colors. [Section, categories and colors](section-theme.md). - **`scopedSearch`**: answer the search inside a "Ver más" page yourself. [The contract](contract.md#scoped-search). - **`kino.error(code, message, { userMessage })`**: your own sentence for the person, with safety rules and attributed to your plugin. [The contract](contract.md#user-message). - **`adult: true` entries** behind the person's 18+ code (they used to be dropped). [18+ content](contract.md#adult). - **Channels in your Home rows** (`kind: "live"` in `home`; they used to be dropped). [Live channels](live-channels.md#home-rows). - **Key pairs in `kino.crypto`**: `generateKeyPair`, `sign`, `verify`, `importKey`, `deriveSharedSecret`. [The kino API](kino-api.md#key-pairs). - **The hidden browser**: `"browser": true` (approved in red, "Puede abrir páginas web ocultas para encontrar el video") and `kino.browser.capture`, which opens an embed in a hidden in-app WebView inside a `resolve` the person started and returns the video requests it made, held so their tokens stay fresh, with the headers and cookies to play them. All its traffic goes through a proxy with a per-capture credential and vetted, pinned IPs; the home network never; one page at a time; cookies and storage wiped. A page that asks for a human ends with `blocked`: **Kino never solves a captcha**. An approved plugin's `resolve` gets 75 s. Also, with `"browser": "pages"` (its own red line), `kino.browser.page`, which reads a page's HTML through the same hidden browser when the site's automatic check passes by itself (never from `categories`; the top document must stay on your hosts, every redirect hop checked, or the read ends `blocked`). [Hidden browser](browser.md). - **`Stream.label` and labelled lazy copies**: name each copy ("Latino · Servidor 1") for the player's new **Servidor** menu, and list copies as `{ label, ref }` that Kino resolves through `resolve(ref)` only when the person picks one, the automatic fallback reaches it (at most 20 s each) or a download's copy choice probes it. A failed pick returns to the copy that was playing. [Labelled and lazy copies](contract.md#lazy-copies). - **`meta`**: describe titles other sources listed (synopsis, images, episodes) when TMDB and AniList have nothing. [Describing other titles](contract.md#meta). No new `apiVersion` (for any plugin): - **`alternatives`** on a `Stream`: up to 8 copies of the same video; Kino moves to the next when one cannot play on the device. [The contract](contract.md#stream). - **Play on the TV from the phone**, **new chapters** of followed series and **"Para ti"** work for titles of any plugin. [What people see](what-people-see.md#v0950). - **Updates**: a check when the app starts (at most every 12 h), a badge for pending ones, and a failed call with a pending update says so. [Publishing](publish.md#updates). - **A signed plugin at two repositories** counts as the same plugin with the same `id` and key. [Signed plugins](signed.md#two-addresses). - **`subtitles` export**: answer the player's "Buscar subtítulos en línea" for any title Kino knows by IMDb or TMDB id, alongside your videos or as a subtitle provider (`"capabilities": ["subtitles"]` alone). [Subtitles for any title](contract.md#subtitles). - **`Stream.skip`**: where this file's opening and ending are, for "Saltar intro" / "Saltar outro"; yours win over AniSkip, a hand correction wins over yours. [The contract](contract.md#stream). - **The manifest's `categories`** (`movies`, `series`, `anime`, `live`, `radio`, `subtitles`, `utilities`, `adult`): the plugin marketplace's category chips. [The manifest](manifest.md). - **Settings form**: `settingsStatus()` is asked again after every action, so `refresh: true` is no longer needed. [The settings form](settings-form.md#ui-types). - **Logs**: only a plugin that declares `telemetry` sends `kino.log` lines with a failure, recommended or not; for now there is no switch to turn it off. [Logs and telemetry](diagnostics.md#telemetry). - **"De la comunidad"** is its own tab of the Plugins screen. [Publishing](publish.md#get-found). - **Stremio subtitle addons** (OpenSubtitles v3, translators such as GTSubs) install as subtitle providers; machine translations show as "Español (traducido)". Nothing for you to write. [Stremio addons](stremio.md#subtitles). - **Reserved ids**: `live`, `local`, `unknown`, `plugin`, `own`, `subtitle-keys`, `subtitle-prefs` (the list changed: older versions reserve a few more, so if one says "El id … está reservado por Kino", pick another). - **Claims and takedowns of community plugins**: [`community-blocklist.json`](claims.md). - **Sending to the TV**: every HLS goes through the phone; a file without `headers` goes direct and, if the TV fails it, through the phone. [Sending to the TV](what-people-see.md#cast). - **`genre`** on a Home row, a live category or a playlist (`peliculas`, `series`, `anime`, `infantil`, `documentales`, `deportes`, `noticias`, `musica`, `entretenimiento`, `otros`): Categorías groups the browsable rows of every plugin by it, and En vivo filters by it across providers. Optional; without it Kino guesses from the title. [The contract](contract.md#returns). - **`streamHeaders`** on a playlist: the `User-Agent` or `Referer` the player sends for every channel of the list, kept apart from the list's own download `headers`. [Live channels](live-channels.md#live-contract). - **A failing synchronous `kino.*` call is catchable** (a full `kino.storage`, a `kino.crypto` error): your `try`/`catch` gets an ordinary `Error`; Kino 0.9.49 ended the whole call there. [Errors your code can catch](kino-api.md#catch). - **After a Kino upgrade**, plugin updates that wait for approval are installed once, from the plugin's own address, with a one-time "Se actualizaron tus plugins" notice listing what each may do now. [Publishing](publish.md#updates). - **The settings form, documented whole**: every field type, attribute and default, with a complete example. [The settings form](settings-form.md#types). (Kino builds before `genre` and `streamHeaders` ignore them; the exact version that first shipped each was not checked.) Also in this version (documented earlier on this page as "next version"): - **Install from a manifest URL.** People can paste the `https` URL of a `kino-plugin.json` on any public server, not only a GitHub `owner/repo`. Such a `url:` install reads `entry` and `icon` next to the manifest, cannot use sealed `secrets`, always counts as unsigned, and never appears in "De la comunidad" (discovery still uses the GitHub topic). A `kino-plugin.json` URL on GitHub, raw.githubusercontent.com or jsDelivr (`cdn.jsdelivr.net/gh/owner/repo@/…`; `@latest` is the default branch, a version range is refused) becomes `owner/repo` as before. [Installing from a manifest URL](index.md#manifest-url), [Publishing](publish.md#manifest-url). - **`liveSearch`, a new optional export for live channels** (no new `apiVersion`: it stays 3 with `channels`). Kino asks it from En vivo's search while some of your channels were never listed; it returns channels like a `liveChannels` page, at most 100 kept, from 2 typed characters, 15 s. [Live channels](live-channels.md#live-search). - **Big live catalogs keep paging.** `liveChannels` gets 10 pages at first, then 5 more each time the person scrolls near the end, up to 10,000 channels (200 pages) per category. Older versions stop at 10 pages. Test with `node sdk/run.mjs . live search `. - **M3U lists** may be UTF-8, Latin-1 or UTF-16; `#EXTINF` attributes may be single-quoted or bare; a list over 20 MB or a guide over 50 MB is cut at its last whole line instead of refused. [Live channels](live-channels.md#live-contract). - **Stremio addons** can be installed by people from the same field (Kino generates the plugin; nothing for you to write). Their `resolve`, like a converted Nuvio scraper's, gets 75 s. What is supported, what is refused (torrents and P2P, always) and how to make an addon work well: [Stremio addons](stremio.md). - `ditu` is no longer a reserved plugin `id` (older versions still refuse it, so avoid it). ## Kino 0.9.46 to 0.9.49 { #v0946 } - **A leading `./` in `entry` and `icon` is accepted.** Kino drops it and installs the plugin. **Kino 0.9.45 and older still refuse it** (`El campo "entry" debe ser una ruta relativa a un archivo .js`), so keep writing `"plugin.js"` and `"icon.png"`. The kit's `validate.mjs` refuses `"./plugin.js"` for that reason. [Details](manifest.md#entry-dot-slash). - **Your logs help when a recommended plugin fails.** For a plugin in Kino's recommended catalog, a failed call's last 30 `kino.log` lines (scrubbed, 2 KB) go with the error report as `plugin_log`. Log steps and statuses, never what the person typed or a secret. [`kino.log`](kino-api.md#log). - A live channel never shows a download button, even in a plugin that declares `download`. !!! note "About the version of each item" These items are in the builds released as 0.9.46 to 0.9.49; the exact version in which each one first appeared was not checked. ## Kino 0.9.45 { #v0945 } **Contract (`contract.json`, `maxApiVersion` 5):** - **Signed plugins, `apiVersion` 5.** Optional author signature in `kino-plugin.json` (`node sdk/seal.mjs --keygen`, `--sign`; `validate.mjs` checks it), key pinned at first install, "Firmado por su autor" on the consent sheet, "Firmado" badge, the author key in the details. It needs Kino 0.9.45+; older apps refuse an `apiVersion` 5 manifest. Earlier drafts of these docs said 0.9.46: it shipped in **0.9.45**. [Signed plugins](signed.md). - **No maximum number of `hosts`.** The old limit of 20 is gone (only the manifest's 16 KB bounds it). Kino 0.9.44 and older still refuse more than 20, and the kit warns about it. [The manifest](manifest.md). - **`kino.apiVersion`** reports 5. **Behavior you may notice (no manifest change):** - **Sending to the TV (Chromecast and DLNA) works for plugin titles.** Direct for mp4/webm and HLS with no `headers`; through the phone when you set `headers`; never for DRM, DASH, progressive MPEG-TS or a format nothing identifies. [Sending to the TV](what-people-see.md#cast). - **Plugins follow the person across their devices** (phone and TV): installs, switches, approvals, settings and passwords (encrypted) sync, and the other device installs your plugin from the same address. [Plugins on other devices](what-people-see.md#sync). - **Install addresses** can also be a `raw.githubusercontent.com/.../kino-plugin.json` (or `manifest.json`, for Nuvio) URL, or a `github.com/.../blob/...` one; a ref that only comes from a pasted URL is not a pin. [Index](index.md), [Nuvio scrapers](nuvio.md). - **HLS downloads**: `EXT-X-DISCONTINUITY` is kept as is unless the format changes at it; a bad key or an empty segment ends as "Este video no se puede descargar"; a retry resumes only with the same content. Downloads stay declarative: `"download"` in `capabilities`, nothing to export. [Downloads](manifest.md#downloads). - **M3U channel headers**: `#EXTHTTP`, `url|User-Agent=...` and `#KODIPROP` headers are read; only `User-Agent`, `Referer`, `Origin` and `Cookie` are kept. [Live channels](live-channels.md). - **`kino.fetch`**: refused redirect hops count toward the 60-request limit, at most 6 fetches in flight, at most 3 host questions per call, IPv6 forms of private addresses refused, no device proxy. A plugin converted from a Nuvio scraper gets 250 requests and a 75 s `resolve`. [Limits](engine-limits.md). !!! note "About the version of each item" The contract file states the version only for signed plugins and the host limit (0.9.45). The other items above are in the build that was released as 0.9.45; the exact version in which each one first appeared was not checked. ## Already there before 0.9.45 { #earlier } `streamHosts: "any"` (apiVersion 4), `liveStreamHosts: "any"` (apiVersion 3 plus the `channels` capability), `fetchHosts: "any"` (written by Kino into converted Nuvio scrapers only, never for your plugin), and the question Kino asks the person the first time a stream uses a host you did not declare. They are documented in [the manifest](manifest.md#stream-hosts), [live channels](live-channels.md) and [the contract](contract.md#forgotten-host). # The manifest `kino-plugin.json`, at most 16 KB: ```json { "id": "archive-org", "name": "Internet Archive", "version": "1.0.0", "apiVersion": 1, "entry": "plugin.js", "description": "Películas de dominio público y televisión clásica de archive.org", "author": "kinotvapp", "homepage": "https://github.com/kinotvapp/kino-plugin-archive", "hosts": ["archive.org", "*.archive.org"], "capabilities": ["search", "home", "browse", "episodes", "resolve"], "color": "#E0A030", "icon": "icon.png" } ``` If a rule below is broken, Kino refuses to install the plugin and shows a message in Spanish that names the field. | Field | Rule | | --- | --- | | `id` | Required. `^[a-z0-9][a-z0-9-]{1,39}$` (2 to 40 lowercase letters, digits or hyphens, not starting with a hyphen). Not one of `live`, `local`, `unknown`, `plugin`, `own`, `subtitle-keys`, `subtitle-prefs` (Kino's own names; older versions reserve a few more: if one says "El id … está reservado por Kino", pick another). It is the plugin's identity: never change it once people have installed it. | | `name` | Required. 1 to 40 characters. | | `version` | Required. `MAJOR.MINOR.PATCH` and nothing else (no `-beta`, no `+build`), each number up to 6 digits and without leading zeros. | | `apiVersion` | Required. `1` to `7`. A higher number than Kino supports is refused with "Este plugin necesita una versión más nueva de Kino". Declare the lowest number that has what you use, so your plugin also runs on older Kino builds: `2` for `download`, `drm`, `insecureHttp`, `"hosts": []` or `live` items; `3` for `channels`/`liveStreamHosts`; `4` for a `list` setting, `streamHosts` or `secrets`; `5` only for a [signed plugin](signed.md) (Kino 0.9.45+); `6` (Kino 0.9.50+) for anything on the [apiVersion 6 list](changelog.md#v0950): typed or larger sealed secrets, `migrate`, `scopedSearch`, request-signed streams, the settings form's `section`/`status`/`action`, `debug`, `telemetry`, `section`, `categories`, `theme`, `userMessage`, `adult` entries, channels in Home rows, `kino.crypto`'s key-pair calls, the `meta` capability, [`"browser": true`](browser.md) with `kino.browser.capture` (and `"browser": "pages"` with `kino.browser.page` too), and [labelled and lazy copies](contract.md#lazy-copies); `7` (Kino 0.9.51+) for the [`tracking`](contract.md#tracking) and [`segments`](contract.md#segments) capabilities. | | `entry` | Required. Relative path of the JavaScript file: letters, digits, `.`, `_`, `-` and `/` only, no `..`, at most 200 characters, ends in `.js`. The file is at most 1 MB. **Write `"plugin.js"`, never `"./plugin.js"`**: Kino 0.9.45 and older refuse a leading `./` (see the warning [below](#entry-dot-slash)). | | `signature` | Optional, from apiVersion 5: `{ "authorKey": "<64 hex>", "value": "<128 hex>" }`, written by `node sdk/seal.mjs --sign`: your signature over the entry file. Needs Kino 0.9.45+. See [Signed plugins](signed.md). Below apiVersion 5 it is ignored. | | `hosts` | Required. at least 1 entry, with no upper limit from Kino 0.9.45 (only the manifest's 16 KB bounds it). Kino 0.9.44 and older refuse more than 20, so with more than 20 hosts the kit warns "Más de 20 hosts: Kino 0.9.44 o anterior rechaza este plugin; necesita Kino 0.9.45 o superior". From apiVersion 2 it may be empty, `[]`, when the plugin has a `url` setting: see [The person's own servers](#own-servers)); each a lowercase DNS name (`archive.org`), `*.` plus a DNS name (`*.archive.org`), or (apiVersion 2 only) an object `{ "host": "…", "insecureHttp": true }` (below). Host names only: no scheme, port or path. No bare `*`, no IP addresses, no `localhost`, nothing ending in `.local`, `.lan`, `.internal`, `.localhost` or `.home.arpa`, and at least one dot. **`*.x` covers subdomains only, not `x` itself**: if you need both, list both. The hosts a person approves later, one by one, while your plugin runs ([A host you forgot](contract.md#forgotten-host)) are not counted against the manifest. | | `capabilities` | Required. A subset of `search`, `home`, `browse`, `episodes`, `resolve`, `download`, `drm`, `channels`, `migrate`, `scopedSearch`, `meta`, `subtitles`, `tracking`, `segments`. Must include `resolve` and at least one of `search` or `home`. `search`, `home`, `browse`, `episodes` and `resolve` must each be an exported function of the entry file, or the install fails with "El plugin no carga: le falta ...". `download` and `drm` need `apiVersion: 2` and are declarative flags instead — the app acts on them, not your code, so nothing extra to export; declaring one shows its consent line ("Puede descargar videos para verlos sin conexión" / "Reproduce video protegido (DRM)") and needs approval again on an update that adds it. `download` gives your titles offline downloads (see [Downloads](#downloads)); `drm` lets a `Stream` carry a Widevine license (see [A Widevine-protected stream](cookbook.md#widevine)). `channels` needs `apiVersion: 3` and the exports `liveCategories` and `liveChannels` (see [Channels in the En vivo tab](live-channels.md#en-vivo-tab)). `migrate` needs `apiVersion: 6` and the export `migrate`; declaring it shows "Revisar lo que tienes guardado (biblioteca, historial, favoritos) para pasarlo a este plugin" and needs approval again on an update that adds it (see [Moving saved titles](migrate.md)). `scopedSearch` needs `apiVersion: 6` and `search` (refused otherwise with "La capacidad \"scopedSearch\" necesita también \"search\""); it exports nothing of its own: your `search` gets `within` when the person searches inside a "Ver más" page (see [Searching inside a "Ver más" page](contract.md#scoped-search)). `meta` needs `apiVersion: 6` and the export `meta`, no consent line (see [Describing other titles](contract.md#meta)). `subtitles` needs the export `subtitles`; `["subtitles"]` alone is a subtitle provider; a plugin that declares only `subtitles`, `tracking` and/or `segments` (a subtitle provider, a tracker, a segment source or a mix of them) is the one exception to "`resolve` and `search` or `home`" (see [Subtitles for any title](contract.md#subtitles)). `tracking` needs `apiVersion: 7` and the export `track`; declaring it shows a red line naming your hosts ("Le contará a seenr.app qué ves y cuándo lo terminas") and needs approval again on an update that adds it, even when Kino approves other updates on its own (see [Telling a tracker what the person watches](contract.md#tracking)). `segments` needs `apiVersion: 7` and the export `segments`; declaring it shows "Agrega el botón para saltar la intro y los créditos", not in red and with no approval of its own (see [Where the intro and credits are](contract.md#segments)). | | `settings` | Optional. What the person fills in on your plugin's "Configurar" screen: see below. | | `permissions` | Optional. A list of names from the closed list in `contract.json`. **The list is empty in this version**: any name is refused with "permiso desconocido: …". It exists so a later version can add permissions (each one shown on the consent screen) without a new `apiVersion`. | | `color` | Optional `#RRGGBB`: the accent of your plugin's tab and chips. A neutral color by default. | | `icon` | Optional relative path to a square `.png`, at most 128 KB. An icon that is missing or too big is skipped without failing the install. | | `discoverable` | Optional `true` or `false` (default `true`), at every `apiVersion`. `false` keeps the plugin out of Kino's community search (see [Get found](publish.md#get-found)); people can still install it by typing its address. Any other value is refused with "El campo \"discoverable\" debe ser true o false". | | `categories` | Optional, at every `apiVersion`: what your plugin offers, for the category chips of Kino's plugin marketplace (Recomendados, "De la comunidad", "Elige tus fuentes"). A list without repeats of `movies`, `series`, `anime`, `live`, `radio`, `subtitles`, `utilities`, `adult`. When present it replaces Kino's guess from your capabilities (`channels` is `live`, `episodes` is `series`, anything that lists and plays is `movies`); an `adult` plugin's chip only shows while the person's 18+ code is unlocked. Any other value is refused with "El campo \"categories\" debe ser una lista sin repetidos de: movies, series, anime, live, radio, subtitles, utilities, adult". Not the same as the `categories()` export (tiles inside Kino's Categorías, see [Section, categories and colors](section-theme.md)). | | `browser` | Optional `true`, `false` or `"pages"`, from `apiVersion` 6; ignored below. `true` lets the plugin open pages in a hidden web view on the device with `kino.browser.capture` (in `resolve`), shown in red as "Puede abrir páginas web ocultas para encontrar el video"; `"pages"` adds `kino.browser.page`, shown as "Puede abrir páginas web ocultas para mostrar contenido y encontrar el video". An update that adds it, or moves `true` to `"pages"`, waits for approval again; an approved plugin's `resolve` gets 75 s. See [Hidden browser](browser.md). Any other value is refused with "El campo \"browser\" debe ser true, false o \"pages\"". | | `debug` | Optional `true` or `false` (default `false`), from `apiVersion` 6; ignored below. From Kino 0.9.50 every installed plugin has a "Modo debug" switch in its Ajustes tab (errors on screen and a "Registro" the person can copy or share with you); this field only turns it **on by default**. Without it the switch starts off and each person turns it on when they want to send you a report. The person's own choice wins once they touch it (`validate.mjs` notes what `true` means). See [Logs and telemetry](diagnostics.md#debug). Any other value is refused with "El campo \"debug\" debe ser true o false". | | `telemetry` | Optional `true`, `false` or `"verbose"` (default `false`), from `apiVersion` 6; ignored below. Asks to share your plugin's diagnostic lines with Kino's error tracker when a call fails, whatever repository the plugin comes from, and turns on `kino.log.report`. It shows on the consent sheet and an update that newly declares it waits for their approval. For now the lines of every plugin that declares it are always sent (there is no switch yet; a later Kino build adds a per-device one, on by default). See [Logs and telemetry](diagnostics.md#telemetry). Any other value is refused with "El campo \"telemetry\" debe ser true, false o \"verbose\"". | | `section` | Optional `{ "label": "…" }` (1 to 20 characters), from `apiVersion` 6; ignored below. Gives your plugin its own section and requires the `section` export: see [Section, categories and colors](section-theme.md). | | `theme` | Optional object, from `apiVersion` 6; ignored below. Up to five `#RRGGBB` colors: `accent`, `onAccent`, `background`, `surface`, `highlight`; any other key is refused with "El campo \"theme\" tiene un color desconocido". The manifest only checks the format; the readability guardrails run when Kino uses the colors ([Your colors](section-theme.md#theme)). | | `fetchHosts` | Not for your plugin: Kino writes `"fetchHosts": "any"` into the manifests it makes when it converts a Nuvio scraper, and honors it **only** on those (after the person approves it in red), so a converted scraper's `kino.fetch` may reach any public host (see [Nuvio scrapers](nuvio.md)). On a plugin written by hand it is ignored: your `kino.fetch` stays on your `hosts`, and `sdk/validate.mjs` warns "fetchHosts solo tiene efecto en plugins convertidos desde Nuvio; en tu plugin se ignora". From `apiVersion: 4` its only value is `"any"`; any other is refused with "El campo \"fetchHosts\" solo admite \"any\"". Below apiVersion 4 it is ignored. | | `description`, `author`, `homepage` | Optional strings. Trimmed and cut to 300, 60 and 200 characters. Kino shows the name, author, version and description when it asks the person to install. | Other keys are ignored. `hosts` does three jobs: it is what the person approves, it is the only set of sites `kino.fetch` can reach, and it is the set a `Stream`'s URLs must be on: the video, its subtitles, its `audioTracks` and a `drm` block's `licenseUrl` (besides the person's own server). One more field, `liveStreamHosts`, is read only with `"apiVersion": 3` and only for plugins with the `channels` capability: see [Channels from any server](live-channels.md#live-stream-hosts). Live items (apiVersion 2) and the En vivo tab (apiVersion 3) have their own page, [Live channels](live-channels.md). ## Paths in `entry` and `icon`: no leading `./` { #entry-dot-slash } !!! danger "Write plugin.js, never ./plugin.js" `entry` and `icon` are paths relative to the manifest. **Kino 0.9.45 and older refuse a leading `./`**: the install fails with `El campo "entry" debe ser una ruta relativa a un archivo .js` (or the same for `"icon"`), and in the app it just looks like the plugin "does not install". An AI-generated plugin wrote `"./plugin.js"` and failed for about 35 installs. Kino 0.9.46 and later accept a leading `./` and drop it, but people on older versions still exist, so always write `"plugin.js"` and `"icon.png"`, with no `./` (a folder is fine: `"src/plugin.js"`). The kit's `validate.mjs` refuses it with: `Quita el "./" del campo "entry" (por ejemplo "plugin.js"): Kino 0.9.45 y anteriores no instalan el plugin con "./"`. ## Playing from any server (`streamHosts`, apiVersion 4) { #stream-hosts } Some sources serve their video from CDNs whose domains you cannot list (they change, or sit on bare TLDs, which a `*.xyz` entry can never cover). With `"apiVersion": 4` a plugin may add: ```json "apiVersion": 4, "streamHosts": "any" ``` `"any"` is the only value and no capability is needed; an older manifest ignores the field. It lets **what the plugin plays** be on **any public host**, over `http` or `https`: - a movie or an episode: exactly the rule of the [broad video permission](contract.md#broad-video) -- the same rule, asked for by you up front instead of granted by the person. In the player, the `url` `resolve` returns, everything its manifest names, every redirect hop, **and** the `subtitles` and `audioTracks` you return may be on any public host, and no video host is ever asked about. A [download](#downloads) of that movie or episode follows the same rule; - a live channel: the rule of [`liveStreamHosts: "any"`](live-channels.md#live-stream-hosts) (the stream, its manifest and redirects; your `subtitles` and `audioTracks` stay on your `hosts`). It changes nothing else: `kino.fetch` (and so every [sealed secret](#secrets)), images and DRM license servers stay on your declared `hosts`, and local or private addresses (and public names that resolve into the home network) are still refused. The consent screen shows it in red ("Puede reproducir video desde cualquier servidor que indique"), and an update that adds it waits for the person to approve again. Prefer listing the real domains when you can: people trust a narrow list more. !!! note "`fetchHosts` is not for you" Plugins that Kino builds itself from a Nuvio scraper ([Nuvio scrapers](nuvio.md)) carry one more field, `"fetchHosts": "any"`, which lets their `kino.fetch` reach any public server. Kino honours it **only** on those converted installs. In a plugin you write, `"any"` is accepted and ignored (the consent screen does not show it and `kino.fetch` stays on your `hosts`), and any other value is refused. Declare your hosts instead. ## Sealed secrets (apiVersion 4) { #secrets } A plugin that ships a fixed key (an API token baked into a site's own client, a per-tenant secret its author owns) can seal it instead of writing it into the manifest as plain text: ``` node sdk/seal.mjs --repo owner/repo --name apiKey ``` (`owner/repo/path` for a plugin that lives in a subfolder.) `--repo` follows the same rules as the address people install from: a trailing `/` and a `.git` are dropped, but a URL (`https://github.com/...`) and an `@ref` are refused rather than guessed at. The value is read from a hidden prompt or piped on stdin -- never as a command-line argument, which would land in shell history. It must be 1 to 4,096 bytes (UTF-8) -- up to 8,192 with `"apiVersion": 6`; the tool prints one line, `kino-sealed:v1:...`, to paste into the manifest: ```json "apiVersion": 4, "secrets": { "apiKey": "kino-sealed:v1:AbC123..." } ``` - Up to 16 secrets; each name matches `^[A-Za-z][A-Za-z0-9_]{0,31}$`. `secrets` needs `"apiVersion": 4`; below that the field is ignored (the plugin installs with no secrets, and `kino.secret` throws for every name), and a Kino too old for apiVersion 4 refuses the whole install with "Este plugin necesita una versión más nueva de Kino". - A seal is bound to the repository (and subfolder) you passed `seal.mjs`, lowercased, **never to a ref**. At install and at every update Kino opens each seal once against the address the person is installing from, only to check it belongs there; each run of the plugin opens them again, in memory, for that run alone. A seal made for a different repository, path or name, or one that was corrupted, is refused with "Los datos sellados de este plugin no son para este repositorio o están dañados"; a build that cannot open seals at all refuses with "Este Kino no puede abrir datos sellados". - **Only from the default branch, never an explicit `@ref`.** GitHub serves any commit reachable in a repository's fork network -- a fork's or a pull request's -- through the parent repository's own address, and not only for an obvious SHA: a short hex prefix or a git-describe ref resolves the same way. So `owner/repo@` can be someone else's manifest, with their own `hosts`, while the seal still reads `owner/repo`. A plugin with secrets installed or updated with any explicit `@ref` -- branch, tag, commit -- is refused with "Los datos sellados solo funcionan si instalas el plugin desde su rama principal, sin @rama", and a run at such an address gets no secrets. - A seal trusts the repository's *name*: if its owner is renamed or deleted and someone else registers that name, their repository opens your seals. Seal again for the new name, and rotate the value if the old one was worth protecting. - Declaring any secret adds "Usa datos sellados por su autor" to the consent sheet; an update that brings secrets to a plugin that had none asks again, exactly like a new host. Adding, changing or removing a secret in a plugin that already declared some does not. ### Typed cipher keys (apiVersion 6) { #typed-keys } A sealed value used as the key of `kino.crypto.encrypt`/`decrypt` can be declared as a key, so Kino reads its bytes with an encoding fixed in the manifest instead of whatever `keyEncoding` the code passes. That is what lets a sealed key work for `des-ede3-*` too (an untyped sealed key stays AES-only): node sdk/seal.mjs --repo owner/repo --name portalKey --use cipher-key --encoding hex prints one JSON line to paste as the secret's value: ```json "apiVersion": 6, "secrets": { "portalKey": { "seal": "kino-sealed:v1:...", "use": "cipher-key", "encoding": "hex" } } ``` - `use` must be `"cipher-key"`; `encoding` is `"hex"` or `"base64"`; no other field is allowed. - The value must decode, under that encoding, to a 16, 24 or 32-byte key: `seal.mjs` refuses anything else, and Kino refuses the install with "El secreto "portalKey" debe ser una clave de 16, 24 o 32 bytes". - `kino.secret("portalKey")` works as the **whole** `key` of any `encrypt`/`decrypt`; the `keyEncoding` you pass is ignored. It is refused, with "no se puede usar un dato sellado aquí", as an HMAC key, a `pbkdf2` input, anywhere in `data`/`iv`/`aad`, and anywhere in a `kino.fetch` request (the URL, header names or values, a text, JSON or form body): a typed key is for `kino.crypto` only and never goes on the wire. - Below apiVersion 6 an object here is not a seal: the manifest is refused. - The SDK kit simulates all of this from the plain value in `.kino-secrets.json`; the key's bytes, in hex (either case) or base64, are also redacted from anything a server sends back. **What this protects, and what it does not.** This is obfuscation, not secrecy: the private key that opens a seal ships inside every copy of Kino. Sealing a value keeps it out of your manifest and your repository's history; it does not stop someone from pulling Kino apart and opening the seal themselves, any more than it stops the site you call from seeing the plain value on its own end. Don't bother sealing a value that is already public (a key already sitting in that site's own player JavaScript gains nothing from being sealed in yours), and never seal **the person's** credentials: those belong in a `password` [setting](#settings). **Using it.** [`kino.secret(name)`](kino-api.md#secret) answers a marker, not the value; Kino swaps the marker for the real value only inside `kino.fetch`, toward your manifest's own `hosts` over `https`, and redacts the value from everything that comes back to your code. The full rules (where the marker is swapped, `kino.crypto`, redaction) are on [The `kino` API](kino-api.md#secret); testing with the Node kit is on [Test it locally](test-locally.md#secrets). ## Settings { #settings } `settings` is a list of at most 12 entries that hold a value (plus, from apiVersion 6, at most 16 that only show or do something: see [The settings form](settings-form.md)). Each one becomes a field on the plugin's "Configurar" screen (Ajustes ▸ Plugins), and your code reads its value with `kino.config.get(key)`: ```json "settings": [ { "key": "server", "label": "Servidor", "type": "url", "required": true, "hint": "http://192.168.1.10:8096" }, { "key": "user", "label": "Usuario", "type": "text", "required": true }, { "key": "password", "label": "Contraseña", "type": "password", "required": true }, { "key": "quality", "label": "Calidad", "type": "select", "default": "hd", "options": [{ "value": "hd", "label": "Alta" }, { "value": "sd", "label": "Normal" }] }, { "key": "subs", "label": "Subtítulos", "type": "toggle", "default": true } ] ``` | type | value | can be `required` | can have a `default` | longest value | | --- | --- | --- | --- | --- | | `text` | text | yes | yes | 500 characters | | `url` | text | yes | no (use `hint` for an example) | 2,048 characters | | `password` | text | yes | yes | 500 characters | | `toggle` | `true` / `false` | no (always has a value) | yes | — | | `select` | one of the `options` values | no (always has a value) | yes | — | | `list` | a list of entries, each an object of the list's `fields` | yes | no | — | | `section` | none (apiVersion 6) | no (holds no value) | no | — | | `status` | none (apiVersion 6) | no (holds no value) | no | — | | `action` | none (apiVersion 6) | no (holds no value) | no | — | - `key` matches `^[a-z][a-zA-Z0-9_]{0,31}$` and is unique; `label` is 1 to 40 characters; `hint` (the example under the field) at most 80. - `select` needs `options` (1 to 20, each a `value` and a `label` of at most 40 characters); its `default` must be one of the values. A `toggle` default is `true` or `false`. - `list` (apiVersion 4) is a list the person builds with an "Agregar" button: each entry is a text line with an "Editar" button, and the dialog to add or edit one shows the list's `fields` (1 to 4, each with a `key`, `label`, a `type` of `text` or `url`, and optionally `hint` and `required`; no `default`). `max` (1 to 50, default 20) caps the entries. `kino.config.get(key)` returns an array of objects `{ [field.key]: string }`, trimmed, without all-blank entries; an empty list is `undefined`. A `required` list needs at least one entry. The `url` fields of the entries become hosts your plugin may reach, exactly like a `url` setting (so `hosts` may be `[]`). ```json { "key": "sources", "label": "Direcciones", "type": "list", "max": 30, "fields": [ { "key": "url", "label": "Dirección", "type": "url", "required": true }, { "key": "category", "label": "Categoría", "type": "text" } ] } ``` - **A `url` setting has no `default`**: a server the person types becomes a host your plugin may reach, so only the person can choose it. A manifest with a `default` on a `url` setting is refused; put an example address in `hint` instead. - **A `required` setting with no value** stops every call to your plugin before it runs: the plugin shows "Falta configurar", its Home rows are not asked for, and anything the person opens from it says "Configura en Ajustes ▸ Plugins" with a button to that screen. - **Passwords** are stored encrypted on the device. Your code can read them (it has to send them), which is why the consent screen says "Este plugin usa tu usuario y contraseña". Kino never writes any setting to its log; do not do it yourself. - **Changing any setting** closes your plugin's sandbox, deletes its cookies and its cached Home rows, so the next call starts a new session with the new values. `kino.storage` is **not** cleared: if you keep a token there, key it by the user and server it belongs to (the cookbook does). - Uninstalling deletes the settings, passwords included. - From apiVersion 6 the form can also show a status, run action buttons and check values before saving, and settings travel between the person's devices: see [The settings form](settings-form.md). ## The person's own servers { #own-servers } A `url` setting is how a plugin talks to a server that is not on the internet: a media server at home, for instance. **The server the person types becomes one more host your plugin may reach**, exactly as typed: its scheme (`http` is allowed here, because home servers rarely have a certificate), host and port. Nothing else on that machine or network is allowed, redirects from it may only go to the same server or to your declared `hosts`, and your stream and image URLs may point at it. The consent screen warns "Se conectará a los servidores que escribas en su configuración", and Ajustes lists what each plugin reaches ("Se conectará a: …"). A plugin whose **only** reach is that server (it never calls a site of its own) declares `"hosts": []` from `"apiVersion": 2`, as long as it has at least one `url` setting: the consent screen then lists no host at all, only the line about the servers the person types, and Ajustes says "Se conectará solo a los servidores que escribas en su configuración" until one is typed. An empty `hosts` with no `url` setting is refused (`El campo "hosts" solo puede estar vacío si el plugin tiene un ajuste de tipo "url"`), and on `"apiVersion": 1` it is refused as always. (Kino never lists a host under the reserved `.invalid` domain either, the placeholder older manifests used.) Only the scheme, host and port count: any path on that server is reachable, and `kino.config.get` returns the value as typed. Kino refuses, with a message under the field, a value that is not an `http`/`https` URL, or whose host is `localhost`, a loopback address (`127.0.0.1`, `::1`), a link-local one (`169.254.x.x`, `fe80::`) or `0.0.0.0`. Addresses in the person's own network (`192.168.x.x`, `10.x.x.x`, a `.local` name) are allowed: that is the point. ## Declaring an insecure host (apiVersion 2) { #insecure-host } A `hosts` entry can also be an object, for a site of yours that has no certificate: ```json "hosts": ["archive.org", { "host": "cdn.example.org", "insecureHttp": true }] ``` This needs `"apiVersion": 2`. `insecureHttp: true` is the only thing it can carry beyond `host`, and it marks the only *declared* hosts (not the person's own server, above) allowed over plain `http`: `kino.fetch`, a `Stream`'s `url`, its `subtitles`, its `audioTracks` and a `drm` block's `licenseUrl` all accept `http://cdn.example.org/…` once it is declared this way, and every redirect hop is judged by the same rule. Every other declared host stays https-only, `https` keeps working on the insecure one, and the host is matched exactly: `sub.cdn.example.org` is not covered. The same rules as a plain string still apply (public DNS name, no `*`, no IP, nothing private/LAN; a name that resolves into the person's own network is still refused) plus one more: **no `*.` wildcard** — an insecure host is named exactly. The consent screen shows it in red, "Conexión sin cifrar con cdn.example.org", and an update that newly marks a host this way waits for approval like a brand new host would. See [A site of yours without a certificate](cookbook.md#insecure-site). ## Downloads (apiVersion 2) { #downloads } Declare `"download"` in `capabilities` (with `"apiVersion": 2`) and Kino offers your titles for offline viewing: "Descargar" on the info page and "Guardar en el dispositivo" in the library, on phones (Kino never downloads on a TV). Nothing extra to export. When the person saves a title, Kino calls your `resolve(ref)` when the download actually runs, exactly as playing would, and saves the `Stream` as **one file**, with your `headers` on every request, through the same host gate as the player for that stream: https on your `hosts` or the person's own server -- or any public host when your manifest has [`streamHosts: "any"`](#stream-hosts) or the person gave your plugin the [broad video permission](contract.md#broad-video) -- every redirect hop checked, never the home network. A download never asks about a host. A server the gate refuses ends the download for good (no "Reintentar": it is not network trouble), with a sentence that names the server; when it is one that playing would have asked about, the sentence says to play the title once to approve it, and after that a new download works. Your `subtitles` are saved next to it. `audioTracks` are **not** saved: the offline copy has only the audio inside the video file, so a source that dubs through separate tracks is heard in its main audio when offline. What downloads, and what does not: - A progressive file (`mp4`, `mkv`, `webm`, `ts`, …) downloads. The saved file takes its extension from your `mime` when you give one, else from the URL, else `mp4`; the player sniffs the bytes anyway. - An HLS VOD stream (`.m3u8`, an `mpegurl` `mime`, or a response that turns out to be a playlist) downloads too, saved as one file: MPEG-TS segments become a `.ts`, fMP4 ones (`EXT-X-MAP`) an `.mp4`. From a master playlist Kino takes the highest variant up to 1080p whose audio is inside the video; AES-128 keys and byte ranges are handled, and your `headers` go on the playlists, the key and every segment. At an `EXT-X-DISCONTINUITY` the segments are kept as they are (the timestamps restart there and the player follows; seeking right at the splice may land a little off), unless the video or audio format changes at it (e.g. H.264 → HEVC, a track added or gone): that is refused like below. Metadata streams (ID3, SCTE-35, private data) don't count as a change, and the same formats arriving under other stream numbers (PIDs) after the splice are written back under the first segment's ones, so the saved `.ts` plays to the end. A retry resumes at the first missing segment when it gets the same content (same variant, same first bytes), even from another CDN; different content starts over. - What cannot be saved ends as "Este video no se puede descargar", a final state with no "Reintentar" (it would refuse the same way) that the person can only remove, and the partial file is deleted: a DASH or Smooth manifest (`.mpd`, `application/dash+xml`, …), a live HLS playlist (no `EXT-X-ENDLIST`), SAMPLE-AES or any DRM key, a key that is not 16 bytes or does not decrypt, an empty segment (asked for 3 times first), a master whose every video variant needs a separate audio rendition (Kino does not save a silent video), a DRM-protected stream and a live channel (a live channel never even shows a download button, also in a plugin that declares both `channels` and `download`). Subtitle renditions inside the playlist are not saved (your `subtitles` are). There is no separate "resolve for download" call: if your source offers DASH and also a file or HLS, prefer those, or accept that those titles play but do not download. - The queue downloads one title at a time, so a `ref` may wait a while before `resolve` is called: keep something stable in it and look the fresh link up inside `resolve` (as recommended in [The contract](contract.md#id-and-ref)). A retry resumes the partial file even when your URL changed. A `resolve` the queue makes that times out fails that download only: it does not count toward the three timeouts in a row that switch your plugin off ("No responde"), which only calls made for the person on screen do. - A plugin that is disabled, waiting for its settings, or uninstalled downloads nothing: its titles show no download button, and a title already queued fails with "Este plugin ya no puede descargar videos". Files already downloaded keep playing offline and stay removable in Descargas, whatever happens to the plugin afterwards. Declaring `download` shows "Puede descargar videos para verlos sin conexión" on the consent sheet, and an update that newly declares it waits for the person's approval ([Publishing](publish.md#updates)). # The contract (apiVersion 1 to 7) Your entry file is one ES module that exports one `async` function for each capability you declared, and nothing is called that you did not declare: ```js export async function search(query) { /* -> Item[] or Page */ } export async function home() { /* -> Row[] */ } export async function browse(ref, cursor) { /* -> Page */ } export async function episodes(ref) { /* -> { series?: SeriesInfo, episodes: Episode[], seasons?: Season[] } */ } export async function resolve(ref) { /* -> Stream */ } export async function liveCategories() { /* -> Array or Playlist */ } export async function liveChannels({ categoryId, cursor }) { /* -> { items: LiveChannel[], next? } */ } export async function guide({ channelIds, from, to }) { /* -> GuideEntry[] */ } export async function liveSearch({ query }) { /* -> { items: LiveChannel[] } */ } export async function subtitles({ imdbId, tmdbId, kind, season, episode, title, year, languages, file }) { /* -> { lang, url, format?, label?, translated? }[] */ } export async function track(event) { /* -> { ok: true } or { skipped: true }, or throw kino.error(code) (apiVersion 7) */ } export async function segments(query) { /* -> { type, startMs, endMs }[] (apiVersion 7) */ } ``` From `"apiVersion": 6` (Kino 0.9.50) there are more optional exports, each with its own page: `sign` ([Signing every request](signed-streams.md)), `migrate` ([Moving saved titles](migrate.md)), `section` and `categories` ([Section, categories and colors](section-theme.md)), and `settingsStatus`, `action` and `validateSettings` ([The settings form](settings-form.md)), and `meta` ([Describing other titles](#meta)). `subtitles` ([Subtitles for any title](#subtitles)) needs no new `apiVersion`. From `"apiVersion": 7` (Kino 0.9.51): `track` ([Telling a tracker what the person watches](#tracking)) and `segments` ([Where the intro and credits are](#segments)). ([`kino.d.ts`](reference/index.md) has the same shapes as TypeScript declarations.) Use named exports (`export async function ...`). Data crosses into and out of your code as JSON, so return plain data: strings, numbers, booleans, arrays and objects. The live-channel functions (`liveCategories`, `liveChannels`, and the optional `guide` and `liveSearch`, apiVersion 3) have their arguments and rules on [Live channels](live-channels.md#live-contract). ## Arguments { #arguments } - `search(query)` gets `{ q, type, season, episode, tmdbId, year, originalTitle, altTitles, cursor }`: - `q` is the text the person typed (it can be empty; return `[]`). - `type` is `"movie"` or `"series"` when Kino leans towards that kind, and `"any"` otherwise. It is a hint, not a filter: Kino derives it from TMDB's movie/tv split, which rarely lines up with a source's own catalogue, and a title can exist as both. Return every plausible match; use `type` at most to put the kind it names first. - `season` and `episode` are `0` unless Kino is looking for a specific episode; `tmdbId` and `year` are `0` when unknown. - `originalTitle` is TMDB's original title when it differs from `q` (else `""`), and `altTitles` up to 5 other titles Kino knows for the work (each at most 200 characters): try them when `q` finds nothing on a source that names things in another language. - `cursor` is `null`, except when the person asked for more results and your previous page said where to continue (see `Page` below). - `within` (apiVersion 6, only for a plugin that declares `scopedSearch`) is present when the person searches inside one of your "Ver más" pages: see [Searching inside a "Ver más" page](#scoped-search). - `home()` gets `null`. - `browse(ref, cursor)` gets the `ref` of one of your Home rows (or a `ref` a previous page gave), and `cursor` `null` for the first page or the `next` of the page before. - `episodes(ref)` gets the `ref` of a `series` item, as you returned it. - `resolve(ref, options)` gets the `ref` of a `movie` item, the `ref` of an episode, or (apiVersion 2) the `ref` of a `live` item. `options` is `undefined` on a normal call; only an apiVersion 6 plugin with a [request-signed](signed-streams.md#retry) stream ever gets it, as `{ retry }`. From apiVersion 6 it also gets the `ref` of one of your [lazy copies](#lazy-copies), when that copy is needed. - `subtitles(arg)` gets `{ imdbId?, tmdbId?, kind, season?, episode?, title?, year?, languages, file? }` (see [Subtitles for any title](#subtitles)) and returns an array shaped like a `Stream`'s `subtitles`, each entry with an optional `label` and `translated`. ## What you return { #returns } ```ts Item = { id: string, ref: string, title: string, kind: "movie" | "series" | "live", year?: string, poster?: string, backdrop?: string, overview?: string, lang?: string, quality?: string, originalTitle?: string, genres?: string[], rating?: number, runtimeMinutes?: number, ids?: { tmdb?: number, imdb?: string }, badges?: string[], adult?: boolean } Row = { id: string, title: string, items: Item[], ref?: string, genre?: Genre } Genre = "peliculas" | "series" | "anime" | "infantil" | "documentales" | "deportes" | "noticias" | "musica" | "entretenimiento" | "otros" Page = { items: Item[], next?: string } SeriesInfo = { title?: string, poster?: string, backdrop?: string, overview?: string, ids?: { tmdb?: number, imdb?: string }, genres?: string[], year?: string } Episode = { season: number, number: number, ref: string, title?: string, still?: string, overview?: string, airDate?: string, runtimeMinutes?: number } Season = { id: string, ref: string, title: string, number?: number, current?: boolean } Stream = { url: string, mime?: string, headers?: Record, subtitles?: { lang: string, url: string, format?: "vtt" | "srt" }[], audioTracks?: { lang: string, url: string, label?: string }[], durationMs?: number, expiresInSeconds?: number, drm?: { type: "widevine", licenseUrl: string, licenseHeaders?: Record }, label?: string, // apiVersion 6 alternatives?: ({ url: string, mime?: string, headers?: Record, label?: string } | { ref: string, label?: string })[], // label and { ref }: apiVersion 6 skip?: { openingStartMs?: number, openingEndMs?: number, endingStartMs?: number }, signing?: "request", signContext?: string, alternateHosts?: string[] } // the last three: apiVersion 6 ``` **Genre (Categorías and the En vivo filter).** A Home `Row`, a live `LiveCategory` and a `playlist` may carry an optional `genre` from a closed list of ten ids: `peliculas`, `series`, `anime`, `infantil`, `documentales`, `deportes`, `noticias`, `musica`, `entretenimiento`, `otros` (Kino shows their Spanish names). It lets Kino line up categories from different plugins: the Categorías tab groups the browsable Home rows (those with a `ref`, when you declare `browse`) of every plugin by genre, and En vivo can narrow its categories by genre. A value outside the list is ignored, never an error, and without a `genre` Kino guesses from the title of the row or group ("Deportes", "Noticias Colombia", "Kids"…), so set it when your titles do not say it. On a `playlist` the genre is the default for the groups of the list (each group's own title is guessed first). Kino versions before this field ignore it. ### How the pieces connect { #pieces } A `movie` item's `ref` goes to `resolve`. A `series` item's `ref` goes to `episodes`, and each episode's `ref` goes to `resolve`. A `live` item's `ref` (apiVersion 2, see [Live channels](live-channels.md#live-items)) goes to `resolve` too, and its Stream plays as live. A row's `ref` goes to `browse`, and so does each page's `next`. ### Seasons { #seasons } Two shapes, and your `episodes` answer says which. When every season of a show is in one list, give each episode its `season` and leave `seasons` out: Kino reads the seasons from the episodes and shows a selector that only filters the list. When your source keeps each season as its own `series` item (its own `id` and `ref`, as a search would list it), return only that season's episodes and list every season of the show in `seasons`, the one you are answering for included: `{ id, ref, title, number?, current? }`, with `title` what the selector shows ("Temporada 2") and `current: true` on the season being listed (Kino also recognizes it by `id`). Kino shows the seasons as chips; choosing another one calls `episodes` with that season's `ref` and opens it as that title, with its own progress in the library. `seasons` is optional and new in this revision of apiVersion 1: a plugin that never returns it keeps working exactly as before. ### Paging ("Ver más") { #paging } If you declare `browse`, a Home row with a `ref` gets a "Ver más" card that opens a grid: Kino calls `browse(ref, null)`, then `browse(ref, next)` while the person scrolls and you keep returning a `next`. `search` may also return a `Page`; its `next` puts "Ver más resultados de " under your results, and Kino calls `search` again with the same query and `cursor: next`. A `next` (and a row's `ref`) is only kept when you declare `browse`; without it Kino drops them with a line in the log. Cursors are opaque to Kino: a page number, an offset, a URL, at most 2048 characters. ### Searching inside a "Ver más" page (`scopedSearch`, apiVersion 6) { #scoped-search } Every "Ver más" page (a Home row, a row of your [section](section-theme.md), one of your Categorías) has a search field at the top ("Buscar en esta categoría"). For every plugin, Kino filters the titles already loaded on that page by name (accents and case aside, every word anywhere in the title); with fewer than 24 matches it keeps loading the next pages of the same `ref` ("Buscando en más páginas…"), at most 10 pages or 300 titles per round, and the person may ask for another round. A new query cancels the running one. Declare `"scopedSearch"` in `capabilities` (apiVersion 6, together with `search`; nothing more to export, no consent line) to answer that search yourself, e.g. with your backend's search restricted to that category. Without `search` the manifest is refused with "La capacidad \"scopedSearch\" necesita también \"search\"". Kino then calls your `search` with the usual query plus `within`, the page's browse `ref` exactly as you gave it: ```js export async function search(query) { if (query.within) { const category = categoryOf(query.within); // your own ref if (!category) return null; // "can't search there": Kino filters the page itself return searchCategory(category, query.q, query.cursor); // Item[] or Page, paged by your `next` } /* the plain search */ } ``` `type` is always `"any"` there, and `season`, `episode`, `tmdbId` and `year` are `0`. The answer is checked like any search answer (the same caps, `adult` titles only while the 18+ code is unlocked), and a `Page`'s `next` pages it as the person scrolls. Kino falls back to its own filter when you answer `null`, throw, or have not answered after 6 s (the page then searches its own titles and drops your late answer; your call keeps the search limit of 15 s); a failure reaches the error board like any failed call, never with the query or the `ref`. Answering `null` is not a failure. `sdk/validate.mjs` warns when you declare `scopedSearch` and your entry never reads `within`; try it with `node sdk/run.mjs --within '' ./plugin.js search "texto"`. ### `id` is stable, `ref` may change { #id-and-ref } `id` is the identity of a title: the person's library, progress and "Continuar viendo" hang off it, so it must be the same every time the same title comes back, in every search and on every Home refresh. `ref` is opaque to Kino: it is just what your `episodes`/`resolve` need to find the title again. It may differ from one call to the next (sources re-issue links), and Kino can hand you a `ref` you returned earlier, for example the one saved with a title in the person's library. So make refs that keep working; if your source's links expire, put something stable in the `ref` (an id) and look the fresh link up inside `resolve`. ### Kino is strict, and forgiving with lists { #validation } Every list is checked entry by entry: a bad entry is dropped (with a line in the log) and the rest survive; anything over a cap is cut. A `Stream` is all or nothing. | Thing | Rules | | --- | --- | | `search` result | At most 100 items (an `Item[]`, or a `Page`). A `live` item whose name has nothing to do with the query is dropped: it stays only when its name carries at least 60% of the words of 3 or more letters of some form of the query (what was typed, `originalTitle` or one of `altTitles`), accents and case ignored -- the rule of [`kino.rank.filterRelevant`](kino-api.md#rank). Movies and series are never judged this way (they may rightly carry another title), and a query with no such word drops nothing. So don't answer a search with your whole channel list when nothing matches. | | `browse` result | A `Page` of at most 100 items. | | `home` result | At most 20 rows of at most 60 items each. A row needs a unique `id` (same pattern as an item id) and a non-blank `title`; rows with no valid items are dropped. Kino shows them after its own rows, labelled with your plugin's name, and caches them for 6 hours (stale rows show while it refreshes; an answer with no valid rows, or over 2 MB, is not cached and is asked again next time). If `home()` fails you contribute no rows and Home is not blocked. | | `episodes` result | At most 5000 episodes. `number` is required and from 1 to 99999 (an episode numbered 0, such as a special, is dropped). `season` should be from 1 to 999; a missing or out-of-range season becomes 1. `ref` is required. A repeated season and number is dropped. Without a `title`, Kino shows "Capítulo N". | | `seasons` (in the `episodes` result) | Optional; at most 50. Each needs an `id` (same pattern as an item id; a repeated one is dropped), a non-empty `ref` of at most 4096 characters and a non-blank `title` (up to 200 characters), or it is dropped. `number` from 1 to 999 and `current` a boolean; a wrong one is ignored, not the season. Anything that is not a list is ignored. | | `id` | `^[A-Za-z0-9._~-]{1,128}$`. Anything else drops the item, so if your source's own ids have other characters (spaces, `/`, `:`, `%`), derive a stable id yourself, such as a slug. Repeated ids in one list are dropped. | | `ref` | A non-empty string of at most 4096 characters. | | `kind` | `"movie"`, `"series"` or (apiVersion 2) `"live"`; a `live` item stays in a `home` row only from apiVersion 6 (below it, it is dropped from Home: see [Channels in your Home rows](live-channels.md#home-rows)). A `series` item from a plugin that does not declare `episodes` is dropped: it could never be opened; a `live` item from an apiVersion 1 plugin is dropped too (see [Live channels](live-channels.md#live-items)). | | Text fields | `title` is required and non-blank, up to 200 characters. `overview` up to 2000; `lang` and `quality` up to 20 (for example `"es"`, `"1080p"`); `year` up to 10 (a number is accepted and converted). Longer text is cut; the text of `SeriesInfo` and `Episode` is cut the same way (200 characters for titles, 2000 for overviews). | | Extra item fields | All optional; a wrong one is ignored, not the item. `genres` at most 5, each at most 30 characters; `badges` (shown as chips, e.g. `"HD"`, `"Latino"`) at most 3 of at most 20; `rating` from 0 to 10; `runtimeMinutes` from 1 to 1000; `ids.tmdb` a positive integer (Kino uses it to match your title with TMDB, to find it again from search, and to enrich its info page -- see below); `ids.imdb` matches `^tt\d{5,10}$` (also enriches a movie's info page when you have no `ids.tmdb`). An episode's `airDate` is `YYYY-MM-DD`. | | `adult` | From apiVersion 6, `adult: true` marks an 18+ entry: Kino shows it only while the person's 18+ code is unlocked on that device (Ajustes ▸ Adultos), and hides it again when they lock it; below apiVersion 6 it is dropped. It applies on Home, in search, "Ver más", your section and Categorías. See [18+ content](#adult). | | Images | `poster`, `backdrop` and `still` must be `http` or `https` URLs of at most 2048 characters, or they are ignored (Kino versions before the one that accepted `http` for images ignore the `http` ones). Images are loaded by Kino directly and are **not** checked against `hosts` (they are display only), and Kino does not send your headers or cookies with them. This is the one exception to the host rule, with one limit: an image on the home network, a private or reserved IP address, or a local name (`localhost`, `.local`, `.lan`, …) is ignored too, and so is a name without a dot over `http` (`router`, `nas`), unless it is on a server the person typed in your settings. A public IPv4 address is fine. | ### `ids.tmdb` enriches the info page, not only matching { #tmdb } When TMDB has this exact title (matched by `ids.tmdb`, or by `ids.imdb` on a movie when you gave no `ids.tmdb`), opening it adds three kinds of field, each filled in differently: - **Only TMDB has these, so they always come from it:** a tagline, the director or (for a series) creator, the cast and the age rating. - **TMDB wins whenever it has an answer; yours is only the fallback for what TMDB left blank:** the year and the genres. A title with its own year or genres still shows TMDB's once matched, not its own. - **Yours wins when you gave one; TMDB only fills the gap:** the synopsis (only replaced if yours was empty), the rating (only if you left it out), and a movie's runtime (only if you left it unset -- a series' runtime is never touched either way, TMDB's included; it prints per episode, not for the whole show). It does **not** add a poster, a backdrop or seasons from TMDB -- those stay exactly what your `Item`/`SeriesInfo`/`episodes` answer gave, or blank if you left them out. ### The `Stream` rules { #stream } - `url` must be `https` and its host must be one of your `hosts`, and so must the host of every subtitle URL, or it must be on a server the person typed in your settings (exactly that scheme, host and port). The one other way to plain `http` is a host you declared `{ "host": "…", "insecureHttp": true }` (apiVersion 2, [see the manifest](manifest.md#insecure-host)): that host, exactly, accepts `http` for the stream, its subtitles, its audio tracks and its license. A stream that breaks this is refused as a whole; a bad subtitle is dropped and the stream still plays. Two things relax this for a movie or an episode: a manifest with [`streamHosts: "any"`](manifest.md#stream-hosts) and the person's [broad video permission](#broad-video); and a host you forgot may be [asked about](#forgotten-host) instead of refused. - `mime` is optional, of the form `video/mp4` (anything else refuses the stream). When it is missing Kino's player detects HLS, DASH or a plain file from the URL and the content. - **Everything the player fetches for the stream follows the `kino.fetch` host rules.** That covers the `url` itself, the variants, segments and `#EXT-X-KEY` keys an HLS manifest names, the `BaseURL`s of a DASH manifest, the subtitles, and every redirect hop of any of them: each must be `https` on one of your `hosts` (or `http` on one you declared `insecureHttp`), never an IP address or a local name, and a declared name that resolves inside the person's own network is refused. A request that breaks this fails before it leaves the device and playback stops with an error, so a manifest that points at another CDN needs that CDN in `hosts`. - `headers` are sent with every one of those player requests (the stream, its manifest's segments and keys, its subtitles, and redirect hops, all on your `hosts`) and, if you declare `download`, with every request that saves the stream to the device (for HLS: the playlists, the key and every segment) — and nowhere else. At most 20; names are letters, digits and hyphens; values are at most 4096 characters with no line breaks; `Host`, `Content-Length`, `Transfer-Encoding` and `Connection` are ignored. - `subtitles`: at most 30, each `{ lang, url, format? }`. `lang` is a short language code such as `"es"` (up to 20 characters; blank becomes `"und"`), `format` is `"vtt"` or `"srt"`. - `audioTracks`: at most 8, each `{ lang, url, label? }` -- a dub or an alternate mix your source serves as its own file, separate from the video. Checked exactly like a subtitle: `url` must be `https` on a declared host, or the person's own server exactly as typed; a bad entry is dropped and the rest of the stream still plays, and so is a `url` already listed (the first entry wins). `lang` up to 16 characters (blank becomes `"und"`); `label`, up to 40 characters, is shown in the audio menu verbatim when given, instead of a name guessed from `lang`. Kino merges each one into the video and offers it, auto-picked by the person's audio preference, in the same menu as the container's own embedded tracks. A stream with no `audioTracks` plays exactly as it always has. Example, a source that dubs into two languages: ```js return { url: videoUrl, audioTracks: [ { lang: "es-419", url: dubUrl("es"), label: "Español (Latinoamérica)" }, { lang: "en", url: dubUrl("en") }, ], }; ``` - `alternatives` (at most 8, each `{ url, mime?, headers? }`): other copies of the same video, best first. When `url` cannot play on the device (a codec it has no decoder for, a broken or unsupported file) or is gone (404, 403), Kino moves on to the next alternative by itself, at the same spot, and only shows an error once none is left. A lost network is not a reason to move on: that is retried as usual. Each entry is checked exactly like `url`, `mime` and `headers`; a bad one is dropped and the rest still count. They share the stream's `subtitles` and `audioTracks`. Ignored next to `drm`, with `signing` (`alternateHosts` is a signed stream's failover) and for a live channel. Return them when your source offers several files of one title (other servers, resolutions, encodes): a device that cannot decode the first one still gets to watch. From apiVersion 6 an alternative may carry a `label`, or be a lazy `{ label, ref }` resolved only when needed: see [Labelled and lazy copies](#lazy-copies). - `label` (apiVersion 6): the Stream's own short name, and each alternative may carry one too -- e.g. `"Latino · Servidor 1"`. Trimmed; at most 48 characters, no control characters, or it is dropped (the copy still plays). When a Stream has two or more copies the player shows them all, by label, in a **Servidor** section at the top of its "Audio y subtítulos" menu (phone and TV, reachable with the D-pad), where the person can switch at any moment and keep watching from the same spot; a copy without a label shows as "Opción 2", "Opción 3"… Labels also go to the plugin's log, with the host only (never a URL or a token). - `signing`, `signContext` and `alternateHosts` (apiVersion 6): an HLS stream that needs a fresh signature on every request. They have their own page: [Signing every request](signed-streams.md). - `durationMs` is optional, in milliseconds. - `skip` is optional: where THIS file's opening and ending are, in milliseconds from its start -- `{ openingStartMs?, openingEndMs?, endingStartMs? }`. Kino shows its "Saltar intro" button from `openingStartMs` (0 when left out or `null`) to `openingEndMs`, and "Saltar outro" (to the next episode) from `endingStartMs`. Every value must be a finite number between 0 and `durationMs` (or 24 h when you give no `durationMs`); the opening needs its `openingEndMs`, after its start; `endingStartMs` must not be before the opening's end. A bad part is dropped and the rest still count; a bad `skip` never stops the stream. Send times for the exact file you return: a different cut of the same episode has its opening somewhere else. Kino keeps them as the episode's markers, also for a downloaded copy, and they sync to the person's other device. A correction the person makes by hand always wins over yours; yours win over the community times Kino looks up on its own for anime (AniSkip), which is not asked at all when you send `skip`. Ignored for a live channel. Older Kino versions ignore the field. ```js return { url: videoUrl, durationMs: 1_420_000, skip: { openingStartMs: 62_000, openingEndMs: 152_000, endingStartMs: 1_290_000 } }; ``` - `expiresInSeconds` (30 to 86400) says when your URL may stop working. If playback fails after that long, Kino calls `resolve` once more and continues where the person was. - **DRM only when declared.** A stream carrying any of `drm`, `license`, `licenseUrl`, `drmLicenseUrl`, `keySystem` or `widevine` is refused ("El video tiene DRM y los plugins no lo soportan") -- unless your manifest declares the `drm` capability (apiVersion 2) and the only such key is a `drm` block `{ type: "widevine", licenseUrl, licenseHeaders? }`: then Kino plays it as Widevine. `licenseUrl` is checked exactly like `url` (https on one of your `hosts`, or the person's own server), and `licenseHeaders` are filtered like `headers` (at most 20) and sent with the license request only. The other five keys are refused even next to a valid `drm` block. See [A Widevine-protected stream](cookbook.md#widevine). ### Labelled and lazy copies (apiVersion 6) { #lazy-copies } A source often has the same episode in several languages and on several servers, and finding each server's video costs time (a page to open, sometimes a [hidden browser](browser.md) capture of 5-25 s). Resolving all of them before the first frame would make every play slow. From `"apiVersion": 6`, a Stream's `alternatives` may name a copy **without resolving it**: `{ label, ref }` instead of `{ url }`. Kino passes that `ref` to your `resolve(ref)` only when the copy is actually needed: - the person picks it in the player's **Servidor** menu (the copy on screen keeps playing while it opens, then the new one starts at the same spot; if it fails, they read "No se pudo abrir …" and keep watching the copy they had); - the automatic fallback reaches it (the copy on screen cannot play on this device, or is gone); - a download's copy choice gets to it (inside the same 30 s budget it spends probing copies; a lazy copy that has not answered by then is skipped, never waited for). That call is a normal `resolve`: the same time limit (75 s for an approved [`browser`](browser.md) plugin) when the person picked the copy -- they chose to wait for it -- but **at most 20 s when the automatic fallback asked**, after which Kino cancels it (ending a capture still running) and moves on to the next copy; the same checks on what it returns, `kino.browser.capture` allowed (for a download's copy choice: only when no other page is open, else `busy` and the copy is skipped), host questions asked as for the title. From its answer Kino uses `url`, `headers`, `mime`, `subtitles` (when it brings some; otherwise the title's are kept), `expiresInSeconds` (a lazy copy past its expiry is resolved again, not the whole title) and `skip` (it shows "Saltar intro" while that copy plays, never saved: the Stream's own `skip` stays the episode's, and a hand correction still wins). Its own `alternatives` are ignored -- a copy never expands into more copies. A `resolve` that fails moves on to the next copy. ```js export async function resolve(ref) { // A copy's own ref: "|". Resolve just that server. if (ref.includes("|")) return resolveServer(ref); const servers = await listServers(ref); // [{ id, lang, name }], fast: no page opened yet const first = await resolveServer(`${ref}|${servers[0].id}`); return { ...first, label: `${servers[0].lang} · ${servers[0].name}`, // "Latino · Servidor 1" alternatives: servers.slice(1, 9).map((s) => ({ label: `${s.lang} · ${s.name}`, // "Subtitulado · Servidor 2" ref: `${ref}|${s.id}`, })), }; } ``` Rules: `ref` is a non-blank string of at most 512 characters with no control characters (a bad or repeated one is dropped); an entry with a `url` is a normal copy whatever else it carries; lazy and concrete copies mix freely and share the limit of 8; each `label` is trimmed, at most 48 characters, no control characters, or it is dropped while the copy still counts. Keep the ref enough to find that one server again: it may be resolved minutes after the title (a person switching servers mid-episode). Below apiVersion 6 the keys are unknown: a `label` is ignored and a `{ ref }` entry, having no `url`, is dropped exactly as before. Try it with `node sdk/run.mjs ./plugin.js resolve ''`, which prints each copy with its label, then `resolve ''` to resolve that copy. A complete plugin that captures its first server and offers the rest this way is on [Hidden browser](browser.md#example). ### A host you forgot may be asked about, once { #forgotten-host } When the person opens a title in the player and the only thing wrong with your `Stream` is that a URL (the video, its license, a subtitle or an audio track) is on an `https` host you did not declare, Kino asks them in the moment ("El video está en ``, un servidor nuevo para este plugin. ¿Permitir?"), the same dialog a [`kino.fetch` to an undeclared host](kino-api.md#fetch) gets. The player also asks when it meets a new host mid-playback (a manifest, a segment, a redirect). "Permitir" adds that host to your plugin's approved hosts (there is no cap on how many a person approves this way; an update keeps them) and the video plays; "Rechazar" (or Back) is remembered for your plugin -- the video fails as described above, a subtitle or audio track is dropped -- and that host is never asked about again until the person chooses "Olvidar rechazos de host". An IP address, a local name, plain `http` or a stream broken in any other way is never asked about, and nothing is asked when nobody is watching: a download fails on that host instead. Don't rely on it: declare the hosts your streams use. ### The broad video permission { #broad-video } For a movie or an episode, those video, subtitle and audio dialogs have a third choice, "Permitir video de cualquier servidor". It is the person's own grant, shown and revocable in Ajustes ▸ Plugins ("Puede reproducir video desde cualquier servidor", "Quitar permiso de video amplio"); the one way for you to ask for the same rule up front is [`streamHosts: "any"`](manifest.md#stream-hosts) (apiVersion 4), approved on the consent sheet. An update or a reinstall keeps it; uninstalling drops it. While it is on, your movie or episode `Stream` is checked the way a live channel's is under [`liveStreamHosts: "any"`](live-channels.md#live-stream-hosts): its `url`, everything its manifest names, every redirect hop, **and** its `subtitles` and `audioTracks` may be on any public host, over `http` or `https`, a public IPv4 address included, and no video host is ever asked about again for your plugin. A [download](manifest.md#downloads) of a movie or an episode follows the same rule. It never covers: - the home network: private, loopback, link-local and CGNAT addresses, IPv6 literals, local names, and a public name that resolves into the LAN; - a `drm` block's `licenseUrl` (still your hosts only, asked about as above); - `kino.fetch`: your own code still reaches only your hosts and the ones approved one by one; - live channels, which have their own rule. It exists for sources whose hosters change domain per video or mid-playback; a plugin with a fixed CDN should still declare it. ## Subtitles for any title { #subtitles } Export `subtitles({ imdbId, tmdbId, kind, season, episode, title, year, languages, file })` and Kino lists what you answer in the player's "Buscar subtítulos en línea" (phone and TV), under your plugin's name, next to OpenSubtitles and SubDL: for **any** movie or episode the person plays -- your titles, another plugin's, a Stremio addon's -- as long as Kino knows its IMDb or TMDB id (a title known only by name is never sent to you). The person picks a track, Kino downloads it, turns it into SRT and adds it like any online subtitle (sync offset, style, remembered for the title). Two ways to offer it: - **A subtitle provider**: `"capabilities": ["subtitles"]` and nothing else. No `resolve`, `search` or `home` needed; the plugin appears nowhere but the subtitle search and Ajustes ▸ Plugins (it is not a source in "Elige tus fuentes"). The consent screen says "Agrega subtítulos a tus películas y series". Older Kino versions refuse a capability they do not know: they do not install it. - **Alongside your videos**: keep your capabilities and just export `subtitles` (declaring `subtitles` too adds the consent line, but older Kino versions would then refuse the plugin). Kino asks every plugin whose install found the export; older versions never call it. The argument: `imdbId` (`tt…`) and/or `tmdbId` (a number), at least one of them; `kind` `"movie"` or `"series"` -- for an episode the ids are the **series'** and `season`/`episode` are set; `title` and `year` are hints; `languages` are the person's subtitle languages, ISO 639-1, best first (`["es", "en"]`). `file` (Kino 0.9.51) is what Kino knows of the file playing, when the person searches from the player: `{ hash?, size?, name? }`, each only when known, and absent when nothing is. `hash` is the 16-hex OpenSubtitles hash and `size` the bytes it was computed with (only for a plain, stable file); `name` is the file's name with its extension ("The.Matrix.1999.1080p.mkv", at most 200 characters), taken only from a URL that ends in a video file name, otherwise a release-style name Kino builds from the title ("Oppenheimer.2023.mkv", "Breaking.Bad.S01E05.mkv"). Never the video's URL. Use it to rank the release that matches the exact file first; older Kino versions never send it, so treat it as a hint. Return an array of `{ lang, url, format?, label?, translated? }`: a `Stream`'s `subtitles` entries plus an optional `label` and `translated: true` for a machine translation (the menu then says "Español (traducido)" and lists it after the person-made tracks of that language). Kino keeps 30, then lists only those in the person's languages, in that order, 15 per plugin: put the asked languages first. `label` (60 characters) is shown next to the language, a release name for example. Each `url` follows a `Stream`'s subtitle rule: your `hosts`, the person's server, or any public host with [`streamHosts: "any"`](manifest.md#stream-hosts). The call is a background one (10 s, it never marks your plugin "No responde"); return `[]` when you have nothing. Try it with `node sdk/run.mjs ./plugin.js subtitles tt0944947 1 1` (`KINO_LANGS=es,en` for the languages). ## Describing other titles (`meta`, apiVersion 6) { #meta } Declare `"meta"` and export `meta(query)` to fill in what Kino could not find about a title on its info page, whatever plugin listed it: a synopsis, a poster or background, genres, a year, a runtime, an episode list. Kino asks TMDB first and, for an anime, AniList; your answer only fills what they left empty, never replaces a value they gave (and never the title's own plugin's). It is the way to describe titles TMDB does not know, like `kitsu:` anime. No consent line. ```js export async function meta(query) { // query: { type: "movie" | "series", ids: { imdb?, tmdb?, kitsu?, mal?, anilist? }, id?, lang? } // id: the title's own Stremio-style id in its source ("kitsu:1376", "tt0944947"), when it has one // lang: the person's language ("es") const found = await lookUp(query.ids); if (!found) return null; // not a title you know: no failure return { title, overview, poster, backdrop, year: "2011", genres: ["Drama"], runtimeMinutes: 57, episodes: [{ season: 1, number: 1, title, overview, still, airDate: "2011-04-17", id: "tt0944947:1:1" }], logo: "https://img.example.org/got-logo.png", // Kino 0.9.51+ ratings: [{ source: "imdb", value: "9.2" }, { source: "rottentomatoes", value: "89%" }], cast: [{ name: "Emilia Clarke", character: "Daenerys Targaryen", photo: "https://img.example.org/ec.jpg" }], }; } ``` Every field is optional. Images follow the same rules as an item's. From Kino 0.9.51 three more (older versions ignore them; no new `apiVersion`): - `logo`: a clear-logo of the title (its name drawn as art, transparent background). The info page shows it instead of the title's name at the top (phone and TV; the name stays for TalkBack and comes back if the image fails to load). Same image rules as a `poster`. - `ratings`: at most 6 `{ source, value }`, one per source. `source` is one of `imdb`, `tmdb`, `rottentomatoes`, `metacritic`, `letterboxd`, `mal`, `anilist`, `trakt`; `value` a number as the site writes it, up to 3 digits with up to 2 decimals, optionally followed by `%` or a scale (`"8.8"`, `"94%"`, `"4.1/5"`; a JSON number works too). Shown next to the page's ★ score ("IMDb 8.8 · Rotten Tomatoes 94%"). Unlike the other fields they add up: TMDB's own score stays, and a `tmdb` rating is dropped when the page already has a score. A bad entry is dropped, the rest kept. - `cast`: at most 20 `{ name, character?, photo? }` (`name` and `character` 60 characters; `photo` an image like a `poster`). Like every other field it only fills a gap: Kino shows TMDB's cast when TMDB has one. Kino shows the names; `character` and `photo` are kept for later. `episodes[].id` is the episode's Stremio-style video id: a Stremio addon's title whose own listing failed can then play those episodes by it. Kino asks every `meta` plugin of the person at once, at most 6 s each, uses the first answer in install order, and remembers it for 30 minutes; a failure or a timeout is just no answer, and the page never waits for you. The Node kit has no `meta` command and does not check that you export it: test it in the app, on the info page of a title TMDB does not know. ## Telling a tracker what the person watches (`tracking`, apiVersion 7) { #tracking } A plugin for a tracking service -- Seenr, Trakt, Simkl, a Plex-style webhook, the person's own server -- declares `"capabilities": ["tracking"]` (alone, with `subtitles`/`segments`, or next to a source's capabilities) and `"apiVersion": 7`, and exports `track(event)`. Kino calls it for every movie or episode the person plays **on that device**, from any source. Kino 0.9.50 and older refuse such a manifest with "Este plugin necesita una versión más nueva de Kino". - **Consent.** The install sheet says, in red, "Le contará a `` qué ves y cuándo lo terminas" (the first 3 hosts, then "y N más"; with no host, "al servidor que escribas en su configuración"). An update that adds `tracking` always waits for the person, even when Kino approves other updates on its own. - **The switch.** Your Ajustes tab gets "Enviar lo que veo" (on by default, synced to the person's other devices). Off, Kino stops calling `track` and deletes what was waiting; disabling or uninstalling the plugin does the same. - **What is never sent.** Live channels and radio, 18+ titles, anything a Chromecast or DLNA TV plays (the phone is only a remote then), a title that is not in the person's library, and an episode whose number Kino does not know. **When.** `start` once the video really plays (and again when it resumes after a pause); `progress` at most every 5 minutes of playback and on every pause (with `paused: true`); `stop` with the position when the person leaves the title (or plays another one); `watched` **once**, when the position crosses Kino's own "visto" rule: 3 minutes or less left **and** at least 90% played. A title opened already past that point (resumed at the credits on another device) does not send `watched` again, and each device sends a title's `watched` to your plugin at most once. ```js { id: "6f1c…", // stable: the same on every retry of this event, use it to ignore a repeat type: "start" | "progress" | "stop" | "watched", at: 1759670000000, // when it happened on the device (epoch ms), not when it reached you kind: "movie" | "episode", ids: { imdb?, tmdb?, tvdb?, anilist?, mal? }, // a movie's; an episode's OWN (see below) title?: "…", // the movie's name; an episode's own name when TMDB has one year?: 1999, // a movie's year show?: { title: "…", year?: 2008, ids: { imdb?, tmdb?, tvdb?, anilist?, mal? } }, // episodes only season?: 1, episode?: 2, // episodes only (season 1 when the source gave none) positionMs?: 1234000, durationMs?: 8160000, progress?: 0.151, // where the person was, 0..1 paused?: true // a progress sent because the person paused } ``` **Which ids.** For an **episode**, `ids` are the **episode's own** (TMDB's episode id, and its IMDb and TVDB ids when TMDB knows them) and the **show's** are in `show.ids`: never use `show.ids` where a service expects an episode id, or the check-in lands on the wrong item. Kino fills the ids it lacks from TMDB when it delivers (at most 8 s); when TMDB doesn't answer the event still goes, and an episode's `ids` may be `{}`: fall back to `show.ids` + `season` + `episode`. `anilist`/`mal` appear only when the title's own source named them. Every key is absent when unknown; `imdb` is a `tt…` string, the rest numbers. **What to return.** Anything (`{ ok: true }` by convention) means delivered. Return `{ skipped: true }` for an event your service has no use for (a tracker that keeps no `progress`, a title it does not have): Kino drops it the same way, but only a real delivery clears the red "No pudo avisar…" line, so a wrong link stays visible until it is fixed. To fail, throw `kino.error(code)`: - `timeout`, `network`, `unavailable`, `rate_limited` (and a timeout of the call itself, or an error thrown without a code): Kino tries the same event again later -- 30 s, doubling up to 6 h, 12 tries at most -- and your later events wait behind it, in order; - `auth_required`, `invalid_request`, `not_found`, `geo_blocked`, `host_not_allowed`, `too_large`: the event is dropped at once. A dropped event, or three failures in a row, shows "No pudo avisar a ``: …" in red in your Ajustes tab and in Gestionar, until a delivery succeeds. The call is a background one (10 s, never "No responde"), never on the player's thread. Kino keeps at most 200 events waiting per plugin (the oldest `progress` goes first, a `watched` never) and drops one still undelivered after 7 days; a newer `progress` replaces a waiting one of the same title, and a `stop` or `watched` replaces its waiting `progress`. Nothing is lost offline or when the app is closed: Kino delivers once there is network. Kino's own logs never carry what the person watched; with "Modo debug" on, your Registro shows each event sent. A converted Stremio addon never gets `tracking` (Stremio has no scrobble protocol). Try it with `node sdk/run.mjs track start` (or `progress`, `stop`, `watched`, plus a JSON object to change the sample). ## Where the intro and credits are (`segments`, apiVersion 7) { #segments } A plugin that knows where a title's intro and credits are -- an IntroDB-like database, an AniSkip-like one, the person's own server -- declares `"capabilities": ["segments"]` (alone, with `subtitles`/`tracking`, or next to a source's capabilities) and `"apiVersion": 7`, and exports `segments(query)`. Kino then shows its "Saltar intro" and "Saltar outro" buttons, and "Saltar automáticamente" jumps the intro, for **any** movie or episode the person plays, from any source, on phone and TV. Kino 0.9.50 and older refuse such a manifest. - **Consent.** "Agrega el botón para saltar la intro y los créditos", not in red (like `subtitles`, your plugin only learns which title plays). An update that adds `segments` needs no approval of its own. - **When.** Once a movie or episode really plays (Kino knows the file's length), in the background: playback never waits for you. Never for live channels or radio, 18+ titles, or a title Kino knows by no id. Every installed `segments` plugin is asked at once; Kino keeps an answer (an empty one too) for the session per title, episode and length (rounded to 10 s), and asks again after 2 minutes only when every plugin failed. ```js { kind: "movie" | "episode", ids: { imdb?, tmdb?, tvdb?, anilist?, mal? }, // a movie's; an episode's OWN, as track() gets them show?: { ids: { imdb?, tmdb?, tvdb?, anilist?, mal? } }, // episodes only: the show's season?: 1, episode?: 2, // episodes only durationMs?: 1440000 // the playing file's length: answer for THAT cut } ``` No title, year or URL is sent; an episode's `ids` may be `{}`, so fall back to `show.ids` + `season` + `episode`. **Return** an array of `{ type, startMs, endMs }` (`[]` or `null` when you know nothing): `type` one of `intro`, `outro`, `recap`, `credits`, `preview`, times in whole ms of the file. Kino checks each entry on its own and drops a bad one without losing the rest: an unknown `type`, a time that is not a whole number, a start below 0, an end not at least 1 s after the start, and -- with `durationMs` known -- a start at or past the end or an end more than 5 s past it (within that it is cut). Of overlapping entries of the same type the earlier one stays. Kino reads the first 100 entries and keeps 10. The earliest `intro` is the intro, the earliest `outro` or `credits` after it is where the ending starts; `recap` and `preview` are accepted and have no button yet. **Who wins.** The person's own correction (the marker editor) always does, and so does the [`skip`](#stream) of the plugin serving the file. For anime, AniSkip (when "Saltar intro en anime" is on) wins part by part: your answer fills only the intro or ending it lacks. Between two `segments` plugins, the first in Ajustes ▸ Plugins' order with a usable answer wins. Nothing you answer is stored or synced. The call is a background one (8 s, never "No responde"); Kino waits for it at most 12 s. Try it with `node sdk/run.mjs segments tt0133093 8160000` (a movie and its length) or `segments tmdb:1396 1 2 2880000` (an episode, by the show's id): it prints what Kino keeps, what it dropped and why, and the button it makes of it. ## Errors people understand { #errors } A plain `throw new Error("…")` reaches the person as a generic failure of your plugin. When the failure is one of the usual ones, throw a typed error instead and Kino says it properly, in Spanish, with your plugin's name: ```js if (r.status === 401) throw kino.error("auth_required", "la sesión venció"); ``` | `kino.error` code | What the person sees | | --- | --- | | `auth_required` | "Configura {plugin} en Ajustes ▸ {plugin}" when your plugin declares settings (its own tab in Ajustes), else "Configura {plugin} en Ajustes ▸ Plugins" ("Menú ▸ Plugins" on the phone), with a button to its Configurar screen | | `not_found` | "No se encontró en {plugin}" | | `geo_blocked` | "Este contenido no está disponible en tu región" | | `rate_limited` | "{plugin} está limitando las peticiones; intenta en unos minutos" | | `unavailable` | "{plugin} no está disponible ahora" | Your message is a detail for the log (cut at 200 characters); the person reads Kino's sentence. An unknown code becomes a plain error. From Kino 0.9.50, `auth_required` reads "Configura {plugin} en Ajustes ▸ {plugin}" when your plugin declares settings (it has its own tab in Ajustes), else "Configura {plugin} en Ajustes ▸ Plugins" ("Menú ▸ Plugins" on the phone), with the same button. ### Your own sentence for the person (`userMessage`, apiVersion 6) { #user-message } When Kino's sentence says too little (a chapter that was taken down, an account to link again), pass your own sentence for the person as a third argument: ```js throw kino.error("not_found", "E100006", { userMessage: "Este capítulo ya no está disponible." }); throw kino.error("auth_required", "E100083", { userMessage: "Tu cuenta se abrió en otro dispositivo. Vuelve a intentarlo, o vincúlala de nuevo.", }); ``` Kino shows it **instead of** its own line, always as "Mensaje de : " ("Mensaje de Demo: Este capítulo ya no está disponible."), only when all of this holds; otherwise the person reads Kino's line and your sentence goes nowhere (it is not logged either; the detail is): - your plugin's name can introduce it: only the characters below, no `:`, no digit glued to a letter, nothing that spells Kino (so a plugin named `M3U` or `Cuevana3` always shows Kino's line; `Cuevana 3` is fine); - the code is one of the five in the table (Kino always words `timeout`, `network`, `host_not_allowed`, `crypto_error` and the rest itself); - it is 1 to 160 characters once trimmed, made only of the letters of Basic Latin and Latin-1 (what Spanish, Portuguese and English write: á é í ó ú ü ñ ç ã õ â ê ô à è…, but not ø æ ð þ ß), the digits 0-9, the plain space and `` . , : ; ¿ ? ¡ ! ' ’ ‘ “ ” « » ( ) % - – — ▸ `` (a `;` only before a space): so no other script, look-alike letter, small capital, line break, tab, other kind of space, invisible character, emoji or `@`; - it reads as plain words: at least two words, no URL, no error prefix (`TypeError:`, `[Tag]`), no `undefined`/`null`/`NaN`, and it doesn't end in `:` `,` `;` or `-`; - fewer than 6 digits in all, whatever separates them (no phone or account number, and so no full date with its year), and no digit glued to a letter (`en 5 minutos` is fine, `5minutos` is not); - no domain: a dot glued to a letter (`site.app`), a dot after a space (`site .app`), a dot followed by a lowercase word of 2 to 6 letters (`site. app`), `www`, or `punto`/`dot` glued to or followed by a domain ending (`punto com`, `puntodev`; "a punto de volver" and "en este punto es mejor" are fine: `es`, `la`, `me` and `to` are not read as endings); - it never spells Kino: read with `1`, `l`, `!`, `¡` as `i`, `0` as `o` and every non-letter dropped, it holds no `kino` anywhere (so avoid a word like "Kinoshita"); - it asks for no credentials, money or contact outside Kino, read word by word (a word split on purpose is read whole: `N e q u i`, `Ne qui`, `con tra seña`, `What s app`, `pun to com`): no `pag…` (pago, pagues, págalo; "página" is fine), `abon…`, `recarg…`, `transfer…`, `consign…`, `deposit…`, `contraseñ…`, `passw…`, `clave…`, `credencial…`, `token…`, `tarjeta`, `PIN`, Nequi, Daviplata, WhatsApp, Telegram, a `código` that came by SMS or is a verification code (`verification…`; "Verifica tu conexión" is fine): your own settings are the only place for those (`recarg…` also refuses "Recarga la lista": say "Vuelve a cargar"); - it holds none of the passwords the person typed in your settings (checked against your plugin's stored values; while they can't be read, the sentence is not shown), nor any sealed secret's value. Never echo what the person typed, in any form. !!! danger "Never use `userMessage` to ask for money, passwords or contact data" Each author is responsible for their own plugin. Kino only **lists** community plugins (its community search); it does not recommend or promote them. If a plugin breaks the rules for plugins -- for example, it uses `userMessage` to ask for money, passwords or contact data, it is malware, or it infringes someone's rights -- Kino removes it from the community index through [`community-blocklist.json`](https://github.com/kinotvapp/kino-plugins/blob/main/community-blocklist.json) (at the root of this repository), and anyone can report it with the ["Reclamo / retiro de plugin"](https://github.com/kinotvapp/kino-plugins/issues/new?template=reclamo-retiro-plugin.yml) issue template. An installed copy stays installed, its card says "Retirado del índice de la comunidad." and it gets no more updates; a fork needs its own report. See [Claims and plugin takedowns](claims.md). Write it for the person, in their language; Kino doesn't translate it. It takes the very place Kino's own line takes, so it never changes what the screen does: `auth_required` keeps the button to your Configurar screen; `geo_blocked` shows the player's "No se puede reproducir" dialog; the other codes show Kino's error line (on a live channel the person keeps zapping). It is also the reason under your plugin's Home row and in the search notices. Your settings form and your section pages show it for `not_found`, `unavailable` and `rate_limited`, and keep their own generic text for `auth_required` and `geo_blocked`. One thing still wins over it, whatever the code: a host the person refused for this call (they can act on that). The sentence counts only for the call that built the error: build it where you throw it, not once at the top of your module. The Node kit (`run.mjs`) prints what the person would read, or why the sentence is not shown. Kino builds older than 0.9.50 ignore the third argument and show their own line, so it is always safe to pass. ### A pending update wins over the error { #pending-update } When a newer version of your plugin waits for the person's approval (it asks for a new host, permission or capability), a failed call does not show the usual sentence but "Hay una versión nueva de : actualízala en Ajustes ▸ Plugins", so the person knows what to do. A refused host, your valid `userMessage` and `auth_required` (which keeps its button) still win over it. See [Updates](publish.md#updates). ## 18+ content (`adult`, apiVersion 6) { #adult } From `"apiVersion": 6`, `adult: true` on an item, on one of your [Categorías](section-theme.md#categories) tiles, or on a live category or channel marks an 18+ entry. Kino shows it only while the person's 18+ code is unlocked on that device (Ajustes ▸ Adultos) and hides it again when they lock it. Nothing to declare in the manifest. Below apiVersion 6 an `adult: true` entry is dropped, as before. - It applies on Home, in search, "Ver más", your section, Categorías and En vivo. A row or a group left with only 18+ entries is not shown while the code is locked. - Every channel of an 18+ category counts as 18+, and an 18+ channel never enters "Recientes". - `liveSearch` hits need a mark: see [Mark every `liveSearch` hit](live-channels.md#live-search-adult). - Sending an 18+ title to the paired TV needs the TV's 18+ content unlocked too ("Desbloquea el contenido 18+ en el TV para verlo allí"). - Your plugin cannot tell whether the code is unlocked, nor get around the lock: always return the mark and Kino decides what shows. # The `kino` API `kino` is a global object, frozen, always there. Nothing else from the outside world is. ```js kino.apiVersion // 7 -- the highest apiVersion this build of Kino understands, not your manifest's kino.appVersion // the version of Kino, for example "1.42.0" kino.lang // "es-CO" ``` Kino also provides the web globals QuickJS lacks, written in JavaScript and frozen: `URL`, `URLSearchParams`, `atob`, `btoa`, `TextEncoder` and `TextDecoder` (UTF-8 only). They behave like the browser's (checked against Node on a corpus of cases), except that `URL` does not convert international domain names to punycode. A `URL` can be changed in place with the usual setters (`protocol`, `username`, `password`, `host`, `hostname`, `port`, `pathname`, `search`, `hash`, `href`), which, as in a browser, never throw: a value they cannot use leaves the URL as it was. The whole API is declared in [`kino.d.ts`](reference/index.md) for your editor. ## `await kino.fetch(url, options?)` { #fetch } ```js const r = await kino.fetch("https://archive.org/metadata/" + encodeURIComponent(id), { method: "GET", // GET (default), POST, PUT, PATCH, DELETE or HEAD headers: { Accept: "application/json" }, body: "a=1&b=2", // see "Bodies" below; sent only with POST, PUT and PATCH redirect: "follow", // or "manual": get the 3xx itself, with its Location header cookies: true, // false: neither send nor store cookies for this request timeoutMs: 20000, // default 15000, at most 30000 }); r.ok // true for 200 to 299 r.status // the HTTP status r.url // the final URL, after redirects r.headers // { "content-type": "...", ... }: names in lowercase, repeated headers joined with ", " r.text() // the body as a string (already downloaded) r.json() // JSON.parse of the body r.base64() // the body bytes as base64: for anything that is not text ``` Kino hands your code either the text or the bytes of a body, depending on its `Content-Type`; the other form is converted inside the engine when you ask for it, which for a body of several MB takes seconds of your call's time. Ask for the form the content is. - **Bodies.** A string is sent as is (`text/plain` unless you set `Content-Type`). `{ json: value }` sends `JSON.stringify(value)` as `application/json`; `{ form: { a: 1 } }` sends `application/x-www-form-urlencoded`; `{ base64: "…" }` sends those bytes. - **https only, and only your hosts.** The host of the request and of **every redirect hop** must match `hosts` (`*.x` matches subdomains of `x`, not `x`), or be a server the person typed in your settings, exactly as typed. A request to anything else fails before it leaves the device. An `http` URL on a declared host fails too, unless you declared that host `{ "host": "…", "insecureHttp": true }` (apiVersion 2, [see the manifest](manifest.md#insecure-host)). An IP address or a local name (`localhost`, `.local`, …) is always refused unless the person typed it. Kino also refuses a declared name that resolves to an address inside the person's own network (loopback, private, link-local, carrier-grade NAT, multicast, and the IPv6 prefixes that embed one), and never sends your traffic through a proxy set on the device. - **Redirects** (301, 302, 303, 307, 308) are followed by Kino, up to 10 hops; each hop is checked and counted as a request -- a hop Kino refuses (or asks the person about) counts too. A 303, or a 301/302 after a POST, turns into a GET without a body. With `redirect: "manual"` you get the 3xx answer instead (a login form usually answers 302 on success). - **A host you forgot may be asked about, during `resolve` and `episodes` only.** When one of those calls fetches an `https` host you did not declare (a redirect hop included), Kino asks the person ("Quiere conectarse por primera vez a ``. ¿Permitir?"). Your call's time limit stops while they decide, and the fetch goes on after "Permitir" (the host is then approved for good); "Rechazar" or Back fails it as `host_not_allowed` and is remembered. The question comes down unanswered, with nothing remembered, if your call ends first (it failed, timed out, or the person left). One call asks about at most 3 hosts, and nothing more once the person rejects one in it: after that, every other undeclared host of that call just fails as `host_not_allowed`. `search`, `home`, `browse`, the live lists, a download and a call that is already over never ask: the fetch just fails as `host_not_allowed`. Don't rely on it: declare your hosts. - **A non-2xx answer does not throw**: check `r.ok`. Everything else that goes wrong throws an error with a `code` you can test (`e.code === "timeout"`): | `e.code` | When | | --- | --- | | `host_not_allowed` | the host (or a redirect hop) is not one you declared or the person typed, or it is `http` on a declared host not marked `insecureHttp` | | `timeout` | no complete answer within `timeoutMs` | | `network` | the connection failed, or too many redirects | | `too_large` | the request over the size cap, or a body over 5 MB | | `invalid_request` | a bad URL, method, `redirect` or `body`, or more requests than a call allows | - **Limits:** 15 s per request by default (30 s at most), a body of at most 5 MB (decoded with the charset of its `Content-Type`, UTF-8 by default), and at most 60 requests in one call to your plugin, redirect hops and refused hops included (a plugin Kino converted from a [Nuvio scraper](nuvio.md) gets 250). At most 6 of your fetches run at the same time; the rest wait their turn. - **Headers you set** are sent as given, except `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Cookie2` and `Accept-Encoding` (Kino asks for gzip itself and always hands you the body decompressed; a copied browser `Accept-Encoding` would get you compressed bytes instead). Unless you set `User-Agent`, Kino sends `Kino/ (plugin )`. A `Content-Type` header sets the type of the body. - **Cookies:** each plugin has its own cookie jar. Kino stores what your hosts set (`Set-Cookie` never reaches your code) and sends it back on later requests, following the usual rules (domain, path, `Secure`, expiry). The jar is saved on the device, so a login survives the sandbox and the app restarting; it is deleted when the person changes your settings or uninstalls the plugin. - **A marker from [`kino.secret`](#secret)** in the URL, a header or the body is swapped for its real value right before the request goes out, and that request is then held to a stricter rule than the one above: only your manifest's `hosts`, over `https`, on every hop. ## `kino.cookies` { #cookies } ```js kino.cookies.get("https://site.example/", "session") // the value, or null kino.cookies.clear() // forget every cookie of this plugin ``` `get` only answers for URLs your plugin may reach. At most 50 cookies per domain and 64 KB in total. ## `kino.secret(name)` (apiVersion 4) { #secret } ```js const key = kino.secret("apiKey"); // a marker, not the value; any other name throws await kino.fetch(`https://api.example.org/v1/list?key=${key}`); ``` A placeholder for a value sealed in your manifest's [`secrets`](manifest.md#secrets) field. Carry the marker wherever you would carry the value. Kino opens each seal at most once per run and never lets your code see the plain value. You may call it at the top level of your module (`const KEY = kino.secret("apiKey");`): the check Kino runs while installing answers a marker for every name your manifest declares too, so the install goes through. **Where the marker becomes the value: only inside `kino.fetch`.** In the URL's path and query (percent-encoded, so the value can't split a segment or add a parameter), inside a JSON body (JSON-escaped), and as is in headers, a text body or a form field. A marker in the URL's scheme, userinfo, host, port or fragment is left as text: a value never becomes part of the host Kino connects to. A header whose value would carry a control character once the secret is in it is refused rather than sent. **Where that request may go: only a host your manifest's `hosts` lists, over `https`, on every redirect hop.** Never a host approved while the plugin runs, never a server the person typed into your settings, and neither `streamHosts: "any"` nor `liveStreamHosts: "any"` extends to it. A hop anywhere else fails as `host_not_allowed`: "este plugin no puede enviar datos sellados a ``" for a host you did not declare, "... sin https a ``" for plain `http` even on a declared one. **`kino.crypto`.** A marker may be the *entire* `key` of an AES `encrypt`/`decrypt` -- exactly one marker, nothing else in the string -- or part of a longer HMAC `key` or PBKDF2 `password`/`salt`. It is always refused, with "no se puede usar un dato sellado aquí", as `data`, `iv` or `aad`, as part of a longer cipher `key`, and as the key of a non-AES cipher (`des-ede3-*`) -- except a [typed cipher key](manifest.md#typed-keys) (apiVersion 6), which is the whole key of any cipher, `des-ede3` included, and nothing else. That is not an arbitrary line: a known `iv` or `aad` under a sealed key lets a cipher be turned into a way to compute the key back, and a key padded out with known bytes shrinks the search down to the unknown part alone. HMAC and PBKDF2 mix their whole input through a hash, so a known prefix or suffix never splits the secret back out. **Redaction.** Anything Kino hands back to your code that could carry a sealed value -- `r.text()`, `r.url`, header values, a text body's `r.base64()`, `kino.cookies.get`, an error message, a `kino.crypto` answer, and every `kino.log` line -- has the value swapped back for its marker first, whether or not this run has used the secret yet. The forms caught: raw, URL percent-encoding (strict, `+` for space and `%20` for space), JSON-escaped (including `\/` for `/` and `\uXXXX` for non-ASCII, in either hex case, as PHP and Python write them) and base64/base64url. A URL the server returns with the value inside comes back with the marker instead, so a `Stream` built from it won't play: markers are swapped only in `kino.fetch` requests, never in what your plugin returns to Kino. Not caught: a binary response (`r.base64()` of something that was never text), a response header's *name*, a lowercase `%xx` a server happens to echo, and a value the server transforms on purpose (hashed, reversed…). A `kino.crypto` error can still say how many bytes a sealed key was, or whether it was valid hex or base64 -- metadata, never the value. Prefer values of at least 8 bytes: a shorter one is still masked wherever it shows up inside unrelated text, which gets noisier the shorter it is. ## `kino.crypto` { #crypto } Synchronous functions for what sites do to hide their links. Every string argument is text in an encoding you choose (`utf8`, `hex` or `base64`); errors carry `code: "crypto_error"`. ```js kino.crypto.hash("sha256", "hola") // hex by default kino.crypto.hmac("sha1", "key", "data", { outputEncoding: "base64" }) kino.crypto.decrypt("aes-128-cbc", { key: "0123456789abcdef", iv: "abcdef9876543210", data: b64 }) kino.crypto.encrypt("aes-256-gcm", { key: k, keyEncoding: "hex", iv: n, ivEncoding: "hex", data: "hola" }) kino.crypto.pbkdf2("sha256", "password", "salt", 10000, 32) // hex kino.crypto.randomBytes(16) // hex kino.crypto.uuid() ``` - `encrypt` takes text (`utf8`) and returns `base64`; `decrypt` takes `base64` and returns text. Change either with `inputEncoding` / `outputEncoding`; keys, IVs and GCM's `aad` take `keyEncoding`, `ivEncoding`, `aadEncoding` (default `utf8`). - CBC and ECB use PKCS#7 padding unless you pass `padding: "none"`. GCM appends its 16-byte tag to the ciphertext, and expects it there to decrypt (as most sites send it). - A wrong key size, a bad padding or a failed GCM tag throws; it never returns garbage silently. - A [`kino.secret`](#secret) marker is only accepted as the whole `key` of an AES `encrypt`/`decrypt`, or as part of an HMAC `key` or `pbkdf2`'s `password`/`salt` -- never in `data`, `iv` or `aad`, nor as a `des-ede3` key. A [typed cipher key](manifest.md#typed-keys) (apiVersion 6) is the exception: the whole key of any cipher, `des-ede3` included, and nothing else. | Function | Algorithms | | --- | --- | | `hash`, `hmac` | `md5`, `sha1`, `sha256`, `sha512` | | `encrypt`, `decrypt` | `aes-128-cbc`, `aes-192-cbc`, `aes-256-cbc`, `aes-128-ecb`, `aes-192-ecb`, `aes-256-ecb`, `aes-128-ctr`, `aes-192-ctr`, `aes-256-ctr`, `aes-128-gcm`, `aes-192-gcm`, `aes-256-gcm`, `des-ede3-cbc`, `des-ede3-ecb` | | `pbkdf2` | `sha1`, `sha256`, `sha512` | | encodings | `utf8`, `hex`, `base64` | | `generateKeyPair` (apiVersion 6) | `ec`, `ed25519`, `x25519`; `ec` on `P-256`, `P-384`; at most 64 private keys alive per runtime (a new one drops the oldest) | | `sign`, `verify` (apiVersion 6) | ECDSA with `SHA-256`, `SHA-384` as `der` or `ieee-p1363`; Ed25519; a signature at most 512 bytes | | `importKey`, `deriveSharedSecret` (apiVersion 6) | public keys as `jwk`, `spki`, `raw`; ECDH (same curve) and X25519 | ### Key pairs, signatures and key agreement (apiVersion 6) { #key-pairs } Some players prove they are a real player by signing a challenge: they make a key pair, sign what the server sends with the private key and send back the public key. `kino.crypto` does that with keys that never leave Kino: ```js // Node: const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); // WebCrypto: await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"]); async function createAttest(challenge) { const { privateKey, publicKey } = kino.crypto.generateKeyPair({ type: "ec", namedCurve: "P-256" }); // WebCrypto's ECDSA signature is r||s (64 bytes on P-256): format "ieee-p1363". Node's default is "der". const signature = kino.crypto.sign({ key: privateKey, data: challenge, hash: "SHA-256", format: "ieee-p1363" }); return { publicKey: publicKey.jwk, signature }; // signature is base64; pass outputEncoding: "hex" for hex } ``` - `generateKeyPair({ type: "ec", namedCurve: "P-256" | "P-384" })`, `{ type: "ed25519" }` or `{ type: "x25519" }` answers `{ privateKey, publicKey }`. `publicKey` is `{ type, namedCurve?, jwk, spki, raw }`: the JWK object in WebCrypto's key order (`{ crv, kty, x, y }`, base64url), the DER SubjectPublicKeyInfo as base64, and the raw key as base64 (`04||x||y` for `ec`, 32 bytes otherwise). - `privateKey` is a handle, `{ type, namedCurve?, handle }`: the key itself stays inside Kino. A handle works only in the sandbox that made it: not in [`sign()`](signed-streams.md)'s signing lane, not after the plugin restarts (it closes after a few idle minutes), never on another device, so make the key in the call that uses it. At most 64 live at once; a new one drops the oldest. Private keys cannot be imported or exported. - `sign({ key, data, encoding?, hash?, format?, outputEncoding? })` reads `data` as `utf8` unless you say `hex` or `base64`, and answers base64. `ec`: `hash` `"SHA-256"` (default) or `"SHA-384"`, `format` `"der"` (default) or `"ieee-p1363"` (64 bytes on P-256, 96 on P-384). `ed25519`: 64 bytes, no `hash`. - `verify({ key, data, signature, signatureEncoding?, hash?, format? })` answers `true`/`false`; a malformed signature is `false`. `key` is a public key (yours, or a peer's from `importKey`), a bare `{ jwk }`, or your own private key. - `importKey({ format: "jwk", key: jwkObject })`, `{ format: "spki", key: base64 }` or `{ format: "raw", key: base64, type, namedCurve? }` answers a public key in the same shape; a point off its curve throws. - `deriveSharedSecret({ privateKey, publicKey })` is ECDH (both `ec` on the same curve: 32 bytes on P-256, 48 on P-384) or X25519 (32 bytes), base64 by default. Hash it (or HKDF it with `hmac`) before using it as a key. - No `kino.secret` marker is accepted anywhere in these five functions. | Node / WebCrypto | `kino.crypto` | | --- | --- | | `generateKeyPairSync("ec", { namedCurve: "P-256" })` / `subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, …)` | `generateKeyPair({ type: "ec", namedCurve: "P-256" })` | | `generateKeyPairSync("ed25519")` / `generateKeyPairSync("x25519")` | `generateKeyPair({ type: "ed25519" })` / `{ type: "x25519" }` | | `publicKey.export({ format: "jwk" })` / `subtle.exportKey("jwk", publicKey)` | `publicKey.jwk` | | `publicKey.export({ format: "der", type: "spki" }).toString("base64")` / `exportKey("spki", …)` | `publicKey.spki` | | `subtle.exportKey("raw", publicKey)` | `publicKey.raw` (base64) | | `crypto.sign("sha256", data, privateKey)` | `sign({ key: privateKey, data, hash: "SHA-256" })` (DER) | | `crypto.sign("sha256", data, { key, dsaEncoding: "ieee-p1363" })` / `subtle.sign({ name: "ECDSA", hash: "SHA-256" }, …)` | `sign({ key, data, hash: "SHA-256", format: "ieee-p1363" })` | | `crypto.sign(null, data, ed25519Key)` / `subtle.sign("Ed25519", …)` | `sign({ key, data })` | | `crypto.verify(…)` / `subtle.verify(…)` | `verify({ key: publicKey, data, signature, … })` | | `createPublicKey({ key: jwk, format: "jwk" })` / `subtle.importKey("jwk", …)` | `importKey({ format: "jwk", key: jwk })` | | `crypto.diffieHellman({ privateKey, publicKey })` / `subtle.deriveBits({ name: "ECDH" or "X25519", public }, …)` | `deriveSharedSecret({ privateKey, publicKey })` | Buffers become strings: pass `encoding`/`outputEncoding` (`hex` or `base64`) where Node takes or gives a Buffer. There is no `kino.crypto.generateKeyPairSync`: a Node or browser library calling it must be adapted to these calls. A plugin that uses them should declare `"apiVersion": 6`, so a Kino without them refuses to install it (`validate` says so). ## `kino.browser` (apiVersion 6) { #browser } Only with `"browser": true` (capture only) or `"browser": "pages"` (capture and page reads) in the manifest, approved by the person in red. `kino.browser.capture(url, options?)` opens a page in a hidden web view inside a `resolve` the person started and returns the video requests it made, with the headers and cookies to play them; `kino.browser.page(url, options?)` returns a page's HTML once it is past the site's automatic check. Kino never solves a captcha: a page that asks for a human ends the call with `blocked`. Prefer `kino.fetch` whenever it works. Everything -- where each may be called, the safety model, timeouts, errors and a complete example -- is on [Hidden browser](browser.md). ## `await kino.meta(query)`: asking Kino about a title (Kino 0.9.53) { #meta } ```js if (typeof kino.meta === "function") { // Kino 0.9.53+: absent before, so check first const m = await kino.meta({ type: "series", ids: { imdb: "tt0944947" }, lang: kino.lang }); if (m) { // { title, overview, year, poster, backdrop, logo, genres, runtimeMinutes, tagline, certification, directors, // episodes: [{ season, number, title, overview, still, airDate, id: "tt0944947:1:1" }], // ids: { imdb, tmdb, tvdb, kitsu, mal, anilist }, ratings, cast, sources: ["tmdb", ...] } } } ``` Kino answers what it knows about a title, and your plugin never touches a TMDB key for it. The answer is built exactly the way Kino's own info page builds a title's page: **Kino's own TMDB lookup** first (an app feature of Kino, on Kino's own key, in Spanish es-MX; no key ever reaches your code), **AniList** for an anime (through the anime id mapping, so a `kitsu`, `mal` or `anilist` id works too), then **the person's installed `meta` plugins** (a Stremio metadata addon configured with their own key, for example). Each later source only fills what the earlier ones left empty; ratings add up. `null` means nobody knew the title: it is never an error. - **The query**: `type` (`"movie"` or `"series"`) and at least one id: `imdb` (`"tt0133093"`), or `tmdb`, `tvdb`, `kitsu`, `mal`, `anilist` as positive integers (a number or a digit string, up to 2147483647). `lang` (`"es"`, `"es-MX"`) goes to the person's meta plugins. A bad query throws `invalid_request`; the whole query is at most 4,096 characters as JSON. - **The answer**: every field appears only when known, except `ids` (every id Kino knows for the title, yours included: ask with an IMDb id and get the TMDB and TVDB ones) and `sources` (`"tmdb"`, `"anilist"`, `"plugin"`: who contributed). `episodes` only for a series (at most 5,000; trimmed from the end so the whole answer stays under 1,000,000 characters), each with its Stremio-style `id`. `ratings` at most 6 (TMDB's vote is `{ source: "tmdb" }`), `cast` at most 20, `genres` at most 5. - **Never yourself**: your own `meta` export is never asked on your behalf, and a `kino.meta` called from inside a `meta` export asks no plugin at all (only TMDB and AniList), so two meta plugins cannot ask each other in a loop. - **Limits**: at most 30 calls a minute per plugin (then `rate_limited`, a token bucket: it refills one call every 2 s); 8 s at most (6 s for each meta plugin; what is known by then is answered), counted inside your call's own time limit; the TMDB/AniList part is cached 30 minutes per query and the meta plugins' answers share the info page's own 30-minute cache. Not from `sign()` (`not_allowed`). No new network destination: TMDB and AniList are Kino's own, and each meta plugin uses its own approved hosts. Kino's telemetry counts calls and error codes, never the ids. - **No new apiVersion**: `kino.meta` is in every plugin on Kino 0.9.53 and later, whatever its manifest says; older Kino has no such function, so feature-detect it (`typeof kino.meta === "function"`). `node sdk/validate.mjs` warns when your code calls it without that check. The other way round -- your plugin describing titles for Kino's info page -- is the [`meta` capability](contract.md#meta). Under the Node kit, `kino.meta` answers `null` unless you point `KINO_META_FIXTURE` at a JSON file of answers (keys `"movie:imdb:tt0133093"` or `"tmdb:1399"`; see [Test it locally](test-locally.md)). ## `await kino.tmdb(path, params?)`: TMDB without a key in your code (Kino 0.9.53) { #tmdb } ```js async function tmdb(path, params) { if (typeof kino.tmdb === "function") return kino.tmdb(path, params); // Kino 0.9.53+: Kino brings the key, never your code // Older Kino: your own "tmdbKey" setting, as before. const key = kino.config.get("tmdbKey"); if (!key) throw kino.error("auth_required", "falta la llave de TMDB"); const q = new URLSearchParams({ ...params, api_key: key }); const r = await kino.fetch(`https://api.themoviedb.org/3${path}?${q}`); if (!r.ok) throw kino.error(r.status === 404 ? "not_found" : "unavailable", "TMDB respondió " + r.status); return r.json(); } const week = await tmdb("/trending/movie/week", { language: "es-MX" }); ``` A read-only door to TMDB's v3 API. Your plugin never holds a key: Kino brings one, in this order: 1. **Kino's own TMDB key**, always. Its calls go through Kino's TMDB cache (shared with Kino's own screens, kept on disk; a request already on its way is shared, not repeated) and through limits of their own, so plugins cannot spend Kino's TMDB quota: at most 20 calls per 10 s per plugin and 60 per 10 s for all plugins together reach TMDB on Kino's key. 2. **The person's own key, only when Kino's fails**: TMDB refuses Kino's key (401/403), TMDB rate-limits it (429), or one of Kino's two limits above is spent. Then the same request goes again with the key the person typed in **Ajustes ▸ App ▸ Tu llave de TMDB** (optional; a v3 API key, 32 hexadecimal characters, or a v4 read access token; synced between their devices), else the key they configured in an **installed Stremio addon** (Kino looks in each addon's saved configuration for a field whose name contains "tmdb", no addon is listed by name, and uses it only after the person said yes once to "Usar la llave de TMDB de tu addon ", also synced). 3. **With neither**: a copy from the cache up to 7 days old when there is one; otherwise `rate_limited` (Kino's limit, or TMDB's 429) or `unavailable` (TMDB refused Kino's key). `no_tmdb_key` is left for a Kino build with no key of its own and a person with none either: `e.userMessage` is then Kino's own sentence for the person, in their language: "Agrega tu llave de TMDB en Ajustes, o instala un addon de TMDB de Stremio configurado con tu llave." / "Add your TMDB key in Settings, or install a Stremio TMDB addon set up with your key." Uncaught, the person reads that same sentence. On such a build, a person's key TMDB refuses is `no_tmdb_key` too. A key your plugin keeps in its own settings (a `tmdbKey` setting) stays your plugin's business: Kino never reads it for `kino.tmdb`. Kino adds the key itself (`api_key` for a v3 key, an `Authorization: Bearer` header for a v4 token): your code never sees any key, Kino's or the person's, and answers, errors and logs never carry one. You do not declare `api.themoviedb.org` in `hosts` for it. - **`path`**: starts with `/discover`, `/trending`, `/search`, `/movie`, `/tv`, `/find`, `/genre`, `/configuration`, `/person` or `/collection` (`"/movie"` or `"/movie/603/credits"`), without the `/3` version, without a query string, no `..` and no `//`. Anything else (an account, a list, a rating: what writes or reads the person's account) is `invalid_request`. GET only. - **`params`**: a plain object of at most 20 strings, numbers or booleans, each at most 500 characters as text, names like `language`, `page`, `with_genres`, `vote_count.gte`, `append_to_response`. Never `api_key`, `session_id`, `guest_session_id`, `request_token` or `access_token` (`invalid_request`). - **The answer**: the body as parsed JSON. `404` is `not_found`, a body over 2 MiB is `too_large`; when TMDB does not answer in 15 s (`timeout`), cannot be reached (`network`) or answers 5xx (`unavailable`), the cached copy (up to 7 days) is served instead when there is one. Anything else that is not a 2xx with JSON is `unavailable`. - **Limits**: at most 40 calls per 10 s per plugin whichever key answers (a token bucket), and the two limits on Kino's key above; a fresh cached answer counts against neither of Kino's. Cached 10 minutes in memory by path and params (so per `language`; only bodies up to 512 KiB), and on disk with Kino's TMDB cache (from 1 hour for lists and searches to 7 days for `/find` and `/genre`), whichever key fetched it. Not counted in `kino.fetch`'s 60 requests per call. Not from `sign()` (`not_allowed`). Kino's telemetry counts calls by how they were served (cache, Kino's key, the person's key) and error codes, never a path or a key. - **No new apiVersion**: Kino 0.9.53 and later; feature-detect it (`typeof kino.tmdb === "function"`) and keep your own key setting only as the fallback for older Kino, as above. A plugin that builds its catalog from TMDB no longer needs to ask each person for a key. A complete example: [A TMDB catalog with no key in the plugin](cookbook.md#tmdb-catalog). Under the Node kit, `kino.tmdb` uses your key from `KINO_TMDB_KEY` (or `"tmdbKey"` in `sdk/config.json`) where the app uses Kino's, with the stricter limit on Kino's key (20 per 10 s; the kit has no person's key to fall back to), and with `KINO_TMDB_FIXTURE` it answers offline from a JSON file keyed `"?"` or `""`. Without either, it throws `no_tmdb_key`, as a Kino build without a key does. ## `kino.sleep(ms)` and `kino.error(code, message?, { userMessage }?)` { #sleep-error } `await kino.sleep(1500)` waits 0 to 5000 ms (for a site that rate-limits you); the time counts inside the call's own limit. `kino.error` builds the typed errors of [Errors people understand](contract.md#errors); its optional `{ userMessage }` (apiVersion 6) is your own sentence for the person, shown under [its rules](contract.md#user-message) as "Mensaje de : …". ### Errors your code can catch { #catch } Every failure a `kino.*` call reports is an ordinary `Error` your `try`/`catch` receives (your own `throw`s still have [the rejection trap](engine-limits.md#rejection-trap)). Test `e.code`, never the text of `e.message`: that is Spanish, for the log, and may change. | Where | `e.code` | | --- | --- | | `kino.fetch` | `host_not_allowed`, `timeout`, `network`, `too_large`, `invalid_request` ([the table](#fetch)) | | `kino.crypto` | `crypto_error` | | `kino.browser.capture` | `browser_unavailable`, `timeout`, `blocked`, `busy`, `not_allowed`, `invalid_request` | | `kino.browser.page` | the same, plus `rate_limited` ([Hidden browser](browser.md#page)) | | inside [`sign()`](signed-streams.md#rules) | `host_not_allowed` from `kino.fetch`; `not_allowed` from `kino.storage`, `kino.cookies` and `kino.sleep` | | `kino.storage` over 256 KB or a bad `ttlMs`, `kino.html.select` over its limits, `kino.secret` with an undeclared name | no `code`: a plain `Error` with a Spanish message | From Kino 0.9.50 a **synchronous** `kino.*` call that fails (a full `kino.storage`, a bad cipher key, a selector that is too long) is caught by your `try`/`catch` like any other error. Kino 0.9.49 and older ended the whole call there even inside a `try`/`catch`, so on those versions keep an eye on what you store. `kino.error(code, message?, { userMessage }?)` builds an `Error` whose `name` is `KinoError_` (`KinoError_not_found`), with `code` and, when you passed one, `userMessage`. A code that is not one of the five becomes `code: "unknown"`, and the person reads a plain error. Throw it as it is, or rethrow a caught one: the code reaches the person only when the error leaves your function with it. Whatever you throw (an `Error`, a string, anything) reaches Kino as text, cut at 2,000 characters. ```js try { kino.storage.set("cache:" + key, JSON.stringify(rows), { ttlMs: 3600000 }); } catch (e) { kino.log("cache: not saved", e.message); // full storage: keep going without the cache } try { return await api("/play/" + encodeURIComponent(ref)); } catch (e) { if (e.code === "timeout" || e.code === "network") return backupStream(ref); throw e; // a kino.error keeps its code and its userMessage } ``` ## `kino.config` { #config } ```js kino.config.get("server") // text, url, password, select: a string; toggle: true/false kino.config.get("sources") // list (apiVersion 4): [{ url: "https://…", category: "Noticias" }, …] kino.config.all() // every setting that has a value, as an object ``` Read-only: the values the person saved, or the `default` of a setting they left alone. A `toggle` without a `default` is `false` and a `select` without one is its first option, so both always have a value. A `text` or `password` with no value and no `default`, a `url` the person left empty (it never has a default) and an empty `list` are `undefined`. A `list` is an array of objects, one per entry, keyed by its `fields`' keys, trimmed, without all-blank entries. The `section`, `status` and `action` types hold no value and never appear here. Every type is on [The settings form](settings-form.md#types). ## `kino.html.select(html, css)` { #html } Parses `html` and returns `[{ text, html, attrs }]` for every element matching the CSS selector (Jsoup's selector syntax): `text` is its text, `html` its inner HTML, `attrs` an object of its attributes. Only the first 2,000,000 characters of `html` are read, at most 500 elements come back, and it throws if the combined text and HTML of the matches goes over 5,242,880 characters (5 MB). A selector longer than 10,000 characters throws `Error("selector CSS demasiado largo (más de 10000 caracteres)")`. **It exists only inside Kino**: the Node kit's version throws, so test anything that uses it in the app. ## `kino.storage` { #storage } ```js kino.storage.get("key") // the string, or null kino.storage.set("key", "v") // values are converted to strings kino.storage.set("key", "v", { ttlMs: 3600000 }) // expires after that many milliseconds kino.storage.remove("key") kino.storage.keys() // every key, as an array (expired keys are already gone) ``` Synchronous, private to your plugin, and it survives restarts of the sandbox and of the app. At most 256 KB in total (measured as the JSON of all keys and values); going over throws `Error("almacenamiento del plugin lleno (256 KB)")`. It is deleted when the person uninstalls the plugin, and it is **not** cleared when they change your settings. `set`'s third argument is optional: leave it out for a permanent entry, exactly as before this option existed. Give `{ ttlMs }` to make the entry expire -- after that many milliseconds `get` returns `null` and `keys()` no longer lists it, even across a restart of the app. `ttlMs` must be a whole number greater than 0 and at most 2,592,000,000 (30 days); anything else throws before your entry is touched, the same way an oversized value already does. An expired entry never counts against the 256 KB cap: it is dropped the next time your plugin reads or writes storage. Example, a Home row cached for an hour: ```js export async function home() { const cached = kino.storage.get("home-rows"); if (cached) return JSON.parse(cached); const rows = await buildHomeRows(); kino.storage.set("home-rows", JSON.stringify(rows), { ttlMs: 60 * 60 * 1000 }); return rows; } ``` ## `kino.log(...args)` { #log } Also `console.log`, `console.info`, `console.warn` and `console.error`: they all go to the log (tag `KinoPlugin` in `adb logcat`; `KinoPlugin/` in a debug build of Kino, or in any build while your plugin's [Modo debug](diagnostics.md#debug) switch is on: `"debug": true` only makes that the default), objects are written as JSON, and a message is cut at 2000 characters. Under the Node kit they go to stderr. When a call of a plugin whose manifest says `"telemetry": true` or `"verbose"` (apiVersion 6) **fails** (it throws, times out, returns something unusable, including `sign`, `settingsStatus`, `action` and `validateSettings`), the lines it logged during that call (the last 30, each cut at 300 characters) travel with the failure report to the maintainers' error tracker as `plugin_log`, so a `kino.log("home: status", r.status)` before the throw is how you see why it failed on someone else's phone. Each report is tagged with your plugin's id and version; at most one report per function and kind of failure an hour. Nothing is sent for a call that succeeds, and no line of any plugin that does not declare `telemetry` (recommended or not, a converted Nuvio scraper): for those Kino only notes that the call failed (your id and version, the function, the kind of failure). Today a declared plugin's lines are always sent; a later Kino build will let the person turn "Enviar registros de errores" off on each device, and then nothing is sent while it is off. Before it leaves the device every line has URLs, hostnames, IPs, e-mails, long ids, long hex/base64 runs, credential-shaped text, the person's setting values and the text of their search or title removed, and the whole is capped at 2 KB (the newest lines win). Still: log what happened (a status, a step, a count), never what the person typed or a secret, and never a setting's value. Works on every `apiVersion`. **`kino.log.report(...args)`** (apiVersion 6, with `telemetry`) writes a line like `kino.log` and also tells the error tracker that your plugin served a **degraded** result even though the call worked: it fell back to a shared account, used a backup source, trimmed a list. The details (the area, the caps) are on [Logs and telemetry](diagnostics.md#report). ## `kino.rank` { #rank } For a search backend that only matches a loose bag of shared words rather than a title as a whole: asking it a long title can return twenty unrelated results that merely share one common word, with the real match buried on page two. These three pure functions make a backend like that behave like a title search, without touching its own JSON shape. ```js kino.rank.shortQuery(query) kino.rank.sortBySimilarity(items, query, getTitle?) kino.rank.filterRelevant(items, query, getTitle?) ``` - **`shortQuery(query)`** returns the title's HEAD, up to its first `:`, `,`, `|`, en dash or em dash: ask your backend that instead of the whole title, so its own ranking has less noise to sort through. A one- or two-letter head ("El", "A") identifies nothing, so the whole (trimmed) text comes back instead; a plain `-` is never a cut point (it would split "Spider-Man"). Try it against your backend first -- some do worse with a short query, not better. - **`sortBySimilarity(items, query, getTitle?)`** reorders `items` so the ones sharing the most words with `query` come first; ties keep the backend's own order. - **`filterRelevant(items, query, getTitle?)`** drops items that only share a stray word with `query`. Reordering alone still shows a full page of near-misses when the title genuinely is not on the backend; this makes an absent title come back with 0 results instead. `query` is a title, or an array of several forms of one worth trying together -- `[query.q, query.originalTitle, ...query.altTitles]`, since a backend may only know a title in one language. `getTitle` reads a title off one of your own `items`; it defaults to `(item) => item.title`, and may itself return an array the same way `query` can, when an item keeps a title in more than one field or language (every form's words are combined). Matching folds accents and case and ignores words of 1-2 letters (the "el", "de", "of" that make unrelated titles look alike); `filterRelevant` keeps an item once it shares at least 60% of a requested title's distinctive words. **Bad input never throws.** Unlike `kino.fetch`/`kino.crypto`/`kino.sleep`, these three never raise a `kino.error` for a malformed argument: `items` that is not an array answers `[]` from either function. An item with no usable title -- `null`, `undefined`, `getTitle` returning something that is not a string (or an array with none in it), or `getTitle` itself throwing -- is treated as "no title" rather than crashing your call: `filterRelevant` drops it like an actual near-miss, and `sortBySimilarity` sorts it after every item that does have one, in your list's own order among themselves. ```js export async function search(query) { const titles = [query.q, query.originalTitle, ...query.altTitles]; const r = await kino.fetch(BASE + "/search?q=" + encodeURIComponent(kino.rank.shortQuery(query.q))); const found = r.json().results; // whatever shape your backend answers with const relevant = kino.rank.filterRelevant(found, titles, (x) => x.name); return kino.rank.sortBySimilarity(relevant, titles, (x) => x.name).map(toItem); } ``` If your backend already ranks a full title well, skip `shortQuery` and run only `filterRelevant`/`sortBySimilarity`, on what it gives you for `query.q` as typed. Two things left out on purpose. Neither retries with the full title: if `shortQuery`'s head happens to be a common word (e.g. "Love, Death & Robots" -> "Love") and the backend returns nothing relevant for it, retry `search` with the full title yourself when the short one comes back empty. And neither does anything with season numbers or ordering: how a backend spells "season 2" in its own titles ("T2", "Temporada 2", …) is specific to that backend, not something these can fold in. # Live channels There are two ways to give Kino live TV, and a plugin can do both: - **`live` items** (apiVersion 2): channels mixed into your "Ver más" pages and search results, next to your movies and series, and from apiVersion 6 into your Home rows too. - **The `channels` capability** (apiVersion 3): your channels in Kino's own En vivo tab, TV guide, channel drawer and Home "Canales en vivo" row, given one by one or as an M3U playlist with an XMLTV guide that Kino downloads and parses itself. Testing them with the Node kit is on [Test it locally](test-locally.md#live). ## Live channels (apiVersion 2) { #live-items } With `"apiVersion": 2` an item may be a live channel: `kind: "live"`, in a `browse` page or `search` result, next to your movies and series. Nothing to declare beyond the version. In a `home` row a channel stays only from `"apiVersion": 6` ([below](#home-rows)). ```js export async function home() { return [{ id: "en-vivo", title: "En vivo", items: [ { id: "canal-1", ref: "live:1", title: "Canal Uno", kind: "live", poster: "https://cdn.example.org/canal-1.png" }, ], }]; } export async function resolve(ref) { if (ref.startsWith("live:")) { const url = await freshPlaylistUrlFor(ref); // look the live link up here, never in home() return { url, mime: "application/vnd.apple.mpegurl" }; } // ...movies and episodes as before } ``` What Kino does with a `live` item: - Its card wears an "EN VIVO" badge (Home, "Ver más", search, phone and TV), and tapping it goes **straight to the player**: no info page, nothing to read or pick. `resolve(ref)` gets the item's `ref`, exactly as for a movie. - In **search**, a `live` item is kept only when its name matches what was asked (most of the words of 3 or more letters of the query, its `originalTitle` or one of its `altTitles`, like [`kino.rank.filterRelevant`](kino-api.md#rank)). A channel plugin that answers every search with its whole list when nothing matches sees those channels dropped; movies and series are never judged this way. - The `Stream` plays as live: an HLS or DASH live manifest (`.m3u8`/`.mpd`) is what the player expects; a progressive file plays too but reads as a channel (no seek bar, no length). `headers`, `subtitles` and `expiresInSeconds` work as for any stream; `durationMs` and `audioTracks` are ignored (a separate audio file cannot follow a live window: put a channel's other languages inside its manifest, e.g. HLS `EXT-X-MEDIA` renditions, and the player's audio menu offers them). - The player shows the live overlay (no progress bar, no seeking, no "next") and starts at the live edge. If it falls behind the live window, or the playlist resets or stalls, it re-joins the live edge in place without calling you (a few times a minute). On any other cut, or when your URL stops working, it calls `resolve` again with the same `ref` after 2 s, then 4 s, then 8 s: three reopens, replenished once the channel has played for five seconds. Only after the third failed reopen does the person read "Se cortó la señal de y no volvió". `expiresInSeconds` plays no part for a channel: a cut always re-resolves. - A channel is never saved: no library row, no resume position, never in "Continuar viendo", and never downloadable (a plugin that declares `download` gets "Este video no se puede descargar" for it). `runtimeMinutes` on the item is ignored; a channel has no `episodes`. Limits: a `live` item from a plugin on `"apiVersion": 1` is dropped silently, like any invalid item (and a row left with no items disappears), so declare `2` before you return one. A channel still counts against the same row and page sizes as any item. These channels appear in your rows, with your plugin's name; to put channels in Kino's En vivo tab and its "Canales en vivo" row, use the apiVersion 3 `channels` capability ([below](#en-vivo-tab)). ### Channels in your Home rows (apiVersion 6) { #home-rows } From `"apiVersion": 6` (Kino 0.9.50) a `kind: "live"` item stays in a `home` row: it shows as a channel card with the "En vivo" badge and opens like a channel from En vivo. A row of channels on Home (say, a country's channels) is just a `home` row whose items are `kind: "live"`. Below 6 Kino drops channels from Home. An `adult: true` channel follows the same [18+ lock](contract.md#adult) as any 18+ entry, and a row left with nothing to show is not shown. ## Channels in the En vivo tab (apiVersion 3) { #en-vivo-tab } Declare `"apiVersion": 3` and the capability `"channels"`, and export `liveCategories()` and `liveChannels({ categoryId, cursor })` (and, optionally, `guide(...)` and `liveSearch(...)`, see [below](#live-contract)). Your channels then appear in Kino's own En vivo tab, TV guide, channel drawer and Home "Canales en vivo" row, in a section with your plugin's name. `channels` does not replace `search`/`home`: the manifest still needs one of them (a plugin with only channels exports a `home()` that returns `[]`). Items of kind `"live"` in your rows keep working; a plugin can do both. On install, and on an update that adds it, the person reads and approves "Agrega canales en vivo a la pestaña En vivo". ## Channels from any server (`liveStreamHosts`, apiVersion 3) { #live-stream-hosts } IPTV lists name their streams on servers you cannot know ahead of time, often plain `http` and often a bare public IP. For that, and only that, a `channels` plugin may add: ```json "apiVersion": 3, "capabilities": ["home", "resolve", "channels"], "liveStreamHosts": "any" ``` It is read only with `"apiVersion": 3` (an older manifest ignores it, like any field it does not know). There, `"any"` is the only value and it needs the `channels` capability: otherwise the manifest is refused with `El campo "liveStreamHosts" solo admite "any"` or `"liveStreamHosts" necesita la capacidad "channels"`. What it allows: **a live channel's stream** (the `url` of a channel's inline `stream`, or the `url` `resolve` returns for a channel; items you mark as live are treated as channels) may be on **any public host**, over `http` or `https`, a public IPv4 address included (not an IPv6 literal). The player then fetches that manifest and its variants, segments and keys, and follows their redirects, under the same rule. The audio and subtitle renditions the HLS manifest itself lists (`#EXT-X-MEDIA`) are part of that stream and follow the same rule too; the `subtitles` and `audioTracks` you return in a `Stream` do not (see below). What it never allows: - the home network: private, loopback, link-local and carrier-grade NAT addresses, IPv6 literals, `localhost` and local names (`.local`, `.lan`, …), and a public name that resolves into any of them (refused when the player connects); - other ports or schemes of a server the person typed: that server is reached exactly as typed, never "any"; - `kino.fetch`: your own requests still reach only your `hosts` and the person's servers; - the playlist and XMLTV downloads a `{ playlist }` declaration asks Kino to make: those URLs must still be on your `hosts` (or the person's server); - subtitles, audio tracks and a `drm` block's `licenseUrl`: still your `hosts` only, and every redirect they make is judged the same way; - movies and episodes: a non-live `Stream` is checked exactly as before (unless your plugin declares [`streamHosts: "any"`](manifest.md#stream-hosts) or the person granted it the [broad video permission](contract.md#broad-video)); - images: the poster rule (http or https, never local) does not change. The consent sheet shows it in red, "Puede reproducir canales desde cualquier servidor que indique su lista", and an update that newly adds it waits for the person's approval, like a new host. ## The channel functions (apiVersion 3) { #live-contract } With the `channels` capability ([above](#en-vivo-tab)) Kino calls up to four more functions. Their arguments: - `liveCategories()` gets `null`. - `liveChannels({ categoryId, cursor })` gets the `id` of one of your categories, and `cursor` `null` for the first page or the `next` of the page before. - `guide({ channelIds, from, to })` gets at most 50 of your channel ids and a window of at most 24 hours: `from` and `to` are epoch milliseconds. - `liveSearch({ query })` (optional) gets what the person typed in En vivo's search, trimmed, at least 2 characters. They return: ```ts LiveCategory = { id: string, title: string, country?: string, adult?: boolean, genre?: Genre } Playlist = { playlist: { url: string, format: "m3u", headers?: Record, streamHeaders?: Record, genre?: Genre, epg?: { url: string, format: "xmltv" }, refreshHours?: number, hideGroups?: string[], resolve?: boolean } } LiveChannel = { id: string, title: string, categoryId?: string, ref?: string, stream?: Stream, logo?: string, number?: number, adult?: boolean } GuideEntry = { channelId: string, title: string, start: number, end: number, description?: string } ``` A plugin can give its channels in three ways, and mix them: 1. **A channel with a `ref`.** The `ref` goes to `resolve(ref)` when the person plays it, exactly like a `live` item's, and its Stream plays as live. 2. **A channel with an inline `stream`.** A `Stream` checked by the same rules as `resolve()`'s answer ([The `Stream` rules](contract.md#stream)); it plays with no call to your plugin. A channel whose `stream` is refused is dropped. With both `ref` and `stream`, the stream plays and the `ref` is only the fallback (but a [request-signed](signed-streams.md) inline stream is set aside, and the `ref` plays). A channel with neither is dropped. Some channels only answer a known player: give the `Stream` a `headers` with the `User-Agent` (or `Referer`) it insists on, and the player sends it with every request for that channel. 3. **A playlist.** Put `{ playlist: { ... } }` entries next to your categories in the `liveCategories()` answer (or return one alone). Kino downloads the M3U list itself, and its XMLTV guide from `epg.url`, and groups the entries into categories. Both URLs must be `https` on one of your `hosts` (or `http` on one declared `insecureHttp`, or a server the person typed), always: a playlist on another host is dropped, and an `epg` on another host only loses the guide. `headers` go with those downloads. `streamHeaders` are what the **player** sends for every channel of the list, for the channels that only answer a known `User-Agent` (or a `Referer`): they are filtered like a Stream's `headers` and kept apart from `headers` on purpose, because those carry your list's own credentials and go only to the list's host, never to the many hosts the channels are on. A header an M3U entry names itself (`#EXTVLCOPT:http-user-agent=...`, `#EXTHTTP:{"User-Agent":"..."}`, a `url|User-Agent=...&Referer=...` suffix, or `#KODIPROP` stream headers) wins; only `User-Agent`, `Referer`, `Origin` and `Cookie` are kept, and a value with a control character is dropped. The list may be UTF-8, Latin-1 or UTF-16 (with or without a BOM); `#EXTINF` attributes may be double-quoted, single-quoted or bare (`tvg-id=abc`). A list over 20 MB, or a guide over 50 MB, is not refused: Kino keeps its start, up to its last whole line (`node sdk/run.mjs live playlist` says when). A guide `` without `stop` ends where the next programme of its channel starts, or one hour after its start when none follows. Kino versions before the one that added `streamHeaders` ignore the field, so the list plays without it. `refreshHours` is 1 to 168 (default 12); `hideGroups` lists group titles not to show (case doesn't matter, at most 50). With `resolve: true`, each entry plays through your `resolve()`, for lists whose links need a fresh token. At most 10 per answer. Each entry gets a channel code, the key of favourites and recents: its `tvg-id` when that is a valid id and the entry is the **first of the list to use it**, else one made from its URL and name. A later entry repeating a `tvg-id` never moves the first one's code, but it gets a URL-and-name code itself, which changes (and its favourites and recents stop matching) when its URL does; a copy inserted *before* the first one takes the `tvg-id` code over. Give every entry a stable, unique `tvg-id`; `node sdk/run.mjs live playlist ` lists the repeated ones. The rules: - Times are epoch milliseconds. - At most 200 categories (playlists don't count), and at most 500 channels per `liveChannels` page. `id` follows the item `id` pattern; an `id` starting with `~` is reserved for Kino's own playlist entries and dropped. A repeated `id` in one answer is dropped. `title` is required. - `country` is an ISO 3166 two-letter code (`"CO"`), informational; anything else is ignored. `number` is 1 to 9999 (anything else counts as no number); `logo` follows the poster rules; `categoryId` is optional and informational in `liveChannels` (a channel is listed under the category `liveChannels` was asked for); one that is not a valid id becomes empty. In a `liveSearch` hit it is how Kino knows whether the hit is 18+ ([below](#live-search-adult)). - Kino pages `liveChannels` until `next` is missing, repeats, or brings nothing new. A category's first listing asks at most 10 pages; when the last one still has a `next`, Kino keeps it and asks 5 more pages each time the person scrolls near the end of the list, up to 10,000 channels (or 200 pages) per category. Kino 0.9.49 and older stop at the first 10 pages and never ask `liveSearch`. - `liveSearch` is optional, for a catalog too big to list whole. Kino asks it from En vivo's search (the phone's field, the TV guide's and the TV channel drawer's) when the person stops typing, only while some of your channels were never listed (a category not opened yet, or one with pages left), and keeps each answer 10 minutes per query. It returns channels exactly like a `liveChannels` page (`next` is ignored), at most 100 kept, with the same `id` a listed channel has, so favourites and recents match. Kino still shows only the ones whose name contains what was typed (or whose number is it). A channel found this way plays like a listed one, and a favourite Kino no longer has in memory nor in its cache is looked up again by name through `liveSearch` (one call) before the first pages of your first 10 categories; without `liveSearch`, only those first pages are looked at, never the pages after them. Its time limit is 15 s and it runs as a background call: a slow search never marks the plugin "No responde". Export it only when you can really search: an empty answer is taken as "nothing found", and Kino keeps saying some channels were not loaded. A preheat of the next channel (Kino resolving a neighbour ahead while one plays) never asks `liveSearch`: it looks only through your listings, and the zap itself asks when needed. - **Mark every `liveSearch` hit** (apiVersion 6, when you have an 18+ category): a hit names no listing, so give it `adult: true`/`adult: false`, or the `categoryId` of the category it belongs to. A hit with `adult: true` or the `categoryId` of an 18+ category is 18+; one with the `categoryId` of a plain category, or `adult: false`, is plain. A hit with neither counts as 18+ for a plugin that has any 18+ category, and, when your categories can't be read (`liveCategories` failed), every hit not marked `adult: false` counts as 18+. While the 18+ code is locked, Kino leaves those hits out of the search, does not open them and keeps them out of "Recientes". `node sdk/run.mjs live search ` and `sdk/validate.mjs --run liveSearch` mark the hits the same way and warn about the ones that carry neither mark. - Kino caches your categories and channels for 1 hour and your guide for 30 minutes. - `guide` is optional. Kino keeps entries for the channels it asked for, with `end` after `start`, inside the window, at most 100 per channel and one per start time. A `guide` that fails or is not exported is simply not asked again for 30 minutes: your channels still list. - `adult: true` on a category or a channel: from apiVersion 6 it marks an 18+ entry, shown only while the person's 18+ code is unlocked on that device (Ajustes ▸ Adultos) and hidden again when they lock it; below apiVersion 6 it is dropped. Every channel of an 18+ category counts as 18+, and an 18+ channel never enters "Recientes". See [18+ content](contract.md#adult). - While a channel plays, Kino may resolve the neighbouring channel ahead so zapping is quick (cancelled when the person leaves). That is one more call to your `resolve`: don't count it as a play. A `liveSearch` for an API that can search its channels by name: ```js export async function liveSearch({ query }) { const r = await kino.fetch("https://api.example.com/channels?q=" + encodeURIComponent(query)); if (!r.ok) throw kino.error("unavailable"); return r.json().items.map(c => ({ id: c.id, title: c.name, ref: c.id, logo: c.logo })); } ``` Use the same `id` your `liveChannels` gives that channel. Test it with `node sdk/run.mjs . live search ` ([Test it locally](test-locally.md#live)). ## Three recipes (apiVersion 3) { #recipes } Three ways to fill the En vivo tab, from the least code to the most control. Each is a complete plugin (see [Channels in the En vivo tab](#en-vivo-tab) and [The channel functions](#live-contract) for the rules). ### 1. A plain M3U list the person types { #recipe-m3u } The person pastes the address of their list (and, if they have one, of its guide) in Configurar; Kino downloads it, groups it and plays each entry itself. ```json { "id": "mi-lista", "name": "Mi lista", "version": "1.0.0", "apiVersion": 3, "entry": "plugin.js", "hosts": [], "capabilities": ["home", "resolve", "channels"], "liveStreamHosts": "any", "settings": [ { "key": "lista", "label": "Lista M3U", "type": "url", "required": true }, { "key": "guia", "label": "Guía XMLTV", "type": "url" } ] } ``` `"hosts": []` is enough: the list and the guide are on servers the person typed. Their streams are not: an IPTV list points at dozens of servers nobody can declare ahead of time, which is what `"liveStreamHosts": "any"` is for ([Channels from any server](#live-stream-hosts)). The person sees it on the consent sheet, in red: "Puede reproducir canales desde cualquier servidor que indique su lista". Leave it out when every stream is on hosts you can declare. ```js // Kino downloads the list (and the guide), groups it and plays each entry by itself. export async function liveCategories() { const guia = kino.config.get("guia"); return [{ playlist: { url: kino.config.get("lista"), format: "m3u", epg: guia ? { url: guia, format: "xmltv" } : undefined, hideGroups: ["Compras"], }, }]; } // Every channel comes from the list: no categories of your own to page. export async function liveChannels() { return { items: [] }; } // The manifest needs search or home; a plugin with only channels has an empty home. export async function home() { return []; } // A direct list never calls resolve: its entries play as they are. export async function resolve() { await null; throw kino.error("not_found"); } ``` ``` node sdk/run.mjs . --config lista=https://iptv-org.github.io/iptv/countries/co.m3u live categories ``` ### 2. A token per channel { #recipe-token } Your API lists the channels, and each play needs a freshly signed URL. `liveChannels` returns `{ id, title, ref }` items; `resolve(ref)` signs the URL when the person plays it. A channel's `expiresInSeconds` is ignored: when a live stream is cut, Kino simply calls `resolve` again. ```json { "id": "mi-tv", "name": "Mi TV", "version": "1.0.0", "apiVersion": 3, "entry": "plugin.js", "hosts": ["api.example.com", "cdn.example.com"], "capabilities": ["home", "resolve", "channels"], "settings": [{ "key": "token", "label": "Código de acceso", "type": "password", "required": true }] } ``` ```js const API = "https://api.example.com"; async function api(path) { const r = await kino.fetch(API + path, { headers: { Authorization: "Bearer " + kino.config.get("token") } }); if (r.status === 401) throw kino.error("auth_required", "código de acceso inválido"); if (!r.ok) throw kino.error("unavailable", "la API respondió " + r.status); return r.json(); } export async function home() { return []; } export async function liveCategories() { const cats = await api("/categorias"); // [{ slug, nombre }] return cats.map((c) => ({ id: c.slug, title: c.nombre })); } // One page of a category. The ref is only the channel's id: the signed URL is made on play. export async function liveChannels({ categoryId, cursor }) { const page = await api("/canales?categoria=" + encodeURIComponent(categoryId) + (cursor ? "&pagina=" + encodeURIComponent(cursor) : "")); return { items: page.canales.map((c) => ({ id: c.id, title: c.nombre, categoryId, ref: c.id, logo: c.logo, number: c.numero })), next: page.siguiente || undefined, }; } // Called on every play, and again when the stream is cut: always a fresh token. export async function resolve(ref) { const s = await api("/firmar/" + encodeURIComponent(ref)); // { url: "https://cdn.example.com/…?token=…" } return { url: s.url, mime: "application/vnd.apple.mpegurl" }; } ``` ``` node sdk/run.mjs . --config token=... live channels noticias ``` ### 3. Mixed { #recipe-mixed } Your own "Destacados" category with inline `stream` items (they play with no call to your plugin, so zapping through them is instant), plus the provider's full list declared with `resolve: true`: Kino downloads and groups it, and each of its entries plays through your `resolve()`, which appends a token. ```json { "id": "mi-mezcla", "name": "Mi mezcla", "version": "1.0.0", "apiVersion": 3, "entry": "plugin.js", "hosts": ["api.example.com", "live.example.com", "listas.example.com"], "capabilities": ["home", "resolve", "channels"] } ``` ```js // Your own featured channels: inline streams, played with no call to the plugin (fast zapping). const DESTACADOS = [ { id: "noticias24", title: "Noticias 24", number: 1, url: "https://live.example.com/noticias24/index.m3u8" }, { id: "deportes", title: "Deportes", number: 2, url: "https://live.example.com/deportes/index.m3u8" }, ]; export async function home() { return []; } export async function liveCategories() { return [ { id: "destacados", title: "Destacados" }, // The provider's full list: Kino downloads and groups it; each entry plays through resolve(). { playlist: { url: "https://listas.example.com/todos.m3u", format: "m3u", epg: { url: "https://listas.example.com/guia.xml.gz", format: "xmltv" }, resolve: true, }, }, ]; } export async function liveChannels({ categoryId }) { if (categoryId !== "destacados") return { items: [] }; return { items: DESTACADOS.map((c) => ({ id: c.id, title: c.title, number: c.number, categoryId, stream: { url: c.url } })), }; } // Only the list's entries get here (resolve: true), with the entry's URL as the ref. export async function resolve(url) { const r = await kino.fetch("https://api.example.com/token"); if (!r.ok) throw kino.error("unavailable", "no hay token"); const { token } = r.json(); return { url: url + (url.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(token) }; } ``` The list's streams must be on your `hosts` here (`live.example.com`), since this manifest does not declare `"liveStreamHosts": "any"`; `live categories` counts the entries that are not as discarded. ``` node sdk/run.mjs . live categories node sdk/run.mjs . live channels destacados ``` The published demo [Tu servidor](cookbook.md#own-server) (1.5.0) uses all three shapes at once (channels with a `ref`, channels with an inline `stream`, and an M3U playlist with an XMLTV guide), plus a second playlist with `resolve: true` whose links need its token, `liveSearch` and paged `liveChannels` ([The channel functions](#live-contract)), all on the person's own server: see `liveCategories`, `channel`, `liveChannels`, `liveSearch` and `resolveListEntry` in its [`plugin.js`](https://github.com/kinotvapp/kino-plugin-own-server/blob/main/plugin.js#L357-L436). # Customize your plugin Everything a plugin can change in how Kino shows it, in one place, each with a short example and the page that has the full rules. Most of it is new in `"apiVersion": 6` (Kino 0.9.50): declare 6 only if you use one of those, because Kino 0.9.49 and older refuse an apiVersion 6 plugin. ## At a glance { #overview } | What | Where the person sees it | How | apiVersion | Rules | | --- | --- | --- | --- | --- | | Name, description, author | the consent sheet and every card of your plugin | `name`, `description`, `author` | 1 | [The manifest](manifest.md) | | Icon | your plugin's cards (Ajustes ▸ Plugins, Recomendados, "De la comunidad") | `icon`: a square `.png`, at most 128 KB | 1 | [The manifest](manifest.md) | | Accent color | your plugin's tab and chips, your name over your search results | `color`: `#RRGGBB` | 1 | [The manifest](manifest.md) | | Marketplace chips | the category chips of Recomendados, "De la comunidad", "Elige tus fuentes" | `categories` in the manifest | any | [The manifest](manifest.md) | | A settings form and its own Ajustes tab | Ajustes ▸ | `settings` (`list` from 4) | 1 | [The settings form](settings-form.md#types) | | Headings, status lines, buttons, checks before saving | the same tab | `section`, `status`, `action` settings; `settingsStatus`, `action`, `validateSettings` | 6 | [The settings form](settings-form.md) | | Colors | your section, your Ajustes tab, your group title in Categorías, the player's accent while you play | `theme` | 6 | [Your colors](section-theme.md#theme) | | A section of your own, with tabs and a banner | TV sidebar, phone chip strip atop Inicio | `"section": { "label" }` + `section({ tab })` | 6 | [A section of your own](section-theme.md#section) | | Tiles in Categorías | Categorías, a group named after you | `categories()` (with `browse`) | 6 | [Your own categories](section-theme.md#categories) | | Home rows, "Ver más" and their genre | Inicio, Categorías | `home()` rows with `ref` and `genre` | 1 | [The contract](contract.md#paging) | | Channels in your Home rows | Inicio | `kind: "live"` items in `home()` | 6 | [Live channels](live-channels.md#home-rows) | | Search inside your "Ver más" pages | "Buscar en esta categoría" | `scopedSearch` | 6 | [The contract](contract.md#scoped-search) | | 18+ entries | everywhere, only with the 18+ code unlocked | `adult: true` | 6 | [18+ content](contract.md#adult) | | Names of the copies of a video | the player's Servidor menu | `Stream.label`, `alternatives` with `label` | 6 | [Labelled and lazy copies](contract.md#lazy-copies) | | Skip buttons | "Saltar intro", "Saltar outro" | `Stream.skip` | any | [The contract](contract.md#stream) | | Skip buttons on any title, from any source | "Saltar intro", "Saltar outro" | `segments` | 7 | [Where the intro and credits are](contract.md#segments) | | Audio and subtitle names | the player's "Audio y subtítulos" menu | `audioTracks[].label`, `subtitles[].lang` | 1 | [The contract](contract.md#stream) | | Your own sentence on an error | "Mensaje de : …" | `kino.error(code, detail, { userMessage })` | 6 | [Your own sentence](contract.md#user-message) | | Subtitles for any title | "Buscar subtítulos en línea" | the `subtitles` export | any | [Subtitles for any title](contract.md#subtitles) | | Info pages of other plugins' titles | a title's info page, where TMDB had nothing; from Kino 0.9.51 a logo instead of the name, other sites' ratings and the cast | `meta` (`logo`, `ratings`, `cast`) | 6 | [Describing other titles](contract.md#meta) | | A tracker's switch and its status | "Enviar lo que veo" and "No pudo avisar a …" in your Ajustes tab | `tracking` | 7 | [Telling a tracker](contract.md#tracking) | | Channels, logos, numbers, guide | En vivo, TV guide, channel drawer | `channels` | 3 | [Live channels](live-channels.md) | ## Your plugin's identity { #identity } ```json { "id": "mi-cine", "name": "Mi cine", "version": "1.0.0", "apiVersion": 6, "entry": "plugin.js", "description": "Cine colombiano y latinoamericano, con subtítulos", "author": "ana", "homepage": "https://github.com/ana/mi-cine", "icon": "icon.png", "color": "#3D5AFE", "categories": ["movies", "series"], "hosts": ["api.example.com"], "capabilities": ["search", "home", "browse", "episodes", "resolve"] } ``` - Write `name` and `description` in Spanish: they are what every card shows. `description` is at most 300 characters, `name` 40. - `icon` is a path next to the manifest, never `./icon.png`. A missing or too-big icon is skipped, never fatal. - `color` paints your tab and chips and your name over your search results. `theme` (below) goes much further, from apiVersion 6. - `categories` only tags your plugin for the marketplace chips. It is not the `categories()` export, which puts tiles inside Kino's Categorías. ## Your settings tab { #settings } Every enabled plugin gets its own tab in Ajustes, with its [Modo debug](diagnostics.md#debug) switch; one with `settings` shows their form there too. Nine field types (`text`, `password`, `url`, `toggle`, `select`, `list`, and from apiVersion 6 `section`, `status`, `action`), defaults, required fields, a check before saving and buttons that run your code: ```json "settings": [ { "key": "account", "label": "Tu cuenta", "type": "section", "hint": "Opcional" }, { "key": "email", "label": "Correo", "type": "text" }, { "key": "password", "label": "Contraseña", "type": "password" }, { "key": "linked", "label": "Estado", "type": "status" }, { "key": "logout", "label": "Cerrar sesión", "type": "action", "confirm": "¿Cerrar la sesión?" }, { "key": "quality", "label": "Calidad", "type": "select", "default": "hd", "options": [{ "value": "hd", "label": "Alta" }, { "value": "sd", "label": "Ahorro de datos" }] } ] ``` ```js export async function settingsStatus() { return { linked: kino.config.get("email") ? "Cuenta vinculada" : "Sin cuenta" }; } export async function action(key) { if (key === "logout") return { message: "Sesión cerrada", clearSettings: ["email", "password"] }; return null; } ``` Every type and attribute, `validateSettings` and a complete example: [The settings form](settings-form.md). There are no conditional fields: every setting always shows. ## Your section, your tiles, your colors { #section-theme } ```json "apiVersion": 6, "section": { "label": "Mi cine" }, "theme": { "accent": "#3D5AFE", "onAccent": "#FFFFFF", "background": "#101820", "surface": "#1A2733", "highlight": "#F7C948" } ``` ```js export async function section({ tab }) { // tab is null the first time const tabs = [{ id: "pelis", label: "Películas" }, { id: "series", label: "Series" }]; const chosen = tabs.some((t) => t.id === tab) ? tab : "pelis"; return { tabs, tab: chosen, hero: { title: "Estreno de la semana", text: "Una película nueva cada viernes.", image: "https://img.example.com/hero.jpg" }, rows: await rowsFor(chosen), // the same rows as home() }; } export async function categories() { // needs the browse capability return [ { id: "comedia", title: "Comedia", ref: "genre:comedia", art: "https://img.example.com/comedia.jpg" }, { id: "terror", title: "Terror", ref: "genre:terror" }, ]; } ``` Kino checks every color for readability when it uses it and falls back to its own for a color that fails (`node sdk/run.mjs . theme` shows the ratios). Errors stay in Kino's red. The rules: [Section, categories and colors](section-theme.md). ## Home rows { #home } ```js export async function home() { return [ { id: "nuevas", title: "Recién llegadas", ref: "nuevas", genre: "peliculas", items: newest }, { id: "canales", title: "Canales de Colombia", genre: "noticias", items: [ // apiVersion 6 { id: "canal-1", ref: "live:1", title: "Canal Uno", kind: "live", poster: "https://img.example.com/c1.png" }, ] }, ]; } ``` - A row with a `ref` (and the `browse` capability) ends in "Ver más"; with `scopedSearch` you answer the search inside it yourself. - `genre` (one of `peliculas`, `series`, `anime`, `infantil`, `documentales`, `deportes`, `noticias`, `musica`, `entretenimiento`, `otros`) is how Categorías groups browsable rows of every plugin. Without it Kino guesses from the title. - Each item can carry `badges` (up to 3 chips such as `"Latino"`, `"4K"`), `quality`, `lang`, `rating`, `year`, `genres`, `overview`, a `poster` and a `backdrop`; `ids.tmdb` lets Kino complete its info page. - `kind: "live"` items stay in Home rows from apiVersion 6, as channel cards with the "En vivo" badge. - `adult: true` keeps an entry hidden until the person unlocks their 18+ code. - Your rows come after Kino's own, under your plugin's name. Rules: [The contract](contract.md#validation). ## In the player { #player } ```js export async function resolve(ref) { const servers = await listServers(ref); // [{ id, lang, name }] const first = await resolveServer(ref, servers[0]); return { url: first.url, label: `${servers[0].lang} · ${servers[0].name}`, // "Latino · Servidor 1" in the Servidor menu alternatives: servers.slice(1, 9).map((s) => ({ label: `${s.lang} · ${s.name}`, ref: `${ref}|${s.id}` })), audioTracks: first.dubs.map((d) => ({ lang: d.lang, url: d.url, label: d.name })), // "Español (Latinoamérica)" subtitles: first.subs.map((s) => ({ lang: s.lang, url: s.url })), durationMs: first.durationMs, skip: { openingStartMs: 62_000, openingEndMs: 152_000, endingStartMs: 1_290_000 }, }; } ``` - `label` and lazy `{ label, ref }` copies (apiVersion 6) fill the **Servidor** section of the player's "Audio y subtítulos" menu; a copy is resolved only when the person picks it or the automatic fallback reaches it ([Labelled and lazy copies](contract.md#lazy-copies)). - `audioTracks[].label` is shown as is in the audio menu; without it Kino names the track from `lang`. - `skip` puts "Saltar intro" and "Saltar outro" on screen; a correction the person makes by hand wins. - With a `theme`, the player's progress bar, slider and focus border take your `accent` while your content plays. ## Your own words { #words } ```js throw kino.error("not_found", "E404", { userMessage: "Este capítulo ya no está disponible." }); ``` The person reads "Mensaje de Mi cine: Este capítulo ya no está disponible." instead of Kino's line, only when the sentence passes [the safety rules](contract.md#user-message) (Spanish, at most 160 characters, no links, never asking for money, credentials or contact data). Status lines and action messages of your settings form are your words too ([The settings form](settings-form.md)). ## What you cannot change { #limits } - Kino's own screens, fonts and layout, the order of Inicio (your rows go after Kino's own) and the player's background and surfaces. - Error colors: always Kino's red, whatever your `theme` says. - Conditional settings: every field always shows. - Anything that runs outside your exports: no screens of your own, no notifications, no background jobs. Kino calls you; you answer with data. - Whether the person's 18+ code is unlocked: you only mark entries `adult: true`, Kino decides. # The settings form (apiVersion 6) The usual [settings](manifest.md#settings) hold values your code reads with `kino.config`. From `"apiVersion": 6` (Kino 0.9.50) the form can also **show** and **do** things, and every installed plugin gets its own tab in Ajustes. This page has [every field type](#types), the three types without a value, the exports that fill and check them, and [a complete example](#example). ## Its own tab in Ajustes { #own-tab } Every installed plugin that is enabled gets **its own tab in Ajustes** (phone and TV), named after the plugin, with its [Modo debug](diagnostics.md#debug) switch; when it has `settings` the tab holds their form too, and Plugins ▸ Configurar opens the same form. That is why, from Kino 0.9.50, the `auth_required` error reads "Configura {plugin} en Ajustes ▸ {plugin}" when your plugin declares settings. ## Every field type { #types } The form shows your settings **in the order of the manifest**, each with its `label` and, under it, its `hint`. Nine types: | `type` | What the person sees | What `kino.config.get(key)` returns | Since | | --- | --- | --- | --- | | `text` | a text field (at most 500 characters) | the text, or `undefined` | apiVersion 1 | | `password` | a hidden text field (at most 500), kept encrypted on the device | the text, or `undefined` | apiVersion 1 | | `url` | an address field (at most 2,048): `http`/`https`; that server becomes a host your plugin may reach ([The person's own servers](manifest.md#own-servers)) | the address as typed, or `undefined` | apiVersion 1 | | `toggle` | a switch | `true` / `false` (`false` when there is no `default`) | apiVersion 1 | | `select` | a choice among `options` | the chosen `value` (the first option when there is no `default`) | apiVersion 1 | | `list` | a list the person builds with "Agregar", each entry a dialog of the list's `fields` | an array of `{ [field key]: string }`, or `undefined` when empty | apiVersion 4 | | `section` | a heading, with `hint` as its explanation | nothing | apiVersion 6 | | `status` | a read-only line your `settingsStatus()` fills | nothing | apiVersion 6 | | `action` | a button that runs your `action(key)` | nothing | apiVersion 6 | What each entry may carry: | Attribute | Types | Rule | | --- | --- | --- | | `key` | all | required, `^[a-z][a-zA-Z0-9_]{0,31}$`, unique in the list | | `label` | all | required, 1 to 40 characters | | `type` | all | required, one of the nine above | | `hint` | all | optional, at most 80 characters: the example or explanation under the field. On a `section`, up to 300, wrapped over several lines (Kino 0.9.51; builds before 0.9.51 refuse one over 80) | | `required` | `text`, `password`, `url`, `list` | optional `true`/`false`. A required setting with no value stops every call ("Falta configurar") | | `default` | `text`, `password`, `toggle`, `select` | optional. Never on `url` or `list` ("… no puede tener valor por defecto: usa "hint"") nor on the three types without a value; it must fit its type (a `select` default is one of its `values`) | | `options` | `select` only | required: 1 to 20 `{ "value", "label" }`, `value` 1..40 characters and unique, `label` 1..40 | | `fields` | `list` only | required: 1 to 4, each `{ key, label, type, hint?, required? }` with `type` `"text"` or `"url"`, no `default` ("Solo un ajuste de tipo list tiene "fields"") | | `max` | `list` only | optional whole number 1..50 (default 20): how many entries | | `confirm` | `action` only, apiVersion 6 | optional, 1 to 120 characters: asked before the action runs, with Cancelar focused ("Solo un ajuste de tipo action tiene "confirm"") | Any other key in an entry is ignored. A manifest that breaks a rule is refused at install with a message that names the setting (`El ajuste "quality" necesita opciones`); a `list` below apiVersion 4 or a `section`/`status`/`action` below apiVersion 6 is refused too. Limits: 12 settings with a value (`text`, `password`, `url`, `toggle`, `select`, `list`) plus, from apiVersion 6, 16 without one. There are **no conditional fields**: every setting is always shown, whatever another one holds. Use a `section` with a `hint` to say which fields go together ("Opcional: sin cuenta ves el catálogo gratis"), make them optional, and let [`validateSettings`](#validate) refuse a combination that makes no sense. **`password` or sealed `secrets`?** A `password` setting is **the person's** credential: they type it, your code reads it, it syncs to their other devices sealed end to end. A key that belongs to **you**, the author (a fixed API key of the site's own player), goes in the manifest's sealed [`secrets`](manifest.md#secrets) instead, and your code only ever holds a marker. ## Three types that hold no value { #ui-types } From apiVersion 6 there are three types that hold no value (never in `kino.config`, never `required`, no `default`), at most 16 of them on top of the 12 valued settings: ```json "settings": [ { "key": "account", "label": "Tu cuenta", "type": "section", "hint": "Opcional: sin cuenta usas la sesión anónima" }, { "key": "email", "label": "Correo", "type": "text" }, { "key": "password", "label": "Contraseña", "type": "password" }, { "key": "linked", "label": "Estado", "type": "status" }, { "key": "logout", "label": "Cerrar sesión", "type": "action", "confirm": "¿Cerrar la sesión de esta cuenta?" } ] ``` - **`section`**: a heading; `hint` is its explanation. - **`status`**: a read-only line. Kino calls your `settingsStatus()` when the form opens (10 s) and shows the text you return under that key: `{ linked: "Vinculada como ana@…" }` (at most 200 characters). Until it answers the line reads "Cargando…"; a missing key or a value that is not a text shows "Sin información"; a timeout or an error shows "No se pudo consultar". The rest of the form keeps working either way. **Required export** when a `status` setting exists. - **`action`**: a button. Kino calls your `action(key)` (30 s), one action at a time (the other buttons and Guardar wait), and shows the `message` you return (at most 300 characters) or "Listo"; if it throws or times out, the person sees the error text instead. Kino asks `settingsStatus()` again after every action (and when the form opens), so the status lines describe what it just did; `refresh: true` is still accepted and changes nothing. `confirm` (1 to 120 characters) asks first, with Cancelar focused. **Required export** when an `action` setting exists. ## Forgetting settings from an action (`clearSettings`) { #clear-settings } An action may also **forget settings of your own** with `clearSettings`: up to 12 keys of your declared valued settings (`text`, `url`, `password`, `toggle`, `select`, `list`). After the action returns, Kino empties them exactly as if the person had emptied the fields and pressed Guardar (a `password` also leaves the Keystore), closes your sandbox and forgets cookies and cached Home rows as for any saved change (`kino.storage` survives), reloads the form and asks `settingsStatus()` again; your `message` is still shown. It is how a "Cerrar sesión" button stops the next expired token from signing the person in again with the saved account. ```js export async function action(key) { if (key === "logout") { await api.logout(); // tell the server first: a throw here keeps the saved account return { message: "Sesión cerrada", clearSettings: ["email", "password"] }; } } ``` (`email` and `password` must be optional settings here; mark the account `required` only if the plugin cannot work signed out.) Limits: - Only the action's own plugin is ever touched. - An entry that is not a string, is unknown, names a `section`/`status`/`action`, names a `required` setting (clearing it would make every later call fail), repeats, or comes after the twelfth is dropped with a log line (`run.mjs` shows `[dropped by Kino]`); a value that is not an array is ignored. - An action that throws or times out clears nothing. ## Checking before saving (`validateSettings`) { #validate } `validateSettings(values)` (optional, 20 s) runs **before** Kino saves. `values` holds only the valued settings: strings trimmed, toggles as booleans, lists as arrays of objects. - Return `null` to accept. - `{ password: "La contraseña no es correcta" }` refuses with that text under the field (at most 200 characters), or a text refuses with a general message. A key that is not one of your valued settings also refuses, its text shown as the general message. - Nothing is saved on a refusal and the person keeps what they typed. - If it throws, times out or answers something Kino cannot read (an array, a number…), nothing is saved either, and the form offers **"Guardar sin comprobar"**; that button is offered only then, never after a refusal with messages. ## Common rules { #rules } - Texts you return are shown after Kino removes any of your stored secrets from them. - These three exports run even while a `required` setting is still empty, so a status line can say what is missing. - Saving any setting closes your sandbox, as always; `kino.storage` survives. - If you declare `telemetry`, a failure of `settingsStatus`, `action` or `validateSettings` is reported like any other function's ([Logs and telemetry](diagnostics.md)). Try them with the kit (it prints what the app would keep): `node sdk/run.mjs . settingsStatus`, `node sdk/run.mjs . action logout`, `node sdk/run.mjs . validateSettings '{"email":"ana@x.co"}'` (`--raw` prints your answer untouched). The kit does not apply the app's secret scrubbing, and `validateSettings` receives exactly the JSON you type: the app sends only valued settings, trimmed. A working example is `plugins/sdk/test/settings-demo` in Kino's repository. ## A complete example { #example } An account that is optional, a status line, a "Cerrar sesión" button, a quality choice, a switch and a list of the person's own mirrors, all read the way [`kino.d.ts`](reference/index.md) declares them: ```json { "id": "mi-cuenta", "name": "Mi cuenta", "version": "1.0.0", "apiVersion": 6, "entry": "plugin.js", "hosts": ["api.example.com"], "capabilities": ["search", "resolve"], "settings": [ { "key": "account", "label": "Tu cuenta", "type": "section", "hint": "Opcional: sin cuenta ves el catálogo gratis" }, { "key": "email", "label": "Correo", "type": "text", "hint": "ana@correo.com" }, { "key": "password", "label": "Contraseña", "type": "password" }, { "key": "linked", "label": "Estado", "type": "status" }, { "key": "logout", "label": "Cerrar sesión", "type": "action", "confirm": "¿Cerrar la sesión de esta cuenta?" }, { "key": "playback", "label": "Reproducción", "type": "section" }, { "key": "quality", "label": "Calidad", "type": "select", "default": "auto", "options": [{ "value": "auto", "label": "Automática" }, { "value": "hd", "label": "Alta" }, { "value": "sd", "label": "Ahorro de datos" }] }, { "key": "spanishSubs", "label": "Solo subtítulos en español", "type": "toggle", "default": true }, { "key": "mirrors", "label": "Espejos propios", "type": "list", "max": 5, "fields": [{ "key": "url", "label": "Dirección", "type": "url", "required": true, "hint": "https://espejo.example.org" }, { "key": "name", "label": "Nombre", "type": "text", "hint": "Espejo de la casa" }] } ] } ``` ```js const API = "https://api.example.com"; // Keyed by the account: settings can change under the plugin (another device, clearSettings). const sessionKey = () => `session:${kino.config.get("email") ?? ""}`; async function login(email, password) { const r = await kino.fetch(`${API}/login`, { method: "POST", body: { json: { email, password } } }); if (r.status === 401) throw kino.error("auth_required", "login 401"); if (!r.ok) throw kino.error("unavailable", `login ${r.status}`); return r.json().token; } async function session() { await null; // the first await before anything can throw const email = kino.config.get("email"); // undefined: signed out, free catalog if (!email) return null; const saved = kino.storage.get(sessionKey()); if (saved) return saved; const token = await login(email, kino.config.get("password") ?? ""); kino.storage.set(sessionKey(), token, { ttlMs: 12 * 60 * 60 * 1000 }); return token; } export async function settingsStatus() { // one text per "status" key const email = kino.config.get("email"); if (!email) return { linked: "Sin cuenta: ves el catálogo gratis" }; try { await session(); return { linked: "Cuenta vinculada" }; } catch (e) { return { linked: e.code === "auth_required" ? "El correo o la contraseña no coinciden" : "No se pudo revisar la cuenta ahora" }; } } export async function action(key) { // one call per "action" button if (key !== "logout") return null; const token = kino.storage.get(sessionKey()); if (token) await kino.fetch(`${API}/logout`, { method: "POST", headers: { Authorization: `Bearer ${token}` } }); kino.storage.remove(sessionKey()); return { message: "Sesión cerrada", clearSettings: ["email", "password"] }; } export async function validateSettings(values) { // before saving: null accepts await null; if (values.email && !values.password) return { password: "Escribe la contraseña de esa cuenta" }; if (values.password && !values.email) return { email: "Escribe el correo de la cuenta" }; if (values.email) { try { await login(values.email, values.password); } catch (e) { if (e.code === "auth_required") return { password: "El correo o la contraseña no coinciden" }; throw e; // the person may "Guardar sin comprobar" } } return null; } export async function search(query) { const token = await session(); const r = await kino.fetch(`${API}/search?q=${encodeURIComponent(query.q)}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }); if (!r.ok) throw kino.error("unavailable", `search ${r.status}`); return r.json().results.map((x) => ({ id: String(x.id), ref: String(x.id), title: x.title, kind: "movie" })); } export async function resolve(ref) { const token = await session(); const quality = kino.config.get("quality"); // "auto", "hd" or "sd": a select always has a value const r = await kino.fetch(`${API}/play/${encodeURIComponent(ref)}?q=${quality}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }); if (!r.ok) throw kino.error("not_found", `play ${r.status}`); const s = r.json(); // { url, path, subtitles: [{ lang, url }] } const mirrors = kino.config.get("mirrors") ?? []; // [{ url, name? }]: each url is a server the person typed return { url: s.url, label: "Principal", subtitles: kino.config.get("spanishSubs") ? s.subtitles.filter((t) => t.lang === "es") : s.subtitles, alternatives: mirrors.slice(0, 8).map((m) => ({ url: m.url.replace(/\/+$/, "") + s.path, label: m.name || "Espejo" })), }; } ``` What the person gets: a tab "Mi cuenta" in Ajustes with two headings; "Estado" reads "Sin cuenta: ves el catálogo gratis" until they type an account; Guardar checks the account before saving it; "Cerrar sesión" asks first, then empties the correo and the contraseña on every device of theirs; each mirror shows up as one more copy in the player's Servidor menu. ## On the person's other devices { #other-devices } When the person pairs two of their devices (phone and TV), Kino keeps their plugins in step. What crosses, and what does not: - **Crosses:** install, updates, on/off, uninstall, and the host approvals. Each device fetches your code itself; only the address and the approval travel. The other device installs **silently** when what it fetched asks for nothing beyond what the person approved on the first one; if you now ask for more (a new host, permission or capability), it waits in "Plugins de tus otros aparatos" for the normal consent sheet. - **Crosses:** the values of `text`, `select`, `toggle`, `url` and `list` settings (a `list` that has a `password` field does not travel), and `password` settings, sealed end to end with the key the devices agreed when they paired, never readable on the way. - **Crosses:** clears. A field the person empties, or an action's `clearSettings`, empties it on their other devices too (passwords included). A `required` setting is never cleared remotely. A device running an older Kino build ignores clears and keeps its value. - **Never crosses:** `kino.storage`, cookies, the cached Home rows. A value that arrives this way is applied **without** your `validateSettings`, and, like any saved change, closes your sandbox. So write the plugin as if a setting can change under it at any time: key any session you keep in `kino.storage` by the account it belongs to, and never put a device identity in a setting, because it would be copied to the other device. ```js const sessionKey = `session:${kino.config.get("email") ?? ""}`; // not just "session" ``` A [signed](signed.md) plugin installed from two different repositories on two devices counts as the same plugin when both have the same `id` and the same author key: see [The same plugin at two addresses](signed.md#two-addresses). # Signing every request (`signing`, apiVersion 6) Some origins want a fresh signature on **every** request: a header that expires in seconds. From `"apiVersion": 6` (Kino 0.9.50), return `signing: "request"` in the stream, and export `sign`: ```js export async function resolve(ref, options) { const session = await openSession(options?.retry); // a retry says why: "conflict" or "expired" return { url: session.playlist, signing: "request", signContext: JSON.stringify({ token: session.token }), headers: { "Content-License": session.license } }; } export async function sign({ url, kind, ref, context }) { // kind: "playlist" | "segment" const { token } = JSON.parse(context); return { headers: { "Content-Auth": await signatureFor(token, Date.now()) } }; } ``` Kino plays the stream through a local proxy. Before every playlist and segment request it calls `sign()` and sends its headers, merged over the stream's own `headers`. !!! note "It is for origins that really require it" If your origin accepts a token that lasts minutes, return it in `headers` or the URL and use [`expiresInSeconds`](contract.md#stream): simpler, and it does not go through the proxy. ## The rules { #rules } - **HLS only**: an HLS `mime`, or a `.m3u8` path. No `drm`, no `audioTracks` (subtitles are fine: they get the stream's own `headers`, like any stream's subtitles, but are never `sign()`ed). An inline `stream` of a `liveChannels` item can't ask for it: a channel that also has a `ref` plays through `resolve(ref)` (the inline stream is set aside), and one without a `ref` is dropped. A stream that breaks these is refused with `La firma por petición solo funciona con video HLS (.m3u8)`, `Un video firmado por petición no puede llevar drm ni pistas de audio aparte` or `Un canal con firma por petición debe reproducirse con resolve()`. A signed video can't be downloaded: its download ends with `Este video no se puede descargar`. - **`sign()` runs apart from your other functions** (the "signing lane"), so a slow `home` never delays a segment. It gets `kino.crypto`, `kino.secret`, `kino.config`, `kino.html` and `kino.log`, and nothing else: `kino.fetch` answers a `host_not_allowed` error ("sign no puede usar la red"), and `kino.storage`, `kino.cookies` and `kino.sleep` fail with the code `not_allowed` and "sign() no puede usar kino.storage: lo que necesites debe venir en signContext" (likewise for the others). It also can't see anything the main runtime holds in memory (not even the private keys of [`generateKeyPair`](kino-api.md#key-pairs)). - What it needs travels in `context`, the `signContext` your `resolve()` returned (a string of up to 4096 characters; a longer or non-string one refuses the stream with `El dato "signContext" no es válido`). A `kino.secret()` marker means nothing there (a marker only works in the runtime that made it): call `kino.secret()` inside `sign()` itself; a `signContext` carrying one is refused with "signContext no puede llevar un kino.secret(): llámalo dentro de sign()". - A stream asking for `signing: "request"` from a plugin that doesn't export `sign` is refused with "El plugin pide firmar el video pero no exporta sign()". - **1.5 s per call** (3 s counting its wait). A slow answer counts as a failed signature. - Answer `{ headers }`, filtered like a stream's `headers` (same names and size rules). A value containing a `kino.secret()` marker is refused ("sign no puede devolver datos sellados"): a marker is only good inside `kino.crypto`, so compute the header there. - Three failed signatures in a row stop the video. - Below apiVersion 6, `signing` and `signContext` are ignored. - A signed stream does not use [`alternatives`](contract.md#stream): its failover is the `alternateHosts` below. ## Reopening: `resolve(ref, { retry })` { #retry } If the origin answers 409, or 401/403 twice in a row, Kino calls `resolve(ref, { retry: { reason, attempt, status } })` again: - `reason`: `"conflict"` (the access is in use elsewhere: get another one) or `"expired"`; - `attempt` from 1 to 3; - `status`, the origin's HTTP status that caused it (401, 403 or 409; absent when Kino did not hear one), for your log. The budget refills once the video has played well for a minute; after the third retry the person sees the error. `options` is `undefined` on a normal call, and only apiVersion 6 plugins ever get it. ## Other hosts that serve the same stream (`alternateHosts`) { #alternate-hosts } `alternateHosts`, up to 6 `"host"` or `"host:port"`, no scheme or path. Kino tries the playlist on each, the one that served last first, for up to 3 rounds, and moves a segment, key or map to another host when its own fails 3 times. `sign()` always gets the URL of the host being asked, so choose that host's token from `context`. ```js return { url: "http://cdn1.example/live/ch.m3u8", signing: "request", alternateHosts: ["cdn2.example", "cdn3.example:8080"], signContext: JSON.stringify({ "cdn1.example": t1, "cdn2.example": t2, "cdn3.example:8080": t3 }) }; // sign({ url, context }): const tokens = JSON.parse(context); const token = tokens[new URL(url).host]; ``` - Each entry meets the same host rule as `url`: a declared host (plain http only on one declared `insecureHttp`), or any public host under `liveStreamHosts: "any"`, never a local one. - An entry that fails it, repeats `url`'s host or another entry, or comes after the sixth is dropped (`run.mjs` and `validate.mjs` show it as `[dropped by Kino]`); a value that is not an array of strings refuses the stream with `El dato "alternateHosts" no es válido`. - With them, the stream counts as `"expired"` only when **every** host rejected the signature, and as `"conflict"` only when the last one answered 409; a host answering 404 or not at all just moves Kino on. - A host the playlist names that is not one of these is never swapped. - Ignored without `signing`. ## Chromecast and DLNA { #cast } A signed stream can be sent to a TV like any other HLS. The TV never signs anything: it pulls the stream through Kino on the phone, which calls `sign()` for every playlist and segment the TV asks for, exactly as for its own player (the same 1.5 s limit, the same "three failed signatures stop the video"). So the phone must stay on the same Wi-Fi as the TV with Kino running for the whole cast. - If the origin refuses the stream while the TV plays it and the player is still open on the phone, Kino calls `resolve(ref, { retry })` again and the TV reloads it from the same point (a live channel from its edge). - Once the person leaves the player, a Chromecast keeps playing until it is stopped or sent something else, but a refusal is no longer re-resolved: the TV's playback just ends. Leaving the player stops a DLNA TV, as for any title. - A signed stream with `drm` is never cast. Kino plays one signed stream at a time: opening another one on the phone ends the cast of the first. ## Test it from your terminal { #test } ``` node sdk/run.mjs ./plugin.js sign '{"url":"https://cdn.example/seg.ts","kind":"segment","ref":"","context":""}' node sdk/run.mjs --retry conflict:1 ./plugin.js resolve '' # or conflict:1:409 to pass a status ``` The first runs `sign` in the same restricted lane; the second calls `resolve` with a retry. With `"telemetry": "verbose"` Kino also reports each playback's signing statistics (p50, p95, max and timeouts): see [Logs and telemetry](diagnostics.md#playback). # Hidden browser (apiVersion 6) Some sites never put the video address in their HTML: an embedded player builds it in the page with its own scripts, and the only way to learn it is to run the page. For those, Kino 0.9.50 lets a plugin open the page in a **hidden web view on the device** and get back the video requests the page made: `kino.browser.capture`. It is the last resort, not the first tool. !!! tip "Prefer `kino.fetch`" Try the cheap path first: a [`kino.fetch`](kino-api.md#fetch) of the page or of the embed, and the address read from its HTML, its JSON or a script variable ([`kino.html.select`](kino-api.md#html) helps). It is faster (no page to start, no player to wait for), it runs in the [Node kit](test-locally.md), and it never needs the person's red consent line. Keep the hidden browser for the servers that really need a page to run. ## When to use it { #when } - An embed whose video address only appears once the page runs its own scripts (an obfuscated player, a token computed in the page, an address fetched by the player after it loads). - A site where each server of an episode is a different embed, some of them plain (use `kino.fetch`) and some not (use the capture for those only). When **not** to use it: - To read a list, a search or a title page: `kino.browser.capture` only exists in `resolve`, and it returns video requests, not HTML. (Kino 0.9.50 also has [`kino.browser.page`](#page), with `"browser": "pages"`, for reading pages; see below.) - To get past a site that asks for a human. Kino never solves a captcha, and neither may your plugin: see [The hard rule](#no-captcha). ## The permission, and what the person sees { #permission } ```json "apiVersion": 6, "browser": true, "streamHosts": "any" ``` - `"browser": true` needs `"apiVersion": 6`; below it the field is ignored. `"browser": "pages"` also allows [`kino.browser.page`](#page), with its own red line. Any other value is refused with "El campo \"browser\" debe ser true, false o \"pages\"". - The consent screen shows it **in red**: "Puede abrir páginas web ocultas para encontrar el video". An update that adds it waits for the person to approve again, like a new host. - A plugin approved for it gets a longer `resolve` limit: **75 s instead of 20 s**, since each page may take up to 25 s. - The page you open must be on a host your `kino.fetch` may reach (your `hosts`), over `https`. The page itself may then load from **any public server** (an embed's player lives on hosts you cannot list ahead of time), and the video it finds is usually on one of those, so a plugin that plays what it captures also needs [`"streamHosts": "any"`](manifest.md#stream-hosts) (another red line). - While a capture runs, the person sees nothing new: the player shows its usual loading state. The page is never on screen. ## Only in a `resolve` the person started { #where } `kino.browser.capture` works only inside `resolve`, and only a `resolve` the person started: they pressed play, or they started a download (its choice among your copies included). Anywhere else -- `search`, `home`, `episodes`, or a `resolve` Kino runs in the background, such as an availability check -- it throws `not_allowed`. Kino never resolves a browser plugin's live channels ahead while the person zaps. There is **one page at a time in the whole app**. A capture started while another page is open throws `busy`; a download's capture never waits for another page (it gets `busy` at once and that copy is skipped). ## The safety model { #safety } The page runs on the person's device, so Kino fences it in: - **A proxy inside Kino.** All of the page's traffic goes through a proxy on the device's loopback, open only while that capture runs and answering only that capture's page, through a credential made for that one capture. The proxy connects only to the address it checked, so a name cannot answer one address to the check and another to the connection (the vetted IP is pinned); the WebView itself resolves no name. - **The home network, never.** Every request the page makes to a local name, a private or loopback address, or a public name that resolves into the home network is answered empty. The start page itself must be public and `https`, or the capture throws `blocked`. - **Every method, the same check.** `POST` works, redirects are followed by the page as in a browser, and WebSocket connections pass the same check. WebRTC is switched off. - **Where the top page may go.** A capture's page may navigate anywhere public at the top level (an embed's redirect chain hops hosts by design): it only ever returns the video requests the page made and the top page's last address, never a document. A [page read](#page) is stricter: its top document must stay on your hosts. - **Clean every time.** Each page starts with no cookies or storage, and everything is wiped when it closes. Nothing is shared with [`kino.cookies`](kino-api.md#cookies), with your other captures or with any other plugin. - **Nothing reaches out.** The page cannot open windows, download files, leave http(s), read files, ask for location, camera or microphone, or show dialogs, and it is hidden from accessibility services. Media is muted. - **No bridge.** There is no channel from the page to your code: what you get back is only what the page requested. - A device whose WebView cannot be pointed at a proxy still captures, with Kino fetching each `GET`/`HEAD` itself (a `POST` then only reaches a public IPv4 address written as such, and WebSocket is off). One whose WebView cannot run scripts at document start, or that has no WebView at all (some TV boxes), gets `browser_unavailable`. ## The hard rule: Kino never solves a captcha { #no-captcha } !!! danger "A page that asks for a human ends the capture with `blocked`" When the page shows a CAPTCHA, ALTCHA, Turnstile, hCaptcha, a reCAPTCHA checkbox, "Verify you are human" or "Confirme que es humano", the capture ends **at once** with `blocked`. Kino never tries to solve, click or tick it, and your plugin must not either: no solving services, no fingerprint tricks, no retry loop to wear the check down. Treat `blocked` as "this server is not for us right now" and move on to your next server, or fail with a clear error. Each author is responsible for their own plugin. Kino only **lists** community plugins (its community search); it does not recommend or promote them. If a plugin breaks the rules for plugins -- for example, it uses `userMessage` to ask for money, passwords or contact data, it is malware, or it infringes someone's rights -- Kino removes it from the community index through [`community-blocklist.json`](https://github.com/kinotvapp/kino-plugins/blob/main/community-blocklist.json) (at the root of this repository), and anyone can report it with the ["Reclamo / retiro de plugin"](https://github.com/kinotvapp/kino-plugins/issues/new?template=reclamo-retiro-plugin.yml) issue template. An installed copy stays installed, its card says "Retirado del índice de la comunidad." and it gets no more updates; a fork needs its own report. See [Claims and plugin takedowns](claims.md). ## `kino.browser.capture(url, options?)` { #capture } It opens `url` in the hidden web view, lets the page run (media muted), presses play, and answers the video requests the page made, manifests (HLS/DASH) first, then MP4s. | option | | | --- | --- | | `timeoutMs` | 1 to 25000 ms; default 18000. The capture ends at the first video request (plus 1 s for its siblings; manifests are returned before MP4s whatever order they came in) or here. | | `headers` | Extra headers for the first page load only (a `Referer` an embed insists on). `Cookie`, `Host` and the like are dropped. | | `match` | A regular expression (case-insensitive, 1-500 characters) for what counts as the video; default: `.m3u8`, `.mpd`, `.mp4`, `master.txt`, `videoplayback`, `/hls/`. | | `autoplay` | Default `true`: start any video, click the usual play/server buttons a few times, and tap the middle of the page every 2 s (a tap reaches a cross-origin embed's player). It never touches a human check: that ends the capture. | The answer is `{ media: [{ url, mime?, headers }], subtitles: [{ url }], finalUrl }`, at most 8 media and 10 subtitles. ### Timeouts { #timeouts } - Each capture: `timeoutMs`, at most 25 s (default 18 s). - The whole `resolve` of an approved browser plugin: 75 s, your fetches and every capture together. Two or three servers fit; plan for the first that answers, not for trying all of them. - `timeout` means the page showed no video request in time. Try the next server. ### Headers and cookies to pass on { #headers } A video request is **never fetched by the page**: Kino holds it (the page gets an empty answer), so a single-use or session-bound token in its address is still unspent when the player asks for it. Each media entry's `headers` are the ones the page's request carried (Referer, Origin, User-Agent, Accept-Language, a token header…, at most 12; never `Range`, `Accept-Encoding` or the app's package) **plus the page's cookies for that address**. Return them as the Stream's `headers` and the player is served what the page would have been. Without them most of these servers answer 403. ## A complete example { #example } A source whose episode page lists several servers, each an embed. The list is read with `kino.fetch`; one server is captured now, the others are offered as [labelled lazy copies](contract.md#lazy-copies) and captured only if the person picks one or the first cannot play. ```js // kino-plugin.json: "apiVersion": 6, "browser": true, "streamHosts": "any", // "hosts": ["example.com"], "capabilities": ["search", "episodes", "resolve"] const BASE = "https://example.com"; async function listServers(episodeRef) { // cheap: plain HTML, no page opened const r = await kino.fetch(`${BASE}/episode/${episodeRef}`); if (!r.ok) throw kino.error("unavailable", `episode ${r.status}`); return kino.html.select(r.text, "li[data-embed]").map((li, i) => ({ id: String(i), name: li.attrs.title || `Servidor ${i + 1}`, lang: li.attrs["data-lang"] || "Latino", })); } async function resolveServer(episodeRef, serverId) { const page = await kino.browser.capture(`${BASE}/embed/${episodeRef}/${serverId}`, { timeoutMs: 18000 }); const [first, ...rest] = page.media; // HLS/DASH first, then MP4 return { url: first.url, headers: first.headers, // Referer, User-Agent, Cookie...: send them back alternatives: rest.map((m) => ({ url: m.url, headers: m.headers })), subtitles: page.subtitles.map((s) => ({ url: s.url, lang: "es" })), }; } export async function resolve(ref) { // A lazy copy's own ref: "|". Resolve just that server. if (ref.includes("|")) { const [episodeRef, serverId] = ref.split("|"); const { alternatives, ...stream } = await resolveServer(episodeRef, serverId); return stream; // a copy's own alternatives are ignored anyway } const servers = await listServers(ref); for (const [i, s] of servers.entries()) { try { const { alternatives, ...stream } = await resolveServer(ref, s.id); return { ...stream, label: `${s.lang} · ${s.name}`, // "Latino · Servidor 1" alternatives: servers.filter((o) => o !== s).slice(0, 8) .map((o) => ({ label: `${o.lang} · ${o.name}`, ref: `${ref}|${o.id}` })), }; } catch (e) { if (["blocked", "timeout"].includes(e.code) && i < 2) continue; // next server, within the 75 s throw e; } } throw kino.error("unavailable", "no server answered"); } ``` ## Common failures { #failures } | Code | What happened | What to do | | --- | --- | --- | | `blocked` | The page asked for a human (captcha), or the start host is not one of yours, not https, or resolves into the home network. | Move on to the next server. Never retry the same one in a loop, never try to solve it. | | `timeout` | The page showed no video request in `timeoutMs`. | Next server; check `match` if the player uses an unusual address. | | `busy` | Another hidden page is open (one in the whole app), or a download's capture met an open page. | Fail this copy; Kino moves on. Don't wait in a loop. | | `browser_unavailable` | No WebView on this device (some TV boxes), or one that cannot run scripts at document start. **Always under the Node kit.** | Keep a `kino.fetch` path for the servers that allow it, so those devices (and the kit) still play something. | | `not_allowed` | Not approved, not in `resolve`, or a `resolve` nobody started. | Call it only from `resolve`. | | `invalid_request` | A bad option (`timeoutMs` out of range, a `match` that is not a valid expression…). | Fix the call. | Debugging: while your plugin's [Modo debug](diagnostics.md#debug) switch is on (`"debug": true` in the manifest only makes it on by default), Kino's logcat lines for the hidden page (tag `KinoPlugin/`) name the hosts and paths it loaded; in a release build with the switch off they never do. See [Logs and telemetry](diagnostics.md). ## `kino.browser.page(url, options?)`: reading a page { #page } Also in Kino 0.9.50 (apiVersion 6), but asked for **by name**: ```json "apiVersion": 6, "browser": "pages" ``` `"pages"` includes everything `true` gives (`kino.browser.capture` too). The red line then says so: "Puede abrir páginas web ocultas para mostrar contenido y encontrar el video". `"browser": true` stays capture-only, with the old line, and its `kino.browser.page` answers `not_allowed`. A plugin the person approved with `true` asks again when its update says `"pages"` -- also on the automatic update pass after a Kino upgrade, and on another device that only approved the old line. Any other value is refused: "El campo \"browser\" debe ser true, false o \"pages\"". Some sites answer every plain `kino.fetch` with their automatic browser check (Cloudflare's "Just a moment…"). `kino.browser.page` loads `url` in the same hidden web view as the capture -- same start-host rule, same proxy and home-network refusal, fresh cookies and storage, one page at a time in the whole app -- and returns the page's HTML once it is loaded, is **no longer the site's check page** (Cloudflare's "Just a moment…", its `cf-chl` markers) and matches `waitFor`. **Where.** From `search`, `home`, `browse`, `episodes`, `section` or `resolve`, only while the person is using the app: their own search, the Home row or section they opened, a list, a title, their play (a `resolve` also for a download they started). A call Kino makes on its own gets `not_allowed`: "Para ti" checking its suggestions after an episode ends, **`categories`** (always Kino's own call, even while the person looks at Categorías), the Home rows it prefetches at start, new chapters, the live zap's resolve ahead, sync, an update check. So does a list's read while the app is not in front. !!! warning "`categories` cannot read pages" Earlier 0.9.50 builds listed `categories` among the exports that may call `kino.browser.page`; it was removed. Build your Categorías tiles from data you already have (a `kino.fetch`, or what a page read in `home` or `section` left in [`kino.storage`](kino-api.md#storage)). **The page must stay on your hosts.** Not only the start address: every top-level navigation -- a redirect, a meta refresh, a script setting `location`, an open redirect -- and the document finally read must be on a host your `kino.fetch` may reach, over https. The first hop that is not stops the read with `blocked` ("la página terminó en un host que el plugin no declaró") and no HTML comes back. Frames, scripts and images inside the page may still load from any public server. Nothing on the page plays (media needs a gesture that never comes), and the hidden page takes none of the person's touches or keys. ```js // A site whose every kino.fetch answers Cloudflare's 403 "Just a moment…" page. const BASE = "https://example.com"; async function read(url, waitFor) { const r = await kino.fetch(url); if (r.ok && !/just a moment|cf-chl/i.test(r.text)) return r.text; // cheap path first const page = await kino.browser.page(url, { waitFor, timeoutMs: 12000 }); // under search's 15 s budget return page.html; } export async function search(query) { try { const html = await read(`${BASE}/?s=${encodeURIComponent(query.q)}`, "class=\"item"); return kino.html.select(html, "article.item").map(/* … your items … */); } catch (e) { // blocked: the site wants a person (a captcha) or never let the device in. Give up quietly. if (e.code === "blocked" || e.code === "busy" || e.code === "rate_limited") return []; throw e; } } ``` | option | | | --- | --- | | `timeoutMs` | 1 to 25000 ms; default 15000. It counts inside your call's own limit (`search` 15 s, your other fetches included; `home`, `browse`, `episodes`, `section` 20 s; `resolve` 75 s for a browser plugin). Kino cuts it to what is left of that limit **minus 1.5 s** for you to use the HTML, so the read ends with its own `timeout` instead of the whole call being cancelled; still, pass about **12000 in `search`** and leave room for your other fetches. | | `waitFor` | A JavaScript regular expression (a string or a `RegExp`, 1-500 characters, matched case-insensitively against the HTML inside the page; a `RegExp` keeps its `m` and `s` flags, the others change nothing for a test): the page is returned only once it matches. Without it, as soon as the page is loaded and past its check page. Use it for pages that fill in their list with scripts. | The answer is `{ html, finalUrl, status, truncated }`: the doctype and the DOM's `outerHTML` after the page's own scripts ran (at most 2,000,000 characters, what `kino.html.select` takes; `truncated` is `true` when it was cut), the top page's last address, and the HTTP status of its last load. **Kino never touches the page in page mode.** No click, no tap, no key, no scroll, no autoplay helper. So the only check that can pass is one that completes by itself, the way it does when a person opens the site: Cloudflare's automatic check usually does, in a few seconds. A page that asks for a human ends the read at once with `blocked`, and a page still on its check page when `timeoutMs` runs out is `blocked` too (the site did not let the device in). Do not retry it in a loop; fall back to another source or return nothing. **Limits.** At most **20 page reads a minute** per plugin (`rate_limited` beyond: a site is never hammered through the hidden browser). Each read opens a fresh page, so cache what you read with [`kino.storage`](kino-api.md#storage). A list's read waits up to 8 s for its turn when another page is open (`busy` after that), and the person pressing play ends it. Errors: `browser_unavailable` (no WebView, and always in the Node kit once the request is valid and the manifest says `"pages"` -- the kit answers `invalid_request` and `not_allowed` first, the way the app does; keep a plain `kino.fetch` path so the kit can still run your plugin), `timeout` (the page did not load, or `waitFor` never matched), `blocked` (a human check, a check page that never passed, a top document off your hosts; or the start host is not yours, not https, or resolves into the home network), `busy`, `not_allowed` (no approved `"browser": "pages"` -- `true` is capture-only --, another function, or nobody is using the app), `rate_limited` and `invalid_request`. ## A real-world example { #real-world } [**Maratón**](https://github.com/xuper-plugin/maraton) (signed, `apiVersion` 6, `"browser": "pages"`) is a plugin built this way: it lists each episode's servers and languages with plain `kino.fetch`, plays the first one through `kino.browser.capture`, and offers the rest as [labelled lazy copies](contract.md#lazy-copies) in the player's Servidor menu, each captured only when the person picks it. For everything else -- settings, sessions, downloads, live channels -- "Tu servidor" ([kinotvapp/kino-plugin-own-server](https://github.com/kinotvapp/kino-plugin-own-server)) stays the complete reference plugin. # Moving saved titles to your plugin (`migrate`, apiVersion 6) When your plugin changes how its refs look, or takes over titles another source used to open, declare the `migrate` capability (with `"apiVersion": 6`, Kino 0.9.50) and export `migrate(input)`. Kino calls it in the background for every value it has saved and can no longer open: | `input` | What it is | Expected answer | | --- | --- | --- | | `{ kind: "title", ref }` | a library title | `{ kind: "movie" \| "series", id, ref }` | | `{ kind: "chapter", ref, season, episode }` | each of its chapters | `{ kind: "episode", ref, season, number }` | | `{ kind: "live", provider, code }` | a channel in favorites or recents | `{ kind: "live", code }` | Answer with what `search()` would return for it today, or `null` when it is not yours. ```js export async function migrate(input) { if (input.kind === "title") { const id = oldIdFrom(input.ref); // your own reading of the old ref if (!id) return null; const t = await lookUp(id); return { kind: t.isSeries ? "series" : "movie", id: t.id, ref: t.ref }; } if (input.kind === "chapter") { const ep = await findEpisode(input.ref, input.season, input.episode); return ep ? { kind: "episode", ref: ep.ref, season: ep.season, number: ep.number } : null; } if (input.kind === "live") { const code = channelCodeFrom(input.provider, input.code); return code ? { kind: "live", code } : null; } return null; } ``` ## The rules { #rules } - Declaring `migrate` adds "Revisar lo que tienes guardado (biblioteca, historial, favoritos) para pasarlo a este plugin" to the consent sheet, and an update that adds it waits for the person's approval like any new reach: your plugin sees what they saved. - Kino asks the installed plugins with `migrate` in order of their id; the first answer wins. - A series moves only when every saved chapter is answered as an `episode`. - Watched progress, intro/outro marks, downloads and favorites move with the title. - A `null` is remembered until your plugin's next version, so returning `null` is cheap. So is a throw from your own code, or a `kino.error` with any code but `timeout`, `network`, `host_not_allowed`, `unavailable`, `rate_limited` or `auth_required`: those few mean "not now", and Kino asks again on a later run (meanwhile no plugin after yours is asked about that value). - Never return a ref that starts with `plg1:`. - Never fetch from inside `migrate` unless you must: it runs for every saved value, 10 s each. - A value nobody claims is not deleted: it stays saved, unopened, in case a plugin claims it later. - With `"telemetry": "verbose"`, `migrate` results are reported as edge cases ([Logs and telemetry](diagnostics.md#telemetry)). Try it with the kit: `node sdk/run.mjs ./plugin.js migrate '{"kind":"title","ref":""}'` (it prints what Kino would keep of your answer). # Your own section, categories and colors (apiVersion 6) Three optional widenings, all from `"apiVersion": 6` (Kino 0.9.50). None of them needs a capability of its own. A complete example of the three is `plugins/sdk/test/section-demo` in Kino's repository (its `highlight` fails on purpose so you can see the fallback). ## A section of your own { #section } Declare `"section": { "label": "Demo" }` (1 to 20 characters) and export `section({ tab })`: ```js export async function section({ tab }) { // tab is null the first time const tabs = [{ id: "pelis", label: "Películas" }, { id: "series", label: "Series" }]; const chosen = tabs.some((t) => t.id === tab) ? tab : "pelis"; return { tabs, // optional, at most 8, labels at most 24 characters tab: chosen, // the tab this answer is for hero: { title: "Destacado", text: "…" }, // optional; also image (http or https); text at most 300 characters rows: [{ id: `${chosen}-a`, title: "Destacadas", ref: `${chosen}-a`, items: [/* KinoItem */] }], }; } ``` - `rows` are exactly the rows of `home` (same shape, same checks and limits); a row with a `ref` gets "Ver más", which calls `browse(ref, null)`. Return `rows: []` for an empty tab: Kino says there is nothing there. - Choosing a tab calls `section({ tab })` again with that tab's `id`. 20 s per call. - Where it shows: on the **TV**, an entry in the sidebar after "Categorías" (at most 3 plugins have one); on the **phone**, a chip in the strip at the top of Inicio (scrollable, at most 8). Only plugins that are enabled, usable and need no setup appear: one that is off, or still asks for a required setting, gets no entry. Entries are ordered by label, then by plugin id. A call that fails shows an error inside the section and never touches the rest of the app. - A manifest that declares `section` must export it: the install fails otherwise, like any other required export. - `adult: true` entries follow the [18+ lock](contract.md#adult), and a `userMessage` on `not_found`, `unavailable` or `rate_limited` is shown in the section ([your own sentence](contract.md#user-message)). ## Your own categories { #categories } Export `categories()` (needs the `browse` capability; no manifest field, Kino sees the export): ```js export async function categories() { return [{ id: "accion", title: "Acción", ref: "accion", art: "https://image.tmdb.org/t/p/w500/x.jpg" }]; } ``` - At most 24 tiles, shown in your order; `title` at most 40 characters, `ref` at most 4,096, `art` an image URL (the Images rule of the [contract](contract.md#validation): `http` or `https`, not checked against `hosts`). - They appear in Categorías as one group named after your plugin, and each tile opens `browse(ref, null)`, paged like any `browse`. - If `categories()` fails or times out you simply contribute no group. 20 s per call. - A tile with `adult: true` is shown only while the person's 18+ code is unlocked on that device, like any [18+ entry](contract.md#adult); a group left with only such tiles is not shown. - A tile's "Ver más" page has a search field; with [`scopedSearch`](contract.md#scoped-search) you answer it yourself. ## Your colors (`theme`) { #theme } `"theme"` takes up to five colors, each `#RRGGBB`, each optional: ```json "theme": { "accent": "#3D5AFE", "onAccent": "#FFFFFF", "background": "#101820", "surface": "#1A2733", "highlight": "#F7C948" } ``` | Token | What it paints | | --- | --- | | `accent` | in your section and your Ajustes tab: selected chip and tab, buttons, focus, the "Ver más" arrow; in the player: the progress bar, the slider, active states and the TV focus border | | `onAccent` | text and icons drawn on top of `accent` | | `background` | the screen behind your section and your Ajustes tab | | `surface` | in your section: the cards, the "Ver más" card and the tabs that are not selected | | `highlight` | emphasized text there; in Categorías, the title of your group | Scope: your section, your Ajustes tab, the title of your group in Categorías (only that title, in `highlight`; its tiles and background stay Kino's) and, while your content plays, the player's `accent` and `onAccent` only (the player keeps Kino's background and surfaces). Nothing else in Kino changes, and **errors are always shown in Kino's red**, whatever your theme says. Kino protects readability, so each color is checked when it is used: - `background` must be dark (relative luminance at most 0.05). - `surface` must be dark (at most 0.12) and at least 1.05:1 apart from the background. - `accent` must reach 3:1 against the background. - `onAccent` must reach 4.5:1 against `accent`. - `highlight` must reach 4.5:1 against the background. - No color may be close to Kino's red `#E50914` (CIE76 distance below 25). Kino's own colors (the fallbacks) are `accent` `#E50914`, `onAccent` `#FFFFFF`, `background` `#0E0E0E`, `surface` `#181818` and `highlight` `#F5F5F5`. A color that fails falls back to Kino's own for that token only (`accent` and `onAccent` are judged and fall back **as a pair**); the install still succeeds and the other colors stay. A color that is not a valid `#RRGGBB` is refused at install time. Preview it, with the ratios and the warnings Kino would print, before publishing: ``` node sdk/run.mjs . theme node sdk/run.mjs . section [tab] node sdk/run.mjs . categories ``` `sdk/validate.mjs` shows the same warnings. # Logs and telemetry (apiVersion 6) Three ways to find out what happens to your plugin on someone else's device, from the most local to the one that leaves the device. All from `"apiVersion": 6` (Kino 0.9.50); below it the fields are ignored. | What | Where you see it | Who turns it on | | --- | --- | --- | | [`kino.log`](kino-api.md#log) | `adb logcat` | always | | [Modo debug](#debug) | error panel on screen, "Registro" page | the person, with the switch every plugin has (`"debug": true` makes it on by default) | | [`"telemetry": true`](#telemetry) / `"verbose"` | the maintainers' error tracker | you ask and the person approves (no switch to turn it off yet) | ## Logcat { #logcat } `kino.log` and `console.*` go to `adb logcat` under the tag `KinoPlugin`; in a debug build of Kino, or in any build while your plugin's [Modo debug](#debug) switch is on, under `KinoPlugin/`. Kino's own [playback metrics](#playback) go under the tag `KinoPlay` in those same cases, so ``` adb logcat -s KinoPlay KinoPlugin/ ``` shows Kino's lines and yours together. ## Modo debug: errors on screen and the Registro page { #debug } Since Kino 0.9.50 **every installed plugin** (yours, a generated Stremio addon, a Nuvio scraper) has a **"Modo debug"** switch in its own tab in Ajustes (Ajustes ▸ *your plugin's name*, phone and TV), with the line "Muestra los errores de este plugin en pantalla y guarda un registro que puedes compartir con su autor." You don't have to do anything for it to be there. While it is on: - every failed call of your plugin shows a panel on screen with the function, the error code, the technical message, the JavaScript stack and your last `kino.log` lines; - the tab gets a **"Ver registro"** button that opens the **Registro**: the last 200 events (your lines, the failures and each playback's `kino:play …` lines), with "Copiar registro" and, on the phone, "Compartir registro". While it is off, nothing is shown and nothing is kept; turning it off clears the Registro. **This is how a person sends you what went wrong.** When someone reports a problem, ask them to turn on Modo debug for your plugin in Ajustes, repeat what failed, and send you a screenshot of the panel or the copied/shared Registro. **`"debug": true` in your manifest only sets the default.** With it, the switch starts on for everyone who installs the plugin (they still see your panels until they turn it off); without it (or with `false`), the switch starts off. Use `true` while you develop, or for a test build you hand to testers; for a plugin you publish to everyone, leave it out and let each person turn it on when they need to send you a report. `validate.mjs` adds a note when it is `true`. Once a person touches the switch, their choice is kept across updates (an update that adds or drops `debug` only moves the default of people who never touched it) and syncs to their other devices. Any value other than `true` or `false` is refused with "El campo \"debug\" debe ser true o false". The Registro is kept in a private file on the device, so it survives a restart; it is never synced or backed up, and it is deleted when the switch goes off or the plugin is uninstalled. Secrets, and the person's own passwords, are masked there like everywhere else, so a shared Registro carries neither. ## `telemetry`: your lines reach the error tracker { #telemetry } From Kino 0.9.50 only a plugin that declares `"telemetry"` sends its `kino.log` lines when a call fails, recommended or not, whatever repository it comes from. For any other plugin (a converted Nuvio scraper included) Kino only notes that the call failed: your id and version, the function and the kind of failure, never a log line. - **Consent.** The consent sheet says "Comparte registros de errores con Kino para corregir fallas". An update that newly declares it waits for the person's approval, like a new host ("Actualización disponible — requiere tu aprobación"). - **No switch yet.** For now, while plugins are being stabilized, a declared plugin's lines are always sent. A later Kino build adds an "Enviar registros de errores" switch to your plugin's tab in Ajustes, on by default, that the person can turn off on each device; then nothing leaves while it is off. - **What is sent.** When a call fails (it throws, times out, returns something unusable, including `sign`, `settingsStatus`, `action` and `validateSettings`), the lines it logged during that call (the last 30, each at most 300 characters, 2 KB in all) as `plugin_log`, tagged with your plugin's id and version. At most one report per function and kind of failure an hour. A call that succeeds sends nothing. - **What is removed before it leaves.** URLs, hostnames, IPs, e-mails, long ids, long hex/base64 runs, credential-shaped text, the person's setting values and the text of their search or title. Still: log what happened (a status, a step, a count), never what the person typed, a secret or a setting's value. - Any value other than `true`, `false` or `"verbose"` is refused with "El campo \"telemetry\" debe ser true, false o \"verbose\"". ### `"verbose"` { #verbose } `"telemetry": "verbose"` shares everything `true` does plus the [playback metrics](#playback) of a sample (a quarter) of the plays that went well, live and cast problem reports, and edge cases (a re-resolve, a failover, a decoder switch, a sign timeout, `migrate` results, settings synced from another device). At most 60 events per plugin until Kino restarts and one a minute per area. Its consent line is "Comparte registros detallados de reproducción y errores con Kino para corregir fallas"; an update from `true` (or nothing) to `"verbose"` waits for the person's approval, while `"verbose"` to `true` applies silently. ## `kino.log.report`: a degraded result { #report } `kino.log.report(...args)` (with `telemetry`) writes a line like `kino.log` and also tells the error tracker that your plugin served a **degraded** result even though the call worked: it fell back to a shared account, used a backup source, trimmed a list. ```js kino.log.report("myplugin:session", "shared_fallback", "tries=2"); // area myplugin:session ``` - The line's first word names the area, and must be a namespaced word of lowercase letters, digits, `_` and `:` with at least one `_` or `:`, up to 24 characters. Any other first word (a bare word, anything with a dot, `@` or `/`, or one that holds one of the person's values) is filed as `other`. - At most one report per plugin and area an hour and 3 per plugin until Kino restarts (and 10 in all, every plugin together, in that same run of Kino), sent as a warning. That cap leaves room for real failures, which have caps of their own. - The whole line is scrubbed like any log line, the text of the call running at the time included. - Without `telemetry` (or, once that switch exists, with it off) it is just a log line. - Report what happened in codes and counts, never values that came from a response. ## Playback metrics and problem reports { #playback } Kino measures every playback of a plugin stream (a film, a chapter, a live channel) and every cast of one to a TV, with no code in your plugin. Each playback gets one record: how long `resolve` took, the time to the first frame (the zap time for a channel), how the player reached the stream (`direct`, `proxy`, `signed_proxy`, `remux`), the video decoder and its switches, resolution and bitrate changes, rebuffers and the time spent stalled, player errors by class with their HTTP status, retries and their reason (`expired`, `conflict`, `network`, `cut`…), and for a [request-signed stream](signed-streams.md) `sign` p50/p95/max and timeouts per playlist and segment, fetch p50/p95 and errors per host **index** (0 = the stream's own host, 1… = its `alternateHosts`). Detectors watch for what a viewer feels: no first frame in 10 s, a frozen picture, long stalls, dropped-frame bursts, audio underruns or sink errors, audio and video drifting apart, the audio track lost, decoder errors, falling behind the live window, HTTP errors per segment class; and for a cast the receiver's load timeouts, errors and idle reasons, a remux that stopped, the TV starting far from the phone's position, and a session that dropped. Nothing they write holds a URL, a host, a token or anything the person typed: only numbers and Kino's own words. Where it goes: - your plugin's **Registro** (while its Modo debug switch is on), one `kino:play …` line per milestone and a summary line; - **logcat** under the tag `KinoPlay` in a debug build of Kino, or in any build while that switch is on; - the **error tracker**, only with `telemetry` (and, once that switch exists, while the person leaves it on): with `true`, one summary per playback that ended on an error the person saw; with `"verbose"`, also a quarter of the playbacks that went well, and one event per problem or edge case (at most 60 per plugin until Kino restarts, one a minute per area). Failure events carry the Kotlin exception's stack and, when your script threw, its own stack frames (`at fn (plugin.js:12:5)`, frames only). # Limits and engine quirks ## Every number in one place { #limits } | What | Limit | | --- | --- | | Manifest / entry file / icon | 16 KB / 1 MB / 128 KB | | Memory / stack, per plugin | 64 MB / 1 MB | | Time per call | `search` 15 s; `home`, `browse`, `episodes`, `resolve` 20 s each (`resolve` of a plugin Kino itself generates, from a Nuvio scraper or a Stremio addon: 75 s); `liveCategories`, `liveChannels`, `guide` 20 s each; `liveSearch` 15 s; `subtitles` 10 s; `track` 10 s (apiVersion 7); `segments` 8 s (apiVersion 7); `meta` 6 s (apiVersion 6; past it, no answer); `section`, `categories` 20 s each (apiVersion 6); `migrate` 10 s; `sign` 1.5 s (and 3 s counting its wait); counting all your fetches and sleeps together, but not the time the person spends answering a host question for that call | | Loading the module (its top level) | 10 s | | Idle sandbox | closed after 5 minutes without calls | | Consecutive timeouts | 3 in a row and Kino disables the plugin ("No responde") | | `kino.fetch` | https only (or the person's own server as typed, or `http` on a host declared `insecureHttp`); 15 s default, 30 s maximum; response body at most 5 MB; the request (URL, headers and body) at most 1,048,576 characters; at most 60 requests per call, every hop counted, refused ones included (250 for a plugin converted from a Nuvio scraper); at most 6 fetches in flight at once; at most 3 host questions per call; at most 10 redirects per request | | Cookies | 50 per domain, 64 KB in total per plugin | | `kino.storage` | 256 KB per plugin; an entry's optional `ttlMs` is 1..2,592,000,000 ms (30 days) | | `kino.sleep` | 0 to 5,000 ms per call | | `kino.meta` (Kino 0.9.53) | at most 30 calls a minute per plugin; 8 s at most (each other `meta` plugin 6 s), inside your call's own limit; the query at most 4,096 characters; the answer at most 1,000,000 characters; cached 30 minutes | | `kino.tmdb` (Kino 0.9.53) | at most 40 calls per 10 s per plugin; on Kino's own key at most 20 per 10 s per plugin and 60 per 10 s for all plugins (then the person's key, else the cache or `rate_limited`); 15 s per call; a body at most 2 MB; at most 20 params of at most 500 characters; cached 10 minutes (bodies up to 512 KB); not counted in `kino.fetch`'s requests per call | | `kino.crypto` | data at most 5 MB per call; PBKDF2 at most 100,000 iterations and 64-byte keys; `randomBytes` at most 1,024 | | `kino.log` / `console.*` | 2,000 characters per message; when a call of a plugin whose manifest declares `telemetry` fails, its last 30 lines (each cut at 300 characters, scrubbed, 2,048 characters in all) go with the failure report | | What a function returns | at most 2,000,000 characters once turned into JSON | | Results | `search` 100 items; `home` 20 rows of 60; `browse` 100 per page; `episodes` 5,000 (and 50 `seasons`); `ref` 4,096 characters; `next` 2,048 characters; `id` matches `^[A-Za-z0-9._~-]{1,128}$` | | Live channels (apiVersion 3) | `liveCategories` 200; `liveChannels` 500 per page, 10 pages at first and 5 more per scroll, 10,000 channels (200 pages) per category; `liveSearch` 100 channels, asked from 2 characters; `guide` 50 channels and 24 h per call, 100 entries per channel; `number` 1..9999 | | Tracking (apiVersion 7) | `progress` at most every 5 minutes of playback; `watched` once, with 3 minutes or less left and at least 90% played; at most 200 events waiting per plugin; an event not delivered within 7 days is dropped; a retryable failure waits 30 s, doubling up to 6 h, 12 tries at most | | Settings | at most 12 with a value, plus at most 16 `section`/`status`/`action` (apiVersion 6); `text` 500, `url` 2,048, `password` 500 characters | | Error messages | your `kino.error` message is a detail for the log, cut at 200 characters; a `userMessage` for the person is at most 160 | | `hosts` | at least 1 entry, no upper limit from Kino 0.9.45 (only the manifest's 16 KB; Kino 0.9.44 and older refuse more than 20); from apiVersion 2, none (`[]`) when a `url` setting exists | | `secrets` (apiVersion 4) | at most 16; names match `^[A-Za-z][A-Za-z0-9_]{0,31}$`; a value is 1..4,096 bytes (1..8,192 from apiVersion 6); from apiVersion 6 a cipher key may be typed: `{ seal, use: "cipher-key", encoding: "hex" | "base64" }`, 16/24/32 bytes | The 60 requests of `kino.fetch` count every hop, refused ones included (a plugin converted from a [Nuvio scraper](nuvio.md) gets 250); at most 6 of your fetches are in flight at once, and one call asks the person about at most 3 hosts ([details](kino-api.md#fetch)). From apiVersion 6, also: | What | Limit | | --- | --- | | Stream | `alternatives` 8 (lazy and concrete together); `label` 48 characters; a lazy copy's `ref` 512 characters; `alternateHosts` 6; `signContext` 4,096 characters; 3 `resolve` retries per signed playback | | Lazy copies | the automatic fallback waits at most 20 s for one copy's `resolve`; a copy the person picked gets the whole `resolve` limit; a download's copy choice probes within 30 s in all | | Hidden browser (`"browser": true` or `"pages"`) | `resolve` 75 s; one page at a time in the whole app; `kino.browser.capture` `timeoutMs` 1..25,000 (default 18,000), at most 8 media and 10 subtitles, 12 headers per media; `kino.browser.page` (`"pages"` only) `timeoutMs` 1..25,000 (default 15,000), 20 reads a minute per plugin, HTML at most 2,000,000 characters. See [Hidden browser](browser.md) | | `subtitles()` (any apiVersion) | 10 s; 30 tracks kept, 15 listed per plugin; `label` 60 characters | | `meta()` | 6 s per plugin; the first answer in install order is kept 30 minutes; from Kino 0.9.51, `ratings` 6 (one per source), `cast` 20 (`name` and `character` 60 characters) | | `segments()` (apiVersion 7) | 8 s, and Kino waits at most 12 s; 100 entries read, 10 kept; each at least 1 s long, with an end at most 5 s past the length; the answer kept for the session per title, episode and length (10 s); asked again after 2 minutes when every plugin failed. See [`segments`](contract.md#segments) | | Settings form | `settingsStatus` 10 s, `action` 30 s, `validateSettings` 20 s; `status` 200 characters, an action's `message` 300, `confirm` 120, a field error 200; `clearSettings` 12 keys | | Setting fields (every apiVersion) | `key` `^[a-z][a-zA-Z0-9_]{0,31}$`; `label` 40 characters; `hint` 80 (300 on a `section` from Kino 0.9.51); `select` 1..20 `options`, each `value` and `label` 40; `list` (apiVersion 4) `max` 1..50 entries (default 20), 1..4 `fields` of type `text` or `url`. See [The settings form](settings-form.md#types) | | Section and categories | section label 20 characters; 8 tabs of 24 characters; hero text 300; `categories` 24 tiles with 40-character titles | | `kino.crypto` key pairs | 64 live private keys per runtime; a signature at most 512 bytes | | `kino.log.report` | one report per plugin and area an hour, 3 per plugin until Kino restarts; area at most 24 characters | | `telemetry: "verbose"` | 60 events per plugin until Kino restarts, one a minute per area | ## How your code lives { #lifecycle } - **One call at a time.** Calls to the same plugin run one after another. The sandbox is reused between calls, but Kino throws it away after 5 idle minutes, after a timeout, when a call is cancelled (for example a newer search replaces an older one), and when the plugin is updated or disabled. Module-level variables are a cache at best: keep anything that must survive in `kino.storage`. - **Load-time code.** When it installs your plugin, Kino loads the module once in a throwaway sandbox without network access, to check that every declared capability is an exported function. Keep the top level to declarations: a network call there fails, and the install with it. - **Errors reach people.** If your function throws, that call fails and the person sees an error that names your plugin, and the text of your `Error` can be part of it. Write those messages for a person, in Spanish, short. - **Runaway code.** Running out of memory or stack fails the call. A synchronous infinite loop (`while (true) {}`) **cannot be interrupted**: at the time limit Kino stops waiting for the call and discards the sandbox, but the loop keeps spinning on its own thread until it ends, which for a real infinite loop means until the app is closed. Three timeouts in a row disable the plugin. - **App closed during a call.** A crash inside the engine, or being killed for memory, can take the whole app down mid-call, and nothing in-process can catch that. Kino notices at the next start: whichever plugins were mid-call at that moment each get an unclean exit counted against them — **including a healthy plugin that simply happened to be running at the same time**, not only the one that actually caused the crash. Two unclean exits in a row for the same plugin, with no call finishing normally in between, switch it off ("No responde") exactly like three timeouts in a row; a call that completes normally resets its count. ## The engine is not Node and not a browser { #not-node } Plugins run in QuickJS. It handles modern JavaScript: `async`/`await`, classes with fields, `?.` and `??`, regular expressions with lookbehind, named groups and `\p{L}` under the `u` flag, template literals, spread, `replaceAll`, `Array.prototype.at` and `flat`, `Object.fromEntries`, `Promise.allSettled`, `Map`, `Set`, `BigInt`. It does **not** have the platform around it: - **Missing globals** (`typeof` is `"undefined"` inside Kino): `setTimeout`, `setInterval`, `setImmediate`, `queueMicrotask`, `Buffer`, `process`, `require`, `fetch`, `AbortController`, `structuredClone`, `performance`, `crypto`, `WeakRef` and `Intl`. Use `kino.sleep` to wait, `kino.fetch` instead of `fetch` and `kino.crypto` instead of `crypto`. `URL`, `URLSearchParams`, `atob`, `btoa`, `TextEncoder`, `TextDecoder` and `console` do exist: Kino provides them. - **Node has almost all of those**, so code that runs fine under the Node kit can still fail in Kino. Before you publish, search your file for the names above. - **Locale-aware methods do not localize:** `localeCompare` ignores its locale and options (so `{ numeric: true }` and `{ sensitivity: "base" }` do nothing; it compares code units), and `(1234.5).toLocaleString("es-CO")` gives `"1234.5"`. Write the comparison you need; the reference plugin has a small `natural()` for numbered names. - **Keep function names short.** A function name of millions of characters makes the engine's native code crash the whole app. As a best-effort guard, `kino.*`, `console.*`, the web globals and the other functions Kino provides are frozen, and on any function `Object.defineProperty`, `Object.defineProperties`, `Reflect.defineProperty` and `__defineGetter__`/`__defineSetter__` refuse to set `name` to a string longer than 1000 characters, to a getter or setter, or to make it writable: they throw a `TypeError` (`Reflect.defineProperty` returns `false`). The guard is not airtight (a huge computed key still names a function); a plugin that crashes the app anyway is switched off (see "App closed during a call" above). Setting `name` on ordinary objects, and `this.name = "MyError"` in an `Error` subclass, work as usual. ## Splitting your code across files { #splitting-files } Kino loads exactly one file (the manifest's `entry`), and the engine has no `require` and no module resolver, so an `import` from `plugin.js` to a second file has nothing to resolve against on the device. That does not mean you must write the whole plugin in one file -- just that the file you publish has to be the finished, single-file result. Write it split, normally, then bundle it before you publish: ``` src/ animeav1.js a helper module plugin.js the entry point; imports from animeav1.js kino-plugin.json package.json ``` ```js // src/animeav1.js export async function searchAnimeAV1(query) { const res = await kino.fetch(`https://animeav1.com/api/search?q=${encodeURIComponent(query.q)}`); if (!res.ok) throw new Error("animeav1 respondió " + res.status); return res.json().results.map((r) => ({ id: r.slug, ref: r.slug, title: r.title, kind: "series", poster: r.image })); } ``` ```js // src/plugin.js -- this import is fine: it runs through the bundler, never on the device import { searchAnimeAV1 } from "./animeav1.js"; export async function search(query) { return searchAnimeAV1(query); } ``` Bundle with [esbuild](https://esbuild.github.io/) (`npm i -D esbuild`), targeting ES module output (Kino runs the published file as one): ```bash npx esbuild src/plugin.js --bundle --format=esm --outfile=plugin.js ``` `plugin.js` at the repo root is what comes out of that command, with `src/animeav1.js` inlined into it and its `export async function search` intact -- that is the file `entry` names and the one Kino fetches. Add it as an npm script (`"build": "esbuild src/plugin.js --bundle --format=esm --outfile=plugin.js"`) and run it before every `sdk/` test or publish. Rollup and webpack work the same way; esbuild needs the least configuration for a plugin this size. ## The trap: a rejection nobody is listening to yet (Kino 0.9.49 and older) { #rejection-trap } **From Kino 0.9.50** a rejection behaves as in Node: a `throw` inside an `async` function is caught by the caller's `try`/`catch`, `.catch()`, `Promise.all` or `Promise.allSettled`, however early it happens -- even before the function's first `await`, and even when the handler is attached a few `await`s later. What still fails the call is a rejection **nobody ever handles**: one still without a handler once the plugin is only waiting on Kino (a `kino.fetch`, a `kino.sleep`, ...). For example a helper called without `await` that throws, or a promise you keep and only `await` after a `kino.fetch`. The call then fails with that error, as Node would stop with an `unhandledRejection`. Await what you start, or give it a `.catch()` right away. **Kino 0.9.49 and older** abort the **whole call** when a promise is rejected before anything has a handler on it, even if your code is inside `try`/`catch`. The Node kit cannot show you this. If your plugin must work there too (people update late), follow these rules: - **Aborts the call:** a `throw` inside an `async` function **before its first `await`**, while the caller is wrapped in `try`/`catch`. A `.catch()` on that call, or `Promise.all`/`Promise.allSettled` around it, do not rescue it either. Also aborts: `new Promise((_, reject) => reject(e))` rejected right away, and `return Promise.reject(e)` from an `async` function. - **Is caught normally:** a `throw` after any `await` (even `await null;`), a rejection coming from `kino.fetch` or `kino.sleep` (for example a refused host), and `await Promise.reject(e)` or `Promise.reject(e).catch(...)` (those builds delay `Promise.reject` by one tick so a handler can attach in time). - **A synchronous `kino.*` call that fails** (`kino.storage.set` over 256 KB, a `kino.crypto` error, a selector `kino.html.select` refuses) is caught normally from Kino 0.9.50. Kino 0.9.49 and older ended the whole call there, even inside `try`/`catch` ([Errors your code can catch](kino-api.md#catch)). - If nobody catches the error anyway, it is harmless: the call fails with that error either way, and a `kino.error` code still reaches the person correctly. - **Nuvio-converted scrapers get a workaround:** when Kino converts a [Nuvio scraper](nuvio.md) it rewrites the async helpers bundlers emit (esbuild's `__async`, TypeScript's `__awaiter`, Babel's `_asyncToGenerator`) so a transpiled function's body starts one tick later, and a throw before its first `await` is caught normally. A native `async` function (yours, or an untranspiled scraper's) still needs the `await` before anything that can throw. So, for those builds, in a helper that a caller may wrap in `try`/`catch`, do the `await` first and validate afterwards: ```js // Wrong before Kino 0.9.50: this throw is NOT caught by the caller's try/catch; it aborts the whole call. async function getJson(url) { if (!url.startsWith("https://")) throw new Error("dirección inválida"); const r = await kino.fetch(url); return r.json(); } // Right everywhere: the first await comes before anything that can throw. async function getJson(url) { const r = await kino.fetch(url); if (!r.ok) throw new Error("archive.org respondió " + r.status); return r.json(); } ``` (If a helper has nothing to await, start it with `await null;`, or check the input in the caller before it calls the helper. The examples on this site do, so they run on older builds too.) # Test it locally The Node kit is the `sdk/` folder of the example plugins ([where to get it](first-plugin.md#get-the-sdk)): `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](manifest.md#secrets) for your manifest, and `--keygen`/`--sign` for a [signed plugin](signed.md)) 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 reads `sdk/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 with `auth_required`, as in the app. - `--record fixtures.json` saves every `kino.fetch` answer; `--replay fixtures.json` answers from that file only, with no network. Record once, then your tests run offline and always the same (the scaffold's `test/plugin.test.mjs` does exactly that). - `KINO_TYPE=movie|series|any` sets the `type` of the search (default `any`). - 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`, `tmdbId` and `year` are `0` otherwise). - Under the Node kit `kino.storage` is a file named `.kino-storage.json` and the cookie jar `.kino-cookies.json`, both next to your manifest. Add them to your `.gitignore`. Delete them to start from scratch. A plugin with `secrets` also reads `.kino-secrets.json` from the same folder ([below](#secrets)). `node sdk/init.mjs` already lists all three in the scaffold's `.gitignore`. - `node sdk/validate.mjs ` checks the manifest with every rule of [the manifest](manifest.md) (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, an `insecureHttp` host, `"liveStreamHosts": "any"` or `"streamHosts": "any"`, marked "(en rojo)"; `secrets` adds "Usa datos sellados por su autor", with a note that only the app can check which repository they were sealed for); `--run [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 run `node /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 and `package.json` say. The line numbers are your `plugin.js`'s. ## Sealed secrets (apiVersion 4) { #secrets } 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`](kino-api.md#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](manifest.md#secrets)). ## Signed plugins (apiVersion 5) { #signing } `validate.mjs` checks a [signed plugin](signed.md) 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) { #live } 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 categories` calls `liveCategories()` and prints what Kino keeps. Then, for each `{ playlist }` in the answer, it downloads the list as the app would (your `headers`, your `hosts` or the person's server only, every redirect too) and prints, on stderr, the same summary as `live playlist` and the list's groups as the categories people will see. - `live channels [cursor]` calls `liveChannels({ categoryId, cursor })`, then plays the first channel that has a `ref` and no `stream` the way Kino would: it sends that `ref` to `resolve()` and checks the answer as a live channel's (so `"liveStreamHosts": "any"` applies). With `validate.mjs --run liveChannels`, a refused answer there is a problem. - `live search ` calls `liveSearch({ query })`, prints the channels Kino keeps (at most 100) and plays the first one with a `ref` like `live channels` does. - `resolve --live` checks a `resolve()` answer as a live channel's. Without `--live` the kit cannot know the `ref` is 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 ` calls `guide()` with those ids and a 24-hour window starting two hours ago. - `live playlist ` needs no plugin: it reads any M3U list with Kino's own rules and prints `N canales en M categorías; K entradas descartadas; L ocultas (adultos)`, the categories, and the first 20 channels as `group › name url`. With `--epg ` 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 { #api6 } ``` 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 '' ./plugin.js search "texto" # scopedSearch node sdk/run.mjs ./plugin.js sign '{"url":"https://cdn.example/seg.ts","kind":"segment","ref":"","context":""}' node sdk/run.mjs --retry conflict:1 ./plugin.js resolve '' # 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":""}' ``` `run.mjs` shows what Kino would drop as `[dropped by Kino]` (`clearSettings`, `alternateHosts`), prints what the person would read for a [`userMessage`](contract.md#user-message) (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](settings-form.md), [Signing every request](signed-streams.md), [Moving saved titles](migrate.md), [Section, categories and colors](section-theme.md), [Logs and telemetry](diagnostics.md). ## `kino.meta` and `kino.tmdb` (Kino 0.9.53) { #kino-services } 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= 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.json` maps `"::"` or `":"` (`"movie:imdb:tt0133093"`, `"tmdb:1399"`) to an answer; the first id of the query with an entry answers, else `null` (Kino's own TMDB/AniList lookup and the person's other plugins do not exist in Node). - `tmdb.json` maps `"?"` or just `""` 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 answers `not_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.tmdb` throws `no_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-api.md#meta), [`kino.tmdb`](kino-api.md#tmdb)); `node sdk/validate.mjs` warns when your code calls either without `typeof kino. === "function"` (older Kino has neither). Samples of both files are in `docs/plugins/fixtures/kino-services/` of Kino's repository. ## What the Node kit does not reproduce { #differences } 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.select` throws (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](engine-limits.md#rejection-trap): 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's `URL` has 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_allowed` even during `resolve` or `episodes`, where the app could [ask the person](kino-api.md#fetch). 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 `meta` command, and `validate.mjs` does not check that a plugin declaring `meta` exports it: try [`meta`](contract.md#meta) in the app. `kino.browser.capture` and `kino.browser.page` always answer `browser_unavailable` ([Hidden browser](browser.md)). # Publishing your plugin ## To appear in the app, two things { #appear-in-the-app } People can always install your plugin by typing `owner/repo`, or by pasting the URL of its `kino-plugin.json` ([below](#manifest-url)). To make it **show up on its own** in Kino (Ajustes ▸ Plugins ▸ "De la comunidad", and the first-run "Elige tus fuentes"), you need: 1. **The topic `kino-plugin` on the GitHub repository.** It is the only way the app discovers a plugin. Set it on the repository that holds `kino-plugin.json` (About ▸ ⚙ ▸ Topics), and keep the repository public and not a fork. 2. **A `description` in `kino-plugin.json`** (up to 300 characters). It is the text on your card in the app; without it the card has no text. (The GitHub repository description is not read by the app, but set it too, for people who open your repository.) One command does the topic and the repository description: ``` gh repo edit OWNER/REPO --add-topic kino-plugin --description "What your plugin does, in one line" ``` Step by step, with the exact clicks and how to check it: [Get listed in Kino](listed.md). Then wait: Kino refreshes the list at most every 12 hours per device, or right away when the person taps "Actualizar". If it still does not appear, see [Why my plugin does not appear](#troubleshooting) and [Every requirement, one by one](#discovery-requirements). 1. **Create a public GitHub repository** and put `kino-plugin.json` and your entry file (for example `plugin.js`) at its root, plus an optional `icon.png` and a `README.md`. Add `.kino-storage.json` to `.gitignore`. (A plugin can also live in a subfolder; people then type `owner/repo/sub/dir`.) 2. **People install it** in Kino from Ajustes > Plugins, typing `owner/repo` in the field ("Escribe usuario/repositorio de GitHub o pega la URL del manifest (kino-plugin.json)") and pressing "Agregar". To point at a release, they type `owner/repo@v1.0.0`. Tag your releases so that people can pin them. They can also paste the URL of your `kino-plugin.json` on GitHub, on raw.githubusercontent.com or on jsDelivr (`https://cdn.jsdelivr.net/gh/owner/repo@v1.0.0/kino-plugin.json`; jsDelivr needs an exact ref such as `@main` or `@v1.0.0`, `@latest` is the default branch, a range such as `@1` is refused): Kino turns any of those into the repository address ([every address Kino accepts](index.md#what-a-plugin-is)). 3. **A private repository cannot be installed.** Kino reads your files from `raw.githubusercontent.com` without any credentials, and GitHub answers a private repository with "not found". Make the repository public, or the plugin cannot be installed. 4. **To ship an update, raise `version`** (a strictly higher `MAJOR.MINOR.PATCH`; an unchanged or lower number is treated as "already up to date", so a fix without a version bump never reaches anyone). Kino checks for updates at most once a day per plugin, and when the person taps "Buscar actualización". From Kino 0.9.50 it also checks every installed plugin when the app starts (at most once every 12 hours). - If the new version does not add anything to `hosts`, `permissions`, `download`, `drm` or an `insecureHttp` host, and needs a supported `apiVersion`, it is installed silently. - If `hosts` or `permissions` grow, or the manifest newly declares `download`, `drm`, or marks an already-approved host `insecureHttp`, Kino does **not** apply it: the plugin shows "Actualización disponible — requiere tu aprobación" and the person sees the new ones (marked "nuevo") before accepting. Removing them needs no approval. - A new **required** setting does not block the update: it installs and the plugin shows "Falta configurar" until the person fills it in. - If the new version needs a higher `apiVersion` than the app supports, the check reports "Este plugin necesita una versión más nueva de Kino" and the installed version keeps working. - While an update waits for approval, the Plugins entry in Ajustes shows a badge with how many wait (phone and TV), and a failed call of that plugin says "Hay una versión nueva de : actualízala en Ajustes ▸ Plugins" instead of the usual error (a refused host, your [`userMessage`](contract.md#user-message) and `auth_required` still win over it). Kino never approves on the person's behalf in that check. - **After a Kino update**, the first start checks every plugin that was installed and enabled before it, right away. In this release (a build switch Kino will turn off later) the updates that wait for approval are then installed **without asking**, once, and only when read from the plugin's own install address; a one-time notice "Se actualizaron tus plugins" lists each plugin and what it may do now (for example "Envía registros de errores a Kino"), with shortcuts to its tab in Ajustes, to disable it and to uninstall it. With the switch off, a one-time sheet "Hay actualizaciones de tus plugins" offers "Actualizar todos" and shows each consent sheet in turn. Later updates of your plugin follow the rules above. 5. **Give it time.** GitHub serves raw files with a cache of about five minutes (measured: `cache-control: max-age=300`), so a change you just pushed can take that long to be visible to an install or an update check. 6. **Keep the `id` and the address.** An `id` that is already installed from a different address is refused ("Ya hay un plugin con ese id"), so renaming or moving your repository makes it a different plugin for the people who installed it. The same goes for a plugin installed from a manifest URL: that URL is its address. The same approval applies to the other additions that need a line on the consent sheet: `channels` ([Live channels](live-channels.md#en-vivo-tab)), `"liveStreamHosts": "any"` ([Channels from any server](live-channels.md#live-stream-hosts)), `"streamHosts": "any"` ([Playing from any server](manifest.md#stream-hosts)), `migrate` ([Moving saved titles](migrate.md)), `telemetry` or a move from `true` to `"verbose"` ([Logs and telemetry](diagnostics.md#telemetry)), `"browser"` or a move from `true` to `"pages"` ([Hidden browser](browser.md#permission); also on the automatic pass after a Kino upgrade) and `secrets` in a plugin that had none ([Sealed secrets](manifest.md#secrets); adding, changing or removing a secret after that asks nothing). Hosts the person approved while your plugin ran ([A host you forgot](contract.md#forgotten-host)) and the broad video permission carry over to every update. A plugin with `secrets` only updates from its default branch, with no `@ref`. ### Sharing it by its manifest URL { #manifest-url } From the Kino version after 0.9.49 you can also share your plugin as the `https` URL of its `kino-plugin.json`, on GitHub or on any other public server (your site, GitHub Pages, jsDelivr's `npm/`); the file must be named exactly `kino-plugin.json`, and `entry` and `icon` are read next to it. A plugin hosted **outside GitHub**: - cannot use [sealed secrets](manifest.md#secrets) (a manifest with `secrets` installed from a URL is refused) and always counts as **unsigned** (a [signature](signed.md) is bound to `owner/repo`); - updates the same way (Kino re-reads that URL and applies a higher `version`, with the same approvals), and that URL is its identity, so do not move it; - is **never listed in "De la comunidad"**: discovery only searches GitHub repositories with the `kino-plugin` topic. To be found, publish on GitHub with the topic. All the rules: [Installing from a manifest URL](index.md#manifest-url). ## Before you publish { #checklist } Check that: - `node sdk/validate.mjs . --run ...` passes for every capability you declare; - `"entry"` (and `"icon"`) are written **without a leading `./`**: `"plugin.js"`, never `"./plugin.js"`. Kino 0.9.45 and older refuse it and the plugin does not install ([why](manifest.md#entry-dot-slash)); - if you want people to know it is yours, [sign it](signed.md) (`apiVersion` 5, Kino 0.9.45+) and sign again after every change to `plugin.js` or `version`; the private key is never committed; - every host your plugin talks to (and every stream and subtitle host) is in `hosts`, including the bare domain next to its `*.` form; - there is no `throw` before the first `await` in a function that a caller wraps in `try`/`catch` ([the rejection trap](engine-limits.md#rejection-trap)); - your file uses none of the [missing globals](engine-limits.md#not-node); - you installed it in Kino and it searches, lists episodes and plays. ## Get found: appear in "De la comunidad" { #get-found } Kino lists community plugins by searching GitHub for public repositories with the topic `kino-plugin` (forks are left out). > **Important: without the `kino-plugin` topic, Kino will not find your plugin.** It is the only way > the app discovers a plugin: a perfect manifest, a public repository and a thousand stars change > nothing if the topic is missing. Put it on **the repository that contains `kino-plugin.json`** (a > common mistake: adding it to another repository by the same author that only holds data, such as an > `.m3u` playlist). Check it in 10 seconds: > > ``` > curl -s https://api.github.com/repos/OWNER/REPO | tr -d ' \n' | grep -o '"topics":\[[^]]*\]' > ``` > > `"kino-plugin"` must appear inside `topics`. An empty `"topics":[]` means Kino cannot see you yet. **Descriptions.** On the card Kino shows the `description` of your **manifest** (up to 300 characters; leave it empty and the card has no text), so write one. The GitHub repository description (About) is not read by the app and does not affect discovery, but set it too: it is what people see when they open your repository. One command does both repository settings: ``` gh repo edit OWNER/REPO --add-topic kino-plugin --description "What your plugin does, in one line" ``` To be listed: 1. On your repository's GitHub page, add the topic `kino-plugin` (About ▸ ⚙ ▸ Topics). 2. Keep `kino-plugin.json` at the root of the repository: Kino reads it to show your plugin's name, description, colour and icon, and skips a repository whose manifest is missing or invalid, needs a newer `apiVersion` than the person's Kino, or says `"discoverable": false`. A plugin in a subfolder can be installed by address but is not searched. 3. Kino keeps the 30 most-starred matches, searches at most every 12 hours per device (and when the person taps "Actualizar"), and shows them in their own tab of the Plugins screen, "De la comunidad" (beside Recomendados; in "Elige tus fuentes", after the recommended plugins). Installing one goes through the same consent sheet as any other plugin. To stay out of the search while keeping the topic, set `"discoverable": false`; `node sdk/validate.mjs .` then prints "No aparecerá en la búsqueda de Kino". The rest of this section spells out every rule the app applies, with its exact value. ### Every requirement, one by one { #discovery-requirements } | # | Requirement | The exact rule | | --- | --- | --- | | 1 | **A public GitHub repository** | The search is made without any credentials, so GitHub only ever returns public repositories. | | 2 | **Not a fork** | The search asks `fork:false`, and the app also drops any result whose `fork` is not `false`. Create your repository with "Use this template" or from scratch, never with "Fork". | | 3 | **A plain repository address** | The result's `html_url` must be exactly `https://github.com//` and its owner's login must match ``. `` matches `^[A-Za-z0-9][A-Za-z0-9-]{0,38}$`; `` matches `^[A-Za-z0-9._-]{1,100}$` and is not `.` or `..`. Every normal GitHub repository passes. | | 4 | **The topic `kino-plugin`** | **Mandatory.** Exactly that topic, set on the repository that holds `kino-plugin.json` (About ▸ ⚙ ▸ Topics, or `gh repo edit owner/repo --add-topic kino-plugin`). Without it the app never sees you, whatever else you have. | | 5 | **`kino-plugin.json` at the root, on the default branch** | The app reads `https://raw.githubusercontent.com///HEAD/kino-plugin.json` (`HEAD` is the default branch). A manifest in a subfolder or only on another branch is not found. | | 6 | **At most 16 KB** | A bigger manifest (16,384 bytes) is dropped. | | 7 | **A valid manifest** | The same parser as the installer: every rule of [The manifest](manifest.md). `node sdk/validate.mjs .` checks it with the same messages. (Discovery reads only the manifest; the entry file and its exports are checked when someone installs.) | | 8 | **An `apiVersion` the person's Kino supports** | A manifest whose `apiVersion` is higher than the build supports is invalid for that build ("Este plugin necesita una versión más nueva de Kino"), so it does not show on devices with an older Kino. Kino 0.9.50 supports up to `6`; 0.9.45 to 0.9.49, up to `5`. | | 9 | **Not `"discoverable": false`** | Leave it out or set `true`. Any value that is not a boolean makes the whole manifest invalid. | | 10 | **An `id` nobody else owns** | See [Why a valid plugin can still be hidden](#discovery-hidden). | | 11 | **Enough stars to be in the top 30** | See [How the app searches](#discovery-search). | The card shows your manifest's `name` and `description`, the tag "por ", and your `color` (`#RRGGBB`) and `icon`: the path the manifest names, which must be a real PNG (it starts with the PNG signature) of at most 128 KB. An icon that is missing, too big or not a PNG only costs the icon; the card falls back to the neutral look. The app keeps each card's colour and icon for a day. ### How the app searches { #discovery-search } - **The one request.** Every device makes exactly this GitHub API call, with no token, no cookies and no redirects followed: ``` https://api.github.com/search/repositories?q=topic:kino-plugin+fork:false&sort=stars&order=desc&per_page=50 ``` Open it in a browser to see what Kino sees. An answer bigger than 1 MB is not read, and the call gives up after 20 s. - **Top 30.** From that one page of up to 50 results (most stars first), the app keeps the first 30 well-formed ones (requirements 2 and 3). There is no second page: a repository below them is never seen. Then it reads each of those 30 manifests; the ones that fail requirements 5 to 9 are dropped **after** taking their slot, so the list can show fewer than 30. - **The manifest scan.** At most 4 manifests at a time, 10 s each, and 20 s for the whole scan. A manifest that could not be read for a passing reason (offline, a timeout, a 5xx or 429, the budget spent) keeps the plugin as it was last seen; a verdict (404, 410, 451, too big, invalid, too new, `"discoverable": false`) drops it. - **On screen.** "De la comunidad" is its own tab of the Plugins screen, beside "Recomendados" (in "Elige tus fuentes" it comes after the recommended plugins), on phones and TVs, in the search's order (most stars first). The search box above the list filters it too, by name, description and "por ". A plugin already installed from the same repository shows "Instalado". - **When it searches.** When the plugins screen opens: the copy saved on the device shows at once, and GitHub is asked again only if that copy is older than **12 hours**. "Actualizar" asks right away, but never twice within **60 seconds** on the device. - **Rate limits.** GitHub limits searches without a token per IP address (a shared mobile or office IP can run out). On a 403 or 429 the device waits what GitHub says (`Retry-After`, else `X-RateLimit-Reset`, else 15 minutes; always between 1 minute and 24 hours) and keeps showing its last list meanwhile. - **Nothing installs by itself.** Tapping a community card opens the same consent sheet as any other plugin, with "Plugin no verificado: solo instálalo si confías en quien lo hizo.", and nothing of the plugin runs before the person taps "Instalar". - **The empty states** the person may read: "Buscando plugins de la comunidad…", "Por ahora no hay plugins de la comunidad para mostrar." and "Todos los plugins de la comunidad que encontramos ya están en Recomendados." ### When GitHub cannot answer { #discovery-fallback } When the search fails (no network to GitHub, a rate limit, a TLS error from a wrong clock) or finds nothing valid, and the device has no search list of its own saved, the app reads a backup community list that the Kino team publishes on jsDelivr, unpkg and archive.org. It holds only `owner/repo` and a star count: each repository's `kino-plugin.json` is still read from GitHub and checked with every rule above. The app asks GitHub again as soon as the spacing and any backoff allow. There is nothing to request to be in that list: it is rebuilt from the same GitHub search (public, not a fork, a `kino-plugin.json` with an `id` that is not `"discoverable": false`, top 30) whenever the Kino team republishes it, so a plugin that appears in the search appears in the next backup list. ### Why a valid plugin can still be hidden { #discovery-hidden } The list drops, whatever the stars: - **A repository that is already recommended.** It shows under "Recomendados" instead, never twice. - **An impostor id.** A plugin whose manifest `id` belongs to a recommended plugin from **another** repository is dropped, so it can never hide the real one. Today the recommended ids are `internet-archive` and `own-server`; the list can grow, so pick an id that is clearly yours. - **An id already installed from another repository.** Its install would be refused ("Ya hay un plugin con ese id"), so it is not offered. This is per device: it hides only where that other plugin is installed. A copy of the template that keeps `"id": "archive-org"` hides itself for everyone who installed the Internet Archive plugin: **always change the `id`**. - **Repeats.** The same repository listed twice, or two repositories with the same `id`: the first one (the one with more stars) wins. - **A repository taken down from the index.** The repositories in [`community-blocklist.json`](claims.md) never show in "De la comunidad" nor in the fallback list ([Claims and plugin takedowns](claims.md)). - **The ids Kino keeps for itself** (`live`, `local`, `unknown`, `plugin`, `own`, `subtitle-keys`; older versions reserve a few more) make the manifest invalid, so they never get this far. ### "Mi plugin no aparece": troubleshooting { #troubleshooting } 1. **Is it in GitHub's answer?** Open the search URL above in a browser and look for your `full_name` in `items`. If it is not there: - check the topic is exactly `kino-plugin` on the repository page, and that it is set on **the same repository that holds `kino-plugin.json`** (not a sibling one); - look from a terminal: `curl -s https://api.github.com/repos/OWNER/REPO | tr -d ' \n' | grep -o '"topics":\[[^]]*\]'` (`"topics":[]` means you do not have it yet); - check the repository is public and not a fork (the page says "forked from …" under the name of a fork; create a new repository from the template instead); - wait: GitHub indexes a new topic or a newly public repository on its own schedule, usually within minutes but with no promised delay; - if there are more than 50 results, yours is not in the first page: see the next point. 2. **Is it in the top 30?** Count the well-formed results above yours in that answer. Below 30, it is not listed; stars are the only ranking. 3. **Is the manifest there?** Open `https://raw.githubusercontent.com///HEAD/kino-plugin.json`. A 404 means it is not at the root of the default branch. 4. **Does `entry` start with `./`?** `"./plugin.js"` installs on Kino 0.9.46 and later but fails on 0.9.45 and older ("El campo \"entry\" debe ser una ruta relativa a un archivo .js"). Write `"plugin.js"`. The same goes for `"icon"`. 4. **Is the manifest valid?** Run `node sdk/validate.mjs .` in the repository: exit code 0 and no "No aparecerá en la búsqueda de Kino" line. Check it is at most 16 KB. 5. **Can that phone read it?** Is `apiVersion` at most what the person's Kino supports? Update Kino, or lower the `apiVersion` if you do not need its features. 6. **Is the `id` yours?** Not a recommended plugin's id, not the id of a plugin already installed on that device from another repository (not `archive-org` from the template). 7. **Is it already recommended?** Then it is under "Recomendados", not "De la comunidad". 8. **Is the device's copy old?** The list refreshes at most every 12 hours; tap "Actualizar" (wait 60 seconds between taps). After a 403/429 from GitHub the device waits up to what GitHub asked. 9. **Did you just push?** `raw.githubusercontent.com` caches files for about five minutes. # What people see - **The consent sheet.** When someone types your address, Kino shows "Instalar ", your version and author, the description, the list of hosts under "Se va a conectar con:" (left out when `hosts` is empty), and the warning "Plugin no verificado: solo instálalo si confías en quien lo hizo." with "Instalar" and "Cancelar". If your manifest has a `password` setting it adds "Este plugin usa tu usuario y contraseña"; a `url` setting adds "Se conectará a los servidores que escribas en su configuración". Declaring `download` adds "Puede descargar videos para verlos sin conexión", `drm` adds "Reproduce video protegido (DRM)", `channels` adds "Agrega canales en vivo a la pestaña En vivo", `secrets` adds "Usa datos sellados por su autor", the `subtitles` capability adds "Agrega subtítulos a tus películas y series", each `insecureHttp` host adds, in red, "Conexión sin cifrar con ", `liveStreamHosts: "any"` adds, in red, "Puede reproducir canales desde cualquier servidor que indique su lista", and `streamHosts: "any"` adds, in red, "Puede reproducir video desde cualquier servidor que indique". From apiVersion 6, `migrate` adds "Revisar lo que tienes guardado (biblioteca, historial, favoritos) para pasarlo a este plugin", `telemetry: true` adds "Comparte registros de errores con Kino para corregir fallas" and `telemetry: "verbose"` adds "Comparte registros detallados de reproducción y errores con Kino para corregir fallas"; `"browser": true` adds, in red, "Puede abrir páginas web ocultas para encontrar el video" and `"browser": "pages"`, in red, "Puede abrir páginas web ocultas para mostrar contenido y encontrar el video" ([Hidden browser](browser.md#permission)). From apiVersion 7, `tracking` adds, in red, "Le contará a qué ves y cuándo lo terminas" ([Telling a tracker](contract.md#tracking)) and `segments` adds "Agrega el botón para saltar la intro y los créditos" ([Where the intro and credits are](contract.md#segments)). On an update, what is new carries a "nuevo" chip. Nothing of yours runs before they accept. - **A long host list folds.** With more than 3 hosts the sheet says "Se va a conectar con N servidores:", lists the first 3 (on an update, the new ones first) and "y N más (M nuevos)", with a "Ver todos" / "Ver menos" toggle. The permission lines and the "Plugin no verificado" warning sit above the list and never fold; "Cancelar" and "Instalar" stay pinned below it while the body scrolls. A short list still reads better: declare what you use, not every mirror you ever saw. - **Host dialogs.** A `kino.fetch` to an undeclared host during `resolve`/`episodes`, and an undeclared host of the video the player opens or meets mid-playback, ask the person ("Rechazar" / "Permitir"; the focus starts on "Rechazar"). For a movie's or episode's video, subtitles or audio the dialog also offers "Permitir video de cualquier servidor" ([the broad video permission](contract.md#broad-video)); once chosen, the plugin's details say "Puede reproducir video desde cualquier servidor" next to "Quitar permiso de video amplio". A plugin with remembered refusals also shows "Olvidar rechazos de host". - **While `resolve` runs** the player shows "Resolviendo fuente …", and after 5 s "… buscando enlaces (N s)". - **When a stream can't play**, the player says why in Spanish, never the player's own English: for example "Este aparato no puede reproducir este formato de video (4K/HEVC)", "El servidor del video respondió con un error" or "No se pudo reproducir este video". - **Configurar.** A plugin with `settings` has a "Configurar" button in Ajustes ▸ Plugins and, from Kino 0.9.50, its own tab in Ajustes with the same form ([The settings form](settings-form.md)). Until every required setting has a value its status is "Falta configurar" and nothing of it runs. - **Ver más.** A Home row with a `ref` ends in a "Ver más" card, and a search page with a `next` shows "Ver más resultados de ": both open a grid that asks you for the next page as the person scrolls. - **Search, Home and the library.** Your results appear in search under your plugin's name (with your `color`), next to the app's own sources; your `home` rows appear on Home after the app's own; your titles play in Kino's player and appear in "Continuar viendo" and the library. Titles of a plugin that declares `download` can be saved for offline viewing ([Downloads](manifest.md#downloads)); Plugin titles can be sent to a TV (Chromecast and DLNA, see [Sending to the TV](#cast) below). A `live` item's card says "EN VIVO" and plays on tap, with no info page; a channel never enters "Continuar viendo" or the library ([Live channels](live-channels.md#live-items)). A plugin found through the `kino-plugin` topic carries the label "De la comunidad" on its card. In search, a live channel whose name has nothing to do with what was typed is left out ([the rule](contract.md#validation)); movies and series always stay. - **Status of each plugin** in Ajustes > Plugins: "Activo", "Desactivado", "Falta configurar", "No responde — actívalo para volver a intentar" (three timeouts in a row; the person can re-enable it), "Actualización disponible — requiere tu aprobación", and "Archivos dañados, reinstálalo" (the installed file no longer matches what was installed). - **Disable and uninstall.** A disabled plugin disappears from search and Home; its titles stay in the library and say "Activa el plugin para ver esto". Uninstalling deletes the plugin's files, its storage and its cached Home rows immediately, but keeps the person's library titles and progress: opening one says "Esto venía del plugin , que ya no está instalado", and installing the plugin again restores them. That is one more reason to keep `id` and `ref` handling stable. Titles already downloaded keep playing offline and can be removed from Descargas. - **Signed plugins.** A signed plugin ([Signed plugins](signed.md)) shows the line "Firmado por su autor" on the consent sheet, a "Firmado" pill on catalog and community cards ("Activo · Firmado" on an installed one), and "Clave del autor: ABCD-EF01-2345-6789" in its details (phone: Gestionar; TV: the installed plugin's actions). Kino 0.9.45 and later. ## Sending to the TV (Chromecast and DLNA) { #cast } Plugin titles can be sent to a TV from the player. Nothing in the manifest turns it on: Kino decides per stream from what your `resolve` returns (`url`, `mime`, `headers`, `drm`): | Your stream | What Kino does | | --- | --- | | mp4/webm (or another progressive file) with **no `headers`**, on a host your plugin may use | The TV fetches the URL itself; the phone moves no bytes. If the TV fails it, it is relayed through the phone. | | HLS (`.m3u8`) | Through the phone (a Chromecast needs CORS on it): playlists are rewritten so every segment and key goes through the phone. | | Any file **with `headers`** (Referer, cookies, tokens in headers) | Through the phone: it fetches with your headers and the TV only sees a local address. | | A [request-signed](signed-streams.md#cast) stream (Kino 0.9.50) | Through the phone, which calls `sign()` for every playlist and segment the TV asks for. | | DRM (Widevine or ClearKey), DASH, progressive MPEG-TS, or a format nothing tells apart | Not sent: the person reads "Este título no se puede enviar a la TV" (protected ones: "Este título está protegido y no se puede enviar a la TV"). | What helps authors: return a real `mime` (`video/mp4`, `application/vnd.apple.mpegurl`) or a URL that ends in the right extension; Kino probes the first bytes of a stream nothing describes (a 1 KB ranged read, 2 s) but that costs a request. Prefer links that need no `headers` (the lightest route); every host involved must be one your plugin may reach (declared, typed, or covered by an "any" permission), over https. While casting, the phone itself stays silent. ## From Kino 0.9.50 { #v0950 } With nothing new in your manifest, except where said: - **Play on the TV from the phone.** With two paired devices, "Reproducir en el TV" works for a title of any plugin: the TV opens it with **its own copy** of the plugin. If the TV can't, the phone says why: "Instala el plugin en el TV para verlo allí", "Activa …", "Configura …", "Actualiza …", "Reinstala …", "En el TV hay otro plugin con ese nombre; instala el mismo desde su repositorio", "Desbloquea el contenido 18+ en el TV para verlo allí" or, for a TV on an older Kino, "Actualiza Kino en el TV para verlo allí". So keep your `id` and your refs the same on every device. - **New chapters of the series the person follows.** Kino checks now and then (at most every 6 hours) the saved series of any usable plugin that declares `episodes`, calling your `episodes(ref)` with the saved `ref`, and adds the missing chapters. A failure leaves that series alone and moves on to the next. Keep your series refs stable. - **"Para ti".** A recommendation that comes from a plugin's catalog is saved by its `ref`: a movie as a movie, a series with its first season (calling `episodes`). - **18+ content.** `adult: true` entries of an apiVersion 6 plugin show only with the 18+ code unlocked ([18+ content](contract.md#adult)). - **Channels on Home.** An apiVersion 6 plugin can put channels in its Home rows ([Channels in your Home rows](live-channels.md#home-rows)). - **A section of its own** in the TV sidebar or the phone's Inicio strip, and a group in Categorías, for a plugin that declares `section` or exports `categories` ([Section, categories and colors](section-theme.md)). - **Search inside a "Ver más" page.** Every "Ver más" page has "Buscar en esta categoría" ([`scopedSearch`](contract.md#scoped-search)). - **Your own tab in Ajustes.** A plugin with `settings` gets a tab named after it, with its form, status lines and buttons, painted in its [`theme`](section-theme.md#theme) colors ([The settings form](settings-form.md)). - **The Servidor menu.** When a Stream has two or more copies, the player's "Audio y subtítulos" menu starts with a **Servidor** section listing them by `label` ("Opción 2", "Opción 3"… without one); the person switches and keeps watching from the same spot ([Labelled and lazy copies](contract.md#lazy-copies)). - **Your own sentence on an error.** A valid `userMessage` shows as "Mensaje de : …" instead of Kino's line ([Your own sentence](contract.md#user-message)). - **Subtitles from your plugin.** A plugin that exports `subtitles` is listed, under its name, in the player's "Buscar subtítulos en línea" for any title Kino knows by IMDb or TMDB id ([Subtitles for any title](contract.md#subtitles)). - **Info pages filled by your plugin.** A `meta` plugin fills what TMDB and AniList left empty on any title's info page ([Describing other titles](contract.md#meta)). - **Hidden pages.** A `"browser"` plugin's page is never on screen: the player shows its usual loading state while it runs ([Hidden browser](browser.md)). - **Updates.** A badge on Ajustes ▸ Plugins counts the updates waiting for approval, and a failed call of a plugin with a pending update says so ([Updates](publish.md#updates)). - **Community plugins.** The section carries the note "Plugins de la comunidad — Kino no los revisa ni responde por su contenido.", and an installed plugin taken down from the index says "Retirado del índice de la comunidad." ([Claims and plugin takedowns](claims.md)). ## Plugins on the person's other devices { #sync } Kino keeps a person's plugins in step between their own devices (for example phone and TV): an install, a switch on or off, an uninstall, an approval or a saved setting on one device is sent to the others, and passwords travel encrypted end to end. Nothing in your plugin changes. What the other device does: it installs **your plugin again from the same address**, silently when what it fetches asks for nothing more than the person approved on the first one; if an update adds something (a host, a capability), it waits in "Plugins de tus otros aparatos" for the person to approve it there. So keep your repository public and your address stable. # Cookbook Three complete shapes, then two short recipes for the apiVersion 2 powers that need a line on the consent sheet. The three recipes for live channels (apiVersion 3) are on [Live channels](live-channels.md#recipes). The first and the third shapes are, nearly line for line, the two reference plugins Kino's own tests run end to end against a fake server. ## An HTML site with a login and hidden links { #html-login } The site has a login form, keeps the session in a cookie, lists titles as HTML with a "next" link, and hides each video URL with AES-128-CBC. The person's user and password are settings. ```json { "id": "mi-sitio", "name": "Mi sitio", "version": "1.0.0", "apiVersion": 1, "entry": "plugin.js", "hosts": ["sitio.example", "cdn.example.com"], "capabilities": ["search", "home", "browse", "resolve"], "settings": [ { "key": "user", "label": "Usuario", "type": "text", "required": true }, { "key": "password", "label": "Contraseña", "type": "password", "required": true } ] } ``` ```js const BASE = "https://sitio.example"; const KEY = "0123456789abcdef"; const IV = "abcdef9876543210"; // The cookie jar keeps the session between calls (and across restarts): log in only when needed. async function login() { const probe = await kino.fetch(BASE + "/session", { redirect: "manual" }); if (probe.status === 200) return; const r = await kino.fetch(BASE + "/login", { method: "POST", body: { form: { user: kino.config.get("user"), password: kino.config.get("password") } }, redirect: "manual", }); if (r.status === 401) throw kino.error("auth_required", "usuario o contraseña incorrectos"); if (r.status !== 302) throw kino.error("unavailable", "el sitio respondió " + r.status); } function cards(html) { return kino.html.select(html, "a.card").map((a) => ({ id: a.attrs["data-id"], ref: a.attrs["data-link"], title: a.text, kind: "movie", })); } async function page(path) { await login(); const r = await kino.fetch(BASE + path); if (r.status === 429) throw kino.error("rate_limited", "demasiadas peticiones"); if (!r.ok) throw kino.error("unavailable", "el sitio respondió " + r.status); const html = r.text(); const next = kino.html.select(html, "a.next").map((a) => a.attrs.href)[0]; return { items: cards(html), next: next || undefined }; } export async function search(query) { return (await page("/buscar?q=" + encodeURIComponent(query.q))).items; } export async function home() { const first = await page("/catalogo"); return [{ id: "catalogo", title: "Catálogo", ref: "/catalogo", items: first.items }]; } export async function browse(ref, cursor) { return page(cursor || ref); } export async function resolve(ref) { await null; const url = kino.crypto.decrypt("aes-128-cbc", { key: KEY, iv: IV, data: ref }); return { url, mime: "video/mp4" }; } ``` `kino.html.select` exists only in the app, so test this one in Kino (or with `--replay` for the parts that do not parse HTML). ## A JSON API with a token { #json-token } The API wants a token it gives out for an API key. Keep the token in `kino.storage`, keyed by the key it came from, and fetch a new one when the API says it expired. ```js const API = "https://api.example.com/v1"; const tokenKey = () => "token:" + kino.config.get("apiKey"); async function token() { await null; const saved = kino.storage.get(tokenKey()); if (saved) return saved; const r = await kino.fetch(API + "/token", { method: "POST", body: { json: { key: kino.config.get("apiKey") } } }); if (r.status === 401) throw kino.error("auth_required", "la clave no sirve"); if (!r.ok) throw kino.error("unavailable", "la API respondió " + r.status); const t = r.json().token; kino.storage.set(tokenKey(), t); return t; } async function api(path) { const r = await kino.fetch(API + path, { headers: { Authorization: "Bearer " + (await token()) } }); if (r.status === 401) { kino.storage.remove(tokenKey()); throw kino.error("auth_required", "el token venció"); } if (r.status === 404) throw kino.error("not_found"); if (r.status === 429) throw kino.error("rate_limited"); if (r.status === 451) throw kino.error("geo_blocked"); if (!r.ok) throw kino.error("unavailable", "la API respondió " + r.status); return r.json(); } export async function search(query) { const p = await api("/search?q=" + encodeURIComponent(query.q) + (query.cursor ? "&page=" + query.cursor : "")); return { items: p.results.map((x) => ({ id: String(x.id), ref: String(x.id), title: x.title, kind: "movie", ids: { tmdb: x.tmdb } })), next: p.nextPage ? String(p.nextPage) : undefined, }; } export async function resolve(ref) { const s = await api("/play/" + encodeURIComponent(ref)); return { url: s.url, expiresInSeconds: 3600 }; } ``` Manifest: `"hosts": ["api.example.com"]`, `"capabilities": ["search", "browse", "resolve"]` (a `next` in a search page needs `browse`), and one setting `{ "key": "apiKey", "label": "Clave de la API", "type": "password", "required": true }`. Since `browse` is declared it must be exported too; `export async function browse(ref, cursor) { throw kino.error("not_found"); }` is enough when only search pages. ## The person's own server { #own-server } A media server at home (Jellyfin, Emby, a NAS…): the person types its address, user and password. The address becomes an allowed host for that install, `http` and a LAN address included; streams, posters and stills may point at it. This is the published demo plugin **Tu servidor** 1.5.0 ([kinotvapp/kino-plugin-own-server](https://github.com/kinotvapp/kino-plugin-own-server), with a reference server to run it against), which uses every feature up to apiVersion 7 (Kino 0.9.51) a server of your own can: seasons, `download`, `audioTracks`, `subtitles`, `durationMs` and `skip`, `live` items, `kino.storage` with a TTL, `kino.rank`, `ids`, `channels` in every shape (a `ref`, an inline `stream`, an M3U playlist with an XMLTV guide, a `resolve: true` playlist, `liveSearch` and paging), the apiVersion 6 set (a section with tabs, Categorías tiles, `scopedSearch`, `migrate`, `adult` entries, labelled and lazy copies, request-signed HLS, the full settings form, `telemetry`, `userMessage`) and the apiVersion 7 capabilities `tracking` and `segments`, plus `meta` with a logo, ratings and cast and `subtitles` with Kino 0.9.51's `file` hint. It is the reference plugin for anything beyond the five basic capabilities; [Example plugins](examples.md#reference-plugin) maps every feature to its function. Its real [`kino-plugin.json`](https://github.com/kinotvapp/kino-plugin-own-server/blob/main/kino-plugin.json): ```json { "id": "own-server", "name": "Tu servidor", "version": "1.5.0", "apiVersion": 7, "entry": "plugin.js", "description": "Ve el contenido de tu propio servidor de video, con sus canales y subtítulos, y cuéntale qué ves. Necesita que escribas su dirección.", "author": "kinotvapp", "homepage": "https://github.com/kinotvapp/kino-plugin-own-server", "hosts": [], "capabilities": [ "search", "home", "browse", "episodes", "resolve", "download", "channels", "scopedSearch", "migrate", "meta", "subtitles", "tracking", "segments" ], "categories": ["movies", "series", "live", "subtitles", "utilities"], "discoverable": true, "debug": false, "telemetry": true, "section": { "label": "Tu servidor" }, "theme": { "accent": "#2BB68F", "onAccent": "#06201A", "background": "#0B1513", "surface": "#15241F", "highlight": "#E8F5F0" }, "settings": [ { "key": "cuenta", "label": "Tu servidor", "type": "section", "hint": "Escribe la dirección de tu servidor (Jellyfin, Emby, un NAS…) tal como la abres en el navegador de tu casa, con su puerto, y tu usuario y contraseña de ese servidor. Kino solo se conecta a esa dirección y a las otras que pongas abajo." }, { "key": "server", "label": "Servidor", "type": "url", "required": true, "hint": "http://192.168.1.10:8096" }, { "key": "user", "label": "Usuario", "type": "text", "required": true }, { "key": "password", "label": "Contraseña", "type": "password", "required": true }, { "key": "estado", "label": "Conexión", "type": "status" }, { "key": "probar", "label": "Probar conexión", "type": "action" }, { "key": "salir", "label": "Cerrar sesión", "type": "action", "confirm": "¿Cerrar la sesión en tu servidor? Kino vuelve a entrar con tu usuario la próxima vez." }, { "key": "reproduccion", "label": "Reproducción", "type": "section", "hint": "Cómo pedir los videos y cada cuánto revisar lo nuevo de tu servidor." }, { "key": "hd", "label": "Solo HD", "type": "toggle" }, { "key": "homeTtl", "label": "Revisar lo nuevo", "type": "select", "default": "15", "options": [{ "value": "5", "label": "Cada 5 minutos" }, { "value": "15", "label": "Cada 15 minutos" }, { "value": "60", "label": "Cada hora" }] }, { "key": "addresses", "label": "Otras direcciones del mismo servidor", "type": "list", "max": 5, "fields": [ { "key": "url", "label": "Dirección", "type": "url", "required": true, "hint": "https://mi-servidor.example.org" }, { "key": "label", "label": "Nombre", "type": "text", "hint": "Desde fuera de casa" } ] }, { "key": "canales", "label": "Canales en vivo", "type": "section", "hint": "Algunos canales solo responden a un reproductor conocido: escribe aquí el User-Agent que piden." }, { "key": "userAgent", "label": "User-Agent de los canales", "type": "text", "hint": "VLC/3.0.20 LibVLC/3.0.20" }, { "key": "quitarAgente", "label": "Usar el User-Agent de Kino", "type": "action" }, { "key": "avisos", "label": "Lo que ves", "type": "section", "hint": "Kino le cuenta a tu servidor qué ves en este aparato y cuándo lo terminas, para que marque lo visto como lo hace su propia app. Apágalo cuando quieras con el interruptor «Enviar lo que veo» de esta misma pestaña." }, { "key": "ultimoAviso", "label": "Último aviso", "type": "status" } ], "color": "#1F8A70", "icon": "icon.png" } ``` `hosts` is empty: the plugin reaches only the server the person types and the other addresses of that server they list (allowed from apiVersion 2 with a `url` setting, see [The person's own servers](manifest.md#own-servers)). Every channel list, guide and stream is on that same server, so it needs no `"liveStreamHosts": "any"`. `"apiVersion": 7` makes Kino 0.9.50 and older refuse it ("Este plugin necesita una versión más nueva de Kino"); declare the lowest number that has what you use. What follows is, line for line, the core of its real [`plugin.js`](https://github.com/kinotvapp/kino-plugin-own-server/blob/main/plugin.js): the requests ([`reach`](https://github.com/kinotvapp/kino-plugin-own-server/blob/main/plugin.js#L56-L74) falls back to the other addresses, `api` turns every status into a typed error), the listing and the playing. The rest -- copies, signing, channels, the section, `migrate`, `meta`, `subtitles`, the settings form -- is in the same file, one function per feature. ```js const VERSION = "1.5.0"; const HLS = "application/vnd.apple.mpegurl"; const trimSlash = (u) => String(u).replace(/\/+$/, ""); const base = () => trimSlash(kino.config.get("server") || ""); const enc = encodeURIComponent; // The other addresses of the SAME server (the `list` setting "addresses": its LAN address and its public // name, say). Each `url` field is an allowed host too. Used three ways: the API falls back to them when // the main address does not answer (`reach`), every file gets them as labelled copies (`withAddresses`), // and the signed video as `alternateHosts` (`signedStream`). const addresses = () => (kino.config.get("addresses") || []).map((a) => ({ url: trimSlash(a.url), label: (a.label || "").trim() || new URL(a.url).host })); // Everything cached in kino.storage belongs to one user on one server: storage survives a change // in Configurar, so a key without them would hand the old server's answers to the new one. const scope = () => kino.config.get("user") + "@" + base(); // The token does NOT change when only the password changes for the same user@server -- a // still-valid token keeps working, exactly like a real session would, until the server rejects it. const tokenKey = () => "token:" + scope(); // Who is asking, the way a Jellyfin client says it: Kino's version and language, this plugin's version, and // a random id for this install (kino.crypto.uuid, kept in kino.storage: per device, never in a setting -- // settings travel to the person's other devices). Headers only, so the Node kit's recordings still match. function clientHeaders() { let id = kino.storage.get("client-id"); if (!id) kino.storage.set("client-id", (id = kino.crypto.uuid())); return { "X-Client": `Kino/${kino.appVersion} own-server/${VERSION} api/${kino.apiVersion}`, "X-Client-Id": id, "Accept-Language": kino.lang, }; } // One request, to the main address and, when it does not answer at all (network or timeout), to the other // addresses in order. A fallback is a degraded result even though the call works: kino.log.report tells // Kino's error tracker (the manifest declares `"telemetry": true`), at most once an hour per area. async function reach(path, init = {}) { const options = { ...init, headers: { ...clientHeaders(), ...init.headers } }; try { return await kino.fetch(base() + path, options); } catch (e) { if (e.code !== "network" && e.code !== "timeout") throw e; const others = addresses(); for (let i = 0; i < others.length; i++) { try { const r = await kino.fetch(others[i].url + path, options); kino.log.report("own_server:address", "main_unreachable", "fallback=" + (i + 1)); return r; } catch (again) { if (again.code !== "network" && again.code !== "timeout") throw again; } } throw e; } } async function token() { await null; const saved = kino.storage.get(tokenKey()); if (saved) return saved; const r = await reach("/auth", { method: "POST", body: { json: { user: kino.config.get("user"), password: kino.config.get("password") } }, }); if (r.status === 401) throw kino.error("auth_required", "usuario o contraseña incorrectos"); if (!r.ok) throw kino.error("unavailable", "el servidor respondió " + r.status); const t = r.json().token; kino.storage.set(tokenKey(), t); return t; } // Every request goes through here. A token invalidated server-side (expired, revoked, or a stale one // from before a real password change) is forgotten and the request tried once more with a fresh login. // A 429 that asks to wait at most 3 s is waited out once with kino.sleep (the wait counts inside the // call's own time limit). Every other failure becomes one of Kino's typed errors. async function api(path, { method = "GET", body, headers = {}, timeoutMs } = {}) { const send = async () => reach(path, { method, body, timeoutMs, headers: { ...headers, "X-Token": await token() } }); let r = await send(); if (r.status === 401) { kino.storage.remove(tokenKey()); r = await send(); } const wait = Number(r.headers["retry-after"]); if (r.status === 429 && wait > 0 && wait <= 3) { await kino.sleep(wait * 1000); r = await send(); } if (r.status === 401) { kino.storage.remove(tokenKey()); throw kino.error("auth_required", "la sesión venció"); } if (r.status === 400 || r.status === 404) throw kino.error("not_found", "el servidor respondió " + r.status); if (r.status === 429) throw kino.error("rate_limited"); // Kino words the other codes itself; this one says more, as "Mensaje de Tu servidor: …" (kino.error's userMessage). if (r.status === 451) throw kino.error("geo_blocked", "el servidor respondió 451", { userMessage: "Tu servidor no deja ver este título desde esta red." }); if (!r.ok) throw kino.error("unavailable", "el servidor respondió " + r.status); return r.status === 204 ? null : r.json(); } // Artwork lives on the same typed server, so `http` and a LAN address are fine here too. Posters // are 2:3 (the cards), backdrops 16:9 (the info page's background, and each episode's still), the // logo a clear-logo on a transparent background (meta's `logo`). const art = (shape, id) => base() + "/img/" + shape + "/" + enc(id) + ".png"; const poster = (id) => art("poster", id); const backdrop = (id) => art("backdrop", id); // `kind` comes from the server: "movie", "series" (one season of a show) or "live" (apiVersion 2). // `ids` only when the server knows them: Kino then matches the title with TMDB and fills in its // info page (cast, director, tagline...). `adult: true` (apiVersion 6) keeps it behind the person's 18+ code. const item = (x) => ({ id: x.id, ref: x.id, title: x.title, kind: x.kind, year: x.year, poster: poster(x.id), backdrop: backdrop(x.id), overview: x.overview, genres: x.genres ? x.genres.slice(0, 5) : undefined, rating: x.rating, runtimeMinutes: x.kind === "live" ? undefined : x.runtime, badges: x.badges, quality: x.quality, lang: x.lang, ids: x.tmdb || x.imdb ? { tmdb: x.tmdb, imdb: x.imdb } : undefined, adult: x.adult || undefined, }); // A browse ref (a Home or section row, a Categorías tile) as the server's filter: a kind, or "genre:". function refFilter(ref) { if (ref === "movie" || ref === "series" || ref === "live") return "kind=" + ref; const genre = /^genre:([a-z0-9-]+)$/.exec(ref); return genre ? "genre=" + genre[1] : null; } // Home rows, one per kind; each row's ref is the kind, which browse() pages through. `genre` lines the // rows up with other plugins' in Categorías and the En vivo filter (the live row leaves it to Kino's guess). const ROWS = [ { id: "novedades", title: "Novedades", kind: "movie", genre: "peliculas" }, { id: "series", title: "Series", kind: "series", genre: "series" }, { id: "en-vivo", title: "En vivo", kind: "live" }, ]; // Home asks the server three times; the answer is kept with a storage TTL the person picks ("Revisar lo // nuevo", a `select` setting), so opening Kino again right away costs no request. An expired entry reads // as null by itself. A copy without TTL ("home-last:") is the fallback when the server is down. export async function home() { const key = "home:" + scope(); const cached = kino.storage.get(key); if (cached) return JSON.parse(cached); let rows; try { rows = []; for (const row of ROWS) { const p = await api("/items?limit=10&kind=" + row.kind); if (p.items.length) rows.push({ id: row.id, title: row.title, ref: row.kind, genre: row.genre, items: p.items.map(item) }); } } catch (e) { const last = kino.storage.get("home-last:" + scope()); if (!last || !["unavailable", "network", "timeout"].includes(e.code)) throw e; kino.log.report("own_server:home", "stale_rows", e.code); return JSON.parse(last); } const minutes = Number(kino.config.get("homeTtl")) || 15; kino.storage.set(key, JSON.stringify(rows), { ttlMs: minutes * 60 * 1000 }); kino.storage.set("home-last:" + scope(), JSON.stringify(rows)); return rows; } export async function browse(ref, cursor) { const filter = refFilter(ref); if (!filter) throw kino.error("not_found", "fila desconocida"); const p = await api("/items?limit=10&" + filter + (cursor ? "&cursor=" + enc(cursor) : "")); return { items: p.items.map(item), next: p.next || undefined }; } // The server matches ANY word of the query, so "Serie de prueba" also brings "Video de prueba 1". // kino.rank turns that into a title search: ask with the title's head, drop the stray-word hits, // best match first -- trying every form of the title Kino knows. `type` is only a hint: the kind it // names goes first, nothing is dropped for it. The answer is a Page: its `next` gets "Ver más resultados". // With `within` (the `scopedSearch` capability) the person is searching inside one of this plugin's // "Ver más" pages: the server searches that row's kind or genre only; null for a ref it cannot search. export async function search(query) { if (query.within !== undefined) { const filter = refFilter(query.within); if (!filter) return null; const p = await api("/items?limit=50&" + filter + "&q=" + enc(query.q) + (query.cursor ? "&cursor=" + enc(query.cursor) : "")); return { items: kino.rank.filterRelevant(p.items, query.q).map(item), next: p.next || undefined }; } if (!query.q.trim()) return []; const titles = [query.q, query.originalTitle, ...(query.altTitles || [])].filter(Boolean); const p = await api("/items?limit=50&q=" + enc(kino.rank.shortQuery(query.q)) + (query.cursor ? "&cursor=" + enc(query.cursor) : "")); const best = kino.rank.sortBySimilarity(kino.rank.filterRelevant(p.items, titles), titles); const wanted = query.type === "movie" || query.type === "series" ? query.type : null; const ordered = wanted ? [...best.filter((x) => x.kind === wanted), ...best.filter((x) => x.kind !== wanted)] : best; return { items: ordered.map(item), next: p.next || undefined }; } // Each season is its own title on this server, so the answer lists every season of the show in // `seasons` (the one being answered marked `current`): Kino shows them as chips and calls // episodes() again with the chosen season's ref. export async function episodes(ref) { const x = await api("/items/" + enc(ref)); if (x.kind !== "series") throw kino.error("not_found"); return { series: { title: x.show.title, overview: x.show.overview, poster: poster(x.id), backdrop: backdrop(x.id), genres: x.genres, year: x.year }, episodes: x.episodes.map((e) => ({ season: x.season, number: e.number, ref: e.id, title: e.title, still: backdrop(e.id), overview: e.overview, airDate: e.airDate, runtimeMinutes: e.runtime, })), seasons: x.seasons.map((s) => ({ id: s.id, ref: s.id, title: "Temporada " + s.number, number: s.number, current: s.id === x.id, })), }; } // Movies and episodes are progressive mp4 files, so with `download` declared Kino can save them; // the live channels are HLS and play as live (never downloadable). A movie with a separate audio // file gets it as an `audioTracks` entry, merged by the player and picked in its audio menu; its // subtitles go in `subtitles`; its length in `durationMs` and, when the server knows where THIS file's // opening and ending are, `skip` ("Saltar intro" / "Saltar outro"). // `options.retry` (apiVersion 6) comes only after the origin refused a signed stream: see signedStream. export async function resolve(ref, options) { // An entry of the list declared with `resolve: true` (liveCategories): its link needs the token. if (/\/channels\/[^/]+\/play$/.test(ref)) return resolveListEntry(ref); // A lazy copy's own ref ("|<copy>", see below): Kino asks for it only when the person picks that // copy in the player's Servidor menu, the fallback reaches it or a download's copy choice does. if (ref.includes("|")) return resolveCopy(ref); const retry = options && options.retry; if (retry) kino.log("resolve: retry", retry.reason, retry.attempt, retry.status || "-"); const x = await api("/items/" + enc(ref) + (retry ? "?fresh=1" : "")); if (x.kind === "live") return { url: base() + x.stream, mime: HLS, headers: agentHeaders() }; if (x.hls) return signedStream(x); const path = x.stream + (kino.config.get("hd") ? "?quality=hd" : ""); const stream = { url: base() + path, mime: "video/mp4", // The token is short-lived server-side (see server.mjs); resolve again once it's stale. expiresInSeconds: 600, }; if (x.durationMs) stream.durationMs = x.durationMs; if (x.skip) stream.skip = x.skip; if (x.subtitles && x.subtitles.length) { stream.subtitles = x.subtitles.map((s) => ({ lang: s.lang, url: base() + s.file, format: s.format })); } if (x.audio && x.audio.length) { stream.audioTracks = x.audio.map((a) => ({ lang: a.lang, label: a.label, url: base() + a.stream })); } if (x.copies && x.copies.length) return withCopies(ref, stream, x.copies); return withAddresses(stream, path); } ``` The apiVersion 7 exports, [`track and segments`](https://github.com/kinotvapp/kino-plugin-own-server/blob/main/plugin.js#L557-L591): the server marks what is in its library as watched, like its own app, and knows where each title's intro and credits are. ```js // `tracking` (apiVersion 7, approved in red: "Le contará al servidor que escribas en su configuración qué // ves y cuándo lo terminas"): Kino calls track() for every movie or episode played on this device, from any // source, and keeps the event in its own queue until it is delivered (offline, app closed...). The server // marks what is in its library as watched, like its own app. The event id is the idempotency key. What the // error codes mean to Kino: `unavailable`/`rate_limited` (and network trouble) retry later, in order; // `auth_required`/`not_found` drop the event. Log the outcome, never the title. export async function track(event) { const answer = await api("/playing", { method: "POST", body: { json: event }, headers: { "Idempotency-Key": event.id } }); kino.log("track:", event.type, answer && answer.ignored ? "skipped" : "delivered"); // A title the server does not have (it played from another source) is of no use to it: dropped, but not // counted as a delivery (only a real one clears the red "No pudo avisar…" line in Ajustes). return answer && answer.ignored ? { skipped: true } : { ok: true }; } // `segments` (apiVersion 7): where a title's intro and credits are, for "Saltar intro" / "Saltar outro" on ANY // movie or episode Kino knows by id, the way an intro-skipper plugin of a media server knows them. Asked in the // background once the file plays; `durationMs` is that file's length, so the server answers for that cut. // For an episode `ids` are the episode's own and may be empty: the show's ids + season + episode then. // (This plugin's own files carry `skip` in their Stream instead, which wins over any segments answer.) export async function segments({ kind, ids, show, season, episode, durationMs }) { const q = new URLSearchParams(); let known = false; if (ids.imdb) { q.set("imdb", ids.imdb); known = true; } if (ids.tmdb) { q.set("tmdb", String(ids.tmdb)); known = true; } if (kind === "episode" && show) { if (show.ids.imdb) { q.set("showImdb", show.ids.imdb); known = true; } if (show.ids.tmdb) { q.set("showTmdb", String(show.ids.tmdb)); known = true; } q.set("season", String(season)); q.set("episode", String(episode)); } if (!known) return null; if (durationMs) q.set("duration", String(durationMs)); const found = await api("/segments?" + q); return found.map((s) => ({ type: s.type, startMs: Math.round(s.startMs), endMs: Math.round(s.endMs) })); } ``` Try it under Node against the bundled server (`node server.mjs`), with `C="--config server=http://192.168.1.10:8096 --config user=ana --config password=s3cr3t"` (or `sdk/config.json`, kept out of git), from your computer's LAN address, not `127.0.0.1`: a loopback address is refused even as the person's own server. `node sdk/run.mjs $C . home`, `… . resolve doblaje`, `… . live categories`, `… . track watched` and `… . segments tt1254207 45000` then show what Kino would get; the repository's README lists every command, and `node --test test/*.test.mjs` runs its tests offline. ## A TMDB catalog with no key in the plugin (Kino 0.9.53) { #tmdb-catalog } A plugin whose Home rows and search come from TMDB (trending, discover by genre, a title's seasons) used to ask every person for a TMDB key in its own settings. With [`kino.tmdb`](kino-api.md#tmdb) Kino brings the key: its own first, behind its cache and limits, and the person's (the one in Ajustes, or the one of their Stremio TMDB addon, once they agree) only when Kino's fails. The plugin carries none and declares no TMDB host for it. The plugin's own setting stays only for Kino 0.9.52 and older. ```json { "id": "tmdb-catalog", "name": "Catálogo TMDB", "version": "1.0.0", "apiVersion": 1, "entry": "plugin.js", "hosts": ["api.themoviedb.org"], "capabilities": ["home", "search", "episodes", "resolve"], "settings": [{ "key": "tmdbKey", "type": "password", "label": "Llave de TMDB (Kino 0.9.52 o anterior)" }] } ``` (`api.themoviedb.org` is in `hosts` only for the fallback's `kino.fetch` on older Kino; `kino.tmdb` itself needs none.) ```js const IMG = "https://image.tmdb.org/t/p/w500"; async function tmdb(path, params = {}) { if (typeof kino.tmdb === "function") return kino.tmdb(path, params); const key = kino.config.get("tmdbKey"); // Kino 0.9.52 and older: the plugin's own setting if (!key) throw kino.error("auth_required", "falta la llave de TMDB"); const r = await kino.fetch(`https://api.themoviedb.org/3${path}?${new URLSearchParams({ ...params, api_key: key })}`); if (!r.ok) throw kino.error(r.status === 404 ? "not_found" : "unavailable", "TMDB respondió " + r.status); return r.json(); } const item = (m, kind) => ({ id: `${kind}-${m.id}`, ref: JSON.stringify({ kind, id: m.id }), kind: kind === "tv" ? "series" : "movie", title: m.title || m.name, year: (m.release_date || m.first_air_date || "").slice(0, 4), poster: m.poster_path ? IMG + m.poster_path : undefined, overview: m.overview || undefined, ids: { tmdb: m.id }, }); export async function home() { try { const [movies, shows] = await Promise.all([ tmdb("/trending/movie/week", { language: "es-MX" }), tmdb("/trending/tv/week", { language: "es-MX" }), ]); return [ { id: "movies", title: "Películas en tendencia", items: movies.results.map((m) => item(m, "movie")) }, { id: "shows", title: "Series en tendencia", items: shows.results.map((m) => item(m, "tv")) }, ]; } catch (e) { // No key at all (a Kino build without one, and no key of the person's): Home shows no rows instead of an error. if (e.code === "no_tmdb_key") return []; throw e; } } export async function search(query) { if (!query.q) return []; // Kino's key answers first, so this is rare. Uncaught, no_tmdb_key reaches the person as Kino's own sentence // ("Agrega tu llave de TMDB en Ajustes, o instala un addon de TMDB de Stremio configurado con tu llave."): nothing to // word yourself. const r = await tmdb("/search/multi", { query: query.q, language: "es-MX", include_adult: false }); return r.results.filter((m) => m.media_type === "movie" || m.media_type === "tv").map((m) => item(m, m.media_type)); } export async function episodes(ref) { const { id } = JSON.parse(ref); const show = await tmdb(`/tv/${id}`, { language: "es-MX" }); const out = []; for (const s of show.seasons.filter((x) => x.season_number > 0).slice(0, 10)) { const season = await tmdb(`/tv/${id}/season/${s.season_number}`, { language: "es-MX" }); for (const e of season.episodes) { out.push({ season: e.season_number, number: e.episode_number, title: e.name, ref: JSON.stringify({ kind: "tv", id, s: e.season_number, e: e.episode_number }) }); } } return { episodes: out }; } export async function resolve(ref) { throw kino.error("not_found", "este catálogo no reproduce: solo lista títulos"); // your source's resolve goes here } ``` Try it without a key (as a Kino build with none), then with yours standing in for Kino's: ``` node sdk/run.mjs . home # [] : no_tmdb_key is caught KINO_TMDB_KEY=<your v3 key> node sdk/run.mjs . home # the two rows node sdk/run.mjs . search matrix # [no_tmdb_key] … and the sentence the person reads ``` ## A Widevine-protected stream (apiVersion 2) { #widevine } Your source serves DASH or HLS encrypted with Widevine and hands out a license from its own server. Declare `"apiVersion": 2` and `"drm"` in `capabilities`, list the license server in `hosts`, and return a `drm` block with the `Stream`: ```json { "id": "mi-servicio", "name": "Mi servicio", "version": "1.0.0", "apiVersion": 2, "entry": "plugin.js", "hosts": ["api.example.com", "cdn.example.com", "license.example.com"], "capabilities": ["search", "resolve", "drm"] } ``` ```js export async function resolve(ref) { const s = await api("/play/" + encodeURIComponent(ref)); // { mpd, licenseToken } return { url: s.mpd, // https://cdn.example.com/…/manifest.mpd mime: "application/dash+xml", drm: { type: "widevine", licenseUrl: "https://license.example.com/widevine", licenseHeaders: { Authorization: "Bearer " + s.licenseToken }, }, expiresInSeconds: 3600, }; } ``` What Kino does with it, and what it does not: - `licenseUrl` must pass the same check as `url`: `https` on one of your `hosts` (or the person's own server as typed), never an IP or a local name; the license request itself goes through the same host gate as the segments, with `licenseHeaders` (filtered like `headers`, at most 20) on it and nothing else. `headers` are not sent to the license server, and `licenseHeaders` are not sent to the CDN. - `type` must be `"widevine"`: PlayReady, FairPlay and ClearKey are not offered. Without the `drm` capability, or with any other DRM-shaped key (`license`, `licenseUrl`, `drmLicenseUrl`, `keySystem`, `widevine`) in the `Stream`, the stream is refused as it always was. - Kino asks Widevine for security level **L3** (software) so the same player, surface and decoder as a clear stream are used, and plays **only if the device confirms L3**: a device that stays at L1 (or won't say) opens no session at all and shows the message below. A license server that refuses L3, or grants it only SD, gives the person SD or that same message: check your server's policy before you ship. - `audioTracks` next to `drm`: the video is protected, the side audio files are played **clear** -- no license is requested for them, so they must be plain, unencrypted files (an encrypted side file fails the whole playback with the message below). `subtitles` and `headers` work as always. - When the license is refused, unreachable or expired, or the device has no Widevine (or no L3), the person reads "No se pudo abrir este video protegido" (after one more `resolve` if `expiresInSeconds` had passed, like any stream). A protected live channel reads the same at once on a device with no L3; its other license failures are cuts, re-resolved like any other (see [Live channels](live-channels.md#live-items)). A protected title is **never downloadable** ("Este video no se puede descargar"), even with `download` declared, and cannot be sent to a TV either ([Sending to the TV](what-people-see.md#cast)). - The consent sheet adds "Reproduce video protegido (DRM)" when `drm` is declared, and an update that newly declares it waits for the person's approval ([Publishing](publish.md#updates)). To test without a real service, a public Widevine test stream works: the manifest at `https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd` with the license server `https://proxy.uat.widevine.com/proxy?provider=widevine_test` (declare `storage.googleapis.com` and `proxy.uat.widevine.com` in `hosts`; no `licenseHeaders` needed). ## A site of yours without a certificate (apiVersion 2) { #insecure-site } Your videos sit on a server of yours that only speaks plain `http` -- a CDN box with no certificate, an old media server on a public name. Declare `"apiVersion": 2` and mark that one host `insecureHttp` in `hosts`; nothing changes in your code beyond the scheme: ```json { "id": "mi-cdn", "name": "Mi CDN", "version": "1.0.0", "apiVersion": 2, "entry": "plugin.js", "hosts": ["api.example.com", { "host": "cdn.example.com", "insecureHttp": true }], "capabilities": ["search", "resolve"] } ``` ```js export async function resolve(ref) { const s = await api("/play/" + encodeURIComponent(ref)); // over https, api.example.com return { url: "http://cdn.example.com/videos/" + s.file, // plain http: only because cdn.example.com is insecureHttp subtitles: s.subs.map((x) => ({ lang: x.lang, url: "http://cdn.example.com/subs/" + x.file })), }; } ``` What the flag does, and what it does not: - Only `cdn.example.com`, exactly, accepts `http`: for `kino.fetch`, a `Stream`'s `url`, `subtitles`, `audioTracks` and a `drm` block's `licenseUrl`, and for every redirect hop that lands on it. `api.example.com` stays https-only, and so does `sub.cdn.example.com` (no wildcard, no subdomains). `https://cdn.example.com/…` keeps working too. - Everything else about a declared host holds: a public DNS name (no IP, no `localhost`, nothing `.local`/`.lan`), and a name that resolves into the person's own network is refused at request time. For a server at home the person types in a `url` setting instead (see [The person's own server](#own-server)): that path takes `http` without this flag. - The consent sheet adds, in red, "Conexión sin cifrar con cdn.example.com", so the person knows that traffic can be read on the way; an update that newly marks an already-approved host `insecureHttp` waits for approval ([Publishing](publish.md#updates)). Prefer `https` whenever the server can: the flag is for the host that cannot. <!-- page: https://kinotvapp.github.io/kino-plugins/en/nuvio/ (docs/nuvio.en.md) --> # Nuvio scrapers Kino can install the scrapers of a Nuvio provider repository without anyone writing a Kino plugin: it converts the chosen scraper into a Kino plugin on the device, at install time. This page says how people add them, what the conversion does and where it stops. You don't need any of it to write a plugin of your own; it matters if you maintain a Nuvio repository, or you want to know why a converted plugin behaves differently from a hand-written one. ## How people add them { #add } 1. In Kino, Ajustes ▸ Plugins (on the TV, the "Plugins" button on Home also gets there), "Agregar plugin", and type the repository's address, `owner/repo`, exactly as for a Kino plugin. The pasted address can also be a `github.com/owner/repo` URL, a `/tree/<ref>/<folder>` one, or a `raw.githubusercontent.com/owner/repo/<ref>/.../manifest.json` URL (also `github.com/.../blob/...` or `/raw/...` to a `.json` file): Kino takes the folder that file is in. Any other file is refused. 2. Kino reads `manifest.json` at the repository root. When it is Nuvio's own format (an object with a `scrapers` array) the address is a Nuvio repository; otherwise Kino treats it as a Kino plugin (`kino-plugin.json`). If the default branch has a `manifest.json` that is not in that format (some repositories keep a template there), Kino also tries the `main` and then the `master` branch. 3. A full-screen picker lists **every** scraper of the manifest, with its logo, types, language, version and author, and filters by type (Todas, Películas, Series, Anime) and language. Each card says "Agregar", "Instalado", or "No disponible" (a scraper the manifest disables, or disables on Android) or "No compatible" (a scraper of peer-to-peer sources, with or without debrid: "Kino no admite scrapers de torrents, ni siquiera con debrid", from Kino 0.9.51). A repository with nothing installable says "Este repositorio de Nuvio no tiene scrapers instalables en Android". 4. "Agregar" converts that scraper and opens the usual [consent sheet](what-people-see.md); after "Instalar" the picker stays open so the person can add another one. Each scraper becomes its own plugin, listed in Ajustes ▸ Plugins like any other. The picker notes that the scrapers are converted from Nuvio and that their original code is under the GPL-3.0 license; the plugin's description says the same ("Convertido desde el plugin de Nuvio …; código original GPL-3.0"). ## What the conversion builds { #conversion } The scraper's own JavaScript is kept byte for byte, wrapped with a compatibility layer and a small adapter, and installed with a generated manifest: - `apiVersion` 6 (4 before Kino 0.9.51), `version` `1.<converter revision>.0` (today `1.4.0`, see [Updates](#updates)), capabilities `search`, `episodes`, `resolve` and `download`. - The plugin's `settings`, when the scraper has `onSettings` (Kino 0.9.51): see [The scraper's settings](#settings). - The types the scraper serves come from its `supportedTypes`, with the usual spellings folded together: `movie`, `movies`, `film`, `films` are movies; `tv`, `series`, `show`, `shows` are series; `anime` is anime (any case). A scraper declaring `["movie", "series"]` serves series too. - `hosts`: detected automatically from the scraper's code (and from a remote domain list it names, when it has one), with `api.themoviedb.org` always first. A manifest caps them at 20: over that, the addresses that look like the scraper's own site go first, and the description warns "se detectaron más de 20 dominios; algunos quedaron fuera". A scraper whose code names no domain at all cannot be converted ("No encontré ningún dominio en el código de …"). - `"streamHosts": "any"`: its movies and episodes may play from any public server ([the rule](manifest.md#stream-hosts)). - `"fetchHosts": "any"`: its `kino.fetch` may reach any **public** server, without a question per host. Local names, private, loopback and link-local addresses, and names that resolve into the home network stay refused, on every redirect hop. This field is honoured **only** for these converted installs; in a hand-written plugin it has no effect ([why](manifest.md#stream-hosts)). So the consent sheet of a converted scraper shows, in red, "Puede reproducir video desde cualquier servidor que indique" and "Puede conectarse a cualquier servidor de internet", plus "Puede descargar videos para verlos sin conexión". Nothing of it runs before the person accepts. ## How a converted scraper behaves { #behavior } Nuvio scrapers have no catalogue and no text search: they only answer "streams for this TMDB id". So the adapter works from TMDB: - **No Home rows.** The plugin shows up in search results, never as Home rows. - **`search` with a TMDB id** answers one item, for the TMDB id Kino is looking for, with TMDB's poster, backdrop, year and synopsis when TMDB answers in time. A scraper answers only for the types its manifest declares: one without movies (or without series) is not offered as a source for them. - **`search` without a TMDB id** (a typed search, e.g. on the TV) asks TMDB's own search for the text instead (`/search/multi`, or only movies or only series when that is all the scraper serves or Kino asked for a series) and answers up to 10 matches, ranked by how close their title is to the query, ties by TMDB popularity. The scraper itself is not called until the person picks one. If TMDB fails, the search answers nothing rather than an error. - **`episodes`** lists the seasons and episodes from TMDB, without season 0 (specials) and without episodes that have not aired yet. - **`resolve`** calls the scraper's `getStreams` exactly as Nuvio does and keeps only the `http`/`https` copies (Kino has no BitTorrent client). From Kino 0.9.51 **every playable copy** is offered: the first one plays and up to 8 more go as [labelled alternative copies](contract.md#lazy-copies) in the **Servidor** menu, in this order: video files before embed pages, and within that 1080p first, then 720p, then anything else, and 2160p/4K last (most phones and TVs here can't decode 4K HEVC). The label is the quality with the server's name ("1080p · Server X"), so two copies of one quality read apart. A Kodi-style address, `url|User-Agent=…&Referer=…`, is split: what follows the `|` becomes request headers. When nothing is left the person reads why: "sin resultados", "solo enlaces P2P", or "error del scraper: …" with what the scraper logged. - **Downloads** work on phones like for any plugin with `download` ([Downloads](manifest.md#downloads)), under the same host rules as playing. ## Limits that differ from a hand-written plugin { #limits } | What | Converted Nuvio scraper | Hand-written plugin | | --- | --- | --- | | `resolve` time | 75 s (the player counts the wait on screen) | 20 s | | `kino.fetch` requests per call | 250 | 60 | | Hosts `kino.fetch` may reach | any public host (`fetchHosts`) | `hosts`, typed servers, and hosts approved one by one | | Where the video may be | any public host (`streamHosts`) | `hosts`, unless `streamHosts` or the broad video permission | Everything else -- memory, body sizes, the home-network refusals, the other time limits -- is the same. ## The runtime a scraper gets { #runtime } The compatibility layer rebuilds, on top of `kino`, what a Nuvio scraper expects: CommonJS `module`/`exports`/`require`, a browser-shaped `fetch`, `axios`, `process.env`, `global`, `setTimeout`/`clearTimeout`, `AbortController`/`AbortSignal`, `require("crypto")` (Node's, for what scrapers use), the browser Web Crypto API (`crypto.subtle`, `crypto.getRandomValues`, `crypto.randomUUID`), `TMDB_API_KEY`, and the real `cheerio-without-node-native`, `crypto-js` and `Buffer`, bundled only when the scraper's code needs them. From Kino 0.9.51 (Nuvio compatibility v2) also: - **Nuvio's globals**: `window`, `self` and `SCRAPER_ID`; `getStreams` is found on `module.exports`, `exports.getStreams`, `default` or as a global; regenerator-based async code works, and so does a `require` inside a `try` or an `if`. - **A Node subset**: `path`, `url`, `util`, `events`, `querystring`, `timers`, `buffer` and `http`/`https`/`undici` (over `kino.fetch`), plus `setInterval`, `setImmediate` and `queueMicrotask`. `fs`, `child_process`, `net`, `os`, `stream` and the like load as empty modules: every member reads as `undefined`, so a scraper's own feature check falls back to `fetch`, and calling one anyway is a `TypeError`. - **Multi-file scrapers**: the sibling files it `require`s are read from the same repository, through the same fetcher (no new destination). At most 16 files and 1 MiB in total; `../` inside the repository is fine, a path that escapes the repository (also `%`-encoded) is refused. A sibling that throws on load is retried. - **Timing**: 30 s per request. A timer a scraper leaves running is cleared once `getStreams` settles, so the call does not wait for it. A `require` of anything not on that list and not a sibling file fails with "Nuvio compat: require('…') was not bundled with this scraper". Kino's TMDB key is never written into the converted plugin's code: `TMDB_API_KEY` holds a fixed marker, and Kino puts the real key in its place only in `https` requests to `api.themoviedb.org` (the same [sealed-secret](manifest.md#secrets) mechanism a plugin's own keys use). A request that carries the marker anywhere else is refused before it leaves, and the key is blanked out of every answer, error and log the plugin sees. That marker is for converted scrapers only. A plugin you write yourself asks TMDB through [`kino.tmdb`](kino-api.md#tmdb) (Kino 0.9.53): Kino's own key behind Kino's cache and limits, the person's key only when Kino's fails, and no key in your code. All of that exists **only** inside a converted scraper. A plugin you write gets the plain Kino engine: none of those globals ([Limits and engine quirks](engine-limits.md#not-node)). The same goes for the async-helper workaround of [the rejection trap](engine-limits.md#rejection-trap). ## The scraper's settings { #settings } From Kino 0.9.51, a scraper's `onSettings` becomes the converted plugin's [settings form](settings-form.md), in its own Ajustes tab, and what the person picks syncs across their devices like any plugin setting. The scraper gets it in `SCRAPER_SETTINGS`, under its own keys. If the scraper's form does not fit Kino's limits, the consent sheet says "Algunos ajustes del scraper no caben y quedaron fuera". A setting that asks for a debrid account makes the scraper refused (see below). ## What is refused { #refused } **Scrapers of peer-to-peer sources, with or without debrid** (Kino 0.9.51): Kino does not carry torrents, so the card says "No compatible" and "Kino no admite scrapers de torrents, ni siquiera con debrid"; it is judged like a [Stremio peer-to-peer addon](stremio.md). One installed before this is switched off by its next update check. ## Updates { #updates } A converted plugin's version is `1.<converter revision>.0`: it moves only when Kino's converter itself changes (today `1.4.0`), because Nuvio's own `version` fields are not reliable. To find updates Kino does not compare versions: "Buscar actualizaciones" (and the background check) re-runs the whole conversion from the repository and compares the resulting code and manifest with the installed ones, so a change in the scraper is found even though the version stays the same. A change that only touches code installs by itself; one that adds hosts or a permission waits for the person's approval, like any [update](publish.md#updates). A plugin converted by an older Kino asks for approval once for what newer conversions add (`fetchHosts`, downloads). ## For Nuvio repository maintainers { #maintainers } - Keep `manifest.json` at the root of the default branch, with `scrapers[]` entries that have `id`, `name`, `filename`, and ideally `supportedTypes`, `contentLanguage`, `version`, `author`, `description` and `logo`: the picker shows and filters by them. - Use `enabled: false` or `disabledPlatforms: ["android"]` for scrapers that should not be offered. - Return direct `http`/`https` video addresses when you have them: they go before embed pages. Give every copy a `quality` and a `name`: they are its label in the Servidor menu. - Write the site's own address as a literal in the code (or in a remote domain list): that is how Kino finds the hosts to declare. <!-- page: https://kinotvapp.github.io/kino-plugins/en/stremio/ (docs/stremio.en.md) --> # Stremio addons Kino can install a Stremio addon without anyone writing a Kino plugin: it reads the addon's `manifest.json` and generates, on the device, a Kino plugin that talks to the addon's server. This page covers how people add one, how each Stremio resource maps, what is refused and how far it goes. It is for you if you maintain an addon and want it to work well in Kino, or if you want to know why an addon behaves differently from a hand-written plugin. You need none of it to write your own plugin. ## How people add one { #add } 1. In Kino, Ajustes ▸ Plugins (on a TV also through the "Plugins" button on Home), "Agregar plugin", type **Stremio**, and paste the `manifest.json` URL ("Pega la URL del manifest.json del addon de Stremio (o de una colección de addons)"). When the address is plainly an addon's, Kino picks the type by itself. 2. The address is normalized the way Stremio does it: `stremio://` means `https://`, a bare base address gets `manifest.json`, and a public name over `http` is upgraded to `https`. `http` is kept only for the person's own network (an IP, a single-label name, `.local`/`.lan`), so an addon on their home server is added by typing its address. `localhost`, loopback and link-local addresses are refused, and so is a path with `.`, `..` or encoded slashes. 3. An address on `github.com` or `raw.githubusercontent.com` is read as a GitHub repository (a Kino plugin or a Nuvio repository), never as an addon. Serve your `manifest.json` from your own domain. 4. A `stremio://…/manifest.json` link the person taps (a "Install" button on an addon page) opens Kino on Plugins ▸ Agregar with the address filled in; Kino reads nothing until the person taps "Agregar", and a link may only name a public host. On Android 12 and later an `https://…/manifest.json` link opens in the browser (Android hands a web link only to the app that verified that domain), so share the `stremio://` link or the address to paste. 5. Kino reads the manifest, generates the plugin and opens the usual [consent sheet](what-people-see.md). Nothing of the addon is used before the person accepts. The full address (which often carries the addon's configuration, a debrid key for instance) is never written into the generated plugin or the logs: it lives in two of the plugin's settings, "Servidor del addon" (`addonUrl`, the server) and "Configuración del addon" (`addonPath`, the rest of the path, kept as a password in the Keystore). The consent sheet says so: "Guarda la configuración del addon (puede incluir tu clave) solo en tus aparatos". That part may be at most 2,048 characters ("La configuración de ese addon es demasiado larga para guardarla en Kino"). <span id="detail-links"></span>**Title links.** From Kino 0.9.51 a Stremio *detail* link opens the title in Kino instead of being ignored: `stremio:///detail/movie/<imdb>` or `stremio:///detail/series/<imdb>[/<imdb>:<season>:<episode>]` (a tracker's "Open in Stremio", like Seenr's). Kino finds the title through TMDB and opens its sources -- the screen a Home card opens, phone and TV; an episode opens its series. Only an IMDb id (`tt` and 5 to 10 digits) is accepted; another type or id, an extra segment, a query or a link over 200 characters is ignored, and a title TMDB does not know shows "No encontré ese título". Addon links (`stremio://…/manifest.json`) work as before. ## What the conversion builds { #conversion } - One plugin per addon, id `stremio-<name>-<hash>`: the same on every device for the same address. In Plugins it carries the "Stremio" badge. - `version` `1.<converter revision>.0` (today `1.10.0`), `apiVersion` 4, or 6 when the addon uses something of 6 (search inside a row, `meta`, 18+ content). - `hosts`: `api.themoviedb.org` (Kino uses TMDB to turn a TMDB id into an IMDb one) and the hosts **your catalogs redirect to** ([below](#redirects)). The addon's server is not a declared host: it is reached as a server the person typed. - `"streamHosts": "any"` (and `"liveStreamHosts": "any"` with channels): your video may be on any public server. The sheet shows, in red, "Puede reproducir video desde cualquier servidor que indique". - `download` when the addon plays movies or series, so they can be downloaded on phones ([Downloads](manifest.md#downloads)); live channels never. - The addon's `logo` is the plugin's icon (at most 128 KB, read within 5 s; one that is not a PNG is converted, or left out). - Kino writes the description: "Addon de Stremio. Ofrece: catálogo, streams, subtítulos, canales en vivo." depending on what you have. ### Category chips { #categories } In the plugin store (Instalados, Recomendados, a collection) each addon falls under the category chips by what its manifest declares: an addon with `stream` goes under **Películas** (`movie`, or when it says nothing), **Series** (`series`), **Anime** (`anime`, or `kitsu`, `mal`, `anilist`, `anidb` ids), **En vivo** (`tv`, `channel`) and **Radio** (`radio`, or a radio `music` catalog); an addon without `stream` (lists, `meta`, collections) goes under **Utilidades** (and Anime when it is about anime); `subtitles` adds **Subtítulos** and `adult` adds **+18**. Declare your `types` and `idPrefixes` accurately: that is where they come from. Kino's own Recomendados never suggest an addon that plays video (one with `stream`), except a few free, legal channel addons checked by hand ("Gratis y legal"). People add yours by its address. ## How each resource maps { #resources } | Stremio resource | In Kino | | --- | --- | | `catalog` of `movie`, `series`, `anime` or another type | Home rows with their "Ver más" (the first 20, 60 titles per row), the type in the title: "Popular · Películas", "Popular · Series". | | `catalog` of `tv`, `channel`, `radio` (or `music` with "radio" in its id or name) | Channel categories in En vivo (at most 200), 500 channels per page. A `radio` one without "radio" in its name shows as "… · Radio". | | `catalog` with the `search` extra | Typed search (up to 3 catalogs, 100 results) and the search inside that row's "Ver más". A `tv`/`channel` one answers the En vivo search (up to 3). | | `catalog` with `search` required | Search only, never a row. | | `catalog` with another required extra (`genre`…) | Left out: Kino cannot know which value to send. An optional `genre` is never sent. | | `skip` extra | Paging of "Ver más" and of channels ([below](#paging)). | | `meta` | Info page and episodes of your titles; it also describes other sources' titles when TMDB and AniList have nothing ([below](#meta)). | | `stream` | What plays ([Streams](#streams)). | | `subtitles` | The video's subtitles and the player's "Buscar subtítulos en línea" ([Subtitles](#subtitles)). | | `addon_catalog` | An addon collection ([below](#collections)). | An addon plays **exactly where Stremio would ask it**: the `types` and `idPrefixes` of its `stream` resource (the resource object's own lists, else the manifest's). An id or type outside them is never asked about (a `kitsu:` id to an addon that only takes `tt`). Kino treats anime as series, and an addon that only declares `anime` (or only `series`) is asked with its own word. ### Ids and matching { #ids } Kino knows a title by its IMDb id (`tt…`) and, when you give it, by its TMDB id (`moviedb_id`, as Cinemeta does). With them your title lines up with the same title from other sources, gets TMDB's info page and can look for subtitles. A title the person opens from a TMDB page is asked of your addon as `tt…` (or `tt…:season:episode`), so **declare `tt` in your `stream` `idPrefixes`** if you want to be asked about other sources' titles. Before offering your addon in that search, Kino confirms you have the title with one request to your `/stream` (at most 3 titles per search). ### Search { #search } - **Typed search**: your catalogs with `search`. With none (or when they return nothing), Kino searches the text on TMDB and offers only what your `stream` can play by IMDb id. - **A title's page** ("Ver otras fuentes"): only when your `stream` takes IMDb ids for that type. A catalog-only addon (Cinemeta, Kitsu) or a channels-only one does not answer here. - **Inside a "Ver más"**: when that row's catalog takes `search`, the search goes to your addon with `search=…` and pages with `skip`. ### Paging { #paging } Kino asks `/catalog/<type>/<id>/skip=N.json` with N = how many titles it already showed (at most 100 a page). It stops on an empty page, when a different `skip` answers exactly the same page (an addon that ignores `skip`), or past 20,000 titles. ### `meta` { #meta } With `meta`, your catalog's titles get an info page and episodes (`videos` with `season` and `episode` above 0; seasons up to 999). An addon with `meta` also **describes other sources' titles** (the [`meta`](contract.md#meta) capability): when TMDB and AniList leave something empty on an info page, Kino asks you with the id your `meta` resource covers (`tt…`, `tmdb:`, `kitsu:`, `mal:`, `anilist:`). A meta-only addon is useful for that and installs too. From Kino 0.9.51 a meta's `logo`, its IMDb rating (`imdbRating`, or the name of its `imdb` link, where AIOMetadata-style addons put it) and its cast (`app_extras.cast`, else `cast`, else its `Cast` links) become the info page's [logo, ratings and cast](contract.md#meta). ### Live channels { #live } A `tv`, `channel` or `radio` catalog is an En vivo category; each `meta` is a channel (its `logo`, or its `poster`). It plays in the live player, with no progress bar and no download. There is no program guide. When a page of channels fails, the person reads "No pude cargar los canales de este addon". ## Streams { #streams } Kino plays **only direct `http(s)` links** in the `url` field. Streams with `infoHash` (P2P/torrent), `nzbUrl` (Usenet), `rarUrls`, `zipUrls`, `tgzUrls`, `tarUrls`, `servers`, `ytId` (YouTube) and `externalUrl` are dropped. Of the rest, Kino ranks best first: first those without `behaviorHints.notWebReady`; then by the resolution your `name`, `title` or `description` names (1080p, 720p, unlabelled, and 2160p/4K last, because most devices cannot decode it); within each tier `.m3u8`/`.mp4` links win, HEVC with DTS/Dolby audio is avoided and 8-bit is preferred over 10-bit (Hi10P). Ties keep your order, so **list your best stream first**. The address never counts as evidence: only what the stream says about itself. - **Copies and automatic fallback.** The best one plays and up to 8 more become [`alternatives`](contract.md#stream): when one cannot play on the device, Kino moves on to the next by itself. The player's **Servidor** menu lists them as "Opción 1", "Opción 2"…: Kino does not use the stream's `name` or `title` as a label. - **Headers.** `behaviorHints.proxyHeaders.request` is sent with the video (at most 20 headers, string values only). `proxyHeaders.response` is ignored. - **`notWebReady`** is not refused: Kino needs no streaming server to play it, it only ranks it last. - **Type.** An address ending in `.m3u8` is HLS and one ending in `.mpd` is DASH; the player detects the rest. - **Time.** Your `/stream` gets 20 s, and the generated plugin's `resolve` 75 s in all (like a [Nuvio scraper](nuvio.md#limits)), the TMDB-to-IMDb lookup and the subtitles included. ## Subtitles { #subtitles } A stream's `subtitles` and your `subtitles` resource's (asked within 5 s, for movies and episodes only) are merged: at most 30, one per address, their three-letter language turned into two (`spa` → `es`). A `.vtt` or `.srt` file is marked with its format. An addon with `subtitles` also answers the player's **"Buscar subtítulos en línea"** for any title Kino knows by IMDb or TMDB id, from any source ([subtitles for any title](contract.md#subtitles)): the person's languages first, named by release (`movieReleaseName` or `subtitleFileName`). From Kino 0.9.51 the request carries what Kino knows of the playing file as Stremio's own extras, `/subtitles/{type}/{id}/videoHash=…&videoSize=…&filename=….json` (each only when known, URL-encoded; `videoSize=0` beside a `filename` whose size is unknown; never the video's URL), so the addon can rank the exact release first; with nothing known the path stays the plain one. An addon with **only** `subtitles` (OpenSubtitles v3) installs as a subtitle provider: nothing on Home, in search or En vivo, and its sheet says "Agrega subtítulos a tus películas y series". A translator (its name or description says "translat" or "traduc", or the language carries the `gt` mark, like GTSubs' `esgt`) shows the tracks from its own server as "Español (traducido)". GTSubs' `info:` notice tracks are skipped. A translator is installed from its configured address (the one its configure page gives, with the language in the path). ## What Kino refuses { #refused } | What | What the person reads | | --- | --- | | A **torrent or P2P** addon: `behaviorHints.p2p`, or "torrent", "magnet" or "p2p" in its id, name or description (from Kino 0.9.51 a description that names them only to deny them, "no incluye streams, torrents ni contenido P2P", does not count; the id and name stay strict). **Also with debrid.** | "Kino no admite addons de torrents, ni siquiera con debrid" | | A manifest without `id` or `name`, or that is not JSON | "Esto no es un addon de Stremio (no encontré su manifest.json)" | | An addon with no `catalog`, `meta`, `stream` or `subtitles` | "Este addon no ofrece nada que Kino pueda usar" | | An address that is not an addon's | "Esa dirección no es la de un addon de Stremio" | | A manifest over 256 KB | "La respuesta del addon es demasiado grande: no parece un addon de Stremio" | | A redirect to the device itself | "El addon redirige a una dirección de este mismo aparato, y Kino no la sigue" | Kino never plays P2P, has no BitTorrent client and no local streaming server, and runs no server on the device: whatever your addon can only deliver through Stremio's streaming server does not work here. A torrent addon installed before this rule is switched off by its next update check and stays in Instalados for the person to remove. When a title only has P2P links, the player says "<addon> solo tiene enlaces P2P de este título". ## Configurable addons { #configurable } - **`behaviorHints.configurationRequired`**: Kino does not install it from its unconfigured address ("… necesita configurarse en su página antes de instalarlo") and offers "Configurar en su página", which opens `<address>/configure` in the **phone's** browser. That page's "Install" button hands back a `stremio://` link with the configured address, which fills "Agregar" and installs normally. A TV never opens the page: it tells the person to do it on the phone, and the configuration reaches it through sync. - **`behaviorHints.configurable`** (or an address that already carries a configuration): the installed plugin offers "Reconfigurar", which opens the same page with the current options. When the person installs the same addon from the same server with another configuration, Kino updates that plugin instead of adding another (unless two configurations are installed, or the new address has no configuration and the old one does: that never wipes their key). - For this to work, **your configure page must end in a `stremio://…/manifest.json` link** with the configuration in the path, as Stremio expects. - **A TMDB key in the configuration** (Kino 0.9.53): when the configuration carries a field whose name contains "tmdb" with a TMDB key (a 32-character v3 key or a v4 read token; plain, JSON, base64 or URL-encoded), Kino offers the person, once, to use it for [`kino.tmdb`](kino-api.md#tmdb) ("Usar la llave de TMDB de tu addon <name>"). Only with their yes, and the key never leaves the device except toward TMDB. A configuration kept on your server (only an opaque id in the address) is simply not found. ## 18+ addons { #adult } An addon with `behaviorHints.adult` installs behind the person's **18+ code** (the same one as in Ajustes): Kino asks for it before the consent sheet, which says "Contenido para adultos (+18)". Everything the addon gives is marked 18+ ([18+ content](contract.md#adult)): it shows only while the code is unlocked on that device. While it is locked, the plugin is not shown in Plugins, in the search filters or among the other devices' offers. If your addon is for adults, **declare `adult`**: an addon that does not say so shows like any other. ## Collections { #collections } An addon whose only use is listing other addons (`addon_catalog`, no `catalog`, `meta` or `stream`) is a collection: it does not install ("… es una colección de addons: elige cuáles agregar"). Kino keeps it in "Tus colecciones de Stremio" and shows its addons as cards, under the same rules as above: P2P ones, ones that offer nothing and 18+ ones while the code is locked do not show. Each addon installs with its own sheet. A collection is read only at a public `https` address with no configuration in the path; at most 20 lists per collection and 300 addons per list. Kino ships and adds no collection on its own. ## Catalog redirects { #redirects } Some addons answer their catalogs with a redirect to another server (Cinemeta sends them to `cinemeta-catalogs.strem.io`), and a plugin may follow a redirect from the person's server only to that server or to a declared host. So at install Kino probes the catalog addresses it will use (each row's first page and a later one, each search catalog, each live category: at most 30, about 10 s in all) and declares every public `https` host they redirect to; the person sees them on the consent sheet. A redirect to a local address or over `http` is never declared. Only **catalog** redirects are discovered. If your `/meta`, `/stream` or `/subtitles` redirects to another host, that request fails: answer those resources from the addon's server (the video itself may live on any public host). ## What syncs { #sync } The generated plugin and its settings travel both ways between the person's paired devices ([sync](what-people-see.md#sync)): the server goes in the sync row and the configuration part goes sealed end to end. The other device reads the addon again by itself and generates the same plugin. "Tus colecciones de Stremio" sync too, and removing one removes it on both. So the person configures on the phone and the TV gets the addon ready, and "Ver en el TV" plays an addon title through the TV's own copy. ## Updates { #updates } The version only moves with Kino's converter. "Buscar actualización" (and the background check) reads the addon again at the saved address, probes the redirects again, regenerates the plugin and compares it with the installed one: a change within what was already approved installs by itself; one that adds hosts, channels or downloads waits for the person's approval. When the address now belongs to another addon ("La dirección ahora es de otro addon") or can no longer be read, the installed plugin stays as it was. ## Limits { #limits } | What | Limit | | --- | --- | | Reading `manifest.json` | 256 KB, 15 s in all, at most 5 redirects | | Catalogs kept | 200 (and when the generated script passes 1 MB, the last ones go and the description says "Aviso: el addon tiene demasiados catálogos; N quedaron fuera.") | | Home rows | 20 catalogs, 60 titles each | | A "Ver más" page | 100 titles; up to `skip` 20,000 | | A request to your catalog or `meta` | 12 s (6 s when describing other sources' titles) | | A request to your `/stream` | 20 s; the whole `resolve` 75 s | | A request to your `/subtitles` | 5 s | | Responses | 5 MB per request, 60 requests per call ([engine limits](engine-limits.md#limits)) | | Streams used | the best + 8 alternatives | | Subtitles | 30 | | Declared redirect hosts | 20 | ## When something fails { #troubleshooting } | What the person sees | What happened | | --- | --- | | "No encontré el addon en esa dirección" | `manifest.json` answered 404. | | "El addon tardó demasiado en responder. Intenta de nuevo en un rato." | The manifest did not arrive within 15 s. | | "No pude conectarme con el addon. Revisa la dirección y tu conexión." | The name does not resolve or the server does not answer. | | "El servidor del addon respondió con un error (N)…" | An error status reading the manifest. | | "<addon> solo trae el catálogo. Busca este título en tus otras fuentes." | The addon has no `stream`: it is catalog-only. | | "<addon> no reproduce este título. Búscalo en tus otras fuentes." | The title's type or id is outside your `stream`'s `types`/`idPrefixes`. | | "Esta fuente ya no tiene este título (<addon>)" | Your `/stream` answered an empty list, or nothing playable. | | "<addon> solo tiene enlaces P2P de este título" | Every stream was P2P. | | "Falta la dirección del addon: escríbela en Configurar" | On an update check, the "Servidor del addon" setting is empty. | **Diagnostics:** each plugin's debug-mode switch, Stremio addons included, and the Registro page are in [Logs and telemetry](diagnostics.md). The generated plugin logs only counts (how many streams came, how many were P2P, how many can play) and the host of a redirect it is not approved for, never your address or your links. ## When to write a Kino plugin instead { #native } A Stremio addon works with nothing to write, but a [Kino plugin](first-plugin.md) can do more: - **Named and lazy copies**: "Latino · Servidor 1" in the Servidor menu, and copies resolved only when the person picks them ([labelled copies](contract.md#lazy-copies)). - **Its own settings form** inside Kino, with status and actions, instead of an external web page ([the settings form](settings-form.md)). - **Request-signed streams** and server failover when a token expires ([signing every request](signed-streams.md)). - **The hidden browser**, for servers that build the video with scripts ([hidden browser](browser.md)). - **Live channels with a guide**, M3U/XMLTV playlists and live search ([live channels](live-channels.md)). - **Its own section, categories and colors** ([section, categories and colors](section-theme.md)), Widevine ([cookbook](cookbook.md#widevine)), `Stream.skip` for skipping intros, your own error sentences ([`userMessage`](contract.md#user-message)), sessions with `kino.storage` and `kino.cookies`. - **An author signature** and a place in "De la comunidad" ([signed plugins](signed.md), [get listed](listed.md)). <!-- page: https://kinotvapp.github.io/kino-plugins/en/claims/ (docs/claims.en.md) --> # Claims and plugin takedowns { #claims } Kino is a player: it does not host, sell or distribute content. The list of community plugins is an automatic index of third-party public repositories that use the kino-plugin topic; Kino does not review, recommend or promote them, and each author is responsible for their own plugin. If a plugin infringes your rights, is harmful or breaks the rules for plugins (for example, it uses `userMessage` to ask for money, passwords or contact data), open an issue at github.com/kinotvapp/kino-plugins/issues naming the repository and the reason. We remove it from the index within 5 business days at most and leave a public record in the repository's history. Authors can ask for a takedown to be reviewed the same way. ## How to file a claim { #how } 1. Open an issue at [github.com/kinotvapp/kino-plugins/issues](https://github.com/kinotvapp/kino-plugins/issues/new/choose) with the **"Reclamo / retiro de plugin"** template. Claims are only received this way, in public; there is no e-mail. 2. Fill in its three fields: - **Repositorio** (repository): the plugin, as `owner/name` (the one shown in "De la comunidad"). - **Motivo** (reason): which right it infringes, why it is harmful, or which rule for plugins it breaks. - **Enlace a la prueba** (link to the evidence): where what you claim can be seen. 3. Do not include personal data you don't want public: anyone can read the issue. ## What Kino does { #what-kino-does } - It adds the repository to [`community-blocklist.json`](https://github.com/kinotvapp/kino-plugins/blob/main/community-blocklist.json), at the root of this repository, within 5 business days at most. Each entry names the repository, the reason, the date and the link to the issue. The reasons: - `claim`: it infringes someone's rights; - `malware`: it is harmful; - `broken`: it does not work; - `rules`: it breaks the rules for plugins ("incumple las reglas para plugins"), for example a `userMessage` that asks for money, passwords or contact data ([The contract](contract.md#user-message)); - `author_request`: its author asked for it. That file's git history is the public record. - The app downloads that list and applies it: - a listed repository never shows in "De la comunidad" nor in the fallback list; - a listed plugin someone already has installed keeps working, but its card says "Retirado del índice de la comunidad." and it no longer receives updates from its repository. It is not uninstalled by force. - installing it by typing its address (`owner/repo` or the URL of its manifest) still works. - The repository match ignores case. A fork of a removed repository is not removed automatically: it needs its own claim. ## If you are the author { #appeal } Ask for the takedown to be reviewed the same way: an issue at [github.com/kinotvapp/kino-plugins/issues](https://github.com/kinotvapp/kino-plugins/issues), linking the original issue and explaining what changed. If it is accepted, the repository leaves the list in another commit, public too. To take your own plugin out of the index, open the issue with the reason "at the author's request" (`author_request`), or set `"discoverable": false` in your manifest ([The manifest](manifest.md)). ## Community plugins and recommended plugins { #community } The app shows the community section with the note "Plugins de la comunidad — Kino no los revisa ni responde por su contenido." The recommended plugins are another list, short and kept by Kino ([Example plugins](examples.md)); community plugins show up on their own when they meet the [discovery requirements](publish.md#get-found). <!-- page: https://kinotvapp.github.io/kino-plugins/en/reference/ (docs/reference/index.en.md) --> # Reference Two files describe the plugin contract for machines. They are copied verbatim from Kino's own repository, where a test pins them to the app's code, and the tables in this guide are generated from `contract.json`. | File | What it is | Download | | --- | --- | --- | | `contract.json` | Every number and rule the app enforces: capabilities and the apiVersion each needs, manifest patterns and sizes, settings types, time limits, `kino.fetch` limits and error codes, crypto algorithms, result caps, live-channel limits. `sdk/contract.mjs` reads it, so `validate.mjs` and `run.mjs` check exactly these values. | [contract.json](contract.json) | | `kino.d.ts` | TypeScript declarations of the whole `kino` global and of every shape your functions take and return. Put it next to `plugin.js` and add `/// <reference path="./kino.d.ts" />` at the top of the file: your editor then completes and checks `kino.*` and your return values. | [kino.d.ts](kino.d.ts) | Both files also ship inside every example plugin repository, next to `sdk/`, and at the end of [`llms-full.txt`](https://kinotvapp.github.io/kino-plugins/llms-full.txt). ## `contract.json` { #contract-json } ??? example "Show contract.json" ```json --8<-- "reference/contract.json" ``` ## `kino.d.ts` { #kino-d-ts } ??? example "Show kino.d.ts" ```ts --8<-- "reference/kino.d.ts" ``` <!-- file: https://kinotvapp.github.io/kino-plugins/reference/contract.json --> ## contract.json ```json { "$comment": "Kino plugin contract, apiVersion 1 to 7. The single source of truth for every number and rule a plugin meets. The app checks each value against its own code, and a test in the app pins this file to it; sdk/validate.mjs, sdk/run.mjs and the guide's tables read this file.", "apiVersion": 7, "maxApiVersion": 7, "apiVersionFromApp": { "6": "0.9.50", "7": "0.9.51" }, "additiveFromApp": { "kino.meta": "0.9.53", "kino.tmdb": "0.9.53" }, "capabilities": { "names": ["search", "home", "browse", "episodes", "resolve", "download", "drm", "channels", "migrate", "scopedSearch", "meta", "subtitles", "tracking", "segments"], "required": ["resolve"], "atLeastOneOf": ["search", "home"], "standalone": ["subtitles", "tracking", "segments"], "anyPluginExports": ["subtitles"], "declarative": ["download", "drm", "scopedSearch"], "apiVersions": { "download": 2, "drm": 2, "channels": 3, "migrate": 6, "scopedSearch": 6, "meta": 6, "tracking": 7, "segments": 7 }, "requires": { "scopedSearch": "search" }, "requiresMessage": "La capacidad \"{capability}\" necesita también \"{requires}\"", "needsApproval": ["download", "drm", "channels", "migrate", "tracking"], "exports": { "channels": ["liveCategories", "liveChannels"], "tracking": ["track"] }, "optionalExports": { "channels": ["guide", "liveSearch"], "resolve": ["sign"] } }, "manifest": { "maxBytes": 16384, "idPattern": "^[a-z0-9][a-z0-9-]{1,39}$", "reservedIds": ["live", "local", "unknown", "plugin", "own", "subtitle-keys", "subtitle-prefs"], "nameMaxChars": 40, "descriptionMaxChars": 300, "authorMaxChars": 60, "homepageMaxChars": 200, "minHosts": 1, "legacyMaxHosts": { "value": 20, "refusedUpToApp": "0.9.44", "noLimitFromApp": "0.9.45" }, "noHostsApiVersion": 2, "insecureHostApiVersion": 2, "colorPattern": "^#[0-9A-Fa-f]{6}$", "entryMaxBytes": 1048576, "iconMaxBytes": 131072, "versionPattern": "^(0|[1-9]\\d{0,5})\\.(0|[1-9]\\d{0,5})\\.(0|[1-9]\\d{0,5})$", "pathSegmentPattern": "^[A-Za-z0-9._-]+$", "maxPathChars": 200, "liveStreamHosts": { "value": "any", "apiVersion": 3, "requires": "channels" }, "streamHosts": { "value": "any", "apiVersion": 4 }, "fetchHosts": { "value": "any", "apiVersion": 4 }, "discoverable": { "default": true }, "categories": { "values": ["movies", "series", "anime", "live", "radio", "subtitles", "utilities", "adult"], "message": "El campo \"categories\" debe ser una lista sin repetidos de: movies, series, anime, live, radio, subtitles, utilities, adult" }, "debug": { "apiVersion": 6, "default": false, "switchLabel": "Modo debug", "switchHint": "Muestra los errores de este plugin en pantalla y guarda un registro que puedes compartir con su autor.", "defaultOnNote": "Modo debug encendido de entrada (\"debug\": true): quien instale el plugin verá sus errores en pantalla y se guardará su registro hasta que apague el interruptor en Ajustes. Sin el campo, cada persona puede encenderlo cuando quiera enviarte un registro" }, "browser": { "apiVersion": 6, "consentLine": "Puede abrir páginas web ocultas para encontrar el video", "notBooleanMessage": "El campo \"browser\" debe ser true, false o \"pages\"", "pageApiVersion": 6, "pagesValue": "pages", "pageConsentLine": "Puede abrir páginas web ocultas para mostrar contenido y encontrar el video" }, "telemetry": { "apiVersion": 6, "consentLine": "Comparte registros de errores con Kino para corregir fallas", "notBooleanMessage": "El campo \"telemetry\" debe ser true, false o \"verbose\"", "switchLabel": "Enviar registros de errores", "report": { "windowSeconds": 3600, "areaMaxChars": 24, "areaPattern": "^(?=[a-z0-9_:]*[_:])[a-z0-9_:]{1,24}$", "otherArea": "other", "perPluginPerSession": 3, "sessionShare": 10 }, "verbose": { "value": "verbose", "consentLine": "Comparte registros detallados de reproducción y errores con Kino para corregir fallas", "successSample": 0.25, "perPluginPerSession": 60, "areaWindowSeconds": 60 }, "logcat": { "playTag": "KinoPlay", "pluginTagPrefix": "KinoPlugin/" } }, "section": { "apiVersion": 6, "labelMaxChars": 20, "export": "section" }, "theme": { "apiVersion": 6, "tokens": ["accent", "onAccent", "background", "surface", "highlight"], "defaults": { "accent": "#E50914", "onAccent": "#FFFFFF", "background": "#0E0E0E", "surface": "#181818", "highlight": "#F5F5F5" }, "brandRed": "#E50914", "minTextContrast": 4.5, "minUiContrast": 3, "maxBackgroundLuminance": 0.05, "maxSurfaceLuminance": 0.12, "minSurfaceContrast": 1.05, "minBrandDeltaE": 25 }, "secrets": { "apiVersion": 4, "namePattern": "^[A-Za-z][A-Za-z0-9_]{0,31}$", "maxSecrets": 16, "maxValueBytes": 4096, "prefix": "kino-sealed:v1:", "largeApiVersion": 6, "largeMaxValueBytes": 8192, "typed": { "apiVersion": 6, "uses": ["cipher-key"], "keyEncodings": ["hex", "base64"], "keyBytes": [16, 24, 32] } }, "signature": { "apiVersion": 5, "fromApp": "0.9.45", "domain": "kino-signed-entry:v1", "authorKeyHexChars": 64, "valueHexChars": 128, "consentLine": "Firmado por su autor", "authorKeyLabel": "Clave del autor", "badFieldMessage": "El campo \"signature\" debe ser { \"authorKey\": 64 caracteres hex, \"value\": 128 caracteres hex } (node sdk/seal.mjs --sign)", "badSignatureMessage": "La firma del autor no es válida: el código no es el que firmó, o no es para este repositorio, este plugin o esta versión" } }, "hostRules": { "labelPattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$", "privateSuffixes": [".local", ".lan", ".internal", ".localhost", ".home.arpa"], "maxHostChars": 253 }, "permissions": [], "settings": { "max": 12, "keyPattern": "^[a-z][a-zA-Z0-9_]{0,31}$", "labelMaxChars": 40, "hintMaxChars": 80, "sectionHintMaxChars": 300, "maxOptions": 20, "optionValueMaxChars": 40, "optionLabelMaxChars": 40, "types": { "text": { "maxChars": 500, "canBeRequired": true, "canHaveDefault": true }, "url": { "maxChars": 2048, "canBeRequired": true, "canHaveDefault": false }, "password": { "maxChars": 500, "canBeRequired": true, "canHaveDefault": true }, "toggle": { "canBeRequired": false, "canHaveDefault": true }, "select": { "canBeRequired": false, "canHaveDefault": true }, "list": { "canBeRequired": true, "canHaveDefault": false }, "section": { "canBeRequired": false, "canHaveDefault": false, "hasValue": false, "apiVersion": 6 }, "status": { "canBeRequired": false, "canHaveDefault": false, "hasValue": false, "apiVersion": 6 }, "action": { "canBeRequired": false, "canHaveDefault": false, "hasValue": false, "apiVersion": 6 } }, "list": { "apiVersion": 4, "defaultMaxEntries": 20, "maxEntries": 50, "maxFields": 4, "fieldTypes": ["text", "url"] }, "ui": { "apiVersion": 6, "maxItems": 16, "confirmMaxChars": 120, "statusMaxChars": 200, "messageMaxChars": 300, "fieldErrorMaxChars": 200, "clearSettings": { "maxEntries": 12 }, "exports": { "status": "settingsStatus", "action": "action", "validate": "validateSettings" } } }, "output": { "section": { "apiVersion": 6, "maxTabs": 8, "maxTabLabelChars": 24, "maxHeroTextChars": 300 }, "categories": { "apiVersion": 6, "export": "categories", "maxCategories": 24, "maxTitleChars": 40 }, "meta": { "apiVersion": 6, "fromApp": "0.9.51", "fields": ["logo", "ratings", "cast"], "ratingSources": ["imdb", "tmdb", "rottentomatoes", "metacritic", "letterboxd", "mal", "anilist", "trakt"], "maxRatings": 6, "ratingValuePattern": "^\\d{1,3}([.,]\\d{1,2})?(%|/\\d{1,3})?$", "maxCast": 20, "maxCastNameChars": 60, "maxEpisodes": 5000, "maxRatingSourceChars": 20, "yearReadChars": 9, "yearPattern": "\\d{4}", "minEpisodeSeason": 0, "answerFields": ["title", "overview", "poster", "backdrop", "episodes", "genres", "logo", "ratings", "cast"], "ratingLabels": { "imdb": "IMDb", "tmdb": "TMDB", "rottentomatoes": "Rotten Tomatoes", "metacritic": "Metacritic", "letterboxd": "Letterboxd", "mal": "MAL", "anilist": "AniList", "trakt": "Trakt" }, "pageCastNames": 5, "cacheTtlMs": 1800000, "maxCachedTitles": 200 }, "retry": { "apiVersion": 6, "reasons": ["conflict", "expired"], "maxAttempts": 3, "statuses": [401, 403, 409] }, "signing": { "value": "request", "apiVersion": 6, "maxContextChars": 4096, "hlsMimes": ["application/x-mpegurl", "application/vnd.apple.mpegurl", "audio/mpegurl"], "alternateHosts": { "apiVersion": 6, "maxEntries": 6, "pattern": "^[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$" } }, "itemIdPattern": "^[A-Za-z0-9._~-]{1,128}$", "maxSearchItems": 100, "maxHomeRows": 20, "maxRowItems": 60, "maxBrowseItems": 100, "maxEpisodes": 5000, "maxSeasons": 50, "maxRefChars": 4096, "maxCursorChars": 2048, "maxResultChars": 2000000, "maxImageUrlChars": 2048, "maxGenres": 5, "maxGenreChars": 30, "maxBadges": 3, "maxBadgeChars": 20, "imdbPattern": "^tt\\d{5,10}$", "minRating": 0, "maxRating": 10, "minRuntimeMinutes": 1, "maxRuntimeMinutes": 1000, "minExpiresInSeconds": 30, "maxExpiresInSeconds": 86400, "airDatePattern": "^\\d{4}-\\d{2}-\\d{2}$", "maxTitleChars": 200, "maxTextChars": 2000, "maxSeasonNumber": 999, "maxEpisodeNumber": 99999, "maxSubtitles": 30, "maxSubtitleLabelChars": 60, "maxAudioTracks": 8, "maxAlternatives": 8, "streamLabels": { "apiVersion": 6, "maxChars": 48, "maxRefChars": 512 }, "skip": { "field": "skip", "fields": ["openingStartMs", "openingEndMs", "endingStartMs"], "maxMs": 86400000 }, "maxHeaders": 20, "drmKeys": ["drm", "license", "licenseUrl", "drmLicenseUrl", "keySystem", "widevine"], "drm": { "field": "drm", "types": ["widevine"] }, "itemKinds": ["movie", "series", "live"], "migrateKinds": ["movie", "series", "episode", "live"], "liveKindApiVersion": 2, "adultApiVersion": 6, "homeLiveApiVersion": 6 }, "live": { "apiVersion": 3, "maxCategories": 200, "maxChannelsPerPage": 500, "maxPagesPerCategory": 10, "pagesPerStep": 5, "maxChannelsPerCategory": 10000, "maxTotalPagesPerCategory": 200, "maxSearchChannels": 100, "minSearchChars": 2, "maxGuideChannels": 50, "maxGuideWindowMs": 86400000, "maxGuideEntriesPerChannel": 100, "maxChannelNumber": 9999, "maxPlaylists": 10, "adultApiVersion": 6, "defaultRefreshHours": 12, "minRefreshHours": 1, "maxRefreshHours": 168, "maxHideGroups": 50, "maxPlaylistBytes": 20971520, "maxEpgBytes": 52428800, "maxChannelsPerProvider": 5000, "maxCategoriesPerProvider": 500, "playlistParseBudgetMs": 20000, "epgParseBudgetMs": 30000, "reservedIdPrefix": "~", "playlistFormats": ["m3u"], "epgFormats": ["xmltv"] }, "genres": ["peliculas", "series", "anime", "infantil", "documentales", "deportes", "noticias", "musica", "entretenimiento", "otros"], "discovery": { "topic": "kino-plugin", "maxResults": 30 }, "search": { "types": ["movie", "series", "any"], "maxAltTitles": 5, "maxAltTitleChars": 200, "scoped": { "capability": "scopedSearch", "apiVersion": 6, "field": "within", "debounceMs": 400, "softTimeoutMs": 6000, "local": { "minMatches": 24, "maxExtraPages": 10, "maxExtraItems": 300 } } }, "timeoutsMs": { "load": 10000, "search": 15000, "home": 20000, "browse": 20000, "episodes": 20000, "resolve": 20000, "nuvioResolve": 75000, "browserResolve": 75000, "liveCategories": 20000, "liveChannels": 20000, "guide": 20000, "liveSearch": 15000, "settingsStatus": 10000, "action": 30000, "section": 20000, "categories": 20000, "validateSettings": 20000, "migrate": 10000, "sign": 1500, "signTotal": 3000, "subtitles": 10000, "track": 10000, "segments": 8000, "meta": 6000 }, "runtime": { "memoryBytes": 67108864, "stackBytes": 1048576, "idleCloseMs": 300000, "timeoutsBeforeUnresponsive": 3, "maxLogChars": 2000, "reportLogLines": 30, "reportLogLineChars": 300, "reportLogChars": 2048, "maxErrorChars": 2000, "maxThrownNameChars": 100 }, "fetch": { "methods": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"], "bodyKinds": ["text", "json", "form", "base64"], "redirectModes": ["follow", "manual"], "defaultTimeoutMs": 15000, "maxTimeoutMs": 30000, "maxBodyBytes": 5242880, "maxRequestChars": 1048576, "maxRequestsPerCall": 60, "nuvioMaxRequestsPerCall": 250, "maxInFlight": 6, "maxHostQuestionsPerCall": 3, "maxRedirects": 10, "errorCodes": ["host_not_allowed", "timeout", "network", "too_large", "invalid_request"] }, "browser": { "apiVersion": 6, "onlyFrom": "resolve", "onlyWhen": "the person pressed play, or a download the person started (never a background resolve such as the live zap's resolve ahead, which Kino skips for a browser plugin); a download's capture never waits for another page: busy at once", "defaultTimeoutMs": 18000, "minTimeoutMs": 1000, "maxTimeoutMs": 25000, "maxMatchChars": 500, "maxMedia": 8, "maxSubtitles": 10, "graceMs": 1000, "errorCodes": ["browser_unavailable", "timeout", "blocked", "busy", "not_allowed", "invalid_request"], "page": { "apiVersion": 6, "manifest": "\"browser\": \"pages\" (\"browser\": true stays capture-only)", "onlyFrom": ["search", "home", "browse", "episodes", "section", "resolve"], "onlyWhen": "the person is using the app: their own search, Home, list, title or play (a resolve also for a download they started); never a background call (prewarm, the live zap's resolve ahead, sync, update checks), and a list's read only with the app in front. Never clicks, taps, types or scrolls: only a check that completes with no input passes", "defaultTimeoutMs": 15000, "minTimeoutMs": 1000, "maxTimeoutMs": 25000, "maxWaitForChars": 500, "maxHtmlChars": 2000000, "perMinute": 20, "pollMs": 500, "lockWaitMs": 8000, "callMarginMs": 1500, "topLevel": "every top-level navigation and the document read stay on the plugin's kino.fetch hosts (https); else blocked, no HTML", "waitForFlags": ["m", "s"], "errorCodes": ["browser_unavailable", "timeout", "blocked", "busy", "not_allowed", "invalid_request", "rate_limited"] } }, "tracking": { "apiVersion": 7, "capability": "tracking", "export": "track", "consentLine": "Le contará a {hosts} qué ves y cuándo lo terminas", "consentLineNoHosts": "Le contará al servidor que escribas en su configuración qué ves y cuándo lo terminas", "consentMaxHosts": 3, "switchLabel": "Enviar lo que veo", "types": ["start", "progress", "stop", "watched"], "kinds": ["movie", "episode"], "idKeys": ["imdb", "tmdb", "tvdb", "anilist", "mal"], "progressIntervalMs": 300000, "watched": { "remainingMs": 180000, "minFraction": 0.9 }, "outbox": { "maxPerPlugin": 200, "maxAgeMs": 604800000, "maxAttempts": 12, "backoffBaseMs": 30000, "backoffMaxMs": 21600000, "failuresBeforeShown": 3, "ledgerMaxPerPlugin": 5000 }, "idResolveTimeoutMs": 8000, "retryCodes": ["timeout", "network", "unavailable", "rate_limited"], "dropCodes": ["auth_required", "invalid_request", "not_found", "geo_blocked", "host_not_allowed", "too_large"] }, "segments": { "apiVersion": 7, "capability": "segments", "export": "segments", "consentLine": "Agrega el botón para saltar la intro y los créditos", "kinds": ["movie", "episode"], "idKeys": ["imdb", "tmdb", "tvdb", "anilist", "mal"], "types": ["intro", "outro", "recap", "credits", "preview"], "openingTypes": ["intro"], "endingTypes": ["outro", "credits"], "minLengthMs": 1000, "maxSegments": 10, "maxEntriesRead": 100, "endSlackMs": 5000, "waitMs": 12000, "durationBucketMs": 10000, "failureRetryMs": 120000 }, "kinoMeta": { "fromApp": "0.9.53", "featureDetect": "typeof kino.meta === \"function\"", "types": ["movie", "series"], "idKeys": ["imdb", "tmdb", "tvdb", "kitsu", "mal", "anilist"], "maxNumericId": 2147483647, "langPattern": "^[A-Za-z]{2,3}([-_][A-Za-z0-9]{2,8})?$", "maxRequestChars": 4096, "perMinute": 30, "timeoutMs": 8000, "providerTimeoutMs": 6000, "cacheTtlMs": 1800000, "maxCachedTitles": 200, "maxTmdbSeasons": 30, "maxAnswerChars": 1000000, "sources": ["tmdb", "anilist", "plugin"], "answerFields": ["title", "overview", "year", "poster", "backdrop", "logo", "genres", "runtimeMinutes", "tagline", "certification", "directors", "episodes", "ids", "ratings", "cast", "sources"], "notFrom": ["sign"], "noPluginsFrom": ["meta"], "errorCodes": ["invalid_request", "rate_limited", "not_allowed"] }, "kinoTmdb": { "fromApp": "0.9.53", "featureDetect": "typeof kino.tmdb === \"function\"", "base": "https://api.themoviedb.org/3", "pathPrefixes": ["/discover", "/trending", "/search", "/movie", "/tv", "/find", "/genre", "/configuration", "/person", "/collection"], "pathPattern": "^/[A-Za-z0-9._/-]{1,255}$", "maxParams": 20, "paramKeyPattern": "^[A-Za-z0-9_.]{1,40}$", "maxParamValueChars": 500, "forbiddenParams": ["api_key", "session_id", "guest_session_id", "request_token", "access_token"], "perWindow": 40, "windowMs": 10000, "timeoutMs": 15000, "maxBodyBytes": 2097152, "cacheTtlMs": 600000, "maxCachedEntries": 300, "maxCachedBodyChars": 524288, "$keyOrder": "Kino's own key first, behind its cache, in-flight dedup and the two kinoKey limits; the person's key (their Ajustes setting, else a Stremio addon's they agreed to) only when Kino's key gets one of personKeyOn; with neither, a cached copy up to staleMaxDays old, else rate_limited (unavailable when TMDB refused Kino's key). no_tmdb_key only on a build without a key of Kino's and a person without one. A key a plugin keeps in its own settings is never read.", "keyOrder": ["kino", "setting", "stremioAddon"], "kinoKeyPerWindow": 20, "kinoKeyGlobalPerWindow": 60, "personKeyOn": [401, 403, 429, "kinoKeyLimit"], "staleMaxDays": 7, "keyPatterns": { "v3": "^[0-9a-fA-F]{32}$", "v4": "^eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$" }, "notFrom": ["sign"], "errorCodes": ["invalid_request", "rate_limited", "no_tmdb_key", "not_found", "too_large", "timeout", "network", "unavailable", "not_allowed"], "noKeyCode": "no_tmdb_key", "noKeyUserMessage": { "es": "Agrega tu llave de TMDB en Ajustes, o instala un addon de TMDB de Stremio configurado con tu llave.", "en": "Add your TMDB key in Settings, or install a Stremio TMDB addon set up with your key." } }, "cookies": { "maxPerHost": 50, "maxTotalBytes": 65536 }, "storage": { "maxTotalBytes": 262144, "maxTtlMs": 2592000000 }, "crypto": { "hashes": ["md5", "sha1", "sha256", "sha512"], "ciphers": ["aes-128-cbc", "aes-192-cbc", "aes-256-cbc", "aes-128-ecb", "aes-192-ecb", "aes-256-ecb", "aes-128-ctr", "aes-192-ctr", "aes-256-ctr", "aes-128-gcm", "aes-192-gcm", "aes-256-gcm", "des-ede3-cbc", "des-ede3-ecb"], "encodings": ["utf8", "hex", "base64"], "pbkdf2Hashes": ["sha1", "sha256", "sha512"], "pbkdf2MaxIterations": 100000, "pbkdf2MaxKeyBytes": 64, "randomMaxBytes": 1024, "maxDataBytes": 5242880, "errorCode": "crypto_error", "keyPairs": { "apiVersion": 6, "types": ["ec", "ed25519", "x25519"], "curves": ["P-256", "P-384"], "signHashes": ["SHA-256", "SHA-384"], "signatureFormats": ["der", "ieee-p1363"], "importFormats": ["jwk", "spki", "raw"], "maxKeysPerRuntime": 64, "maxSignatureBytes": 512 } }, "sleep": { "maxMs": 5000 }, "errors": { "codes": ["auth_required", "not_found", "geo_blocked", "rate_limited", "unavailable"], "maxMessageChars": 200, "maxUserMessageChars": 160 } } ``` <!-- file: https://kinotvapp.github.io/kino-plugins/reference/kino.d.ts --> ## kino.d.ts ```ts // TypeScript declarations for Kino plugins (apiVersion 1 to 7; 7 adds tracking and segments; apiVersion 5 only adds the manifest's signature, 6 the plain-plugin SDK: typed and larger secrets, migrate, signed streams, the settings form, debug, telemetry, section, categories, theme and scopedSearch). Reference them from plugin.js // with `/// <reference path="./kino.d.ts" />` for editor help; Kino itself runs plain JavaScript. // The numbers in the comments come from contract.json, which is authoritative. The app checks that // every `kino` member declared here exists in its runtime and nothing else does (KinoDtsTest). // ---------- what your functions receive and return ---------- /** `search(query)`: `type` is a hint, never a filter. `cursor` is null on the first page. */ interface KinoSearchQuery { q: string; type: "movie" | "series" | "any"; year: number; season: number; episode: number; tmdbId: number; /** TMDB's original title when it differs from `q`, else "". */ originalTitle: string; /** Other known titles, at most 5, each at most 200 characters. */ altTitles: string[]; cursor: string | null; /** * apiVersion 6, capability "scopedSearch": the browse `ref` of the "Ver más" page the person searches inside (a Home * row, a section row, a category), exactly as you gave it; absent on a plain search. Return null when you cannot * search there: Kino then filters the page's loaded titles itself. */ within?: string; } interface KinoItem { /** ^[A-Za-z0-9._~-]{1,128}$, stable: the library keys the title by it. */ id: string; /** Your own opaque reference, at most 4096 characters. */ ref: string; title: string; /** * `"live"` needs `"apiVersion": 2` (a v1 plugin's live item is dropped): a live channel, whose * `ref` goes to `resolve` and plays as live straight from its card, with an "EN VIVO" badge; it * has no `runtimeMinutes` (ignored) and no episodes, is never saved to the library, never resumed * and never downloaded. */ kind: "movie" | "series" | "live"; year?: string | number; /** http or https, at most 2048 characters; a public name or a public IPv4 address, never the home network or a local name (over http not even a name without a dot), except the person's own server. */ poster?: string; backdrop?: string; overview?: string; originalTitle?: string; /** At most 5, each at most 30 characters. */ genres?: string[]; /** 0..10 */ rating?: number; /** 1..1000; ignored on a `live` item. */ runtimeMinutes?: number; ids?: { tmdb?: number; /** ^tt\d{5,10}$ */ imdb?: string }; lang?: string; quality?: string; /** At most 3, each at most 20 characters; shown as chips. */ badges?: string[]; /** apiVersion 6: true = an 18+ entry, shown only while the person's 18+ code is unlocked on that device (Ajustes ▸ Adultos). Below apiVersion 6 it is dropped. */ adult?: boolean; } /** A Home row. `ref` needs the `browse` capability: the row gets "Ver más", which calls browse(ref, null). */ type KinoGenre = | "peliculas" | "series" | "anime" | "infantil" | "documentales" | "deportes" | "noticias" | "musica" | "entretenimiento" | "otros"; interface KinoRow { id: string; title: string; /** * Items of kind "live" (channels) stay in a Home row from apiVersion 6, as channel cards with the "En vivo" badge * that open like a channel from En vivo (a 18+ one only while the person's code is unlocked); below 6 they are * dropped from Home. A row left with nothing to show is not shown. */ items: KinoItem[]; ref?: string; /** * What the row (or category, or list) is about, from Kino's closed vocabulary: "peliculas", "series", "anime", * "infantil", "documentales", "deportes", "noticias", "musica", "entretenimiento" or "otros". Kino groups Categorías * by it and filters En vivo by it across plugins. Optional: without it Kino guesses from the title; a value outside * the list is ignored. Kino versions before this field ignore it. */ genre?: KinoGenre; } /** `next` (at most 2048 characters, opaque) needs the `browse` capability; the app passes it back as the cursor. */ interface KinoPage { items: KinoItem[]; next?: string; } interface KinoEpisode { /** 1..999, default 1. */ season?: number; /** 1..99999 */ number: number; ref: string; title?: string; still?: string; overview?: string; /** YYYY-MM-DD */ airDate?: string; runtimeMinutes?: number; } interface KinoSeriesInfo { title?: string; poster?: string; backdrop?: string; overview?: string; ids?: { tmdb?: number; imdb?: string }; genres?: string[]; year?: string | number; } /** * One season of a series, for a source that keeps each season as its own `series` item: that item's * `id` and `ref` (`episodes(ref)` lists it). `title` is what the season selector shows ("Temporada 2"); * `current` marks the season whose episodes came in the same answer (Kino also recognizes it by `id`). */ interface KinoSeason { /** The season's own item id, same pattern as an item id. */ id: string; /** The season's own series ref, at most 4096 characters. */ ref: string; title: string; /** 1..999; leave it out when the source has no numbering. */ number?: number; current?: boolean; } interface KinoEpisodes { series?: KinoSeriesInfo; episodes: KinoEpisode[]; /** * Only when each season is a separate item: every season of the show, this one included, at most * 50. Leave it out when `episodes` already holds every season (Kino reads the seasons from them). */ seasons?: KinoSeason[]; } interface KinoStream { /** * https on a declared host (http only on one declared `insecureHttp`), or the person's own server exactly as typed. * A live channel's stream of a plugin approved for `"liveStreamHosts": "any"` may be on any public host, http or https. */ url: string; mime?: string; /** * Sent with every request the player makes for this stream (and, for a plugin that declares the * `download` capability, with the request that saves it to the device). At most 20. */ headers?: Record<string, string>; subtitles?: { lang: string; url: string; format?: "vtt" | "srt" }[]; /** * Separately-hosted audio tracks (a dub, an alternate mix), at most 8: Kino plays your video with * each merged in as its own track, offered and auto-picked by the person's audio-language * preference exactly like the container's own. `lang` is a short code like `subtitles`' (up to 16 * characters; blank becomes `"und"`); `label`, if given (up to 40 characters), is shown verbatim * instead of a name guessed from `lang`. Checked the same way as `subtitles`: https on a declared * host, or the person's own server exactly as typed; a bad entry is dropped and the rest survive, * and so is a `url` already listed (the first entry wins). Ignored for a `live` item's stream: a * channel's other languages go inside its manifest. */ audioTracks?: { lang: string; url: string; label?: string }[]; /** Ignored for a `live` item's stream: a channel has no length. */ durationMs?: number; /** 30..86400: after that long, a failed playback calls resolve() once more. */ expiresInSeconds?: number; /** * apiVersion 2, and only with the `drm` capability declared: the stream is Widevine-protected and * Kino fetches its license from `licenseUrl` (checked exactly like `url`: https on a declared host, http only on one declared `insecureHttp`) * sending `licenseHeaders` (filtered like `headers`, at most 20) with the license request only. * Without the capability any DRM-shaped key refuses the stream. A protected title never downloads. * Kino negotiates Widevine at security level L3 (software), and only when the device confirms L3: * the license server must allow it. `audioTracks` next to `drm` are played clear (no license for * them): plain, unencrypted files only. */ drm?: { type: "widevine"; licenseUrl: string; licenseHeaders?: Record<string, string> }; /** * apiVersion 6: `"request"` makes Kino sign every playlist and segment request of this stream with your * `sign()` export, right before sending it, through a local proxy. HLS only (an HLS `mime` or a `.m3u8` * path); never with `drm` or `audioTracks`; not on an inline `liveChannels` stream (use `resolve()`). */ signing?: "request"; /** * apiVersion 6, with `signing`: up to 4096 characters `sign()` gets back as `context` (it can't read kino.storage). * Never a `kino.secret()` marker (refused): a marker only works in the runtime that made it, so call * `kino.secret()` inside `sign()` itself. */ signContext?: string; /** * apiVersion 6, with `signing`: other hosts serving this same stream at the same path and scheme, up * to 6, each `"host"` or `"host:port"` (no scheme, no path, no IPv6). Kino tries the playlist on each * (3 rounds, the one that served last first) and moves a segment, key or map to another one when its * own fails 3 times. `sign()` always gets the URL of the host being asked, so pick that host's token * from `context`. Each entry meets the same host rule as `url` (declared hosts, or any public host * under `liveStreamHosts: "any"`); one that fails it, repeats `url`'s host or another entry, or comes * after the sixth is dropped. Not an array of strings: the stream is refused. Ignored without `signing`. */ alternateHosts?: string[]; /** * apiVersion 6: this copy's short name, e.g. "Latino · Streamwish", shown in the player's "Servidor" menu (phone and * TV) and in logs. Trimmed; at most 48 characters, no control characters, or it is dropped (the copy still plays). * Ignored below apiVersion 6. */ label?: string; /** * Other copies of the same video, best first, at most 8: when `url` cannot play on the device (a codec it lacks, a broken * file) or is gone, Kino moves on to the next one by itself, at the same spot, before showing any error. Each is checked * exactly like `url`, `mime` and `headers`; a bad entry is dropped. They share this stream's subtitles and audio tracks. * Ignored next to `drm`, with `signing` (use `alternateHosts`) and for a live channel. * * apiVersion 6: each may carry a `label` (same rules as the Stream's), and may be `{ label, ref }` instead of a URL: a * lazy copy. `ref` (a non-blank string of at most 512 characters) goes to your `resolve(ref)` ONLY when that copy is * needed -- the person picks it in the "Servidor" menu, the automatic fallback reaches it, or a download's copy choice * probes it within its 30 s budget. That call is a normal `resolve` (same time limit, same checks, * `kino.browser.capture` allowed); only its `url`, `headers`, `mime`, `subtitles`, `expiresInSeconds` and `skip` are * used, never its own `alternatives`. Its `skip` applies while that copy plays and is never saved (the Stream's `skip` * stays the episode's). A failure moves on to the next copy. Below apiVersion 6 a ref-only entry has no `url` and is * dropped. */ alternatives?: KinoStreamAlternative[]; /** * Where THIS file's opening and ending are, in ms from its start: Kino's "Saltar intro" shows from * `openingStartMs` (0 when left out or null) to `openingEndMs`, "Saltar outro" from `endingStartMs`. Each a * finite number in 0..`durationMs` (0..86 400 000 without `durationMs`); the opening needs its end, * after its start; the ending not before the opening's end. A bad part is dropped, never the stream. * A person's hand correction wins over it; it wins over AniSkip. Ignored for a live channel. */ skip?: { openingStartMs?: number; openingEndMs?: number; endingStartMs?: number }; } /** One of a Stream's `alternatives`: a URL (labelled from apiVersion 6), or (apiVersion 6) a lazy `{ label, ref }`. */ type KinoStreamAlternative = | { url: string; mime?: string; headers?: Record<string, string>; label?: string } | { ref: string; label?: string }; /** apiVersion 3, capability "channels": a section of the En vivo tab. */ interface KinoLiveCategory { id: string; title: string; /** ISO 3166 alpha-2, e.g. "CO". Informational. */ country?: string; /** * What the row (or category, or list) is about, from Kino's closed vocabulary: "peliculas", "series", "anime", * "infantil", "documentales", "deportes", "noticias", "musica", "entretenimiento" or "otros". Kino groups Categorías * by it and filters En vivo by it across plugins. Optional: without it Kino guesses from the title; a value outside * the list is ignored. Kino versions before this field ignore it. */ genre?: KinoGenre; /** apiVersion 6: true = an 18+ entry, shown only while the person's 18+ code is unlocked on that device (Ajustes ▸ Adultos). Below apiVersion 6 it is dropped. Every channel listed in it is 18+ too. */ adult?: boolean; } /** * apiVersion 3: an M3U playlist Kino downloads itself (from a declared host only, never under * `liveStreamHosts: "any"`), with an optional XMLTV guide. Put it next to your categories in the * `liveCategories()` answer, or return it alone. At most 10 per answer. `refreshHours` is 1..168, * default 12. `hideGroups` are group titles not to show (case-insensitive), at most 50. With * `resolve: true` each entry plays through your `resolve(<entry url>)` instead of directly. */ interface KinoPlaylist { playlist: { url: string; format: "m3u"; headers?: Record<string, string>; /** * Headers the PLAYER sends for every channel of the list: a `User-Agent` some channels only answer to, a * `Referer`. Filtered like a Stream's `headers` (at most 20). Kept apart from `headers` on purpose: those carry * the list's own credentials and go only to the list's host, never to the hosts the channels are on. A header an * M3U entry names itself (`#EXTVLCOPT:http-user-agent=...`) wins. Kino versions before this field ignore it. */ streamHeaders?: Record<string, string>; /** The [genre](KinoLiveCategory) of every group this list produces; without it Kino guesses from each group's title. */ genre?: KinoGenre; epg?: { url: string; format: "xmltv" }; refreshHours?: number; hideGroups?: string[]; resolve?: boolean; }; } /** * One channel: give `ref` (resolved on play, exactly like a `live` item's) or `stream` (played as * is, checked like `resolve()`'s answer). With both, the stream plays and `ref` is the fallback. * An `id` starting with `~` is reserved for Kino and dropped. */ interface KinoLiveChannel { id: string; title: string; ref?: string; stream?: KinoStream; /** * In liveChannels, informational: the channel is listed under the category it was asked for. Not a valid id = empty. * In a liveSearch hit (apiVersion 6), the category it belongs to: one of your 18+ categories makes it 18+, a plain one * makes it plain. A hit with neither `categoryId` nor `adult` counts as 18+ when you have any 18+ category. */ categoryId?: string; /** https image, like a poster. */ logo?: string; /** 1..9999. */ number?: number; /** * apiVersion 6: true = an 18+ entry, shown only while the person's 18+ code is unlocked on that device (Ajustes ▸ Adultos). * Below apiVersion 6 it is dropped. On a liveSearch hit, `false` says it is plain (needed when your categories can't be * read and it carries no `categoryId`): mark every hit with `adult` or `categoryId`. */ adult?: boolean; } interface KinoLiveChannelPage { items: KinoLiveChannel[]; /** Opaque cursor for the next page; omit or null at the end. */ next?: string | null; } /** One programme. `start`/`end` are epoch milliseconds. */ interface KinoGuideEntry { channelId: string; title: string; start: number; end: number; description?: string; } /** apiVersion 6, capability "migrate": one value Kino stored and can no longer open. */ type KinoMigrateInput = | { kind: "title"; ref: string } | { kind: "chapter"; ref: string; season: number | null; episode: number | null } | { kind: "live"; provider: string; code: string }; /** Your answer: the same id/ref you would return from search() today. `ref` never starts with "plg1:". */ type KinoMigrateAnswer = | { kind: "movie" | "series"; id: string; ref: string } | { kind: "episode"; ref: string; season?: number; number: number } | { kind: "live"; code: string }; /** apiVersion 6, with `"section": { "label" }` in the manifest: your own section (TV sidebar, chip atop Inicio on the phone). */ interface KinoSectionAnswer { /** At most 8; each `id` matches the item id pattern, each `label` at most 24 characters. */ tabs?: { id: string; label: string }[]; /** The tab this answer is for; an unknown or missing one reads as the first tab. */ tab?: string; /** A banner above the rows: `title` as an item title, `text` at most 300 characters, `image` http or https (the Images rule). */ hero?: { title: string; image?: string; text?: string }; /** The same shape and limits as `home`. */ rows: KinoRow[]; } /** apiVersion 6, optional, needs the `browse` capability: a tile of your Categorías group; it opens `browse(ref, null)`. */ interface KinoCategory { /** The item id pattern. */ id: string; /** At most 40 characters. */ title: string; /** http or https image (the Images rule), same rules as a poster. */ art?: string; /** At most 4096 characters. */ ref: string; /** apiVersion 6: true = an 18+ entry, shown only while the person's 18+ code is unlocked on that device (Ajustes ▸ Adultos). Below apiVersion 6 it is dropped. */ adult?: boolean; } /** Your module's exports. `resolve` is required, and at least one of `search`/`home`. */ interface KinoPlugin { /** apiVersion 6, capability "migrate". Return null for anything that is not yours. 10 s per call. */ migrate?(input: KinoMigrateInput): Promise<KinoMigrateAnswer | null>; /** apiVersion 6, required when the manifest declares `section`. `tab` is null the first time. 20 s per call. */ section?(arg: { tab: string | null }): Promise<KinoSectionAnswer>; /** apiVersion 6, optional, needs `browse`: up to 24 tiles in Categorías, in your order. 20 s per call. */ categories?(arg: null): Promise<KinoCategory[]>; /** With `query.within` (capability "scopedSearch", apiVersion 6): null = "can't search inside this page". 15 s per call. */ search?(query: KinoSearchQuery): Promise<KinoItem[] | KinoPage | null>; home?(): Promise<KinoRow[]>; browse?(ref: string, cursor: string | null): Promise<KinoPage>; episodes?(ref: string): Promise<KinoEpisodes>; /** `options.retry` (apiVersion 6) only when Kino resolves again after the origin refused your stream. `attempt` is 1 to 3; `status` is the origin's HTTP status when Kino heard one. */ resolve(ref: string, options?: { retry?: { reason: "conflict" | "expired"; attempt: number; status?: 401 | 403 | 409 } }): Promise<KinoStream>; /** * apiVersion 6, needed when a stream says `signing: "request"`: headers for one request, computed with * kino.crypto / kino.secret only. kino.fetch answers host_not_allowed; kino.storage, kino.cookies and * kino.sleep fail with not_allowed. 1.5 s limit. */ sign?(request: { url: string; kind: "playlist" | "segment"; ref: string; context: string }): Promise<{ headers: Record<string, string> }>; /** apiVersion 3, capability "channels" (required with it). At most 200 categories. */ liveCategories?(): Promise<Array<KinoLiveCategory | KinoPlaylist> | KinoPlaylist>; /** * apiVersion 3, capability "channels" (required with it). At most 500 per page. Kino asks 10 * pages at first and 5 more each time the person scrolls near the end, up to 10,000 channels. */ liveChannels?(arg: { categoryId: string; cursor: string | null }): Promise<KinoLiveChannelPage | KinoLiveChannel[]>; /** * apiVersion 3, optional with "channels": channels whose name matches `query`, listed or not (the * En vivo search, while some of your channels were never listed). At most 100 kept; `next` ignored. * apiVersion 6 with an 18+ category: give every hit `adult` or `categoryId`; an unmarked hit counts as 18+. */ liveSearch?(arg: { query: string }): Promise<KinoLiveChannelPage | KinoLiveChannel[]>; /** apiVersion 3, optional with "channels". At most 50 channels and a 24 h window per call. */ guide?(arg: { channelIds: string[]; from: number; to: number }): Promise<KinoGuideEntry[]>; /** apiVersion 6: required when a setting has `type: "status"`. One text per status setting key, shown as-is (at most 200 characters; a missing key or a non-text reads "Sin información"). 10 s. */ settingsStatus?(): Promise<Record<string, string>>; /** apiVersion 6: required when a setting has `type: "action"`. Runs when the person presses that button (30 s); the `message` (at most 300 characters, default "Listo") is shown; settingsStatus() is asked again after every action (and when the form opens); `refresh: true` is still accepted and changes nothing. `clearSettings` (up to 12 keys of your own optional, valued settings: not a `required` one, not a section/status/action) is emptied by Kino right after a successful action, as if the person had emptied the field and saved (a password leaves the Keystore; your sandbox closes as for any saved change; `kino.storage` survives); anything else in it is dropped. A throwing action clears nothing. */ action?(key: string): Promise<{ message?: string; refresh?: boolean; clearSettings?: string[] } | null | void>; /** * apiVersion 6, optional: checks the values BEFORE Kino saves them (20 s). `null` accepts; `{ key: "mensaje" }` * refuses with the message under that field (a key that is not one of your valued settings refuses too, as a * general message); a text refuses with that text. If it throws, times out or answers anything else, nothing is * saved and the person may "Guardar sin comprobar". */ validateSettings?(values: Record<string, string | boolean | Array<Record<string, string>>>): Promise<Record<string, string> | string | null>; } /** A subtitles() track: a Stream's `subtitles` entry plus an optional `label` and `translated` (a machine translation). */ interface KinoSubtitleTrack { lang: string; url: string; format?: "vtt" | "srt"; label?: string; translated?: boolean } /** * `subtitles` -- optional for any plugin (Kino asks every plugin that exports it), required with the capability * "subtitles". Tracks for a title Kino knows by IMDb or TMDB id (an episode's ids are the series'); `languages` are ISO * 639-1, best first. Checked like a Stream's `subtitles`: 30 kept, then only the person's languages are listed. 10 s, a * background call. */ type KinoSubtitlesFn = (arg: { imdbId?: string; tmdbId?: number; kind: "movie" | "series"; season?: number; episode?: number; title?: string; year?: number; languages: string[]; /** * The file playing (Kino 0.9.51+), only what is known, absent when nothing is; never its URL. `hash`: the 16-hex * OpenSubtitles hash and `size` the bytes it was computed with; `name`: the file name with its extension. */ file?: { hash?: string; size?: number; name?: string }; }) => Promise<KinoSubtitleTrack[]>; /** Ids a tracking event carries, each only when known. */ interface KinoTrackingIds { imdb?: string; tmdb?: number; tvdb?: number; anilist?: number; mal?: number } /** * `track(event)`'s argument (apiVersion 7, the "tracking" capability): what the person plays on this device. For a movie * `ids` are the movie's; for an episode `ids` are the EPISODE's own (may be `{}`) and the show's are in `show.ids` -- never * use the show's ids as the episode's. `watched` fires once, with 3 minutes or less left and at least 90% played. */ interface KinoTrackingEvent { /** Stable across retries of this event: the idempotency key. */ id: string; type: "start" | "progress" | "stop" | "watched"; /** When it happened on the device, epoch ms. */ at: number; kind: "movie" | "episode"; ids: KinoTrackingIds; /** A movie's name, or an episode's own name when TMDB has one. */ title?: string; year?: number; show?: { title: string; year?: number; ids: KinoTrackingIds }; season?: number; episode?: number; positionMs?: number; durationMs?: number; /** positionMs / durationMs, 0..1. */ progress?: number; /** A `progress` sent because the person paused. */ paused?: true; } /** * `track` -- required with the capability "tracking" (apiVersion 7). Return anything (`{ ok: true }`) when delivered; throw * `kino.error(code)` otherwise: `timeout`/`network`/`unavailable`/`rate_limited` retry later (in order, with backoff), * `auth_required`/`invalid_request`/`not_found`/`geo_blocked`/`host_not_allowed`/`too_large` drop the event. 10 s, a * background call. */ type KinoTrackFn = (event: KinoTrackingEvent) => Promise<unknown>; /** * `segments(query)`'s argument (apiVersion 7, the "segments" capability): the title that started playing. For a movie `ids` * are the movie's; for an episode `ids` are the EPISODE's own (maybe `{}`) and the show's are in `show.ids`. */ interface KinoSegmentsQuery { kind: "movie" | "episode"; ids: KinoTrackingIds; show?: { ids: KinoTrackingIds }; season?: number; episode?: number; /** The playing file's length: answer for that cut. */ durationMs?: number; } /** One segment of the file, in whole ms. Kino uses `intro` and the first `outro`/`credits`; `recap` and `preview` have no button yet. */ interface KinoSegment { type: "intro" | "outro" | "recap" | "credits" | "preview"; startMs: number; endMs: number } /** * `segments` -- required with the capability "segments" (apiVersion 7). Each bad entry is dropped on its own (unknown type, * not whole ms, under 1 s, past the file's end, overlapping one of its type); 10 kept. 8 s, a background call. */ type KinoSegmentsFn = (query: KinoSegmentsQuery) => Promise<KinoSegment[] | null>; /** A plugin that plays (`resolve` required, as above), optionally finding subtitles, tracking or segments too. */ interface KinoPlayingPlugin extends KinoPlugin { subtitles?: KinoSubtitlesFn; track?: KinoTrackFn; segments?: KinoSegmentsFn; meta?: KinoMetaFn } /** A subtitle provider: capabilities only "subtitles" (and maybe "tracking" or "segments"), so `subtitles` is its export. */ interface KinoSubtitleProvider { subtitles: KinoSubtitlesFn; track?: KinoTrackFn; segments?: KinoSegmentsFn } /** A tracker: capabilities only "tracking" (and maybe "subtitles" or "segments"). */ interface KinoTracker { track: KinoTrackFn; subtitles?: KinoSubtitlesFn; segments?: KinoSegmentsFn } /** A segment source: capabilities only "segments" (and maybe "subtitles" or "tracking"). */ interface KinoSegmentSource { segments: KinoSegmentsFn; subtitles?: KinoSubtitlesFn; track?: KinoTrackFn } /** Your module's exports: one of these. */ type KinoPluginModule = KinoPlayingPlugin | KinoSubtitleProvider | KinoTracker | KinoSegmentSource; // ---------- the kino API ---------- type KinoErrorCode = "auth_required" | "not_found" | "geo_blocked" | "rate_limited" | "unavailable"; type KinoFetchErrorCode = "host_not_allowed" | "timeout" | "network" | "too_large" | "invalid_request"; /** Kino 0.9.53: what `kino.meta` and `kino.tmdb` throw besides the fetch codes (see contract.json `kinoMeta`/`kinoTmdb`). */ type KinoServiceErrorCode = "rate_limited" | "not_allowed" | "no_tmdb_key" | "not_found" | "unavailable"; /** * Kino 0.9.53, `kino.meta(query)`: the title to ask about. `type` and at least one id; ids are positive integers up to * 2147483647 (a number or a digit string) except `imdb` ("tt0133093"). `lang` ("es", "es-MX") goes to the person's meta * plugins. Anything else is `invalid_request`; the whole query as JSON is at most 4096 characters. */ interface KinoMetaRequest { type: "movie" | "series"; ids: { imdb?: string; tmdb?: number | string; tvdb?: number | string; kitsu?: number | string; mal?: number | string; anilist?: number | string }; lang?: string; } /** * Kino 0.9.53, what `kino.meta` answers (or null: nobody knew the title). Merged the way Kino's info page merges: Kino's * own TMDB lookup first (Spanish, es-MX), AniList for an anime, then the person's other `meta` plugins, each only filling * what the earlier ones left empty (ratings add up). Every field but `ids` and `sources` appears only when known. */ interface KinoMetaAnswer { title?: string; /** Up to 2000 characters, HTML stripped. */ overview?: string; /** "1999". */ year?: string; poster?: string; backdrop?: string; /** A clear-logo of the title (its name drawn as art). */ logo?: string; /** At most 5. */ genres?: string[]; /** A movie's runtime, 1..1000 (never a series'). */ runtimeMinutes?: number; tagline?: string; /** Age rating ("12+", "PG-13"). */ certification?: string; /** A movie's directors, or a series' creators. */ directors?: string[]; /** A series' episodes, at most 5000 (trimmed from the end to keep the answer under 1,000,000 characters); `id` is the Stremio-style video id ("tt0944947:1:1"). */ episodes?: { season: number; number: number; title?: string; overview?: string; still?: string; airDate?: string; id?: string }[]; /** Every id Kino knows for the title, the ones you asked with included. */ ids: { imdb?: string; tmdb?: number; tvdb?: number; kitsu?: number; mal?: number; anilist?: number }; /** At most 6, one per source; TMDB's own vote is `{ source: "tmdb" }`. */ ratings?: { source: "imdb" | "tmdb" | "rottentomatoes" | "metacritic" | "letterboxd" | "mal" | "anilist" | "trakt"; value: string }[]; /** At most 20. */ cast?: { name: string; character?: string; photo?: string }[]; /** Who contributed to this answer. */ sources: ("tmdb" | "anilist" | "plugin")[]; } type KinoEncoding = "utf8" | "hex" | "base64"; type KinoKeyType = "ec" | "ed25519" | "x25519"; /** A public key as a JWK, in WebCrypto's key order: EC `{ crv, kty: "EC", x, y }`, OKP `{ crv: "Ed25519" | "X25519", kty: "OKP", x }` (base64url, no padding). */ interface KinoJwk { readonly crv: string; readonly kty: "EC" | "OKP"; readonly x: string; readonly y?: string } /** apiVersion 6: a handle to a private key that lives only inside Kino, in this runtime. */ interface KinoPrivateKey { readonly type: KinoKeyType; readonly namedCurve?: "P-256" | "P-384"; readonly handle: string } /** apiVersion 6: `spki` is DER as base64; `raw` is base64 (an uncompressed point 04||x||y for ec, 32 bytes otherwise). */ interface KinoPublicKey { readonly type: KinoKeyType; readonly namedCurve?: "P-256" | "P-384"; readonly jwk: KinoJwk; readonly spki: string; readonly raw: string } interface KinoError extends Error { /** `KinoError_<code>` (e.g. `KinoError_not_found`). */ readonly name: string; readonly code: KinoErrorCode | KinoFetchErrorCode | KinoServiceErrorCode | "crypto_error" | "unknown"; /** * The sentence you passed as `{ userMessage }`, cut at 161 characters; absent when you passed none. On a `no_tmdb_key` * from `kino.tmdb` (Kino 0.9.53) it is Kino's own sentence for the person, in their language: show it as is. */ readonly userMessage?: string; } interface KinoErrorOptions { /** * Your own sentence for the person, shown INSTEAD of Kino's line for the code as "Mensaje de <plugin>: <sentence>", * only when: your plugin's name has no ":", no digit glued to a letter, spells no Kino and uses only the characters * below; the code is one of * the five `KinoErrorCode`s; it is 1..160 characters once trimmed, made only of Basic Latin and Latin-1 letters * (á é í ó ú ü ñ ç ã õ…, not ø æ ð þ ß), digits 0-9, the plain space and . , : ; ¿ ? ¡ ! ' ’ ‘ “ ” « » ( ) % - – — ▸ * (; only before a space); it reads as * plain words (two or more, no URL, no "TypeError:" prefix, no undefined/null/NaN, not ending in : , ; -); fewer than * 6 digits in all and none glued to a letter; no domain (site.app, site .app, site. app, www, punto/dot + com, app…); * no "kino" once 1 l ! ¡ read as * i, 0 as o and non-letters dropped; no credential, money or contact stem (pag…, abon…, recarg…, transfer…, * contraseñ…, passw…, clave…, token…, tarjeta, PIN, Nequi, Daviplata, WhatsApp, Telegram, SMS/verification code); * and none of the person's passwords or a sealed value. Otherwise Kino's line stays. A host the person refused still * wins, whatever the code. It counts only for the call that built the error. Older Kino builds ignore it. Plugins * that use it to ask for money, credentials or contact outside Kino are removed from the catalog. */ userMessage?: string; } interface KinoFetchOptions { method?: "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE"; headers?: Record<string, string>; /** A string, or JSON, a form, or raw bytes as base64. URL + headers + body at most 1,048,576 characters. */ body?: string | { json: unknown } | { form: Record<string, string | number | boolean> } | { base64: string }; /** "follow" (default, at most 10 hops, each host-checked) or "manual" (returns the 3xx). */ redirect?: "follow" | "manual"; /** true (default): send and store cookies from the plugin's jar. */ cookies?: boolean; /** Default 15000, at most 30000. */ timeoutMs?: number; } interface KinoResponse { readonly ok: boolean; readonly status: number; readonly url: string; /** Lower-cased names; repeated headers joined with ", "; never set-cookie. */ readonly headers: Readonly<Record<string, string>>; text(): string; json(): any; /** The body bytes (at most 5 MB) as base64. */ base64(): string; } interface KinoHtmlMatch { text: string; html: string; attrs: Record<string, string>; } interface KinoCipherOptions { key: string; iv?: string; data: string; /** CBC/ECB only. Default "pkcs7". */ padding?: "pkcs7" | "none"; /** GCM only: additional authenticated data. */ aad?: string; /** Default utf8 to encrypt, base64 to decrypt. */ inputEncoding?: KinoEncoding; /** Default base64 from encrypt, utf8 from decrypt. */ outputEncoding?: KinoEncoding; keyEncoding?: KinoEncoding; ivEncoding?: KinoEncoding; aadEncoding?: KinoEncoding; } type KinoCipher = | "aes-128-cbc" | "aes-192-cbc" | "aes-256-cbc" | "aes-128-ecb" | "aes-192-ecb" | "aes-256-ecb" | "aes-128-ctr" | "aes-192-ctr" | "aes-256-ctr" | "aes-128-gcm" | "aes-192-gcm" | "aes-256-gcm" | "des-ede3-cbc" | "des-ede3-ecb"; /** apiVersion 6: `kino.browser.capture` options. */ interface KinoBrowserCaptureOptions { /** 1..25000 ms; default 18000. */ timeoutMs?: number; /** Extra headers for the first page load only (a `Referer` an embed insists on). */ headers?: Record<string, string>; /** A regular expression (case-insensitive, up to 500 characters) for what counts as the video request; default: m3u8, mpd, mp4, `master.txt`, `videoplayback`, `/hls/`. */ match?: string; /** Default true: mute and start the page's player, click common play buttons, tap the middle. */ autoplay?: boolean; } /** apiVersion 6: what `kino.browser.capture` saw. */ interface KinoBrowserCapture { /** At most 8, manifests first. `headers` carries Referer, User-Agent and, when the request had them, Origin and Cookie. */ media: { url: string; mime?: string; headers: Record<string, string> }[]; /** `.vtt`/`.srt` requests the page made, at most 10. */ subtitles: { url: string; lang?: string }[]; /** The top page's last address. */ finalUrl: string; } /** apiVersion 6, `"browser": "pages"`: `kino.browser.page` options. */ interface KinoBrowserPageOptions { /** * 1..25000 ms; default 15000. Counts inside your call's own time limit; Kino cuts it to what is left of that limit * minus 1.5 s. Pass about 12000 in `search` (15 s for the whole call). */ timeoutMs?: number; /** * A JavaScript regular expression (source string or RegExp, whose `m` and `s` flags are kept; matched * case-insensitively against the page's HTML, up to 500 characters): the page is returned only once it matches. Without it, as soon as the page is loaded and is no * longer the site's browser check page. Use it for pages that fill in their list with scripts. */ waitFor?: string | RegExp; } /** apiVersion 6, `"browser": "pages"`: what `kino.browser.page` read. */ interface KinoBrowserPage { /** The doctype and the DOM's outerHTML (after the page's scripts ran), at most 2,000,000 characters. Feed it to `kino.html.select`. */ html: string; /** The top page's last address (after redirects and the browser check). */ finalUrl: string; /** The HTTP status of the top page's last load: 200 unless that load answered an error. */ status: number; /** True when `html` was cut at 2,000,000 characters. */ truncated: boolean; } declare namespace kino { const apiVersion: number; const appVersion: string; const lang: string; /** Only to the manifest's hosts over https (http only on a host declared `insecureHttp`), or to the person's own server as typed. Never throws for a non-2xx status. */ function fetch(url: string, options?: KinoFetchOptions): Promise<KinoResponse>; /** * `throw kino.error("not_found", "…")`: the app words the message; yours is a detail of at most 200 characters. * `throw kino.error("not_found", "…", { userMessage: "Este capítulo ya no está disponible." })`: your own sentence * for the person, shown instead of Kino's line when it is safe (see `KinoErrorOptions`). */ function error(code: KinoErrorCode, message?: string, options?: KinoErrorOptions): KinoError; /** 0..5000 ms, counts inside the call's own timeout. */ function sleep(ms: number): Promise<void>; /** * Kino 0.9.53 (no new apiVersion: absent on older Kino, so check `typeof kino.meta === "function"` first). Asks Kino about * a title and answers what it knows, without your plugin ever touching a TMDB key: Kino's own TMDB lookup (its app * feature, the one its info page uses), AniList for an anime, then the person's installed `meta` plugins (never yours, * and none at all when called from your own `meta` export). Null when nobody knows the title (never an error). * At most 30 calls a minute per plugin (`rate_limited`); 8 s at most, inside your call's own time limit; cached 30 min. * Throws `invalid_request` (a bad query), `rate_limited`, `not_allowed` (from `sign()`). */ function meta(query: KinoMetaRequest): Promise<KinoMetaAnswer | null>; /** * Kino 0.9.53 (no new apiVersion: check `typeof kino.tmdb === "function"` first). A GET to TMDB's v3 API * (`https://api.themoviedb.org/3` + `path`) with no key in your plugin. Kino's own TMDB key goes first, behind Kino's * TMDB cache, a shared in-flight request and its own limits (at most 20 calls per 10 s per plugin and 60 per 10 s for * all plugins on Kino's key). Only when Kino's key fails (TMDB answers 401/403/429 for it, or one of those limits is * spent) does the same request go again with the PERSON'S key: the one they typed in Ajustes ("Tu llave de TMDB", * optional), else the one they configured in an installed Stremio addon (once they agree). With neither, a cached copy * up to 7 days old, else `rate_limited`. A key your plugin keeps in its own settings is never used. Your plugin never * sees any key and needs no `hosts` entry for TMDB. `path` starts with /discover, /trending, /search, /movie, /tv, * /find, /genre, /configuration, /person or /collection, without the version and without a query string; `params` (at * most 20; never `api_key` or a session) become the query. Answers the parsed JSON body (a cached copy when TMDB is down * or unreachable). At most 40 calls per 10 s per plugin whichever key; bodies up to 2 MiB; cached 10 min in memory by * path and params, and on disk with Kino's TMDB cache. Throws `no_tmdb_key` (only on a Kino build without a key of its * own and a person without one: `e.userMessage` is Kino's sentence telling the person what to do), `invalid_request`, * `rate_limited`, `not_found`, `too_large`, `timeout`, `network`, `unavailable`, `not_allowed` (from `sign()`). */ function tmdb(path: string, params?: Record<string, string | number | boolean>): Promise<any>; /** * apiVersion 6, with `"browser": true` (or `"pages"`) in the manifest (the person approves it in red): from `resolve` only, and only * a `resolve` the person started (they pressed play, or they started a download; never a background one), opens * `url` in a hidden web view, lets the page run its own player (muted; with `autoplay`, the default, it also clicks the * usual play buttons and taps the middle of the page), and answers the video requests the page made, HLS/DASH first, * each with the headers to play it with (put them in your Stream's `headers`). The start `url` must be a host your * `kino.fetch` may reach; the page may then load from any public server, never the home network. One page at a * time in the whole app (a download's capture never waits: `busy` at once). Throws a typed error: `browser_unavailable` * (no WebView on this device, and always under the Node kit), `timeout` (no video in `timeoutMs`, default 18000, at * most 25000), `blocked`, `busy`, `not_allowed` (no approved `"browser": true`, not inside `resolve`, or a `resolve` * nobody started), `invalid_request`. */ namespace browser { function capture(url: string, options?: KinoBrowserCaptureOptions): Promise<KinoBrowserCapture>; /** * apiVersion 6, with `"browser": "pages"` in the manifest (`true` is capture-only and answers `not_allowed` here; the * person approves "Puede abrir páginas web ocultas para mostrar contenido y * encontrar el video", in red): from `search`, `home`, `browse`, `episodes`, `section` or `resolve`, only * while the person is using the app (never a background call), loads `url` in the same hidden web view and returns its * HTML once the page is past the site's automatic browser check (Cloudflare's "Just a moment…") and matches `waitFor`. * Kino never clicks, taps, types or scrolls in it and never solves a captcha: a page that asks for a human answers * `blocked` at once, and one still on its check page when the time runs out answers `blocked` too. The top document * must stay on your hosts: a redirect or navigation elsewhere answers `blocked`, never that site's HTML. Same start-host * rule, same one page at a time, fresh cookies per call; at most 20 page reads per minute per plugin. Throws a typed * error: `browser_unavailable` (always under the Node kit: keep a plain `kino.fetch` path), `timeout`, `blocked`, * `busy` (another page is open, or the person pressed play and the read was ended), `not_allowed`, `rate_limited`, * `invalid_request`. */ function page(url: string, options?: KinoBrowserPageOptions): Promise<KinoBrowserPage>; } /** apiVersion 4: a marker for a secret the manifest's `secrets` declares (throws for any other name). Kino swaps it for the value in `kino.fetch`, toward the manifest's own hosts only; your code never sees the value. apiVersion 6: a secret declared `{ seal, use: "cipher-key", encoding }` is accepted as the whole `key` of any `kino.crypto.encrypt`/`decrypt` (des-ede3 included), read with the manifest's encoding; it is refused everywhere else, including `kino.fetch`. */ function secret(name: string): string; /** Writes to Kino's log (and console.* does the same); lines are cut at 2000 characters. When a call of a plugin whose manifest says `"telemetry": true` or `"verbose"` (apiVersion 6) fails (a plugin that does not declare it never sends a line; a later Kino build adds a per-device "Enviar registros de errores" switch, on by default, that stops them) (sign and the settings exports included), the last 30 lines it logged (cut at 300 characters, scrubbed of URLs, hosts, ids, secrets and the person's text, 2 KB in all) go with the failure report; never for a call that succeeds. Log what happened, never what the person typed. */ function log(...args: unknown[]): void; namespace log { /** apiVersion 6, with `"telemetry": true`: a log line that also tells Kino's error tracker your plugin served a degraded result (a fallback account, a backup source). The line's first word is its area (`[a-z0-9_:]`, with a `_` or `:`, up to 24 characters; anything else is filed as "other"); at most one report per area an hour and 3 per plugin per session, scrubbed like any line. Without telemetry (or, once the per-device switch exists, with it off) it is only a log line. With `"telemetry": "verbose"` a plugin's playback metrics, live/cast problems and edge cases also reach the board (see the README). In a debug build, or while the plugin's "Modo debug" switch is on (every plugin has one in Ajustes; `"debug": true` only makes it on by default), every line is in logcat under `KinoPlugin/<id>` and Kino's playback lines under `KinoPlay`. */ function report(...args: unknown[]): void; } namespace html { /** Jsoup CSS selectors; at most 500 matches. Only inside Kino. */ function select(html: string, css: string): KinoHtmlMatch[]; } namespace storage { /** 256 KB in total for this plugin. Returns `null` once the entry has expired (see `set`). */ function get(key: string): string | null; /** * `options.ttlMs` makes the entry expire: after that many milliseconds, `get` returns `null` and * `keys()` leaves it out, even across a restart of the app. A whole number greater than 0, * at most 2,592,000,000 ms (30 days); anything else throws before the entry is touched. Leave * out `options` (or `ttlMs`) for a permanent entry, exactly as before this option existed. An * expired entry never counts against the 256 KB cap: it is dropped the next time your plugin * reads or writes storage. */ function set(key: string, value: string, options?: { ttlMs?: number }): void; function remove(key: string): void; /** Expired keys are already gone. */ function keys(): string[]; } namespace config { /** A setting's value (string; boolean for a toggle; an array of `{ [field.key]: string }` for a `list`, apiVersion 4); undefined when unset with no default (a `url` setting never has one). Read-only. */ function get(key: string): string | boolean | Array<Record<string, string>> | undefined; function all(): Record<string, string | boolean | Array<Record<string, string>>>; } namespace cookies { /** The value of cookie `name` for `url` (a host the plugin may reach), or null. */ function get(url: string, name: string): string | null; /** Forgets every cookie of this plugin (they also go when its settings change). */ function clear(): void; } namespace crypto { /** Default input utf8, output hex. Data at most 5 MB. Errors carry code "crypto_error". */ function hash(alg: "md5" | "sha1" | "sha256" | "sha512", data: string, options?: { inputEncoding?: KinoEncoding; outputEncoding?: KinoEncoding }): string; function hmac(alg: "md5" | "sha1" | "sha256" | "sha512", key: string, data: string, options?: { keyEncoding?: KinoEncoding; inputEncoding?: KinoEncoding; outputEncoding?: KinoEncoding }): string; /** GCM appends the 16-byte tag to the ciphertext. */ function encrypt(alg: KinoCipher, options: KinoCipherOptions): string; /** GCM expects the 16-byte tag appended. */ function decrypt(alg: KinoCipher, options: KinoCipherOptions): string; /** iterations at most 100000, keyLength at most 64 bytes. Password uses keyEncoding, salt inputEncoding. */ function pbkdf2(hash: "sha1" | "sha256" | "sha512", password: string, salt: string, iterations: number, keyLength: number, options?: { keyEncoding?: KinoEncoding; inputEncoding?: KinoEncoding; outputEncoding?: KinoEncoding }): string; /** 1..1024 bytes, hex by default. */ function randomBytes(n: number, outputEncoding?: KinoEncoding): string; /** A random (v4) UUID. */ function uuid(): string; /** * apiVersion 6: a new key pair. The private key stays inside Kino: you get a handle that works only in this * runtime (not in sign()'s signing lane, not after the plugin restarts) and is gone when it closes; at most 64 * live at once (a new one drops the oldest). Node's generateKeyPairSync / WebCrypto's generateKey map here. */ function generateKeyPair(options: { type: "ec"; namedCurve: "P-256" | "P-384" } | { type: "ed25519" } | { type: "x25519" }): { readonly privateKey: KinoPrivateKey; readonly publicKey: KinoPublicKey }; /** apiVersion 6: a peer's public key (jwk object, spki base64, or raw base64 with `type` and, for ec, `namedCurve`). Private keys cannot be imported. */ function importKey(options: { format: "jwk"; key: KinoJwk } | { format: "spki"; key: string } | { format: "raw"; key: string; type: KinoKeyType; namedCurve?: "P-256" | "P-384" }): KinoPublicKey; /** * apiVersion 6: signs `data` (read with `encoding`, default utf8) with your private key; base64 by default. * ec: `hash` "SHA-256" (default) or "SHA-384"; `format` "der" (default, Node's crypto.sign) or "ieee-p1363" * (r||s, 64 bytes on P-256, 96 on P-384: WebCrypto's). ed25519: 64 bytes, no `hash`. x25519 does not sign. */ function sign(options: { key: KinoPrivateKey | string; data: string; encoding?: KinoEncoding; hash?: "SHA-256" | "SHA-384"; format?: "der" | "ieee-p1363"; outputEncoding?: "base64" | "hex" }): string; /** apiVersion 6: true when `signature` (base64 by default) is valid for `data`; a malformed signature is false. `key`: a public key, a `{ jwk }`, or your own private key. */ function verify(options: { key: KinoPublicKey | { jwk: KinoJwk } | KinoPrivateKey | string; data: string; encoding?: KinoEncoding; signature: string; signatureEncoding?: KinoEncoding; hash?: "SHA-256" | "SHA-384"; format?: "der" | "ieee-p1363" }): boolean; /** apiVersion 6: ECDH (ec, same curve) or X25519: the shared secret, base64 by default (32 bytes; 48 on P-384). */ function deriveSharedSecret(options: { privateKey: KinoPrivateKey | string; publicKey: KinoPublicKey | { jwk: KinoJwk }; outputEncoding?: "base64" | "hex" }): string; } namespace rank { /** * The title's HEAD, up to its first `:`, `,`, `|`, en dash or em dash -- for a search backend * that ranks a short query better than a long one. A one- or two-letter head identifies * nothing, so the whole (trimmed) text comes back instead; a plain "-" is never a cut point (it * would split a hyphenated word like "Spider-Man"). */ function shortQuery(query: string): string; /** * Reorders `items` so the ones sharing the most words with `query` come first; ties keep * `items`' own order. `query` is a title, or several forms of one (try `query.q`, * `query.originalTitle` and `query.altTitles` together: a backend may only know a title in one * language). `getTitle` reads a title off an item, string or array of them; it defaults to * `(item) => item.title`. Never throws: `items` not an array answers `[]`; an item with no * usable title (missing, not a string, or `getTitle` itself failing) sorts after every item * that has one, in `items`' own order among themselves. */ function sortBySimilarity(items: any[], query: string | string[], getTitle?: (item: any) => string | string[]): any[]; /** * Drops items sharing too few words with `query` (under 60% of its distinctive words of 3+ * letters), and any item with no usable title along with them. Never throws: `items` not an * array answers `[]`. Reordering alone (`sortBySimilarity`) still shows a full page of near-misses when * the title genuinely is not on the backend, so an absent title comes back with 0 results * instead. */ function filterRelevant(items: any[], query: string | string[], getTitle?: (item: any) => string | string[]): any[]; } } // ---------- web globals Kino adds (QuickJS has none of them natively) ---------- // URL, URLSearchParams, atob, btoa, TextEncoder and TextDecoder (UTF-8 only) behave like the // browser's, without IDN/punycode. Use the lib "dom" typings, or declare them in your editor. /** * The manifest's optional `categories` (every apiVersion; read only by Kino's plugin marketplace for its category chips). * A list without repeats; when present it replaces what Kino guesses from the capabilities. */ type KinoManifestCategory = "movies" | "series" | "anime" | "live" | "radio" | "subtitles" | "utilities" | "adult"; /** * apiVersion 6, capability "meta": what `meta(query)` is asked about a title ANOTHER source listed (never one of your own), * as the app's TitleMetaQuery builds it. Keys appear only when known; Kino asks nobody about a title with no id at all. */ interface KinoMetaQuery { type: "movie" | "series"; /** Every id Kino knows for the title (from the card, TMDB or its anime mapping); each one only when known, never 0 or "". */ ids: { imdb?: string; tmdb?: number; kitsu?: number; mal?: number; anilist?: number }; /** The title's own Stremio-style id in its source ("kitsu:1376", "tt0944947"), only for a Stremio addon's title. */ id?: string; /** The person's language ("es"). */ lang?: string; } /** * `meta`'s answer (or null: a title you do not know -- not a failure). Every field optional; Kino only fills what TMDB and * AniList left empty, and an answer with only `year` and/or `runtimeMinutes` counts as none. Text fields may also be numbers * (read as text); each is trimmed and cut, and anything Kino cannot use is dropped on its own (`node sdk/run.mjs . meta` * says which and why). Images follow an item's poster rule: http(s), a public name or IPv4 (http never to a name without * a dot), or the person's own server; a URL longer than 2048 characters is dropped, so keep them shorter. Fields not * listed here are ignored. */ interface KinoTitleMeta { /** Read (up to 200 characters) but not shown: the page keeps the source's own title. */ title?: string | number; /** Up to 2000 characters; HTML tags are stripped on the page. */ overview?: string | number; poster?: string; backdrop?: string; /** Only its first 9 characters are read, and their first four digits are the year: 1999, "1999", "1999-2003". */ year?: string | number; /** Strings only, trimmed, up to 30 characters each; the first 5 non-empty ones. */ genres?: string[]; /** 1..1000 (a numeric string or a decimal is read as Android's JSON optInt does). Shown only for a movie. */ runtimeMinutes?: number | string; /** * At most 5000 entries read (invalid ones count); `season` 0..999 (0 kept, but never listed on the page), `number` * 1..99999, one per season and number (the first wins), sorted. Texts as an item's; `airDate` "YYYY-MM-DD"; * `id` (up to 4096 characters) the episode's Stremio-style video id. */ episodes?: { season: number | string; number: number | string; title?: string | number; overview?: string | number; still?: string; airDate?: string; id?: string | number }[]; /** Kino 0.9.51+: a clear-logo of the title, shown instead of its name on the info page. */ logo?: string; /** * Kino 0.9.51+: one per source (the first valid one wins; `source` is case-insensitive), at most 6 kept; added to the * page's score, never replacing it (a `tmdb` one is left out when the page has a score). `value`: up to 3 digits, up to 2 * decimals with "." or ",", then optionally "%" or "/N" ("8.8", "94%", "4.1/5", "72/100"); a number >= 0 is written * without trailing zeros (8.80 is "8.8"). */ ratings?: { source: "imdb" | "tmdb" | "rottentomatoes" | "metacritic" | "letterboxd" | "mal" | "anilist" | "trakt"; value: string | number }[]; /** * Kino 0.9.51+: at most 20 with a name, one per name (the first wins); `name` and `character` up to 60 characters. * Shown only when TMDB has no cast, as "Reparto:" with the first 5 names; `character` and `photo` are kept, not shown. */ cast?: { name: string | number; character?: string | number; photo?: string }[]; } /** * `meta` -- required with the capability "meta" (apiVersion 6). Asked with every other meta plugin at once; the first * answer in install order wins, within 6 s (a timeout or a throw is just no answer, never shown), remembered 30 minutes. */ type KinoMetaFn = (query: KinoMetaQuery) => Promise<KinoTitleMeta | null>; ```