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:
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<LiveCategory | Playlist> 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), migrate (Moving saved titles),
section and categories (Section, categories and colors), and settingsStatus,
action and validateSettings (The settings form), and meta
(Describing other titles). subtitles (Subtitles for any title) needs no new
apiVersion. From "apiVersion": 7 (Kino 0.9.51): track (Telling a tracker what the person
watches) and segments (Where the intro and credits are).
(kino.d.ts 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.
Arguments¶
search(query)gets{ q, type, season, episode, tmdbId, year, originalTitle, altTitles, cursor }:qis the text the person typed (it can be empty; return[]).typeis"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; usetypeat most to put the kind it names first.seasonandepisodeare0unless Kino is looking for a specific episode;tmdbIdandyearare0when unknown.originalTitleis TMDB's original title when it differs fromq(else""), andaltTitlesup to 5 other titles Kino knows for the work (each at most 200 characters): try them whenqfinds nothing on a source that names things in another language.cursorisnull, except when the person asked for more results and your previous page said where to continue (seePagebelow).within(apiVersion 6, only for a plugin that declaresscopedSearch) is present when the person searches inside one of your "Ver más" pages: see Searching inside a "Ver más" page.
home()getsnull.browse(ref, cursor)gets therefof one of your Home rows (or arefa previous page gave), andcursornullfor the first page or thenextof the page before.episodes(ref)gets therefof aseriesitem, as you returned it.resolve(ref, options)gets therefof amovieitem, therefof an episode, or (apiVersion 2) therefof aliveitem.optionsisundefinedon a normal call; only an apiVersion 6 plugin with a request-signed stream ever gets it, as{ retry }. From apiVersion 6 it also gets therefof one of your lazy copies, when that copy is needed.subtitles(arg)gets{ imdbId?, tmdbId?, kind, season?, episode?, title?, year?, languages, file? }(see Subtitles for any title) and returns an array shaped like aStream'ssubtitles, each entry with an optionallabelandtranslated.
What you return¶
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<string, string>,
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<string, string> },
label?: string, // apiVersion 6
alternatives?: ({ url: string, mime?: string, headers?: Record<string, string>, 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¶
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) 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¶
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")¶
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 <name>" 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)¶
Every "Ver más" page (a Home row, a row of your section, 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:
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 '<ref>' ./plugin.js search "texto".
id is stable, ref may change¶
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¶
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. 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). 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). |
| 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. |
| 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¶
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¶
urlmust behttpsand its host must be one of yourhosts, 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 plainhttpis a host you declared{ "host": "…", "insecureHttp": true }(apiVersion 2, see the manifest): that host, exactly, acceptshttpfor 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 withstreamHosts: "any"and the person's broad video permission; and a host you forgot may be asked about instead of refused.mimeis optional, of the formvideo/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.fetchhost rules. That covers theurlitself, the variants, segments and#EXT-X-KEYkeys an HLS manifest names, theBaseURLs of a DASH manifest, the subtitles, and every redirect hop of any of them: each must behttpson one of yourhosts(orhttpon one you declaredinsecureHttp), 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 inhosts. headersare sent with every one of those player requests (the stream, its manifest's segments and keys, its subtitles, and redirect hops, all on yourhosts) and, if you declaredownload, 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-EncodingandConnectionare ignored.subtitles: at most 30, each{ lang, url, format? }.langis a short language code such as"es"(up to 20 characters; blank becomes"und"),formatis"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:urlmust behttpson 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 aurlalready listed (the first entry wins).langup 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 fromlang. 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 noaudioTracksplays exactly as it always has. Example, a source that dubs into two languages: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. Whenurlcannot 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 likeurl,mimeandheaders; a bad one is dropped and the rest still count. They share the stream'ssubtitlesandaudioTracks. Ignored next todrm, withsigning(alternateHostsis 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 alabel, or be a lazy{ label, ref }resolved only when needed: see Labelled and 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,signContextandalternateHosts(apiVersion 6): an HLS stream that needs a fresh signature on every request. They have their own page: Signing every request.durationMsis optional, in milliseconds.-
skipis optional: where THIS file's opening and ending are, in milliseconds from its start --{ openingStartMs?, openingEndMs?, endingStartMs? }. Kino shows its "Saltar intro" button fromopeningStartMs(0 when left out ornull) toopeningEndMs, and "Saltar outro" (to the next episode) fromendingStartMs. Every value must be a finite number between 0 anddurationMs(or 24 h when you give nodurationMs); the opening needs itsopeningEndMs, after its start;endingStartMsmust not be before the opening's end. A bad part is dropped and the rest still count; a badskipnever 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 sendskip. Ignored for a live channel. Older Kino versions ignore the field.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 callsresolveonce more and continues where the person was. - DRM only when declared. A stream carrying any of
drm,license,licenseUrl,drmLicenseUrl,keySystemorwidevineis refused ("El video tiene DRM y los plugins no lo soportan") -- unless your manifest declares thedrmcapability (apiVersion 2) and the only such key is adrmblock{ type: "widevine", licenseUrl, licenseHeaders? }: then Kino plays it as Widevine.licenseUrlis checked exactly likeurl(https on one of yourhosts, or the person's own server), andlicenseHeadersare filtered likeheaders(at most 20) and sent with the license request only. The other five keys are refused even next to a validdrmblock. See A Widevine-protected stream.
Labelled and lazy copies (apiVersion 6)¶
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 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
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.
export async function resolve(ref) {
// A copy's own ref: "<episode>|<server>". 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 '<ref>', which prints
each copy with its label, then resolve '<a copy's ref>' to resolve that copy.
A complete plugin that captures its first server and offers the rest this way is on Hidden browser.
A host you forgot may be asked about, once¶
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 <host>, un servidor nuevo para este plugin.
¿Permitir?"), the same dialog a kino.fetch to an undeclared host 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¶
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"
(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": 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 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
drmblock'slicenseUrl(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¶
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. Noresolve,searchorhomeneeded; 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(declaringsubtitlestoo 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". 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)¶
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.
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 aposter.ratings: at most 6{ source, value }, one per source.sourceis one ofimdb,tmdb,rottentomatoes,metacritic,letterboxd,mal,anilist,trakt;valuea 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 atmdbrating is dropped when the page already has a score. A bad entry is dropped, the rest kept.cast: at most 20{ name, character?, photo? }(nameandcharacter60 characters;photoan image like aposter). Like every other field it only fills a gap: Kino shows TMDB's cast when TMDB has one. Kino shows the names;characterandphotoare 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)¶
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
<your hosts>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 addstrackingalways 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
trackand 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.
{
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 <your plugin>: …" 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 <plugin dir> track start (or progress, stop, watched, plus a JSON object to
change the sample).
Where the intro and credits are (segments, apiVersion 7)¶
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 addssegmentsneeds 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
segmentsplugin 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.
{
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 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 <plugin dir> 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¶
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:
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)¶
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:
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 <your plugin's name>: <your sentence>" ("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 namedM3UorCuevana3always shows Kino's line;Cuevana 3is fine); - the code is one of the five in the table (Kino always words
timeout,network,host_not_allowed,crypto_errorand 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]), noundefined/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 minutosis fine,5minutosis 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, orpunto/dotglued to or followed by a domain ending (punto com,puntodev; "a punto de volver" and "en este punto es mejor" are fine:es,la,meandtoare not read as endings); - it never spells Kino: read with
1,l,!,¡asi,0asoand every non-letter dropped, it holds nokinoanywhere (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): nopag…(pago, pagues, págalo; "página" is fine),abon…,recarg…,transfer…,consign…,deposit…,contraseñ…,passw…,clave…,credencial…,token…,tarjeta,PIN, Nequi, Daviplata, WhatsApp, Telegram, acódigothat 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.
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 (at the root of this repository), and anyone can report it
with the "Reclamo / retiro de plugin" 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.
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¶
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
<name>: 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.
18+ content (adult, apiVersion 6)¶
From "apiVersion": 6, adult: true on an item, on one of your Categorías
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".
liveSearchhits need a mark: see Mark everyliveSearchhit.- 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.