El contrato (apiVersion 1 a 7)¶
Tu archivo de entrada es un módulo ES que exporta una función async por cada capacidad que
declaraste, y no se llama nada que no hayas declarado:
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) */ }
Desde "apiVersion": 6 (Kino 0.9.50) hay más exports opcionales, cada uno con su página:
sign (Firma por petición), migrate (Pasar lo guardado),
section y categories (Sección, categorías y colores), y settingsStatus,
action y validateSettings (Formulario de ajustes), y meta
(Describir otros títulos). subtitles (Subtítulos para cualquier título) no
necesita un apiVersion nuevo. Desde "apiVersion": 7 (Kino 0.9.51): track (Contarle a un
servicio de seguimiento qué ve la persona) y segments (Dónde están la intro y los
créditos).
(kino.d.ts tiene las mismas formas como declaraciones de TypeScript.)
Usa exports con nombre (export async function ...). Los datos entran y salen de tu código como
JSON, así que devuelve datos simples: textos, números, booleanos, arreglos y objetos.
Las funciones de canales en vivo (liveCategories, liveChannels, y las opcionales guide y
liveSearch, apiVersion 3) tienen sus argumentos y reglas en Canales en vivo.
Argumentos¶
search(query)recibe{ q, type, season, episode, tmdbId, year, originalTitle, altTitles, cursor }:qes el texto que escribió la persona (puede estar vacío; devuelve[]).typees"movie"o"series"cuando Kino se inclina por ese tipo, y"any"si no. Es una pista, no un filtro: Kino lo saca de la división película/serie de TMDB, que casi nunca coincide con el catálogo propio de una fuente, y un título puede existir como las dos cosas. Devuelve todas las coincidencias posibles; usatype, como mucho, para poner primero el tipo que nombra.seasonyepisodeson0salvo que Kino esté buscando un capítulo específico;tmdbIdyyearson0cuando no se conocen.originalTitlees el título original de TMDB cuando es distinto deq(si no,""), yaltTitleshasta 5 títulos más que Kino conoce de la obra (cada uno de máximo 200 caracteres): pruébalos cuandoqno encuentra nada en una fuente que nombra las cosas en otro idioma.cursoresnull, salvo cuando la persona pidió más resultados y tu página anterior dijo dónde seguir (miraPageabajo).within(apiVersion 6, solo en un plugin que declarascopedSearch) aparece cuando la persona busca dentro de una de tus páginas "Ver más": mira Buscar dentro de un "Ver más".
home()recibenull.browse(ref, cursor)recibe elrefde una de tus filas de Inicio (o unrefque dio una página anterior), ycursornullpara la primera página o elnextde la página anterior.episodes(ref)recibe elrefde un ítemseries, tal como lo devolviste.resolve(ref, options)recibe elrefde un ítemmovie, elrefde un capítulo o (apiVersion 2) elrefde un ítemlive.optionsesundefineden una llamada normal; solo un plugin apiVersion 6 con un stream firmado por petición lo recibe, como{ retry }. Desde apiVersion 6 también recibe elrefde una de tus copias perezosas, cuando esa copia hace falta.subtitles(arg)recibe{ imdbId?, tmdbId?, kind, season?, episode?, title?, year?, languages, file? }(ver Subtítulos para cualquier título) y devuelve un arreglo con la forma de lossubtitlesde unStream, cada entrada con unlabely untranslatedopcionales.
Lo que devuelves¶
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 y { ref }: apiVersion 6
skip?: { openingStartMs?: number, openingEndMs?: number, endingStartMs?: number },
signing?: "request", signContext?: string, alternateHosts?: string[] } // las tres últimas: apiVersion 6
Género (Categorías y el filtro de En vivo). Una Row del Home, una LiveCategory de En vivo y una playlist pueden llevar un genre opcional de una lista cerrada de diez ids: peliculas, series, anime, infantil, documentales, deportes, noticias, musica, entretenimiento, otros (Kino muestra sus nombres en español). Sirve para que Kino alinee categorías de plugins distintos: la pestaña Categorías agrupa por género las filas del Home que se pueden explorar (las que tienen ref, si declaras browse) de todos los plugins, y En vivo puede acotar sus categorías por género. Un valor fuera de la lista se ignora, nunca es un error, y sin genre Kino lo adivina por el título de la fila o del grupo ("Deportes", "Noticias Colombia", "Kids"…), así que ponlo cuando tus títulos no lo digan. En una playlist el género es el de partida para los grupos de la lista (antes se intenta adivinar por el título de cada grupo). Las versiones de Kino anteriores a este campo lo ignoran.
Cómo se conectan las piezas¶
El ref de un ítem movie va a resolve. El ref de un ítem series va a episodes, y el ref
de cada capítulo va a resolve. El ref de un ítem live (apiVersion 2, mira
Canales en vivo) también va a resolve, y su Stream se reproduce en
vivo. El ref de una fila va a browse, y lo mismo el next de cada página.
Temporadas¶
Hay dos formas, y tu respuesta de episodes dice cuál. Cuando todas las temporadas de una serie
están en una sola lista, dale a cada capítulo su season y deja seasons por fuera: Kino saca las
temporadas de los capítulos y muestra un selector que solo filtra la lista. Cuando tu fuente guarda
cada temporada como su propio ítem series (con su propio id y ref, como lo listaría una
búsqueda), devuelve solo los capítulos de esa temporada y lista todas las temporadas de la serie en
seasons, incluida la que estás respondiendo: { id, ref, title, number?, current? }, con title lo
que muestra el selector ("Temporada 2") y current: true en la temporada que se está listando (Kino
también la reconoce por id). Kino muestra las temporadas como chips; escoger otra llama a
episodes con el ref de esa temporada y la abre como ese título, con su propio progreso en la
biblioteca. seasons es opcional y nuevo en esta revisión de apiVersion 1: un plugin que nunca lo
devuelve sigue funcionando exactamente igual que antes.
Paginación ("Ver más")¶
Si declaras browse, una fila de Inicio con ref tiene una tarjeta "Ver más" que abre una
cuadrícula: Kino llama browse(ref, null) y después browse(ref, next) mientras la persona baja y
tú sigas devolviendo un next. search también puede devolver una Page; su next pone "Ver más
resultados de <name>" debajo de tus resultados, y Kino vuelve a llamar search con la misma
consulta y cursor: next. Un next (y el ref de una fila) solo se conserva cuando declaras
browse; sin eso Kino los descarta con una línea en el log. Los cursores son opacos para Kino: un
número de página, un desplazamiento, una URL, de máximo 2048 caracteres.
Buscar dentro de un "Ver más" (scopedSearch, apiVersion 6)¶
Cada página "Ver más" (una fila de Inicio, una fila de tu sección, una de tus
Categorías) tiene un campo de búsqueda arriba ("Buscar en esta categoría"). Para todos los plugins,
Kino filtra por nombre los títulos que ya cargó esa página (sin importar tildes ni mayúsculas, cada
palabra en cualquier parte del título); con menos de 24 coincidencias sigue cargando las páginas
siguientes del mismo ref ("Buscando en más páginas…"), máximo 10 páginas o 300 títulos por ronda, y la
persona puede pedir otra ronda. Una consulta nueva cancela la que está corriendo.
Declara "scopedSearch" en capabilities (apiVersion 6, junto con search; no hay nada más que
exportar ni línea de consentimiento) para responder tú esa búsqueda, por ejemplo con la búsqueda de tu
backend restringida a esa categoría. Sin search el manifiesto se rechaza con "La capacidad
\"scopedSearch\" necesita también \"search\"". Kino llama tu search con la consulta de siempre más
within, el ref de browse de esa página tal como lo diste:
export async function search(query) {
if (query.within) {
const category = categoryOf(query.within); // tu propio ref
if (!category) return null; // "ahí no puedo buscar": Kino filtra la página él mismo
return searchCategory(category, query.q, query.cursor); // Item[] o Page, paginada por tu `next`
}
/* la búsqueda normal */
}
Ahí type siempre es "any", y season, episode, tmdbId y year son 0. La respuesta se revisa
como cualquier respuesta de búsqueda (los mismos topes, los títulos adult solo con el código +18
desbloqueado), y el next de una Page la pagina mientras la persona baja. Kino vuelve a su propio
filtro cuando respondes null, lanzas un error, o no has respondido a los 6 s (la página busca entonces
en sus propios títulos y descarta tu respuesta tardía; tu llamada conserva el límite de 15 s de la
búsqueda). Una falla llega al tablero de errores como cualquier llamada fallida, nunca con la consulta
ni el ref; responder null no es una falla. sdk/validate.mjs avisa cuando declaras scopedSearch y
tu archivo de entrada nunca lee within; pruébalo con
node sdk/run.mjs --within '<ref>' ./plugin.js search "texto".
id es estable, ref puede cambiar¶
id es la identidad de un título: de él cuelgan la biblioteca de la persona, su progreso y
"Continuar viendo", así que tiene que ser el mismo cada vez que vuelve el mismo título, en cada
búsqueda y en cada actualización de Inicio. ref es opaco para Kino: es solo lo que tus episodes
y resolve necesitan para volver a encontrar el título. Puede cambiar de una llamada a otra (las
fuentes vuelven a emitir enlaces), y Kino te puede entregar un ref que devolviste antes, por ejemplo
el que quedó guardado con un título en la biblioteca de la persona. Así que haz refs que sigan
funcionando; si los enlaces de tu fuente vencen, pon algo estable en el ref (un id) y busca el
enlace fresco dentro de resolve.
Kino es estricto, y tolerante con las listas¶
Cada lista se revisa entrada por entrada: una entrada mala se descarta (con una línea en el log) y
el resto sobrevive; lo que pasa de un tope se corta. Un Stream es todo o nada.
| Qué | Reglas |
|---|---|
Resultado de search |
Máximo 100 ítems (un Item[] o una Page). Un ítem live cuyo nombre no tiene nada que ver con la búsqueda se descarta: se queda solo si su nombre lleva al menos el 60 % de las palabras de 3 o más letras de alguna forma de la búsqueda (lo que se escribió, originalTitle o uno de altTitles), sin importar tildes ni mayúsculas -- la regla de kino.rank.filterRelevant. Las películas y series nunca se juzgan así (pueden llevar con razón otro título), y una búsqueda sin ninguna palabra así no descarta nada. Así que no respondas una búsqueda con toda tu lista de canales cuando nada coincide. |
Resultado de browse |
Una Page de máximo 100 ítems. |
Resultado de home |
Máximo 20 filas de máximo 60 ítems cada una. Una fila necesita un id único (mismo patrón que el id de un ítem) y un title no vacío; las filas sin ítems válidos se descartan. Kino las muestra después de sus propias filas, con el nombre de tu plugin, y las guarda 6 horas (las filas viejas se muestran mientras se actualizan; una respuesta sin filas válidas, o de más de 2 MB, no se guarda y se vuelve a pedir la próxima vez). Si home() falla no aportas filas y el Inicio no se bloquea. |
Resultado de episodes |
Máximo 5000 capítulos. number es obligatorio y va de 1 a 99999 (un capítulo con número 0, como un especial, se descarta). season debería ir de 1 a 999; una temporada que falta o está fuera de rango se vuelve 1. ref es obligatorio. Una temporada y número repetidos se descartan. Sin title, Kino muestra "Capítulo N". |
seasons (en el resultado de episodes) |
Opcional; máximo 50. Cada una necesita un id (mismo patrón que el id de un ítem; uno repetido se descarta), un ref no vacío de máximo 4096 caracteres y un title no vacío (hasta 200 caracteres), o se descarta. number de 1 a 999 y current booleano; uno mal puesto se ignora, no la temporada. Lo que no sea una lista se ignora. |
id |
^[A-Za-z0-9._~-]{1,128}$. Cualquier otra cosa descarta el ítem, así que si los ids de tu fuente tienen otros caracteres (espacios, /, :, %), deriva tú un id estable, como un slug. Los ids repetidos en una lista se descartan. |
ref |
Un texto no vacío de máximo 4096 caracteres. |
kind |
"movie", "series" o (apiVersion 2) "live"; un ítem live se queda en una fila de home solo desde apiVersion 6 (por debajo se quita del Inicio: mira Canales en tus filas de Inicio). Un ítem series de un plugin que no declara episodes se descarta: nunca se podría abrir; un ítem live de un plugin apiVersion 1 también se descarta (mira Canales en vivo). |
| Campos de texto | title es obligatorio y no vacío, hasta 200 caracteres. overview hasta 2000; lang y quality hasta 20 (por ejemplo "es", "1080p"); year hasta 10 (se acepta un número y se convierte). El texto más largo se corta; el texto de SeriesInfo y Episode se corta igual (200 caracteres para títulos, 2000 para sinopsis). |
| Campos extra del ítem | Todos opcionales; uno mal puesto se ignora, no el ítem. genres máximo 5, cada uno de máximo 30 caracteres; badges (se muestran como chips, p. ej. "HD", "Latino") máximo 3 de máximo 20; rating de 0 a 10; runtimeMinutes de 1 a 1000; ids.tmdb un entero positivo (Kino lo usa para emparejar tu título con TMDB, para volver a encontrarlo desde la búsqueda y para enriquecer su página de información -- mira abajo); ids.imdb cumple ^tt\d{5,10}$ (también enriquece la página de información de una película cuando no tienes ids.tmdb). El airDate de un capítulo es YYYY-MM-DD. |
adult |
Desde apiVersion 6, adult: true marca una entrada +18: Kino la muestra solo mientras el código +18 de la persona está desbloqueado en ese aparato (Ajustes ▸ Adultos), y la vuelve a esconder cuando lo bloquea; por debajo de apiVersion 6 se descarta. Aplica en Inicio, en la búsqueda, en "Ver más", en tu sección y en Categorías. Mira Contenido +18. |
| Imágenes | poster, backdrop y still tienen que ser URL http o https de máximo 2048 caracteres, o se ignoran (las versiones de Kino anteriores a la que aceptó http en imágenes ignoran las http). Las imágenes las carga Kino directamente y no se revisan contra hosts (son solo para mostrar), y Kino no envía tus headers ni tus cookies con ellas. Es la única excepción a la regla de hosts, con un límite: una imagen en la red local, en una dirección IP privada o reservada, o en un nombre local (localhost, .local, .lan, …) también se ignora, y también un nombre sin punto por http (router, nas), salvo que esté en un servidor que la persona escribió en tus ajustes. Una dirección IPv4 pública sirve. |
ids.tmdb enriquece la página de información, no solo el emparejamiento¶
Cuando TMDB tiene exactamente este título (emparejado por ids.tmdb, o por ids.imdb en una
película cuando no diste ids.tmdb), al abrirlo se agregan tres tipos de campo, cada uno llenado de
forma distinta:
- Solo TMDB los tiene, así que siempre vienen de ahí: un eslogan, el director o (en una serie) el creador, el reparto y la clasificación por edad.
- TMDB gana siempre que tenga respuesta; el tuyo solo es el respaldo de lo que TMDB dejó vacío: el año y los géneros. Un título con su propio año o géneros igual muestra los de TMDB una vez emparejado, no los suyos.
- El tuyo gana cuando lo diste; TMDB solo llena el hueco: la sinopsis (solo se reemplaza si la tuya estaba vacía), la calificación (solo si la dejaste por fuera) y la duración de una película (solo si no la pusiste -- la duración de una serie nunca se toca, ni la de TMDB; se muestra por capítulo, no para toda la serie).
No agrega un póster, un fondo ni temporadas desde TMDB -- esos quedan exactamente como los dio
tu respuesta de Item/SeriesInfo/episodes, o vacíos si los dejaste por fuera.
Las reglas del Stream¶
urltiene que serhttpsy su host tiene que ser uno de tushosts, igual que el host de cada URL de subtítulos, o estar en un servidor que la persona escribió en tus ajustes (exactamente ese esquema, host y puerto). La otra única vía ahttpplano es un host que declaraste{ "host": "…", "insecureHttp": true }(apiVersion 2, mira el manifiesto): ese host, exacto, aceptahttppara el stream, sus subtítulos, sus pistas de audio y su licencia. Un stream que rompe esto se rechaza completo; un subtítulo malo se descarta y el stream igual se reproduce. Dos cosas aflojan esto para una película o un episodio: un manifiesto constreamHosts: "any"y el permiso amplio de video de la persona; y por un host que se te olvidó se puede preguntar en vez de rechazarlo.mimees opcional, con la formavideo/mp4(cualquier otra cosa rechaza el stream). Cuando falta, el reproductor de Kino detecta HLS, DASH o un archivo simple por la URL y el contenido.- Todo lo que el reproductor pide para el stream sigue las reglas de hosts de
kino.fetch. Eso cubre laurlmisma, las variantes, segmentos y llaves#EXT-X-KEYque nombra un manifiesto HLS, losBaseURLde un manifiesto DASH, los subtítulos y cada salto de redirección de cualquiera de ellos: cada uno tiene que serhttpsen uno de tushosts(ohttpen uno que declarasteinsecureHttp), nunca una dirección IP ni un nombre local, y un nombre declarado que resuelve dentro de la red de la persona se rechaza. Una petición que rompe esto falla antes de salir del dispositivo y la reproducción se detiene con un error, así que un manifiesto que apunta a otro CDN necesita ese CDN enhosts. - Los
headersse envían con cada una de esas peticiones del reproductor (el stream, los segmentos y llaves de su manifiesto, sus subtítulos y los saltos de redirección, todo en tushosts) y, si declarasdownload, con cada petición que guarda el stream en el dispositivo (en HLS: las playlists, la llave y cada segmento) — y en ninguna otra parte. Máximo 20; los nombres son letras, dígitos y guiones; los valores tienen máximo 4096 caracteres sin saltos de línea;Host,Content-Length,Transfer-EncodingyConnectionse ignoran. subtitles: máximo 30, cada uno{ lang, url, format? }.langes un código de idioma corto como"es"(hasta 20 caracteres; vacío se vuelve"und"),formates"vtt"o"srt".-
audioTracks: máximo 8, cada una{ lang, url, label? }-- un doblaje o una mezcla alterna que tu fuente sirve como archivo propio, aparte del video. Se revisa exactamente como un subtítulo:urltiene que serhttpsen un host declarado, o el servidor propio de la persona exactamente como lo escribió; una entrada mala se descarta y el resto del stream se reproduce igual, y lo mismo unaurlque ya estaba en la lista (gana la primera entrada).langhasta 16 caracteres (vacío se vuelve"und");label, hasta 40 caracteres, se muestra tal cual en el menú de audio cuando lo das, en vez de un nombre adivinado a partir delang. Kino une cada una al video y la ofrece, escogida automáticamente según la preferencia de audio de la persona, en el mismo menú que las pistas que trae el contenedor. Un stream sinaudioTracksse reproduce exactamente como siempre. Ejemplo, una fuente que dobla a dos idiomas:return { url: videoUrl, audioTracks: [ { lang: "es-419", url: dubUrl("es"), label: "Español (Latinoamérica)" }, { lang: "en", url: dubUrl("en") }, ], }; -
alternatives(máximo 8, cada una{ url, mime?, headers? }): otras copias del mismo video, la mejor primero. Cuandourlno se puede reproducir en el aparato (un códec para el que no tiene decodificador, un archivo roto o no soportado) o ya no está (404, 403), Kino pasa solo a la siguiente alternativa, en el mismo punto, y solo muestra un error cuando no queda ninguna. Una red caída no es razón para cambiar: eso se reintenta como siempre. Cada entrada se revisa exactamente comourl,mimeyheaders; una mala se descarta y las demás siguen contando. Comparten lossubtitlesyaudioTracksdel stream. Se ignoran junto adrm, consigning(la conmutación de un stream firmado esalternateHosts) y en un canal en vivo. Devuélvelas cuando tu fuente ofrece varios archivos de un título (otros servidores, resoluciones, codificaciones): un aparato que no puede decodificar el primero igual alcanza a verlo. Desde apiVersion 6 una alternativa puede llevar unlabel, o ser una{ label, ref }perezosa que se resuelve solo cuando hace falta: ver Copias con etiqueta y perezosas. label(apiVersion 6): el nombre corto del Stream, y cada alternativa puede llevar el suyo -- por ejemplo"Latino · Servidor 1". Se recorta; máximo 48 caracteres, sin caracteres de control, o se descarta (la copia igual se reproduce). Cuando un Stream tiene dos o más copias, el reproductor las muestra todas, por etiqueta, en una sección Servidor arriba de su menú "Audio y subtítulos" (celular y TV, alcanzable con el control), donde la persona puede cambiar en cualquier momento y seguir viendo desde el mismo punto; una copia sin etiqueta sale como "Opción 2", "Opción 3"… Las etiquetas también van al registro del plugin, solo con el host (nunca una URL ni un token).signing,signContextyalternateHosts(apiVersion 6): un stream HLS que necesita una firma fresca en cada petición. Tienen su propia página: Firma por petición.durationMses opcional, en milisegundos.-
skipes opcional: dónde están la entrada y el cierre de ESTE archivo, en milisegundos desde su comienzo --{ openingStartMs?, openingEndMs?, endingStartMs? }. Kino muestra su botón "Saltar intro" desdeopeningStartMs(0 si falta o esnull) hastaopeningEndMs, y "Saltar outro" (al siguiente capítulo) desdeendingStartMs. Cada valor debe ser un número finito entre 0 ydurationMs(o 24 h si no dasdurationMs); la entrada necesita suopeningEndMs, después de su inicio;endingStartMsno puede ir antes del final de la entrada. Una parte mala se descarta y el resto sigue contando; unskipmalo nunca detiene el stream. Manda los tiempos del archivo exacto que devuelves: otro corte del mismo capítulo tiene la entrada en otro lado. Kino los guarda como las marcas del capítulo, también para una copia descargada, y se sincronizan con el otro aparato de la persona. Una corrección que la persona hace a mano siempre le gana a la tuya; la tuya le gana a los tiempos de la comunidad que Kino busca por su cuenta para anime (AniSkip), que ni se consulta cuando mandasskip. Se ignora en un canal en vivo. Las versiones anteriores de Kino ignoran el campo.return { url: videoUrl, durationMs: 1_420_000, skip: { openingStartMs: 62_000, openingEndMs: 152_000, endingStartMs: 1_290_000 } }; -
expiresInSeconds(de 30 a 86400) dice cuándo puede dejar de funcionar tu URL. Si la reproducción falla después de ese tiempo, Kino llama aresolveuna vez más y sigue donde iba la persona. - DRM solo si se declara. Un stream que traiga cualquiera de
drm,license,licenseUrl,drmLicenseUrl,keySystemowidevinese rechaza ("El video tiene DRM y los plugins no lo soportan") -- salvo que tu manifiesto declare la capacidaddrm(apiVersion 2) y la única clave de ese tipo sea un bloquedrm{ type: "widevine", licenseUrl, licenseHeaders? }: ahí Kino lo reproduce como Widevine.licenseUrlse revisa exactamente comourl(https en uno de tushosts, o el servidor propio de la persona), ylicenseHeadersse filtran como losheaders(máximo 20) y solo se envían con la petición de licencia. Las otras cinco claves se rechazan aunque estén junto a un bloquedrmválido. Mira Un stream protegido con Widevine.
Copias con etiqueta y perezosas (apiVersion 6)¶
Una fuente suele tener el mismo capítulo en varios idiomas y en varios servidores, y encontrar el video
de cada servidor cuesta tiempo (una página que abrir, a veces una captura del
navegador oculto de 5 a 25 s). Resolverlos todos antes del primer cuadro haría lento cada
play. Desde "apiVersion": 6, las alternatives de un Stream pueden nombrar una copia sin
resolverla: { label, ref } en vez de { url }. Kino le pasa ese ref a tu resolve(ref) solo
cuando la copia de verdad hace falta:
- la persona la escoge en el menú Servidor del reproductor (la copia en pantalla sigue reproduciéndose mientras abre, y después la nueva arranca en el mismo punto; si falla, lee "No se pudo abrir …" y sigue viendo la copia que tenía);
- el cambio automático llega a ella (la copia en pantalla no se puede reproducir en este aparato, o ya no está);
- la elección de copia de una descarga llega a ella (dentro del mismo presupuesto de 30 s que gasta probando copias; una copia perezosa que no ha respondido para entonces se salta, nunca se espera).
Esa llamada es un resolve normal: el mismo límite de tiempo (75 s para un plugin
browser aprobado) cuando la persona escogió la copia -- decidió esperarla -- pero
máximo 20 s cuando la pidió el cambio automático, y después Kino la cancela (terminando una captura
que siga corriendo) y pasa a la siguiente copia; las mismas revisiones de lo que devuelve,
kino.browser.capture permitido (en la elección de copia de una descarga: solo si no hay otra página
abierta; si no, busy y la copia se salta), y las preguntas de host como para el título. De su
respuesta Kino usa url, headers, mime, subtitles (cuando trae; si no, se quedan los del título),
expiresInSeconds (una copia perezosa vencida se resuelve de nuevo, no todo el título) y skip (muestra
"Saltar intro" mientras se reproduce esa copia, nunca se guarda: el skip del Stream sigue siendo el del
capítulo, y una corrección a mano sigue ganando). Sus propias alternatives se ignoran -- una copia
nunca se abre en más copias. Un resolve que falla pasa a la siguiente copia.
export async function resolve(ref) {
// El ref propio de una copia: "<capítulo>|<servidor>". Resuelve solo ese servidor.
if (ref.includes("|")) return resolveServer(ref);
const servers = await listServers(ref); // [{ id, lang, name }], rápido: aún sin abrir página
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}`,
})),
};
}
Reglas: ref es un texto no vacío de máximo 512 caracteres sin caracteres de control (uno malo o
repetido se descarta); una entrada con url es una copia normal, lleve lo que lleve; las copias
perezosas y las concretas se mezclan libremente y comparten el límite de 8; cada label se recorta,
máximo 48 caracteres, sin caracteres de control, o se descarta mientras la copia sigue contando. Que el
ref alcance para volver a encontrar ese servidor: puede resolverse minutos después del título (una
persona que cambia de servidor a mitad del capítulo). Por debajo de apiVersion 6 las llaves son
desconocidas: un label se ignora y una entrada { ref }, al no tener url, se descarta igual que
antes. Pruébalo con node sdk/run.mjs ./plugin.js resolve '<ref>', que imprime cada copia con su
etiqueta, y luego resolve '<el ref de una copia>' para resolver esa copia.
Un plugin completo que captura su primer servidor y ofrece los demás así está en Navegador oculto.
Por un host que se te olvidó se puede preguntar, una vez¶
Cuando la persona abre un título en el reproductor y lo único malo de tu Stream es que una URL (el
video, su licencia, un subtítulo o una pista de audio) está en un host https que no declaraste, Kino
le pregunta en el momento ("El video está en <host>, un servidor nuevo para este plugin.
¿Permitir?"), el mismo diálogo que recibe un kino.fetch a un host no declarado.
El reproductor también pregunta cuando se encuentra un host nuevo en plena reproducción (un
manifiesto, un segmento, una redirección). "Permitir" agrega ese host a los hosts aprobados de tu
plugin (no hay tope de cuántos puede aprobar una persona así; una actualización los conserva) y el
video se reproduce; "Rechazar" (o Atrás) queda recordado para tu plugin -- el video falla como se
describe arriba, un subtítulo o una pista de audio se descartan -- y nunca se vuelve a preguntar por
ese host hasta que la persona elija "Olvidar rechazos de host". Nunca se pregunta por una dirección
IP, un nombre local, http plano ni un stream roto de cualquier otra forma, y no se pregunta nada
cuando nadie está mirando: una descarga falla en ese host. No te confíes: declara los hosts que usan
tus streams.
El permiso amplio de video¶
Para una película o un episodio, esos diálogos de video, subtítulos y audio tienen una tercera opción,
"Permitir video de cualquier servidor". Es un permiso que da la persona, visible y revocable en
Ajustes ▸ Plugins ("Puede reproducir video desde cualquier servidor", "Quitar permiso de video
amplio"); la única forma de que tú pidas la misma regla de entrada es
streamHosts: "any" (apiVersion 4), aprobado en la hoja de
consentimiento. Una actualización o una reinstalación lo conservan; desinstalar lo quita.
Mientras está activo, el Stream de tu película o episodio se revisa como el de un canal en vivo con
liveStreamHosts: "any": su url, todo lo que nombra su
manifiesto, cada salto de redirección y sus subtitles y audioTracks pueden estar en cualquier
host público, por http o https, incluida una dirección IPv4 pública, y nunca más se pregunta por
un host de video para tu plugin. Una descarga de una película o un episodio
sigue la misma regla. Nunca cubre:
- la red de la casa: direcciones privadas, de loopback, link-local y CGNAT, literales IPv6, nombres locales y un nombre público que resuelve dentro de la LAN;
- la
licenseUrlde un bloquedrm(sigue solo en tus hosts, con la pregunta de arriba); kino.fetch: tu propio código sigue llegando solo a tus hosts y a los aprobados uno por uno;- los canales en vivo, que tienen su propia regla.
Existe para fuentes cuyos servidores de video cambian de dominio en cada video o en plena reproducción; un plugin con un CDN fijo debería igual declararlo.
Subtítulos para cualquier título¶
Exporta subtitles({ imdbId, tmdbId, kind, season, episode, title, year, languages, file }) y Kino lista lo
que respondas en "Buscar subtítulos en línea" del reproductor (celular y TV), bajo el nombre de tu
plugin, junto a OpenSubtitles y SubDL: para cualquier película o capítulo que la persona reproduzca
-- tus títulos, los de otro plugin, los de un addon de Stremio -- siempre que Kino conozca su id de IMDb
o de TMDB (un título que solo se conoce por nombre nunca te llega). La persona escoge una pista, Kino la
descarga, la convierte a SRT y la agrega como cualquier subtítulo en línea (desfase, estilo, recordado
para el título).
Dos formas de ofrecerlo:
- Un proveedor de subtítulos:
"capabilities": ["subtitles"]y nada más. No hace faltaresolve,searchnihome; el plugin no aparece en ninguna parte salvo en la búsqueda de subtítulos y en Ajustes ▸ Plugins (no es una fuente en "Elige tus fuentes"). La pantalla de consentimiento dice "Agrega subtítulos a tus películas y series". Las versiones anteriores de Kino rechazan una capacidad que no conocen: no lo instalan. - Junto a tus videos: deja tus capacidades y solo exporta
subtitles(declarar tambiénsubtitlesagrega la línea de consentimiento, pero entonces las versiones anteriores de Kino rechazarían el plugin). Kino le pregunta a todo plugin cuya instalación encontró el export; las versiones anteriores nunca lo llaman.
El argumento: imdbId (tt…) y/o tmdbId (un número), al menos uno; kind "movie" o "series"
-- en un capítulo los ids son los de la serie y vienen season/episode; title y year son
pistas; languages son los idiomas de subtítulos de la persona, ISO 639-1, el mejor primero (["es",
"en"]). file (Kino 0.9.51) es lo que Kino sabe del archivo que suena, cuando la persona busca desde
el reproductor: { hash?, size?, name? }, cada uno solo cuando se conoce, y ausente cuando no se sabe
nada. hash es el hash de OpenSubtitles de 16 hexadecimales y size los bytes con que se calculó (solo
para un archivo plano y estable); name es el nombre del archivo con su extensión
("The.Matrix.1999.1080p.mkv", máximo 200 caracteres), tomado solo de una URL que termina en un nombre de
archivo de video; si no, un nombre al estilo de un release que Kino arma con el título
("Oppenheimer.2023.mkv", "Breaking.Bad.S01E05.mkv"). Nunca la URL del video. Úsalo para poner primero
la versión que coincide con ese archivo exacto; las versiones anteriores de Kino nunca lo mandan, así
que tómalo como una pista. Devuelve un arreglo de { lang, url, format?, label?, translated? }: entradas de los
subtitles de un Stream más un label opcional y translated: true para una traducción automática
(el menú entonces dice "Español (traducido)" y la lista después de las pistas hechas por personas en ese
idioma). Kino conserva 30, y después lista solo las de los idiomas de la persona, en ese orden, 15 por
plugin: pon primero los idiomas pedidos. label (60 caracteres) se muestra junto al idioma, por ejemplo
el nombre de un release. Cada url sigue la regla de subtítulos de un Stream: tus hosts, el
servidor de la persona, o cualquier host público con streamHosts: "any".
Es una llamada en segundo plano (10 s, nunca marca tu plugin "No responde"); devuelve [] cuando no
tienes nada. Pruébalo con node sdk/run.mjs ./plugin.js subtitles tt0944947 1 1 (KINO_LANGS=es,en
para los idiomas).
Describir otros títulos (meta, apiVersion 6)¶
Declara "meta" y exporta meta(query) para completar lo que Kino no encontró de un título en su
página de información, sin importar qué plugin lo listó: una sinopsis, un póster o fondo, géneros, un
año, una duración, una lista de capítulos. Kino le pregunta primero a TMDB y, para un anime, a AniList;
tu respuesta solo llena lo que ellos dejaron vacío, nunca reemplaza un valor que dieron (ni el del
plugin propio del título). Es la forma de describir títulos que TMDB no conoce, como anime kitsu:. Sin
línea de consentimiento.
export async function meta(query) {
// query: { type: "movie" | "series", ids: { imdb?, tmdb?, kitsu?, mal?, anilist? }, id?, lang? }
// id: el id estilo Stremio del título en su fuente ("kitsu:1376", "tt0944947"), cuando tiene
// lang: el idioma de la persona ("es")
const found = await lookUp(query.ids);
if (!found) return null; // un título que no conoces: no es una falla
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" }],
};
}
Todos los campos son opcionales. Las imágenes siguen las mismas reglas que las de un ítem. Desde Kino
0.9.51 hay tres más (las versiones anteriores los ignoran; sin apiVersion nuevo):
logo: el logo del título (su nombre dibujado como arte, con fondo transparente). La ficha lo muestra en lugar del nombre del título, arriba (celular y TV; el nombre se queda para TalkBack y vuelve si la imagen no carga). Las mismas reglas de imagen que unposter.ratings: máximo 6{ source, value }, uno por fuente.sourcees uno deimdb,tmdb,rottentomatoes,metacritic,letterboxd,mal,anilist,trakt;valueun número como lo escribe el sitio, hasta 3 dígitos con hasta 2 decimales, con un%o una escala opcional ("8.8","94%","4.1/5"; también sirve un número de JSON). Se muestran junto a la nota ★ de la ficha ("IMDb 8.8 · Rotten Tomatoes 94%"). A diferencia de los demás campos, se suman: la nota propia de TMDB se queda, y un ratingtmdbse descarta cuando la ficha ya tiene nota. Una entrada mala se descarta y el resto se conserva.cast: máximo 20{ name, character?, photo? }(nameycharacterde 60 caracteres;photouna imagen como unposter). Como todo campo, solo llena un hueco: Kino muestra el reparto de TMDB cuando TMDB lo tiene. Kino muestra los nombres;characteryphotose guardan para después.
episodes[].id es el id de video estilo Stremio del capítulo: un título de un addon de Stremio cuyo
propio listado falló puede entonces reproducir esos capítulos con él. Kino le pregunta a la vez a todos
los plugins meta de la persona, máximo 6 s cada uno, usa la primera respuesta en orden de instalación
y la recuerda 30 minutos; una falla o un tiempo agotado es simplemente no responder, y la página nunca
te espera. El kit de Node no tiene un comando meta ni revisa que lo exportes: pruébalo en la app, en
la ficha de un título que TMDB no conoce.
Contarle a un servicio de seguimiento qué ve la persona (tracking, apiVersion 7)¶
Un plugin de un servicio de seguimiento -- Seenr, Trakt, Simkl, un webhook al estilo de Plex, el
servidor propio de la persona -- declara "capabilities": ["tracking"] (solo, con
subtitles/segments, o junto a las capacidades de una fuente) y "apiVersion": 7, y exporta
track(event). Kino lo llama por cada película o capítulo que la persona reproduce en ese aparato,
de cualquier fuente. Kino 0.9.50 y anteriores rechazan ese manifiesto con "Este plugin necesita una
versión más nueva de Kino".
- Consentimiento. La hoja de instalación dice, en rojo, "Le contará a
<tus hosts>qué ves y cuándo lo terminas" (los 3 primeros hosts, después "y N más"; sin hosts, "al servidor que escribas en su configuración"). Una actualización que agregatrackingsiempre espera a la persona, aunque Kino apruebe otras actualizaciones por su cuenta. - El interruptor. Tu pestaña de Ajustes recibe "Enviar lo que veo" (encendido de entrada,
sincronizado con los otros aparatos de la persona). Apagado, Kino deja de llamar a
tracky borra lo que estaba esperando; desactivar o desinstalar el plugin hace lo mismo. - Lo que nunca se manda. Canales en vivo y radio, títulos +18, lo que reproduce un Chromecast o un televisor DLNA (el celular es solo un control remoto ahí), un título que no está en la biblioteca de la persona, y un capítulo cuyo número Kino no conoce.
Cuándo. start cuando el video de verdad suena (y otra vez cuando se reanuda tras una pausa);
progress como mucho cada 5 minutos de reproducción y en cada pausa (con paused: true); stop con la
posición cuando la persona sale del título (o pone otro); watched una vez, cuando la posición
cruza la regla de "visto" de Kino: quedan 3 minutos o menos y se vio al menos el 90 %. Un título
abierto ya pasado ese punto (retomado en los créditos en otro aparato) no vuelve a mandar watched, y
cada aparato le manda el watched de un título a tu plugin máximo una vez.
{
id: "6f1c…", // estable: el mismo en cada reintento de este evento; úsalo para ignorar un repetido
type: "start" | "progress" | "stop" | "watched",
at: 1759670000000, // cuándo pasó en el aparato (epoch ms), no cuándo te llegó
kind: "movie" | "episode",
ids: { imdb?, tmdb?, tvdb?, anilist?, mal? }, // los de la película; los PROPIOS de un capítulo (abajo)
title?: "…", // el nombre de la película; el del capítulo cuando TMDB lo tiene
year?: 1999, // el año de una película
show?: { title: "…", year?: 2008, ids: { imdb?, tmdb?, tvdb?, anilist?, mal? } }, // solo capítulos
season?: 1, episode?: 2, // solo capítulos (temporada 1 si la fuente no dio ninguna)
positionMs?: 1234000, durationMs?: 8160000, progress?: 0.151, // dónde iba la persona, 0..1
paused?: true // un progress mandado porque la persona pausó
}
Qué ids. En un capítulo, ids son los propios del capítulo (el id de capítulo de TMDB, y
sus ids de IMDb y TVDB cuando TMDB los conoce) y los de la serie van en show.ids: nunca uses
show.ids donde un servicio espera el id de un capítulo, o el registro cae en el ítem equivocado. Kino
completa con TMDB los ids que le faltan al entregar (máximo 8 s); si TMDB no responde, el evento igual
sale, y los ids de un capítulo pueden venir {}: recurre a show.ids + season + episode.
anilist/mal solo aparecen cuando la fuente propia del título los nombró. Toda clave falta cuando no
se conoce; imdb es un texto tt…, las demás son números.
Qué devolver. Cualquier cosa ({ ok: true } por convención) significa entregado. Devuelve
{ skipped: true } para un evento que a tu servicio no le sirve (uno que no guarda progress, un título
que no tiene): Kino lo descarta igual, pero solo una entrega de verdad borra la línea roja "No pudo
avisar…", así que un enlace equivocado sigue a la vista hasta que se arregle. Para fallar, lanza
kino.error(code):
timeout,network,unavailable,rate_limited(y un tiempo agotado de la propia llamada, o un error lanzado sin código): Kino reintenta el mismo evento más tarde -- 30 s, duplicando hasta 6 h, máximo 12 intentos -- y tus eventos siguientes esperan detrás, en orden;auth_required,invalid_request,not_found,geo_blocked,host_not_allowed,too_large: el evento se descarta de una vez.
Un evento descartado, o tres fallas seguidas, muestra "No pudo avisar a <tu plugin>: …" en rojo en tu
pestaña de Ajustes y en Gestionar, hasta que una entrega funcione. Es una llamada en segundo plano (10 s,
nunca "No responde"), nunca en el hilo del reproductor. Kino guarda máximo 200 eventos en espera por
plugin (el progress más viejo sale primero, un watched nunca) y descarta uno sin entregar después de
7 días; un progress nuevo reemplaza al que espera del mismo título, y un stop o watched reemplaza
su progress en espera. Nada se pierde sin conexión ni con la app cerrada: Kino entrega cuando hay red.
Los registros propios de Kino nunca llevan lo que la persona vio; con "Modo debug" encendido, tu
Registro muestra cada evento enviado. Un addon de Stremio convertido nunca recibe tracking (Stremio no
tiene protocolo de scrobble). Pruébalo con node sdk/run.mjs <carpeta del plugin> track start (o
progress, stop, watched, y un objeto JSON para cambiar el ejemplo).
Dónde están la intro y los créditos (segments, apiVersion 7)¶
Un plugin que sabe dónde están la intro y los créditos de un título -- una base al estilo de IntroDB,
una al estilo de AniSkip, el servidor propio de la persona -- declara "capabilities": ["segments"]
(solo, con subtitles/tracking, o junto a las capacidades de una fuente) y "apiVersion": 7, y
exporta segments(query). Kino muestra entonces sus botones "Saltar intro" y "Saltar outro", y "Saltar
automáticamente" salta la intro, en cualquier película o capítulo que la persona reproduzca, de
cualquier fuente, en celular y TV. Kino 0.9.50 y anteriores rechazan ese manifiesto.
- Consentimiento. "Agrega el botón para saltar la intro y los créditos", no en rojo (como
subtitles, tu plugin solo se entera de qué título suena). Una actualización que agregasegmentsno necesita aprobación propia. - Cuándo. Cuando una película o capítulo de verdad suena (Kino conoce la duración del archivo), en
segundo plano: la reproducción nunca te espera. Nunca para canales en vivo o radio, títulos +18, ni un
título que Kino no conoce por ningún id. Se le pregunta a la vez a cada plugin
segmentsinstalado; Kino guarda una respuesta (también una vacía) durante la sesión por título, capítulo y duración (redondeada a 10 s), y vuelve a preguntar después de 2 minutos solo cuando fallaron todos.
{
kind: "movie" | "episode",
ids: { imdb?, tmdb?, tvdb?, anilist?, mal? }, // los de la película; los PROPIOS del capítulo, como en track()
show?: { ids: { imdb?, tmdb?, tvdb?, anilist?, mal? } }, // solo capítulos: los de la serie
season?: 1, episode?: 2, // solo capítulos
durationMs?: 1440000 // la duración del archivo que suena: responde por ESE corte
}
No se manda título, año ni URL; los ids de un capítulo pueden venir {}, así que recurre a
show.ids + season + episode. Devuelve un arreglo de { type, startMs, endMs } ([] o null
cuando no sabes nada): type uno de intro, outro, recap, credits, preview, tiempos en ms
enteros del archivo. Kino revisa cada entrada por separado y descarta una mala sin perder las demás: un
type desconocido, un tiempo que no es entero, un inicio menor que 0, un final que no está al menos 1 s
después del inicio y -- con durationMs conocido -- un inicio en el final o después, o un final más de
5 s pasado de él (dentro de eso se recorta). De entradas del mismo tipo que se cruzan se queda la
primera. Kino lee las primeras 100 entradas y conserva 10. La intro más temprana es la intro, y el
outro o credits más temprano después de ella es donde empieza el final; recap y preview se
aceptan y todavía no tienen botón.
Quién gana. La corrección de la propia persona (el editor de marcas) siempre gana, y también el
skip del plugin que sirve el archivo. En anime, AniSkip (con "Saltar intro en anime"
encendido) gana parte por parte: tu respuesta solo llena la intro o el final que le falte. Entre dos
plugins segments gana el primero, en el orden de Ajustes ▸ Plugins, con una respuesta utilizable. Nada
de lo que respondes se guarda ni se sincroniza. Es una llamada en segundo plano (8 s, nunca "No
responde"); Kino la espera máximo 12 s. Pruébalo con
node sdk/run.mjs <carpeta del plugin> segments tt0133093 8160000 (una película y su duración) o
segments tmdb:1396 1 2 2880000 (un capítulo, por el id de la serie): muestra lo que Kino conserva, lo
que descartó y por qué, y el botón que arma con eso.
Errores que la gente entiende¶
Un throw new Error("…") simple le llega a la persona como una falla genérica de tu plugin. Cuando la
falla es de las comunes, lanza un error con tipo y Kino lo dice bien, en español, con el nombre de tu
plugin:
if (r.status === 401) throw kino.error("auth_required", "la sesión venció");
Código de kino.error |
Lo que ve la persona |
|---|---|
auth_required |
"Configura {plugin} en Ajustes ▸ {plugin}" cuando tu plugin declara ajustes (tiene su propia pestaña en Ajustes); si no, "Configura {plugin} en Ajustes ▸ Plugins" ("Menú ▸ Plugins" en el celular), con un botón a su pantalla Configurar |
not_found |
"No se encontró en {plugin}" |
geo_blocked |
"Este contenido no está disponible en tu región" |
rate_limited |
"{plugin} está limitando las peticiones; intenta en unos minutos" |
unavailable |
"{plugin} no está disponible ahora" |
Tu mensaje es un detalle para el log (cortado a 200 caracteres); la persona lee la frase de Kino. Un código desconocido se vuelve un error simple.
Tu propia frase para la persona (userMessage, apiVersion 6)¶
Cuando la frase de Kino dice muy poco (un capítulo que retiraron, una cuenta que hay que vincular de nuevo), pasa tu propia frase para la persona como tercer argumento:
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 la muestra en lugar de su propia línea, siempre como "Mensaje de <nombre de tu plugin>: <tu frase>" ("Mensaje de Demo: Este capítulo ya no está disponible."), y solo cuando se cumple todo esto; si no, la persona lee la línea de Kino y tu frase no va a ninguna parte (tampoco al log; el detalle sí):
- el nombre de tu plugin puede presentarla: solo los caracteres de abajo, sin
:, sin un dígito pegado a una letra, nada que deletree Kino (así que un plugin llamadoM3UoCuevana3siempre muestra la línea de Kino;Cuevana 3sirve); - el código es uno de los cinco de la tabla (
timeout,network,host_not_allowed,crypto_errory los demás siempre los redacta Kino); - tiene de 1 a 160 caracteres sin los espacios de los extremos, hechos solo de las letras del latín
básico y Latin-1 (lo que escriben el español, el portugués y el inglés: á é í ó ú ü ñ ç ã õ â ê ô à
è…, pero no ø æ ð þ ß), los dígitos 0-9, el espacio normal y
. , : ; ¿ ? ¡ ! ' ’ ‘ “ ” « » ( ) % - – — ▸(un;solo antes de un espacio): nada de otra escritura, letras parecidas, versalitas, saltos de línea, tabuladores, otros espacios, caracteres invisibles, emojis ni@; - se lee como palabras normales: al menos dos palabras, sin URL, sin prefijo de error (
TypeError:,[Tag]), sinundefined/null/NaN, y no termina en:,;ni-; - menos de 6 dígitos en total, los separe lo que los separe (ningún teléfono ni número de cuenta, y por
eso tampoco una fecha completa con su año), y ningún dígito pegado a una letra (
en 5 minutossirve,5minutosno); - ningún dominio: un punto pegado a una letra (
site.app), un punto después de un espacio (site .app), un punto seguido de una palabra en minúsculas de 2 a 6 letras (site. app),www, nipunto/dotpegado o seguido de una terminación de dominio (punto com,puntodev; "a punto de volver" y "en este punto es mejor" sirven:es,la,meytono se leen como terminaciones); - nunca deletrea Kino: leída con
1,l,!,¡comoi,0comooy sin ningún carácter que no sea letra, no contienekinoen ninguna parte (así que evita una palabra como "Kinoshita"); - no pide credenciales, dinero ni contacto por fuera de Kino, leída palabra por palabra (una palabra
partida a propósito se lee entera:
N e q u i,Ne qui,con tra seña,What s app,pun to com): nada depag…(pago, pagues, págalo; "página" sirve),abon…,recarg…,transfer…,consign…,deposit…,contraseñ…,passw…,clave…,credencial…,token…,tarjeta,PIN, Nequi, Daviplata, WhatsApp, Telegram, ni uncódigoque llegó por SMS o que es de verificación (verification…; "Verifica tu conexión" sirve): tus propios ajustes son el único lugar para eso (recarg…también rechaza "Recarga la lista": di "Vuelve a cargar"); - no contiene ninguna de las contraseñas que la persona escribió en tus ajustes (se compara con los valores guardados de tu plugin; mientras no se pueden leer, la frase no se muestra), ni el valor de ningún secreto sellado. Nunca repitas lo que la persona escribió, de ninguna forma.
Nunca uses userMessage para pedir plata, contraseñas o datos de contacto
Cada autor es responsable de su propio plugin. Kino solo lista los plugins de la comunidad (su
búsqueda de la comunidad); no los recomienda ni los promociona. Si un plugin incumple las reglas
para plugins -- por ejemplo, usa userMessage para pedir plata, contraseñas o datos de contacto,
es malware o infringe los derechos de alguien --, Kino lo retira del índice de la comunidad con
community-blocklist.json (en la raíz de este repositorio), y cualquiera lo puede reportar
con la plantilla de issue "Reclamo / retiro de plugin". Una copia instalada sigue
instalada, su tarjeta dice "Retirado del índice de la comunidad." y no recibe más actualizaciones;
un fork necesita su propio reporte. Ver Reclamos y retiro de plugins.
Escríbela para la persona, en su idioma; Kino no la traduce. Ocupa exactamente el lugar de la línea de
Kino, así que nunca cambia lo que hace la pantalla: auth_required conserva el botón a tu pantalla
Configurar; geo_blocked muestra el diálogo "No se puede reproducir" del reproductor; los demás códigos
muestran la línea de error de Kino (en un canal en vivo la persona sigue haciendo zapping). También es
el motivo bajo la fila de Inicio de tu plugin y en los avisos de búsqueda. Tu formulario de ajustes y
las páginas de tu sección la muestran para not_found, unavailable y rate_limited, y conservan su
propio texto genérico para auth_required y geo_blocked. Una sola cosa le gana, sea cual sea el
código: un host que la persona rechazó para esa llamada (sobre eso puede actuar). La frase cuenta solo
para la llamada que construyó el error: créala donde lanzas, no una vez arriba en tu módulo. El kit de
Node (run.mjs) imprime lo que leería la persona, o por qué no se muestra la frase. Las versiones de
Kino anteriores a la 0.9.50 ignoran el tercer argumento y muestran su propia línea, así que pasarlo
siempre es seguro.
Una actualización pendiente le gana al error¶
Si una versión nueva de tu plugin está esperando la aprobación de la persona (pide un host, un
permiso o una capacidad nueva), una llamada fallida no muestra la frase de siempre sino "Hay una versión
nueva de <nombre>: actualízala en Ajustes ▸ Plugins", para que la persona sepa qué hacer. Le siguen
ganando un host rechazado, tu userMessage válido y auth_required (que conserva su botón). Mira
Actualizaciones.
Contenido +18 (adult, apiVersion 6)¶
Desde "apiVersion": 6, adult: true en un ítem, en una de tus Categorías
o en una categoría o un canal en vivo marca una entrada +18. Kino la muestra solo mientras el código +18
de la persona está desbloqueado en ese aparato (Ajustes ▸ Adultos) y la vuelve a esconder cuando lo
bloquea. No hay nada que declarar en el manifiesto. Por debajo de apiVersion 6 una entrada adult: true
se descarta, como antes.
- Aplica en Inicio, la búsqueda, "Ver más", tu sección, Categorías y En vivo. Una fila o un grupo que se queda solo con entradas +18 no se muestra mientras el código está bloqueado.
- Todo canal de una categoría +18 cuenta como +18, y un canal +18 nunca entra a "Recientes".
- Los resultados de
liveSearchnecesitan una marca: mira Marca cada resultado deliveSearch. - Enviar un título +18 al TV emparejado pide que el TV también tenga el contenido +18 desbloqueado ("Desbloquea el contenido 18+ en el TV para verlo allí").
- Tu plugin no sabe si el código está desbloqueado ni puede saltarse el candado: siempre devuelve la marca y Kino decide qué se ve.