Skip to content

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 }:
    • 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.
  • 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 stream ever gets it, as { retry }. From apiVersion 6 it also gets the ref of 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 a Stream's subtitles, each entry with an optional label and translated.

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.

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

  • 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): 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" and the person's broad video permission; and a host you forgot may be asked about 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 BaseURLs 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:

    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.

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

    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.

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

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". 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 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)

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

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

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".
  • liveSearch hits need a mark: see Mark every liveSearch hit.
  • 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.