Lleva tu blog de video a la tele
Tienes un blog en el que cada entrada tiene un video y quieres verlo en la pantalla grande.
La mayoría de los blogs publican un feed RSS, y el video de una entrada es un <enclosure> dentro de él.
En este tutorial escribes un plugin que lee un feed así y lo instalas en Yonto.
Vas a usar todas las partes de un plugin: el manifiesto, las cuatro funciones obligatorias, un ajuste que rellena el usuario, una caché, un error que el usuario puede leer y una prueba que se ejecuta sin red. El plugin terminado tiene unas 80 líneas. Necesitas Node.js 20 o posterior y, para instalarlo al final, un Yonto en una televisión, un emulador o un teléfono.
Empieza un plugin
Sección titulada «Empieza un plugin»yonto-plugin init video-blog --name "Video blog"Esto crea un directorio video-blog con video-blog-plugin.js, un marcador de posición que ya pasa lint y doctor.
Reemplazarás su contenido en el paso 1.
El feed
Sección titulada «El feed»Este es el feed que lee el plugin.
Crea un directorio site dentro de video-blog y guárdalo ahí como feed.xml.
La tercera entrada no tiene video, lo cual importa más adelante.
<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/"> <channel> <title>Sourdough Diaries</title> <link>https://sourdough.example.com/</link> <description>Bread, on camera.</description> <item> <title>My first sourdough loaf</title> <link>https://sourdough.example.com/first-loaf/</link> <guid isPermaLink="false">post-3</guid> <pubDate>Sat, 07 Jun 2025 08:00:00 +0000</pubDate> <description><![CDATA[Starter, flour, water & a lot of patience.]]></description> <enclosure url="https://sourdough.example.com/video/first-loaf.mp4" length="48211032" type="video/mp4"/> <media:thumbnail url="https://sourdough.example.com/img/first-loaf.jpg"/> </item> <item> <title>Feeding the starter</title> <link>https://sourdough.example.com/feeding/</link> <guid isPermaLink="false">post-2</guid> <pubDate>Sat, 24 May 2025 08:00:00 +0000</pubDate> <description>How often, how much, and what the bubbles mean.</description> <enclosure url="https://sourdough.example.com/video/feeding.mp4" length="30118222" type="video/mp4"/> <media:thumbnail url="https://sourdough.example.com/img/feeding.jpg"/> </item> <item> <title>Why I stopped using a Dutch oven (text only)</title> <link>https://sourdough.example.com/dutch-oven/</link> <guid isPermaLink="false">post-1</guid> <pubDate>Sat, 10 May 2025 08:00:00 +0000</pubDate> <description>No video this week, so this post is not a title.</description> </item> </channel></rss>Sírvelo desde tu equipo, en una segunda terminal que dejarás abierta:
cd video-blog/sitepython3 -m http.server 87651. El manifiesto
Sección titulada «1. El manifiesto»Reemplaza todo el contenido de video-blog-plugin.js con esto.
Empieza con el manifiesto, un comentario que abre el archivo:
/* yonto-plugin{ "kind": "content-source", "id": "video-blog", "name": "Video blog", "version": "1.0.0", "contractVersion": 21, "description": "A video blog, read from its RSS feed: every post with a video attached becomes a title.", "probeQuery": "starter", "provides": "source-type", "allowedHosts": [], "configSchema": [ { "id": "feedUrl", "label": "Feed URL", "type": "url", "required": true } ]}*/provides: "source-type"lo convierte en un tipo de fuente que el usuario configura, así que aparece en la lista de tipos del formulario de Añadir fuente. Un plugin que es en sí una única fuente fija dice"source".configSchemaes ese formulario. Tiene un campo, unaurlque el usuario debe rellenar.allowedHostsenumera los sitios a los que puede acceder el plugin. Está vacío porque el único sitio que lee este plugin es el que el usuario escribe enfeedUrl, y el host de un campourlya queda permitido por eso.probeQueryes una palabra quedoctorbusca cuando prueba el plugin.contractVersiones la versión de la interfaz de plugins en la que te apoyas, y no la eliges tú. Escribes el plugin, ejecutaslinty pones el número que te indica. Hoy es21para todo plugin, el contrato más antiguo que una app todavía ejecuta.
2. Lee el feed
Sección titulada «2. Lee el feed»Debajo del manifiesto, añade una función que descargue el feed y convierta cada elemento en una entrada:
const FEED_TTL_SECONDS = 300;
async function readPosts() { const cacheKey = `feed:${yonto.config.feedUrl}`; const cached = await yonto.store.get(cacheKey); if (cached) return cached;
let response; try { response = await yonto.fetch(yonto.config.feedUrl); } catch (error) { yonto.log(`feed request failed: ${error.code}`); throw yonto.error.unreachable('the feed did not answer'); } if (response.status !== 200) throw yonto.error.unavailable(`the feed answered ${response.status}`);
const $ = yonto.xml.load(response.body); if ($('rss > channel').length === 0) throw yonto.error.unavailable('the address is not an RSS feed');
const posts = $('item') .toArray() .map((item) => postFrom($(item))) .filter((post) => post.videoUrl); try { await yonto.store.set(cacheKey, posts, FEED_TTL_SECONDS); } catch (error) { yonto.log(`feed not cached: ${error.code}`); } return posts;}
function postFrom(item) { const enclosure = item.find('enclosure'); const isVideo = (enclosure.attr('type') ?? '').startsWith('video/'); return { id: item.find('guid').text().trim() || item.find('link').text().trim(), title: item.find('title').text().trim(), synopsis: item.find('description').text().trim(), year: (item.find('pubDate').text().match(/\d{4}/) ?? [])[0], posterUrl: item.find('media\\:thumbnail').attr('url'), videoUrl: isVideo ? enclosure.attr('url') : undefined, mimeType: isVideo ? enclosure.attr('type') : undefined, };}Cada pieza es una parte del host que volverás a usar:
yonto.config.feedUrles lo que escribió el usuario. Todos los ajustes llegan como cadenas, y un campo que se deja vacío no existe.yonto.fetchresponde con la respuesta sea cual sea su estado, y solo se rechaza cuando no hay respuesta, con uncodeque dice por qué. El plugin lo convierte enyonto.error.unreachable(…), y un mal estado enunavailable(…). Lo que pases es lo que lee el usuario, en la pantalla de error de la propia app, así que di qué parte falló en una frase corta.yonto.logescribe una línea en el registro. No pongas nunca en él una URL, una clave ni lo que buscó el usuario, porque una televisión conserva ese registro.yonto.xml.loaddevuelve el$de cheerio sobre un documento XML.yonto.html.loadhace lo mismo con una página web. Una etiqueta con espacio de nombres se selecciona con los dos puntos escapados,media\\:thumbnail.yonto.storees una caché que pertenece a esta fuente. El feed se guarda cinco minutos, así que abrir un título no lo descarga de nuevo, ysetpuede rechazarse, así que trátala como una caché y nunca dependas de ella.- El
.filterconserva solo las entradas con video, y por eso la entrada del Dutch oven no está en tu biblioteca.
3. Las cuatro funciones
Sección titulada «3. Las cuatro funciones»Por último, exporta las funciones a las que llama Yonto:
const summaryOf = ({ id, title, posterUrl, year }) => ({ id, title, posterUrl, year, type: 'movie' });
export default { async getCategories() { return [{ id: 'latest', name: 'Latest' }]; },
async getMediaList(categoryId, { page }) { if (page > 1) return []; return (await readPosts()).map(summaryOf); },
async getMediaDetail(id) { const post = (await readPosts()).find((candidate) => candidate.id === id); if (!post) throw yonto.error.notFound(`no post ${id}`); return { ...summaryOf(post), synopsis: post.synopsis, playbackOptions: [{ label: 'Watch', stream: { url: post.videoUrl, mimeType: post.mimeType } }], }; },
async search(query) { const needle = query.toLowerCase(); const posts = await readPosts(); return posts .filter((post) => `${post.title} ${post.synopsis}`.toLowerCase().includes(needle)) .map(summaryOf); },};getCategoriesson las pestañas de Inicio. Un blog tiene una sola lista, así que hay una categoría.getMediaListse llama con el id de una categoría y{ page }, contando desde 1. Un feed no tiene páginas, así que la página 2 está vacía, lo que le indica a Yonto que la lista terminó.getMediaDetailse llama con unidque entregó este plugin. SusplaybackOptionsson lo que el usuario puede reproducir, y unstreames una URL que abre el reproductor. Pedir una entrada que no existe lanzanotFound.searches obligatoria, y una fuente que no puede buscar sigue exportando una y lanzayonto.error.unavailable(reason). Responder[]le diría al usuario que se buscó y no se encontró nada.
4. Compruébalo
Sección titulada «4. Compruébalo»En otra terminal, en el directorio video-blog:
yonto-plugin lint✓ manifest id=video-blog version=1.0.0 contract=21 provides=source-type hosts=[]✓ bundle /…/video-blog/video-blog-plugin.js builds✓ contract 21lint lee el manifiesto y el código sin ejecutarlos.
Si hubieras declarado 20, lo rechazaría: ninguna app ejecuta un plugin por debajo de 21.
Ahora, el feed en vivo.
Un plugin que necesita un ajuste lo lee de doctor.json, junto al plugin, así que crea uno:
{ "feedUrl": "http://localhost:8765/feed.xml" }Una dirección de tu propio equipo se rechaza para un plugin, salvo la que una persona escribió en un campo url, y doctor.json hace las veces de esa persona.
yonto-plugin doctordoctor recorre el plugin como lo hace la app, con cada paso alimentado por el anterior, e imprime una línea por paso.
Si eliges un probeQuery que las entradas no contienen, search falla con EMPTY_RESULT.
Es a propósito: una respuesta vacía es la forma más común de que se rompa un plugin, porque un sitio cambia y el plugin sigue funcionando y no devuelve nada, así que doctor la cuenta como un fallo.
También puedes llamar a una función y ver lo que devuelve:
yonto-plugin run . getMediaDetail '"post-3"'Los argumentos son JSON, así que una cadena lleva dos niveles de comillas.
5. Guarda una grabación
Sección titulada «5. Guarda una grabación»Ejecuta doctor una vez más y guarda lo que dijo el feed:
yonto-plugin doctor --recordyonto-plugin doctor --replay--record escribe cada respuesta en fixtures/, y --replay ejecuta toda la batería a partir de ellas con la red desactivada.
Súbelas al repositorio y un cambio que hagas el año que viene se podrá comprobar en un segundo, sin que el blog esté en línea.
6. Instálalo en Yonto
Sección titulada «6. Instálalo en Yonto»Sirve el directorio del plugin:
python3 -m http.server 8000En Yonto, elige Ajustes, Plugins, Instalar desde una URL, y pega la dirección de video-blog-plugin.js, por ejemplo http://10.0.2.2:8000/video-blog-plugin.js desde un emulador de Android.
Para una televisión o un teléfono reales, usa en su lugar la dirección de tu equipo en la red local, ya que 10.0.2.2 solo significa tu equipo para un emulador.
Yonto avisa de que un enlace http:// no está cifrado, algo esperable en una prueba en tu propia red.
Elige Leer el plugin, comprueba el nombre y a qué puede acceder, y elige Instalar.
Yonto dice “A continuación configurarás una” y te lleva a Añadir fuente con Video blog elegido.
Escribe la dirección del feed de tu blog, elige Probar conexión y luego Guardar.
La dirección del feed tiene que ser una a la que pueda acceder la televisión, y localhost es la propia televisión.
Los videos del feed de ejemplo son inventados, así que, para ver algo, apunta la fuente a un blog real cuyas entradas lleven enclosures de video.
Hasta dónde puedes llegar
Sección titulada «Hasta dónde puedes llegar»- Pasa las categorías de una entrada a
getCategoriesy filtra por la elegida engetMediaList. - Lee un blog que pagina su feed, devolviendo
nextCursordesdegetMediaList. - Lee la API JSON de un blog de WordPress en lugar de un feed RSS.
Solo hace falta
JSON.parse(response.body).
El plugin terminado, con su feed grabado, es una plantilla: yonto-plugin init my-blog --template video-blog escribe una copia para que partas de ella.
Lee la referencia de plugins para todo lo que este tutorial dejó fuera.