从 jpyy.com 到 Nuvio Provider:一个 IP 绑定流媒体源的完整开发记录
一、目标站点技术分析:jpyy.com
在写任何代码之前,理解目标站点的技术架构是第一步。jpyy.com(金牌影视)的技术栈有几个关键特征,它们共同决定了后续所有架构选择的方向。
1.1 签名保护机制
站点所有 API 请求都必须携带 sign 头,算法为:
1 | sign = SHA1(MD5(sorted_params + "&key=" + SIGN_KEY + "&t=" + t)) |
其中 sorted_params 是请求参数按 key 升序排列后拼接的字符串,SIGN_KEY 是固定值 cb808529bae6b6be45ecfab29a4889bc,t 是毫秒时间戳。
技术含义:任何客户端在发起请求前,都必须在本地完成 MD5 → SHA1 的链式计算。这意味着客户端必须有能力执行哈希运算。在 Node.js 环境中这不成问题,但在受限的客户端沙箱中,选择什么哈希实现就成为一个需要认真对待的问题。
1.2 RSC 页面数据格式
站点基于 Next.js 构建,页面数据通过 React Server Components(RSC)流式协议下发。响应体不是标准 JSON,而是以 id:data 格式逐行返回的流式文本:
1 | 1:{"result":{"totalPage":3,...}} |
技术含义:不能直接 JSON.parse 整个响应体,必须先按 id: 前缀逐行切分,再对每行的 data 部分尝试解析。更复杂的是,有些数据会被拆分到多行,需要处理续行逻辑。
1.3 域名群与动态发现
站点并不只有一个固定域名。它运营着一个域名群——多个备用域名轮换使用,以应对网络封锁或运营策略调整。已知的域名包括 0996zp.com、sdzhgt.com、hnytxj.com、ghw9zwp5.com、x8kb9k8.com 等。
如果客户端硬编码某一个域名,一旦该域名失效,整个服务就会中断。因此,站点提供了一个官方域名获取 API:
1 | GET /api/mw-movie/anonymous/website/get/domain?websiteSeoId=86 |
这个接口本身也需要签名,算法与站内其他 API 一致。它返回当前可用的域名列表,客户端可以从中选取一个有效的域名作为 BASE_URL。
技术含义:域名动态发现是保持服务长期可用的关键。不能假设某一个域名永远有效,必须有机制在域名失效时自动切换到备用域名。
1.4 IP 绑定的流地址
这是最关键的一条。站点下发的 stream URL 与请求方 IP 绑定——谁发起请求,URL 就绑定谁的 IP。
技术含义:这是一个无法通过服务端逻辑优化绕过的限制。如果流地址在服务端获取,URL 绑定的就是服务器 IP,客户端播放时会因 IP 不匹配被拒绝。唯一可行的方案是在客户端本地获取流地址。这个判断直接决定了整个项目的架构方向。
1.5 剧集数据模型
站点的剧集结构是:
1 | vodId → detail → episodeList → [ { nid: 1298345, name: "1" }, { nid: 1298346, name: "2" }, ... ] |
name 字段的值是 "1"、"2"、"3"……直到该条目的最后一集。站点没有 Season 概念,episodeList 是 absolute episode(绝对集数)。
更复杂的是,多季剧集在站点上表现为每季一个独立的 vodId,每个 vodId 下的 episodeList 从 1 开始重新计数。
技术含义:站点的数据结构是“每季独立条目 + 条目内绝对集数”。这与 Stremio/Nuvio 使用的“单一 ID + Season/Episode”模型存在根本差异,ID 转换必须在架构层面显式处理。
1.6 站点技术特征总结
| 特征 | 技术约束 | 对架构的影响 |
|---|---|---|
| 签名保护 | 客户端需本地计算 SHA1(MD5(…)) | 需在受限环境中选择合适的哈希实现 |
| RSC 流格式 | 不能直接 JSON.parse | 需专门编写解析器 |
| 域名群 | 多个备用域名,官方 API 可获取 | 需实现域名动态发现与缓存 |
| IP 绑定 | 流地址与请求方 IP 绑定 | 必须在客户端本地获取流地址 |
| 绝对集数 | episodeList 是 absolute episode | 需处理 Season/Episode 与绝对集数的转换 |
| 每季独立 vodId | 多季剧集有多个 vodId | SeriesResolver 需建立 Season → vodId 映射 |
二、第一版:单一 JS 脚本的可行性验证
最初的实现是一份独立的 JavaScript 文件,在本地 Node.js 环境中运行。目标很纯粹:验证整条链路的可行性。
2.1 核心模块
签名生成。最初手写了 MD5 和 SHA1 的纯 JS 实现,避免依赖 Node.js 内置的 crypto 模块。这套实现后来被 crypto-js 替代,但手写的过程让我彻底理解了签名机制的每一步。
RSC 解析。编写了 parseRscRecords 函数:
1 | function parseRscRecords(body) { |
这个解析器后来成为整个项目的基石,在 Stremio Addon 和 Nuvio Provider 中几乎没有改动。
搜索匹配。根据标题搜索站点,从返回的列表中筛选出最匹配的条目。最初的实现非常简单——按标题完全匹配或包含关系判断。
流地址获取。调用 /api/mw-movie/anonymous/v2/video/episode/url,传入 vodId 和 nid,拿到多清晰度的播放地址。
2.2 阶段性的收获
这个阶段最大的价值是验证了数据流的完整性。从签名计算 → 搜索 → 详情 → 剧集 → 流地址,每一步都能在终端里看到输出。此时还没有遇到 IP 绑定、Season 转换、多语言匹配等问题,但已经确认了技术上的可行性。后来无论架构如何演进,核心逻辑都源自这份脚本。
三、第二版:Stremio Addon 的架构探索
3.1 为什么选择 Stremio Addon
目标是将资源接入 Stremio 客户端。Stremio Addon 协议成熟、文档完善、社区参考丰富。Stremio Addon 本质上是一个 HTTP 服务器,部署在云端,客户端通过 /stream/{type}/{id}.json 这样的端点向服务器发起请求。
3.2 云端基础设施选型:Cloudflare、Vercel、Upstash
在确定 Stremio Addon 路线后,接下来的问题是选择什么样的云端基础设施。这个决定直接影响 Addon 的响应速度、稳定性和运维成本。
为什么选择边缘计算(Vercel Edge Functions)
传统的 Node.js 容器冷启动需要 1–5 秒,而 Vercel Edge Functions 基于 V8 隔离,冷启动时间仅 50–300ms。对于 Stremio Addon 这种“用户点击播放 → 需要快速返回流列表”的场景,冷启动延迟直接决定用户体验。边缘计算的另一个优势是全球节点就近响应——用户在亚洲发起请求,由亚洲的边缘节点处理,而不必绕道美国机房。
但 Edge Functions 也有严格的资源限制:128MB 内存、50ms CPU 时间,且不能使用 Node.js API。这意味着复杂的加密计算或大型依赖包会被排除在外。对于 jpyy 的签名计算(一次 MD5 + 一次 SHA1),50ms 的 CPU 时间足够,但需要将哈希实现精简到最小。
Cloudflare Workers 作为备选
Cloudflare Workers 是另一个值得考虑的边缘计算平台,同样基于 V8 隔离,冷启动极快,免费额度更慷慨(每天 10 万次请求)。它和 Vercel Edge Functions 的取舍在于:Cloudflare 的全球节点覆盖更广,但 Vercel 与 Next.js 生态的集成更顺畅。两者在架构设计上可以互换,最终选择取决于具体需求和团队熟悉度。
Upstash Redis:跨实例缓存
边缘计算的一个固有问题是:每个边缘节点都是无状态的,请求可能被路由到任意节点。如果 Addon 在东京节点缓存了 TMDB 元数据,大阪节点的用户请求过来时,缓存就失效了。
Upstash Redis 提供了跨实例的共享缓存。它是一个基于 REST API 的 Serverless Redis 服务,完美适配边缘计算环境——不需要建立 TCP 连接,直接通过 HTTP 读写,延迟在 10ms 以内。在 Stremio Addon 中,Upstash 被用来缓存三类数据:
| 数据类型 | 缓存 TTL | 理由 |
|---|---|---|
| IMDb → vodId 映射 | 7 天 | 转换结果稳定,极少变化 |
| TMDB 元数据 | 7 天 | 标题、别名、年份短期不变 |
| 站点域名 | 7 天 | 域名变化频率低 |
| 详情页数据 | 30 分钟 | 内容变化频率较低 |
| 搜索结果 | 10 分钟 | 短时间内重复搜索常见 |
三层缓存架构
最终确定的缓存策略是三层结构:
1 | 请求 → L1 内存缓存(< 1ms,实例级,约 60 秒) |
内存缓存是每个边缘节点自己的“私有缓存”,生命周期最短(约 60 秒),但速度最快。Upstash Redis 是全局共享的,命中率更高,但需要一次网络往返。Vercel CDN 则是静态内容层,通过 Cache-Control 响应头控制。
Redis 冷却降级机制
在本地开发环境中,Upstash Redis 的延迟可能较高(因为本地机器到 Redis 节点有物理距离)。为了避免 Redis 拖慢整个请求,加入了一个冷却降级机制:当 Redis 连续 3 次响应超过 500ms 或失败时,进入 60 秒冷却期,期间跳过 Redis 读写,仅使用内存缓存。冷却结束后自动恢复。这个机制保证了即使 Redis 出现网络抖动,Addon 也不会整体变慢。
3.3 项目结构与技术栈
整个 Stremio Addon 的项目结构如下:
1 | jpyyVecel/ |
技术栈的核心特点是零第三方依赖——整个项目仅使用原生 fetch,没有引入任何 npm 包。这不仅简化了部署,也避免了边缘计算环境中的依赖兼容性问题。
3.4 核心难题:ID 转换逻辑无处安放
在开发过程中,一个严重的问题逐渐显现:ID 转换逻辑散落在各处。
Stremio 场景下,一个内容可能来自不同的 ID 体系:tt0944947(IMDb ID)、jp146932(站内 ID)、146932(站点的 vodId)。不同的 ID 需要不同的处理流程。最初我把转换逻辑写在了 Search 里,后来又写到了 Meta 里,再后来又写到了 Stream 里。每处都需要 startsWith 判断,代码迅速失控。
3.5 重构:Resolver + Adapter 架构
代码失控让我意识到,必须在架构层面解决 ID 混乱的问题。经过几轮迭代,确定了一套分层设计:
1 | Search |
MediaResolver 是唯一的 ID 转换入口。任何地方都不自己搜索或转换,全部走 MediaResolver.resolve(id),返回统一的 MediaInfo 结构。ID 体系的变化被隔离在一个地方。
SeriesResolver 负责根据 Season 找到对应的 vodId。例如 tt0455275 返回 { seasonMap: { 1: { vodId: "82132" }, 2: { vodId: "91342" }, ... } }。这个设计完全符合站点“每季独立 vodId”的数据结构。
EpisodeResolver 完全照搬原始脚本的逻辑。输入 vodId 和 episode,在 episodeList 中找到 name == episode 的条目,返回 nid。它完全不知道 IMDb、Season 或 TMDB 的存在。
StreamResolver 输入 vodId 和 nid,调用流地址 API,返回播放链接。
这个架构解决了一个核心问题:ID 的统一性。所有 ID 转换都发生在 MediaResolver 内部,其他层拿到的都是已经归一化的对象。后来引入的 MediaContext 对象,在整个 Resolver 链中传递同一个上下文,每层只补充自己负责的字段。
3.6 最大的架构陷阱:Season/Episode 与 Absolute Episode 的对撞
这是整个开发过程中最深刻的教训。
问题:Stremio 的剧集模型是 Season/Episode(如 tt0944947:2:5 表示第二季第五集)。而站点的 episodeList 是 absolute episode。
最初的 EpisodeResolver 实现是:
1 | resolve(vodId, episodeNum) { |
对于国产剧(单季、集数连续),这个逻辑恰好能工作。但对于多季剧集,tt0944947:2:5 中的 episode=5 会被错误地匹配到 episode.name == "5",而实际上需要的是整部剧的绝对第 N 集。
根因:站点的 episodeList 是绝对集数,而 TMDB 是季集分离。直接混用两套 ID 体系,单季剧碰巧能工作,多季剧必然出错。
正确的解决方案:引入 SeriesResolver 负责 Season → vodId 的映射,让 EpisodeResolver 只负责站点层的 vodId → nid:
1 | S03E02 |
这个设计完全符合站点“每季独立 vodId”的数据结构——不做任何硬性的 Season → Absolute 映射,而是让 SeriesResolver 自然地完成转换。EpisodeResolver 的职责被严格限定:永远不知道 IMDb 或 Season 的存在。
3.7 JP ID 模式:站内 ID 的降级方案
除了 IMDb ID,Addon 也支持站内 ID(jp146932 格式)。对于 JP ID,流程简单得多:
1 | jp132032(已经知道是第四季的 vodId) |
因为 JP ID 本身就是 vodId,SeriesResolver 完全不需要介入。这为其他使用站内 ID 的 Addon 提供了兼容性。
3.8 Stremio Addon 的 idPrefixes 机制
Stremio Addon 可以通过 manifest 中的 idPrefixes 字段声明支持哪些 ID 格式:
1 | { |
这样,Stremio 只会把以 jp 或 tt 开头的 ID 请求发送给这个 Addon。这是 Stremio 协议为兼容非标准 ID 体系提供的原生机制,也是 Stremio Addon 相比 Nuvio Provider 的一个明显优势。
3.9 域名动态追踪的完整设计
站点域名可能随时更改,为了保持服务可用,设计了三层追踪策略:
1 | 请求失败 |
官方 API 的调用也需要签名,算法与站点 API 一致。通过 Redis 锁(domain:probe:lock,TTL 60 秒)防止多实例同时探测,避免对源站造成压力。
域名优先级为:用户自定义配置 > 官方 API 动态发现 > 硬编码默认域名。在大多数情况下,官方 API 会自动获取最新域名,硬编码的默认值只作为最终兜底。
3.10 多语言 IMDb 匹配
站点收录的内容以中文译名为主,而 TMDB 返回的往往是英文原名。为了解决这个问题,Addon 设计了多语言匹配策略:
第一步:收集候选标题。从 TMDB 的 /alternative_titles 和 /translations 接口拉取所有别名,按优先级排序:纯中文 > 中文混合 > 原始标题 > 其他语言。
第二步:多标题搜索。用前 5 个候选标题逐个搜索站点,合并去重。
第三步:多别名匹配。对每个搜索结果,计算它与所有候选标题的最高相似度,取最大值作为匹配分数。再叠加年份加分和精确匹配加分。
效果示例:
1 | [Candidates] 5 个标题: 三体 | 3 Body Problem | 3体 | ... |
3.11 为什么 Stremio Addon 最终不可行
架构设计完成后,Addon 在本地测试中运行正常。云端基础设施(Vercel Edge Functions + Upstash Redis + Cloudflare 备选)也配置完毕,缓存策略和域名追踪都工作良好。但当进入实际使用阶段时,一个根本性的问题浮出水面:IP 绑定。
站点下发的 stream URL 与请求方 IP 绑定。Addon 部署在 Vercel Edge Functions 上,获取到的 URL 绑定的是 Vercel 边缘节点的 IP。当 Stremio 客户端用自己的 IP 去请求这个 URL 时,服务端校验发现 IP 不匹配,直接拒绝播放。
这是一个无法通过服务端逻辑优化绕过的问题。Upstash 缓存再快、Vercel Edge Functions 冷启动再短、Cloudflare 节点再多,都无法解决 IP 绑定这个根本约束。唯一可行的方案,是在客户端本地获取流地址。这个判断直接排除了 Stremio Addon 路线,将方向转向了 Nuvio Provider。
四、第三版:Nuvio Provider 的架构迁移
4.1 Nuvio Provider 与 Stremio Addon 的本质差异
Nuvio 同时兼容两种内容源:Stremio Addon 和 Local Scraper(Provider)。前者运行在服务端,后者运行在客户端本地。
| 维度 | Stremio Addon | Nuvio Provider |
|---|---|---|
| 代码运行位置 | 服务器端 | 客户端本地(用户设备) |
| 运行环境 | Node.js / Vercel Edge / Cloudflare Workers | React Native + Hermes |
| Node.js 模块 | 完全可用 | 不可用(crypto、fs 等) |
async/await |
原生支持 | 需转译或改用 Promise 链 |
| ID 处理 | 支持 idPrefixes 自定义 |
固定接收 TMDB ID |
| 部署方式 | 需要服务器 | 无需服务器,文件即可分发 |
| 缓存能力 | 内存 + Upstash Redis + CDN 三层缓存 | 仅内存 Map |
| 日志调试 | 云端日志直接可见 | iOS + Hermes 下 console.log 不显示 |
Nuvio 官方文档明确说明,Providers 在用户设备上本地运行,Nuvio 应用使用 Hermes JavaScript 引擎。
4.2 架构迁移的核心变化
参数变化。Stremio Addon 接收的是 videoID(格式由 idPrefixes 决定,可以是 tt 或 jp)。Nuvio Provider 的 getStreams 接收的是固定的 tmdbId(纯数字)、mediaType、season、episode。
这意味着,Stremio Addon 中 SeriesResolver 的“根据 Season 找到 vodId”的逻辑,在 Nuvio Provider 中需要从 TMDB 元数据出发:先通过 TMDB API 获取影片标题和别名,再搜索站点找到 vodId。
环境变化。Stremio Addon 中可以直接使用 async/await 和 Node.js 内置模块。Nuvio Provider 运行在 Hermes 引擎中,async/await 需要通过构建工具转译,Node.js 的 crypto 模块不可用。
缓存能力退化。这是最显著的损失。Stremio Addon 中可以使用 Upstash Redis 实现跨实例缓存,使用 Vercel CDN 实现静态内容缓存。Nuvio Provider 运行在客户端沙箱中,无法连接任何外部缓存服务,只能用内存 Map 做进程内缓存。这意味着之前设计的三层缓存架构完全无法复用,只能重新设计一套客户端可用的缓存策略。
日志调试。Stremio Addon 部署在云端,可以直接在 Vercel 日志面板或 Cloudflare Workers 日志中查看 console.log 输出。Nuvio Provider 在 iOS 真机上运行时,Hermes 的 console.log 输出不会显示在 Plugin Tester 中。
4.3 Hermes 环境下的关键技术方案
async/await 的转译。Hermes 引擎不原生支持动态加载代码中的 async/await 语法。源码中只写标准 async/await,由 build.js 自动转译为 Hermes 兼容的生成器函数。构建产物顶部会生成 __async 辅助函数。
关键原则:源码中只写标准 async/await,不要手动写 __async、function* 或 yield。我曾犯过这个错误——把构建产物的形式直接写进源码,导致 build.js 不再生成 __async 辅助函数,运行时直接报 ReferenceError: property '__async' doesn't exist。
加密模块的选择。Nuvio 环境内置了 crypto-js,可以直接通过 require('crypto-js') 调用 MD5 和 SHA1。Node.js 的内置 crypto 模块以及一些假设 Node/浏览器环境的加密库(如 node-forge)在 Hermes 环境中不可用。
日志调试的变通方案。在 getStreams 中维护一个 step 变量,在关键步骤前更新它。出错时返回一条错误条目,把出错步骤和错误信息写入 title 字段:
1 | async function getStreams(tmdbId, mediaType, season, episode) { |
这样即使 console.log 不显示,也能在 Results 里看到 [searchVod] RSC 请求失败 HTTP 403 @ /vod/search/... 这样的精确定位信息。
4.4 ID 转换:从 TMDB ID 到站内 ID
Nuvio Provider 的核心复杂度在于 ID 转换。getStreams 接收的是 TMDB ID,而站点使用内部的 vodId 和 nid。转换链路如下:
1 | TMDB ID → TMDB 元数据(标题/别名/年份) → 站内搜索 → 匹配算法 → vodId → nid → stream URL |
多语言别名匹配。TMDB 返回的往往是英文原名(如 “3 Body Problem”),而站点收录的是中文译名(”三体”)。解决方案是从 TMDB 拉取所有别名,按“纯中文 > 中文为主 > 其他”的优先级排序:
1 | function getTitlePriority(title) { |
相似度评分与多别名匹配。对每个站内搜索结果,计算它与所有候选标题的最高相似度。如果 TMDB 返回了 5 个别名,只要其中任何一个与站内标题匹配,就能得到高分:
1 | function scoreVod(item, metadata) { |
相似度计算采用组合策略:包含关系直接返回 0.85,否则用编辑距离(Levenshtein Distance)计算。再叠加年份匹配、精确匹配和季数标记的加分,最终得分超过阈值 0.5 的条目才被接受。
搜索查询数量限制。为了平衡速度与覆盖,电影最多生成 5 个查询词,电视剧最多 6 个,优先使用已排序的中文别名。
4.5 剧集处理在 Nuvio Provider 中的延续
Stremio Addon 阶段关于剧集处理的思考,在 Nuvio Provider 中依然适用。对于多季剧集,需要在 Provider 内部从站内详情页的 vodName 中提取季数信息,筛选出对应季的 vodId,再在该条目的 episodeList 中按 Episode 查找 nid:
1 | function extractSeasonNumber(title) { |
4.6 域名动态发现的客户端实现
在 Nuvio Provider 中,由于运行在客户端沙箱,无法使用 Redis 缓存,域名追踪策略简化为:
1 | 请求失败 |
官方 API 的调用仍然需要签名,算法与站点 API 一致。内存缓存使用 Map 实现,TTL 设置为 7 天(但实际受限于应用生命周期)。
4.7 缓存能力的退化与重新设计
从 Stremio Addon 迁移到 Nuvio Provider 时,最大的能力损失是缓存。Stremio Addon 拥有三层缓存(内存 → Upstash Redis → Vercel CDN),而 Nuvio Provider 只能用内存 Map。
这意味着之前为 Stremio Addon 设计的缓存策略完全无法复用。但可以借鉴其分层思路,在客户端沙箱内重新设计:
| 数据类型 | 缓存方式 | TTL | 依据 |
|---|---|---|---|
| TMDB 元数据 | 内存 Map | 6 小时 | 标题、别名在短时间内不变 |
| RSC 搜索请求 | 内存 Map | 5 分钟 | 同一影片短时间内重复搜索常见 |
| 详情页数据 | 内存 Map | 30 分钟 | 详情页内容变化频率低 |
| 流地址 | 不缓存 | — | IP 绑定,且有效期短 |
Nuvio 自身的元数据管线中,TMDB 数据的缓存 TTL 为 7 天,Metadata Screen 的缓存 TTL 为 5 分钟。Provider 的内存缓存可以参照这个比例,根据沙箱内存限制适当缩短。
五、技术探讨:运行效率与云端工具
在 Nuvio Provider 的开发过程中,一个反复出现的权衡是:如何在客户端沙箱的限制下,尽可能提升运行效率。这引出了几个值得深入探讨的技术方向。
5.1 云端工具能否用于 Provider 场景
一个自然的想法是:既然 Provider 运行在客户端,能否借助云端工具来分担一些计算或缓存任务?答案是——部分可以,但受限于 IP 绑定。
对于 IP 绑定的流媒体源,流地址的获取必须在客户端完成,因此 Vercel Edge Functions、Cloudflare Workers 等边缘计算平台无法直接参与流地址的获取。但它们在以下环节仍有价值:
构建与转译。build.js 使用的 esbuild 本身就是一种高效的构建工具。对于包含大量依赖的 Provider,可以在 CI/CD 流程中执行构建,确保产物始终是最新的。
预解析与缓存。如果能提前将 TMDB 元数据预解析并缓存到云端(如 Upstash Redis),Provider 在客户端只需要查询缓存,而不是每次都请求 TMDB API。但问题在于:Provider 运行在 Hermes 沙箱中,无法直接连接 Redis。除非 Nuvio 在应用层面提供了某种桥接机制,否则这个方向在 Provider 场景下走不通。
Stremio Addon 作为补充。对于不涉及 IP 绑定的内容(如元数据、目录),可以编写一个独立的 Stremio Addon 部署在 Vercel 上,利用 Edge Functions 的低延迟特性。Provider 和 Addon 可以并行运行,Provider 负责 IP 绑定的流地址,Addon 负责元数据增强。
5.2 边缘计算与冷启动优化
如果选择 Stremio Addon 路线(仅用于非 IP 绑定的场景),Vercel Edge Functions 是值得考虑的选择。它们运行在基于 V8 隔离的轻量级运行时上,冷启动时间仅 50–300ms,而传统的 Node.js 容器冷启动需要 1–5 秒。
Cloudflare Workers 是另一个值得考虑的备选,同样基于 V8 隔离,免费额度更慷慨,全球节点覆盖更广。两者在架构设计上可以互换,选择取决于具体需求。
但 Edge Functions 也有严格的资源限制:128MB 内存、50ms CPU 时间,且不能使用 Node.js API。这意味着复杂的加密计算或大型依赖包会被排除在外。
5.3 多级缓存策略的价值与边界
在 Stremio Addon 阶段,三层缓存架构(内存 → Upstash Redis → Vercel CDN)显著提升了响应速度。Upstash Redis 的跨实例缓存让不同边缘节点的请求能共享热点数据,Vercel CDN 则通过 Cache-Control 响应头进一步减轻源站压力。
Redis 冷却降级机制是一个值得记录的设计:当 Redis 连续 3 次响应超过 500ms 或失败时,进入 60 秒冷却期,期间跳过 Redis 读写,仅使用内存缓存。这个机制保证了即使 Redis 出现网络抖动,Addon 也不会整体变慢。
但在 Nuvio Provider 中,这套机制完全无法复用。客户端沙箱不允许连接外部缓存服务,只能用内存 Map。这意味着 Provider 的缓存命中率天然低于 Stremio Addon——每次用户重新打开应用,内存缓存都会清空。
5.4 预解析与异步加载
对于 TMDB 元数据的获取,可以在 Provider 初始化时进行预解析——在用户点击播放之前,提前获取并缓存常见的元数据。但这需要 Nuvio 提供初始化钩子,目前 Provider 的 getStreams 是按需调用的,没有全局初始化时机。
另一种思路是在 getStreams 内部并行发起多个 TMDB 请求。电影需要请求详情、别名、翻译三个接口,电视剧还需要季详情。这些请求之间没有依赖关系,使用 Promise.all 可以显著减少总耗时。
5.5 构建工具的选择
Nuvio 官方的 build.js 使用了 esbuild 进行打包和转译。esbuild 的特点是构建速度极快,对于包含大量依赖的 Provider,构建时间可以控制在毫秒级别。
构建产物的体积直接影响加载速度。官方文档提到,对于使用 node-forge、cheerio 等重型依赖的 Provider,--minify 可以将文件体积减少约 50%。更小的文件意味着更快的加载时间和更少的内存占用。
5.6 技术方案的边界
需要清醒认识到的是:Provider 运行在客户端沙箱中,无法直接使用云端工具来提升运行时效率。所有优化必须在客户端层面完成——内存缓存、并行请求、减少查询词数量、限制别名数量。云端工具的价值主要体现在开发阶段(构建、测试)和辅助场景(非 IP 绑定的 Stremio Addon)中。
六、沉淀下来的技术方案
经过三版迭代,最终写入 Nuvio Provider 代码的技术方案如下:
6.1 模块划分
1 | src/jpyy/ |
6.2 签名算法
1 | import CryptoJS from "crypto-js"; |
6.3 RSC 解析器
1 | function parseRscRecords(body) { |
6.4 多语言别名匹配
从 TMDB 的 /alternative_titles 和 /translations 接口拉取所有别名,按纯中文 > 中文为主 > 其他排序,取前 5 个作为搜索候选。
6.5 相似度评分
包含关系返回 0.85,否则用编辑距离计算。对每个站内结果,取与所有候选标题的最高相似度,再叠加年份、精确匹配、季数加分。
6.6 调试方案
getStreams 中维护 step 变量,出错时返回带 [step] error.message 的错误条目。
6.7 部署方式
- 构建:
node build.js --minify jpyy - 托管:GitHub raw content URL
- 添加仓库:在 Nuvio 的 Settings > Plugins 中输入不带
manifest.json的根 URL
七、经验总结
7.1 目标站点的技术分析决定了架构方向
jpyy.com 的六个技术特征——签名保护、RSC 流格式、域名群、IP 绑定、绝对集数、每季独立 vodId——共同构成了整个项目的约束条件。在写代码之前,必须先把目标站点的技术特征分析清楚,否则后期会不断发现“原来还有这个限制”。
7.2 架构选择由运行环境决定,而非技术偏好
IP 绑定流媒体 → 必须在客户端获取流地址 → Stremio Addon 不可行 → Nuvio Provider 的 Hermes 环境 → 有限的 API 和语法限制。每一步选择都是上一层约束推导出来的结果,而非任意决定。
7.3 ID 统一是架构设计的核心
在 Stremio Addon 阶段,最大的设计问题不是代码实现,而是 ID 没有统一。tt、jp、vod、nid、tmdb、title 到处互相转换,导致 Search 转一次、Meta 转一次、Stream 又转一次。引入 MediaContext 对象后,整个 Resolver 链传递同一个上下文,每层只补充自己负责的字段,代码才变得清晰。
7.4 Season/Episode 与 Absolute Episode 不能混用
这是剧集处理的经典陷阱。Stremio 和 Nuvio 都使用 Season/Episode 模型,而大多数中文站点使用 absolute episode。必须显式地在两者之间做转换,不能依赖“碰巧能工作”的巧合。
7.5 每个环境都有自己的“方言”
Stremio Addon 中 async/await 原生可用,Nuvio Provider 中必须转译;Stremio Addon 中 crypto 模块直接可用,Nuvio Provider 中只能用 crypto-js;Stremio Addon 中 console.log 云端可见,Nuvio Provider 中 iOS 真机上不显示。了解每个环境的约束,才能写出在该环境中可靠的代码。
7.6 调试策略需要随环境调整
在 Stremio Addon 中,云端日志面板是最自然的调试方式。在 Nuvio Provider 中,日志不显示,就需要改变思路——把诊断信息编码进返回数据,让 Results 标签页成为你的“调试终端”。
7.7 先跑通再优化,但架构决策要尽早做
最初那份单一 JS 脚本验证了整条链路的可行性。但在扩展到 Stremio Addon 时,如果没有及时重构 Resolver + Adapter 架构,代码会随着 ID 转换的复杂度急剧膨胀。架构比代码重要——如果架构定错,后面改 Series 会越来越痛苦。
7.8 兼容性需要显式的声明机制
Stremio Addon 的 idPrefixes 是一个优秀的设计——它让 Addon 明确声明自己支持的 ID 格式,客户端据此分发请求。Nuvio Provider 目前缺少这个机制,Provider 只能接收 TMDB ID。这意味着如果你的数据源需要处理站内 ID,要么依赖其他 Addon 传递,要么走 Stremio Addon 路线(但 IP 绑定可能不允许)。
7.9 云端工具的边界
对于 IP 绑定的流媒体源,云端工具无法直接参与流地址的获取。但在构建、测试、以及非 IP 绑定的辅助场景(如元数据增强、目录浏览)中,Vercel Edge Functions、Cloudflare Workers 和 Upstash Redis 仍然能发挥价值。Provider 的运行时效率优化,必须完全在客户端沙箱内完成——内存缓存、并行请求、减少查询词数量、控制别名数量。从三层缓存到单层内存缓存的退化,是 Stremio Addon 迁移到 Nuvio Provider 时最需要接受的现实。
整个项目的演进过程,就是不断遇到约束、理解约束、并在约束下寻找最优解的过程。从单一脚本到 Stremio Addon(Vercel Edge Functions + Upstash Redis + Cloudflare Workers 三层缓存架构),再到 Nuvio Provider(客户端本地运行 + 内存缓存),每一版都在上一版的基础上修正了架构缺陷。最终沉淀下来的技术方案,不是一开始就能设计出来的,而是在反复的试错和思考中逐步成型的。
