Live channels¶
There are two ways to give Kino live TV, and a plugin can do both:
liveitems (apiVersion 2): channels mixed into your "Ver más" pages and search results, next to your movies and series, and from apiVersion 6 into your Home rows too.- The
channelscapability (apiVersion 3): your channels in Kino's own En vivo tab, TV guide, channel drawer and Home "Canales en vivo" row, given one by one or as an M3U playlist with an XMLTV guide that Kino downloads and parses itself.
Testing them with the Node kit is on Test it locally.
Live channels (apiVersion 2)¶
With "apiVersion": 2 an item may be a live channel: kind: "live", in a browse page or search
result, next to your movies and series. Nothing to declare beyond the version. In a home row a
channel stays only from "apiVersion": 6 (below).
export async function home() {
return [{
id: "en-vivo", title: "En vivo",
items: [
{ id: "canal-1", ref: "live:1", title: "Canal Uno", kind: "live", poster: "https://cdn.example.org/canal-1.png" },
],
}];
}
export async function resolve(ref) {
if (ref.startsWith("live:")) {
const url = await freshPlaylistUrlFor(ref); // look the live link up here, never in home()
return { url, mime: "application/vnd.apple.mpegurl" };
}
// ...movies and episodes as before
}
What Kino does with a live item:
- Its card wears an "EN VIVO" badge (Home, "Ver más", search, phone and TV), and tapping it goes
straight to the player: no info page, nothing to read or pick.
resolve(ref)gets the item'sref, exactly as for a movie. - In search, a
liveitem is kept only when its name matches what was asked (most of the words of 3 or more letters of the query, itsoriginalTitleor one of itsaltTitles, likekino.rank.filterRelevant). A channel plugin that answers every search with its whole list when nothing matches sees those channels dropped; movies and series are never judged this way. - The
Streamplays as live: an HLS or DASH live manifest (.m3u8/.mpd) is what the player expects; a progressive file plays too but reads as a channel (no seek bar, no length).headers,subtitlesandexpiresInSecondswork as for any stream;durationMsandaudioTracksare ignored (a separate audio file cannot follow a live window: put a channel's other languages inside its manifest, e.g. HLSEXT-X-MEDIArenditions, and the player's audio menu offers them). - The player shows the live overlay (no progress bar, no seeking, no "next") and starts at the live
edge. If it falls behind the live window, or the playlist resets or stalls, it re-joins the live
edge in place without calling you (a few times a minute). On any other cut, or when your URL
stops working, it calls
resolveagain with the samerefafter 2 s, then 4 s, then 8 s: three reopens, replenished once the channel has played for five seconds. Only after the third failed reopen does the person read "Se cortó la señal de <canal> y no volvió".expiresInSecondsplays no part for a channel: a cut always re-resolves. - A channel is never saved: no library row, no resume position, never in "Continuar viendo", and
never downloadable (a plugin that declares
downloadgets "Este video no se puede descargar" for it).runtimeMinuteson the item is ignored; a channel has noepisodes.
Limits: a live item from a plugin on "apiVersion": 1 is dropped silently, like any invalid
item (and a row left with no items disappears), so declare 2 before you return one. A channel
still counts against the same row and page sizes as any item. These channels appear in your rows,
with your plugin's name; to put channels in Kino's En vivo tab and its "Canales en vivo" row, use
the apiVersion 3 channels capability (below).
Channels in your Home rows (apiVersion 6)¶
From "apiVersion": 6 (Kino 0.9.50) a kind: "live" item stays in a home row: it shows as a channel
card with the "En vivo" badge and opens like a channel from En vivo. A row of channels on Home (say, a
country's channels) is just a home row whose items are kind: "live". Below 6 Kino drops channels
from Home. An adult: true channel follows the same 18+ lock as any 18+ entry, and
a row left with nothing to show is not shown.
Channels in the En vivo tab (apiVersion 3)¶
Declare "apiVersion": 3 and the capability "channels", and export liveCategories() and
liveChannels({ categoryId, cursor }) (and, optionally, guide(...) and liveSearch(...), see below). Your channels then
appear in Kino's own En vivo tab, TV guide, channel drawer and Home "Canales en vivo" row, in a
section with your plugin's name. channels does not replace search/home: the manifest still
needs one of them (a plugin with only channels exports a home() that returns []). Items of kind
"live" in your rows keep working; a plugin can do both. On install, and on an update that adds it,
the person reads and approves "Agrega canales en vivo a la pestaña En vivo".
Channels from any server (liveStreamHosts, apiVersion 3)¶
IPTV lists name their streams on servers you cannot know ahead of time, often plain http and
often a bare public IP. For that, and only that, a channels plugin may add:
"apiVersion": 3,
"capabilities": ["home", "resolve", "channels"],
"liveStreamHosts": "any"
It is read only with "apiVersion": 3 (an older manifest ignores it, like any field it does not
know). There, "any" is the only value and it needs the channels capability: otherwise the
manifest is refused with El campo "liveStreamHosts" solo admite "any" or
"liveStreamHosts" necesita la capacidad "channels".
What it allows: a live channel's stream (the url of a channel's inline stream, or the url
resolve returns for a channel; items you mark as live are treated as channels) may be on any
public host, over http or https, a public IPv4 address included (not an IPv6 literal). The player then fetches that manifest and
its variants, segments and keys, and follows their redirects, under the same rule. The audio and
subtitle renditions the HLS manifest itself lists (#EXT-X-MEDIA) are part of that stream and follow
the same rule too; the subtitles and audioTracks you return in a Stream do not (see below).
What it never allows:
- the home network: private, loopback, link-local and carrier-grade NAT addresses, IPv6 literals,
localhostand local names (.local,.lan, …), and a public name that resolves into any of them (refused when the player connects); - other ports or schemes of a server the person typed: that server is reached exactly as typed, never "any";
kino.fetch: your own requests still reach only yourhostsand the person's servers;- the playlist and XMLTV downloads a
{ playlist }declaration asks Kino to make: those URLs must still be on yourhosts(or the person's server); - subtitles, audio tracks and a
drmblock'slicenseUrl: still yourhostsonly, and every redirect they make is judged the same way; - movies and episodes: a non-live
Streamis checked exactly as before (unless your plugin declaresstreamHosts: "any"or the person granted it the broad video permission); - images: the poster rule (http or https, never local) does not change.
The consent sheet shows it in red, "Puede reproducir canales desde cualquier servidor que indique su lista", and an update that newly adds it waits for the person's approval, like a new host.
The channel functions (apiVersion 3)¶
With the channels capability (above) Kino calls up to
four more functions. Their arguments:
liveCategories()getsnull.liveChannels({ categoryId, cursor })gets theidof one of your categories, andcursornullfor the first page or thenextof the page before.guide({ channelIds, from, to })gets at most 50 of your channel ids and a window of at most 24 hours:fromandtoare epoch milliseconds.liveSearch({ query })(optional) gets what the person typed in En vivo's search, trimmed, at least 2 characters.
They return:
LiveCategory = { id: string, title: string, country?: string, adult?: boolean, genre?: Genre }
Playlist = { playlist: { url: string, format: "m3u", headers?: Record<string, string>,
streamHeaders?: Record<string, string>, genre?: Genre,
epg?: { url: string, format: "xmltv" }, refreshHours?: number,
hideGroups?: string[], resolve?: boolean } }
LiveChannel = { id: string, title: string, categoryId?: string, ref?: string, stream?: Stream,
logo?: string, number?: number, adult?: boolean }
GuideEntry = { channelId: string, title: string, start: number, end: number, description?: string }
A plugin can give its channels in three ways, and mix them:
- A channel with a
ref. Therefgoes toresolve(ref)when the person plays it, exactly like aliveitem's, and its Stream plays as live. - A channel with an inline
stream. AStreamchecked by the same rules asresolve()'s answer (TheStreamrules); it plays with no call to your plugin. A channel whosestreamis refused is dropped. With bothrefandstream, the stream plays and therefis only the fallback (but a request-signed inline stream is set aside, and therefplays). A channel with neither is dropped. Some channels only answer a known player: give theStreamaheaderswith theUser-Agent(orReferer) it insists on, and the player sends it with every request for that channel. -
A playlist. Put
{ playlist: { ... } }entries next to your categories in theliveCategories()answer (or return one alone). Kino downloads the M3U list itself, and its XMLTV guide fromepg.url, and groups the entries into categories. Both URLs must behttpson one of yourhosts(orhttpon one declaredinsecureHttp, or a server the person typed), always: a playlist on another host is dropped, and anepgon another host only loses the guide.headersgo with those downloads.streamHeadersare what the player sends for every channel of the list, for the channels that only answer a knownUser-Agent(or aReferer): they are filtered like a Stream'sheadersand kept apart fromheaderson purpose, because those carry your list's own credentials and go only to the list's host, never to the many hosts the channels are on. A header an M3U entry names itself (#EXTVLCOPT:http-user-agent=...,#EXTHTTP:{"User-Agent":"..."}, aurl|User-Agent=...&Referer=...suffix, or#KODIPROPstream headers) wins; onlyUser-Agent,Referer,OriginandCookieare kept, and a value with a control character is dropped. The list may be UTF-8, Latin-1 or UTF-16 (with or without a BOM);#EXTINFattributes may be double-quoted, single-quoted or bare (tvg-id=abc). A list over 20 MB, or a guide over 50 MB, is not refused: Kino keeps its start, up to its last whole line (node sdk/run.mjs live playlistsays when). A guide<programme>withoutstopends where the next programme of its channel starts, or one hour after its start when none follows. Kino versions before the one that addedstreamHeadersignore the field, so the list plays without it.refreshHoursis 1 to 168 (default 12);hideGroupslists group titles not to show (case doesn't matter, at most 50). Withresolve: true, each entry plays through yourresolve(<entry url>), for lists whose links need a fresh token. At most 10 per answer.Each entry gets a channel code, the key of favourites and recents: its
tvg-idwhen that is a valid id and the entry is the first of the list to use it, else one made from its URL and name. A later entry repeating atvg-idnever moves the first one's code, but it gets a URL-and-name code itself, which changes (and its favourites and recents stop matching) when its URL does; a copy inserted before the first one takes thetvg-idcode over. Give every entry a stable, uniquetvg-id;node sdk/run.mjs live playlist <list>lists the repeated ones.
The rules:
- Times are epoch milliseconds.
- At most 200 categories (playlists don't count), and at most 500 channels per
liveChannelspage.idfollows the itemidpattern; anidstarting with~is reserved for Kino's own playlist entries and dropped. A repeatedidin one answer is dropped.titleis required. countryis an ISO 3166 two-letter code ("CO"), informational; anything else is ignored.numberis 1 to 9999 (anything else counts as no number);logofollows the poster rules;categoryIdis optional and informational inliveChannels(a channel is listed under the categoryliveChannelswas asked for); one that is not a valid id becomes empty. In aliveSearchhit it is how Kino knows whether the hit is 18+ (below).- Kino pages
liveChannelsuntilnextis missing, repeats, or brings nothing new. A category's first listing asks at most 10 pages; when the last one still has anext, Kino keeps it and asks 5 more pages each time the person scrolls near the end of the list, up to 10,000 channels (or 200 pages) per category. Kino 0.9.49 and older stop at the first 10 pages and never askliveSearch. liveSearchis optional, for a catalog too big to list whole. Kino asks it from En vivo's search (the phone's field, the TV guide's and the TV channel drawer's) when the person stops typing, only while some of your channels were never listed (a category not opened yet, or one with pages left), and keeps each answer 10 minutes per query. It returns channels exactly like aliveChannelspage (nextis ignored), at most 100 kept, with the sameida listed channel has, so favourites and recents match. Kino still shows only the ones whose name contains what was typed (or whose number is it). A channel found this way plays like a listed one, and a favourite Kino no longer has in memory nor in its cache is looked up again by name throughliveSearch(one call) before the first pages of your first 10 categories; withoutliveSearch, only those first pages are looked at, never the pages after them. Its time limit is 15 s and it runs as a background call: a slow search never marks the plugin "No responde". Export it only when you can really search: an empty answer is taken as "nothing found", and Kino keeps saying some channels were not loaded. A preheat of the next channel (Kino resolving a neighbour ahead while one plays) never asksliveSearch: it looks only through your listings, and the zap itself asks when needed.- Mark every
liveSearchhit (apiVersion 6, when you have an 18+ category): a hit names no listing, so give itadult: true/adult: false, or thecategoryIdof the category it belongs to. A hit withadult: trueor thecategoryIdof an 18+ category is 18+; one with thecategoryIdof a plain category, oradult: false, is plain. A hit with neither counts as 18+ for a plugin that has any 18+ category, and, when your categories can't be read (liveCategoriesfailed), every hit not markedadult: falsecounts as 18+. While the 18+ code is locked, Kino leaves those hits out of the search, does not open them and keeps them out of "Recientes".node sdk/run.mjs <plugin dir> live search <query>andsdk/validate.mjs --run liveSearchmark the hits the same way and warn about the ones that carry neither mark. - Kino caches your categories and channels for 1 hour and your guide for 30 minutes.
guideis optional. Kino keeps entries for the channels it asked for, withendafterstart, inside the window, at most 100 per channel and one per start time. Aguidethat fails or is not exported is simply not asked again for 30 minutes: your channels still list.adult: trueon a category or a channel: from apiVersion 6 it marks an 18+ entry, shown only while the person's 18+ code is unlocked on that device (Ajustes ▸ Adultos) and hidden again when they lock it; below apiVersion 6 it is dropped. Every channel of an 18+ category counts as 18+, and an 18+ channel never enters "Recientes". See 18+ content.- While a channel plays, Kino may resolve the neighbouring channel ahead so zapping is quick (cancelled
when the person leaves). That is one more call to your
resolve: don't count it as a play.
A liveSearch for an API that can search its channels by name:
export async function liveSearch({ query }) {
const r = await kino.fetch("https://api.example.com/channels?q=" + encodeURIComponent(query));
if (!r.ok) throw kino.error("unavailable");
return r.json().items.map(c => ({ id: c.id, title: c.name, ref: c.id, logo: c.logo }));
}
Use the same id your liveChannels gives that channel. Test it with
node sdk/run.mjs . live search <query> (Test it locally).
Three recipes (apiVersion 3)¶
Three ways to fill the En vivo tab, from the least code to the most control. Each is a complete plugin (see Channels in the En vivo tab and The channel functions for the rules).
1. A plain M3U list the person types¶
The person pastes the address of their list (and, if they have one, of its guide) in Configurar; Kino downloads it, groups it and plays each entry itself.
{
"id": "mi-lista", "name": "Mi lista", "version": "1.0.0", "apiVersion": 3, "entry": "plugin.js",
"hosts": [],
"capabilities": ["home", "resolve", "channels"],
"liveStreamHosts": "any",
"settings": [
{ "key": "lista", "label": "Lista M3U", "type": "url", "required": true },
{ "key": "guia", "label": "Guía XMLTV", "type": "url" }
]
}
"hosts": [] is enough: the list and the guide are on servers the person typed. Their streams are
not: an IPTV list points at dozens of servers nobody can declare ahead of time, which is what
"liveStreamHosts": "any" is for (Channels from any server).
The person sees it on the consent sheet, in red: "Puede reproducir canales desde cualquier servidor
que indique su lista". Leave it out when every stream is on hosts you can declare.
// Kino downloads the list (and the guide), groups it and plays each entry by itself.
export async function liveCategories() {
const guia = kino.config.get("guia");
return [{
playlist: {
url: kino.config.get("lista"),
format: "m3u",
epg: guia ? { url: guia, format: "xmltv" } : undefined,
hideGroups: ["Compras"],
},
}];
}
// Every channel comes from the list: no categories of your own to page.
export async function liveChannels() {
return { items: [] };
}
// The manifest needs search or home; a plugin with only channels has an empty home.
export async function home() {
return [];
}
// A direct list never calls resolve: its entries play as they are.
export async function resolve() {
await null;
throw kino.error("not_found");
}
node sdk/run.mjs . --config lista=https://iptv-org.github.io/iptv/countries/co.m3u live categories
2. A token per channel¶
Your API lists the channels, and each play needs a freshly signed URL.
liveChannels returns { id, title, ref } items; resolve(ref) signs the URL when the person
plays it. A channel's expiresInSeconds is ignored: when a live stream is cut, Kino simply calls
resolve again.
{
"id": "mi-tv", "name": "Mi TV", "version": "1.0.0", "apiVersion": 3, "entry": "plugin.js",
"hosts": ["api.example.com", "cdn.example.com"],
"capabilities": ["home", "resolve", "channels"],
"settings": [{ "key": "token", "label": "Código de acceso", "type": "password", "required": true }]
}
const API = "https://api.example.com";
async function api(path) {
const r = await kino.fetch(API + path, { headers: { Authorization: "Bearer " + kino.config.get("token") } });
if (r.status === 401) throw kino.error("auth_required", "código de acceso inválido");
if (!r.ok) throw kino.error("unavailable", "la API respondió " + r.status);
return r.json();
}
export async function home() {
return [];
}
export async function liveCategories() {
const cats = await api("/categorias"); // [{ slug, nombre }]
return cats.map((c) => ({ id: c.slug, title: c.nombre }));
}
// One page of a category. The ref is only the channel's id: the signed URL is made on play.
export async function liveChannels({ categoryId, cursor }) {
const page = await api("/canales?categoria=" + encodeURIComponent(categoryId) + (cursor ? "&pagina=" + encodeURIComponent(cursor) : ""));
return {
items: page.canales.map((c) => ({ id: c.id, title: c.nombre, categoryId, ref: c.id, logo: c.logo, number: c.numero })),
next: page.siguiente || undefined,
};
}
// Called on every play, and again when the stream is cut: always a fresh token.
export async function resolve(ref) {
const s = await api("/firmar/" + encodeURIComponent(ref)); // { url: "https://cdn.example.com/…?token=…" }
return { url: s.url, mime: "application/vnd.apple.mpegurl" };
}
node sdk/run.mjs . --config token=... live channels noticias
3. Mixed¶
Your own "Destacados" category with inline stream items (they play with no call to
your plugin, so zapping through them is instant), plus the provider's full list declared with
resolve: true: Kino downloads and groups it, and each of its entries plays through your
resolve(<entry url>), which appends a token.
{
"id": "mi-mezcla", "name": "Mi mezcla", "version": "1.0.0", "apiVersion": 3, "entry": "plugin.js",
"hosts": ["api.example.com", "live.example.com", "listas.example.com"],
"capabilities": ["home", "resolve", "channels"]
}
// Your own featured channels: inline streams, played with no call to the plugin (fast zapping).
const DESTACADOS = [
{ id: "noticias24", title: "Noticias 24", number: 1, url: "https://live.example.com/noticias24/index.m3u8" },
{ id: "deportes", title: "Deportes", number: 2, url: "https://live.example.com/deportes/index.m3u8" },
];
export async function home() {
return [];
}
export async function liveCategories() {
return [
{ id: "destacados", title: "Destacados" },
// The provider's full list: Kino downloads and groups it; each entry plays through resolve().
{
playlist: {
url: "https://listas.example.com/todos.m3u",
format: "m3u",
epg: { url: "https://listas.example.com/guia.xml.gz", format: "xmltv" },
resolve: true,
},
},
];
}
export async function liveChannels({ categoryId }) {
if (categoryId !== "destacados") return { items: [] };
return {
items: DESTACADOS.map((c) => ({ id: c.id, title: c.title, number: c.number, categoryId, stream: { url: c.url } })),
};
}
// Only the list's entries get here (resolve: true), with the entry's URL as the ref.
export async function resolve(url) {
const r = await kino.fetch("https://api.example.com/token");
if (!r.ok) throw kino.error("unavailable", "no hay token");
const { token } = r.json();
return { url: url + (url.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(token) };
}
The list's streams must be on your hosts here (live.example.com), since this manifest does not
declare "liveStreamHosts": "any"; live categories counts the entries that are not as discarded.
node sdk/run.mjs . live categories
node sdk/run.mjs . live channels destacados
The published demo Tu servidor (1.5.0) uses all three shapes at once
(channels with a ref, channels with an inline stream, and an M3U playlist with an XMLTV guide),
plus a second playlist with resolve: true whose links need its token, liveSearch and paged
liveChannels (The channel functions), all on the person's own server: see
liveCategories, channel, liveChannels, liveSearch and resolveListEntry in its
plugin.js.