跳转到内容

把你的视频博客搬上电视

你有一个博客,每篇文章都带一段视频,你想在大屏幕上看。 大多数博客都会发布 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/site
python3 -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,这是应用仍会运行的最低契约。

在清单下面加一个函数,用来获取订阅源,并把每个条目变成一篇文章:

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 那篇不会出现在你的片库里。

最后,导出杨桃会调用的函数:

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)。 返回 [] 表示搜过了,但什么也没找到。

在另一个终端里,进入 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 21

lint 只读清单和代码,不运行它们。 如果你声明的是 20,它会拒绝:任何应用都不会运行低于 21 的插件。

接下来用真实的订阅源试一试。 需要设置的插件,会从插件旁边的 doctor.json 读取设置,所以新建一个:

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

插件访问你自己电脑上的地址会被拒绝,只有人在 url 字段里输入的地址除外,这里的 doctor.json 就相当于那个人。

终端窗口
yonto-plugin doctor

doctor 像应用一样把插件走一遍,每一步都用上一步的结果当输入,每一步打印一行。 如果 probeQuery 在文章里搜不到,search 会以 EMPTY_RESULT 失败。 这是有意为之:结果为空是插件最常见的故障,网站变了,插件还在“正常”工作,却什么也不返回,所以 doctor 把它算作失败。

你也可以只调用一个函数,看它返回什么:

终端窗口
yonto-plugin run . getMediaDetail '"post-3"'

参数是 JSON,所以字符串要加两层引号。

再运行 doctor,把订阅源的响应录下来:

终端窗口
yonto-plugin doctor --record
yonto-plugin doctor --replay

--record 把每个响应写进 fixtures/,--replay 不联网,用它们把整套检查跑完。 把它们提交到仓库,明年再改插件,一秒钟就能验证,不需要那个博客在线。

在插件所在的目录里启动服务:

终端窗口
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。

  • 用 getCategories 返回文章的分类,再在 getMediaList 里按所选分类过滤。
  • 要读取分页的博客订阅源,让 getMediaList 返回 nextCursor 即可。
  • 改读 WordPress 博客的 JSON API,代替 RSS 订阅源。 只需要 JSON.parse(response.body)。

做完的插件连同录制好的订阅源,就是一个现成的模板:yonto-plugin init my-blog --template video-blog 会生成一份副本供你起步。 教程没讲到的,请看插件参考。