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.
Start a plugin
Section titled “Start a plugin”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.
The feed
Section titled “The feed”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:
cd video-blog/sitepython3 -m http.server 87651. The manifest
Section titled “1. The manifest”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.configSchemais that form. It has one field, aurlthe viewer must fill in.allowedHostslists the sites the plugin may reach. It is empty because the only site this plugin reads is the one the viewer types intofeedUrl, and aurlfield’s host is allowed by that.probeQueryis a worddoctorsearches for when it tests the plugin.contractVersionis the version of the plugin interface you rely on, and you do not choose it. You write the plugin, runlint, and put back the number it names. Today that is21for every plugin, the oldest contract an app still runs.
2. Read the feed
Section titled “2. Read the feed”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.feedUrlis what the viewer typed. Every setting arrives as a string, and a field left empty is not there at all.yonto.fetchanswers with the response whatever its status, and rejects only when there is no answer, with acodesaying why. The plugin turns that intoyonto.error.unreachable(…), and a bad status intounavailable(…). 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.logwrites 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.loadreturns cheerio’s$over an XML document.yonto.html.loaddoes the same for a web page. A namespaced tag is selected with its colon escaped,media\\:thumbnail.yonto.storeis a cache that belongs to this source. The feed is kept for five minutes, so opening a title does not fetch it again, andsetmay be refused, so treat it as a cache and never rely on it.- The
.filterkeeps only posts with a video, which is why the Dutch oven post is not in your library.
3. The four functions
Section titled “3. The four functions”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); },};getCategoriesis the tabs along Home. A blog has one list, so there is one category.getMediaListis 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.getMediaDetailis called with anidthis plugin handed out. ItsplaybackOptionsare what the viewer can play, and astreamis a URL the player opens. Asking for a post that is not there throwsnotFound.searchis required, and a source that cannot search still exports one and throwsyonto.error.unavailable(reason). Answering[]would tell the viewer it searched and found nothing.
4. Check it
Section titled “4. Check it”In another terminal, in the video-blog directory:
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 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.
yonto-plugin doctordoctor 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:
yonto-plugin run . getMediaDetail '"post-3"'Arguments are JSON, so a string is quoted twice.
5. Keep a recording
Section titled “5. Keep a recording”Run doctor once more and keep what the feed said:
yonto-plugin doctor --recordyonto-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.
6. Install it in Yonto
Section titled “6. Install it in Yonto”Serve the plugin’s directory:
python3 -m http.server 8000In 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.
Where to take it
Section titled “Where to take it”- Give a post’s categories to
getCategories, and filter by the one chosen ingetMediaList. - Read a blog that pages its feed, by returning
nextCursorfromgetMediaList. - 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.