Skip to content

Take your video blog to TV

You have a blog where each post has a video, and you would like to watch it on the big screen. Most blogs publish an RSS feed, and a post’s video is an <enclosure> in it. In this tutorial you write a plugin that reads such a feed and install it in Yonto.

You will use every part of a plugin: the manifest, the four required functions, a setting the viewer fills in, a cache, an error the viewer can read and a test that runs without a network. The finished plugin is about 80 lines. You need Node.js 20 or later and, to install it in the end, a Yonto on a television, an emulator or a phone.

Terminal window
yonto-plugin init video-blog --name "Video blog"

This creates a video-blog directory holding video-blog-plugin.js, a placeholder that already passes lint and doctor. You will replace its contents in step 1.

Here is the feed the plugin reads. Make a site directory inside video-blog and save it there as feed.xml. The third post has no video, which matters later.

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

Serve it on your computer, in a second terminal that you leave open:

Terminal window
cd video-blog/site
python3 -m http.server 8765

Replace everything in video-blog-plugin.js with this. It starts with the manifest, a comment that opens the file:

/* 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" makes this a kind of source a viewer configures, so it appears in the Add source form’s type list. A plugin that is itself one fixed source says "source" instead.
  • configSchema is that form. It has one field, a url the viewer must fill in.
  • allowedHosts lists the sites the plugin may reach. It is empty because the only site this plugin reads is the one the viewer types into feedUrl, and a url field’s host is allowed by that.
  • probeQuery is a word doctor searches for when it tests the plugin.
  • contractVersion is the version of the plugin interface you rely on, and you do not choose it. You write the plugin, run lint, and put back the number it names. Today that is 21 for every plugin, the oldest contract an app still runs.

Below the manifest, add a function that fetches the feed and turns each item into a post:

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,
};
}

Each piece is a part of the host you will use again:

  • yonto.config.feedUrl is what the viewer typed. Every setting arrives as a string, and a field left empty is not there at all.
  • yonto.fetch answers with the response whatever its status, and rejects only when there is no answer, with a code saying why. The plugin turns that into yonto.error.unreachable(…), and a bad status into unavailable(…). What you pass is what the viewer reads, in the app’s own error screen, so say which part failed in a short sentence.
  • yonto.log writes a line to the log. Never put a URL, a key or what the viewer searched for in it, because a television keeps that log.
  • yonto.xml.load returns cheerio’s $ over an XML document. yonto.html.load does the same for a web page. A namespaced tag is selected with its colon escaped, media\\:thumbnail.
  • yonto.store is a cache that belongs to this source. The feed is kept for five minutes, so opening a title does not fetch it again, and set may be refused, so treat it as a cache and never rely on it.
  • The .filter keeps only posts with a video, which is why the Dutch oven post is not in your library.

Finally export the functions Yonto calls:

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);
},
};
  • getCategories is the tabs along Home. A blog has one list, so there is one category.
  • getMediaList is called with a category’s id and { page }, counting from 1. A feed has no pages, so page 2 is empty, which tells Yonto the list ended.
  • getMediaDetail is called with an id this plugin handed out. Its playbackOptions are what the viewer can play, and a stream is a URL the player opens. Asking for a post that is not there throws notFound.
  • search is required, and a source that cannot search still exports one and throws yonto.error.unavailable(reason). Answering [] would tell the viewer it searched and found nothing.

In another terminal, in the video-blog directory:

Terminal window
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 21

lint reads the manifest and the code without running them. If you had declared 20, it would refuse it: no app runs a plugin below 21.

Next, the live feed. A plugin that needs a setting reads it from doctor.json next to the plugin, so create one:

{ "feedUrl": "http://localhost:8765/feed.xml" }

An address on your own computer is refused for a plugin, except one a person typed into a url field, and doctor.json stands in for that person.

Terminal window
yonto-plugin doctor

doctor walks the plugin the way the app does, each step fed by the one before, and prints a line per step. If you picked a probeQuery the posts do not contain, search fails with EMPTY_RESULT. That is deliberate: an empty answer is the most common way a plugin breaks, a site changes and the plugin keeps working and returns nothing, so doctor counts it as a failure.

You can also call one function and see what it returns:

Terminal window
yonto-plugin run . getMediaDetail '"post-3"'

Arguments are JSON, so a string is quoted twice.

Run doctor once more and keep what the feed said:

Terminal window
yonto-plugin doctor --record
yonto-plugin doctor --replay

--record writes every response into fixtures/, and --replay runs the whole battery from them with the network off. Commit them, and a change you make next year can be checked in a second, without the blog being up.

Serve the plugin’s directory:

Terminal window
python3 -m http.server 8000

In Yonto, choose Settings, Plugins, Install from URL, and paste the address of video-blog-plugin.js, for example http://10.0.2.2:8000/video-blog-plugin.js from an Android emulator. For a real television or phone, use your computer’s address on the local network instead, since 10.0.2.2 only means your computer to an emulator. Yonto warns that an http:// link is not encrypted, which is expected for a test on your own network. Choose Read the plugin, check the name and where it can reach, then choose Install. Yonto says “You’ll set one up next” and takes you to Add source with Video blog chosen. Type your blog’s feed address, choose Test connection, then Save.

The feed address has to be one the television can reach, and localhost is the television itself. The sample feed’s videos are made up, so to watch something, point the source at a real blog whose posts carry video enclosures.

  • Give a post’s categories to getCategories, and filter by the one chosen in getMediaList.
  • Read a blog that pages its feed, by returning nextCursor from getMediaList.
  • Read a WordPress blog’s JSON API in place of an RSS feed. JSON.parse(response.body) is all that takes.

The finished plugin, with its recorded feed, is a template: yonto-plugin init my-blog --template video-blog writes a copy to start from. Read the plugin reference for everything this tutorial left out.