把你的视频博客搬上电视
你有一个博客,每篇文章都带一段视频,你想在大屏幕上看。
大多数博客都会发布 RSS 订阅源,文章里的视频就是订阅源里的 <enclosure>。
这个教程会带你写一个读取这种订阅源的插件,并安装到杨桃里。
插件的各个部分都会用到:清单、四个必需的函数、由观众填写的设置、缓存、观众能读懂的错误,以及一个不用联网就能跑的测试。 完成后的插件大约 80 行。 你需要 Node.js 20 或更高版本,最后安装时还要有一台装了杨桃的电视、模拟器或手机。
yonto-plugin init video-blog --name "Video blog"这会创建 video-blog 目录,里面的 video-blog-plugin.js 是个占位插件,一开始就能通过 lint 和 doctor。
第 1 步会把它的内容换掉。
下面是插件要读取的订阅源。
在 video-blog 目录里新建 site 目录,保存为 site/feed.xml。
第三篇文章没有视频,这一点之后会用到。
<?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>另开一个终端,在电脑上启动一个本地服务来提供它,不要关掉这个终端:
cd video-blog/sitepython3 -m http.server 8765把 video-blog-plugin.js 的内容全部替换成下面这些。
先写清单,也就是文件开头的一段注释:
/* 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"表示这是一类由观众来配置的片源,会出现在添加片源表单的类型列表里。 如果插件本身就是一个固定的片源,就写"source"。configSchema就是那张表单。 这里只有一个字段,是观众必填的url。allowedHosts列出插件可以访问的网站。 这里留空,因为插件只会读观众填进feedUrl的那个网站,而url字段里的主机本来就允许访问。probeQuery是doctor测试时用来搜索的一个词。contractVersion是插件所依赖的接口版本,不是你自己选的。 先写插件,运行lint,再把它给出的数字填回来。 现在每个插件都是21,这是应用仍会运行的最低契约。
2. 读取订阅源
Section titled “2. 读取订阅源”在清单下面加一个函数,用来获取订阅源,并把每个条目变成一篇文章:
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, };}这里用到的都是宿主提供的接口,以后还会常用到:
yonto.config.feedUrl是观众输入的内容。 每个设置都是字符串,留空的字段根本不存在。yonto.fetch不管状态码是什么都会返回响应,只有完全没有响应时才会拒绝,并带一个code说明原因。 插件把前一种情况转成yonto.error.unreachable(…),把错误的状态码转成unavailable(…)。 你传入的文字会原样显示在应用的错误界面里,观众读到的就是它,所以用一句短话说明是哪一部分出了问题。yonto.log往日志里写一行。 别把网址、密钥或观众的搜索内容写进去,因为电视会保留这份日志。yonto.xml.load返回 cheerio 的$,用来解析 XML 文档。yonto.html.load解析网页,用法一样。 选择带命名空间的标签时,冒号要转义,写成media\\:thumbnail。yonto.store是这个片源自己的缓存。 订阅源会缓存五分钟,所以打开标题时不会再取一次。set也可能被拒绝,所以只把它当缓存,永远不要依赖它。.filter只留下带视频的文章,所以 Dutch oven 那篇不会出现在你的片库里。
3. 四个函数
Section titled “3. 四个函数”最后,导出杨桃会调用的函数:
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是首页上方的标签。 一个博客只有一个列表,所以只有一个分类。getMediaList收到分类的 id 和{ page },页码从 1 开始。 订阅源没有分页,所以第 2 页返回空列表,杨桃就知道列表到头了。getMediaDetail收到的是这个插件之前返回过的id。playbackOptions是观众可以播放的内容,stream是播放器要打开的网址。 文章不存在时,抛出notFound。search是必需的,不支持搜索的片源也要导出它,并抛出yonto.error.unavailable(reason)。 返回[]表示搜过了,但什么也没找到。
4. 检查它
Section titled “4. 检查它”在另一个终端里,进入 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 只读清单和代码,不运行它们。
如果你声明的是 20,它会拒绝:任何应用都不会运行低于 21 的插件。
接下来用真实的订阅源试一试。
需要设置的插件,会从插件旁边的 doctor.json 读取设置,所以新建一个:
{ "feedUrl": "http://localhost:8765/feed.xml" }插件访问你自己电脑上的地址会被拒绝,只有人在 url 字段里输入的地址除外,这里的 doctor.json 就相当于那个人。
yonto-plugin doctordoctor 像应用一样把插件走一遍,每一步都用上一步的结果当输入,每一步打印一行。
如果 probeQuery 在文章里搜不到,search 会以 EMPTY_RESULT 失败。
这是有意为之:结果为空是插件最常见的故障,网站变了,插件还在“正常”工作,却什么也不返回,所以 doctor 把它算作失败。
你也可以只调用一个函数,看它返回什么:
yonto-plugin run . getMediaDetail '"post-3"'参数是 JSON,所以字符串要加两层引号。
5. 保存一份录制
Section titled “5. 保存一份录制”再运行 doctor,把订阅源的响应录下来:
yonto-plugin doctor --recordyonto-plugin doctor --replay--record 把每个响应写进 fixtures/,--replay 不联网,用它们把整套检查跑完。
把它们提交到仓库,明年再改插件,一秒钟就能验证,不需要那个博客在线。
6. 安装到杨桃
Section titled “6. 安装到杨桃”在插件所在的目录里启动服务:
python3 -m http.server 8000在杨桃里,依次点设置、插件、从网址安装,粘贴 video-blog-plugin.js 的地址,比如在 Android 模拟器里是 http://10.0.2.2:8000/video-blog-plugin.js。
真实的电视或手机要换成电脑在局域网里的地址,因为 10.0.2.2 只有在模拟器里才指你的电脑。
杨桃会提示 http:// 链接未加密,在你自己的网络里测试时这是正常的。
点读取插件,看清名称和它能访问的范围,再点安装。
杨桃会提示“安装后请设置一个片源”,并带你进入添加片源,其中已经选好了 Video blog。
输入你博客的订阅源地址,点测试连接,再点保存。
订阅源的地址必须是电视能访问到的,别用 localhost,它指的是电视本身。
示例订阅源里的视频是虚构的,想真正看点东西,请把片源指向一个真实的博客,它的文章要带视频 enclosure。
还可以怎么扩展
Section titled “还可以怎么扩展”- 用
getCategories返回文章的分类,再在getMediaList里按所选分类过滤。 - 要读取分页的博客订阅源,让
getMediaList返回nextCursor即可。 - 改读 WordPress 博客的 JSON API,代替 RSS 订阅源。
只需要
JSON.parse(response.body)。
做完的插件连同录制好的订阅源,就是一个现成的模板:yonto-plugin init my-blog --template video-blog 会生成一份副本供你起步。
教程没讲到的,请看插件参考。