HGhub 开发者HPP 1.0 草案

06 · 能力模块

← 总览

插件对象由若干能力命名空间组成。某个命名空间存在,就表示插件实现了该能力; 宿主据此决定界面上展示哪些入口。

export default function (hg) {
  return {
    media:  { getDetail, resolvePlayback },   // 点播源必需;纯直播源可只实现 live
    search: { search },                        // 可选:有它才有搜索
    home:   { getSections },                   // 可选:有它才有首页内容
  };
}

所有能力方法与 transformResource 都是 (params, ctx) 形状(见 02 §5); 卸载钩子 dispose 为例外。数据类型见 05。 带 ? 的方法在该能力内可选。

1. 能力一览

能力 方法 对应的界面
media(点播源必需) getDetail resolvePlayback listEpisodes? matchUrl? 详情页、播放器
resolver(解析器必需) resolve 无独立界面;为数据源提供播放地址
home getSections listSection? 首页(分区)
feed list 沉浸式信息流(竖屏连续播放)
catalog getCategories list getFilters? 分类页与筛选
search search getFilters? suggest? trending? 搜索页、聚合搜索、换源
live getGroups listChannels resolve getProgrammes? 直播频道
articles list getDetail 图文
comments list send? like? 评论面板
interact like? favorite? follow? 点赞、收藏、关注按钮
auth getMethods submit getProfile logout 插件页的登录表单
library listMedia listFolders? listFollowing? 云端收藏与历史
progress report 无界面(播放进度回报)
updates check 片库的"有更新"角标
endpoints list 插件页的站点线路选择
ui getActions submit 插件页的操作表单
— transformResource? dispose? 顶层钩子(§16)

界面部分见 08 宿主界面。榜单没有独立页面:上游的排行榜用 catalog 的排序项 或 home 里的一个分区表达。

2. media(点播源必需)

media: {
  getDetail(params: { id: string }, ctx): MediaDetail

  resolvePlayback(params: {
    mediaId: string;
    episodeId: string;
    playlistId?: string;
    serverId?: string;                         // 用户选定的播放线路;省略由插件选默认
    reason: "play" | "preload" | "refresh";
    codecs: ("h264" | "hevc" | "av1")[];       // 当前播放路径实际可用,按偏好排序
    maxHeight?: number;
  }, ctx): PlaybackInfo

  listEpisodes?(params: PageParams & { mediaId: string; playlistId: string }, ctx): Page<Episode>

  matchUrl?(params: { url: string }, ctx): { mediaId: string; episodeId?: string } | null
}

3. resolver(解析器)

大量影视源使用同一批视频托管商。把"某个托管商的播放页 → 可播放地址"做成独立插件, 多个数据源就能共用一个解析器。

meta: {
  kind: "resolver",
  matches: ["*.host-a.example", "player.host-b.example"],
  name: "某某解析器",
}

resolver: {
  resolve(params: {
    url: string;                               // 待解析的播放页地址
    headers?: Record<string, string>;          // 数据源给出的非凭据请求头
    serverId?: string;                         // 仅属于当前解析器的播放线路
    reason: "play" | "preload" | "refresh";
    codecs: ("h264" | "hevc" | "av1")[];
    maxHeight?: number;
  }, ctx): PlaybackInfo
}

meta.matches 是解析器能处理的主机模式(写法见下)。宿主的调用时机:

  1. 数据源返回 kind: "page" 的流。
  2. 宿主按主机选择解析器:流的 resolverId 指明了插件时用它;否则匹配 matches; 命中多个时使用用户为该主机记住的选择,没有记忆则让用户选(可勾选"记住")。
  3. 宿主用 url 与数据源给出的非凭据请求头调用 resolver.resolve,把返回的流当作该集的流交给播放器。
  4. 没有可用的解析器时,提示用户"该来源需要安装解析器",并列出当前已安装的解析器供选择。

边界:

主机模式写法:

写法 匹配
api.example.com 仅该主机
*.example.com example.com 的任意层级子域,不含 example.com 本身
* 任意主机。只对解析器有意义,会让它在所有 page 流上成为候选

4. home

home: {
  getSections(params: PageParams, ctx): Page<Section>
  listSection?(params: PageParams & { sectionId: string }, ctx): Page<MediaItem>
}

interface Section {
  id: string;
  title?: string;
  layout: "banner" | "row" | "grid" | "list";
  imageAspect?: "portrait" | "landscape";   // 卡片用竖版封面还是横版图,默认 portrait
  items: MediaItem[];
  more?: boolean;        // 为 true 时显示"查看更多",宿主调用 listSection
}
layout 呈现
banner 顶部轮播,使用 backdrop(缺失时回退 cover)
row 横向滑动的一行卡片
grid 多列网格
list 带简介的纵向列表

5. feed

feed: {
  list(params: PageParams, ctx): Page<MediaItem>
}

沉浸式信息流:每条内容全屏展示并自动播放。条目应当带 entryEpisodeId, 否则宿主需要先调用 getDetail 才能播放。适合短剧、短视频类上游。

6. catalog

catalog: {
  getCategories(params: {}, ctx): Category[]
  list(params: PageParams & {
    categoryId: string;
    filters?: Record<string, string[]>;   // 过滤器 id → 已选项 id
    sort?: string;
  }, ctx): Page<MediaItem>
  getFilters?(params: { categoryId: string }, ctx): { filters?: Filter[]; sorts?: FilterOption[] }
}

interface Category {
  id: string;
  label: string;
  children?: Category[];        // 至多两层
  filters?: Filter[];
  sorts?: FilterOption[];
}

interface Filter {
  id: string;
  label: string;
  options: FilterOption[];
  multiple?: boolean;           // 默认单选
}

interface FilterOption { id: string; label: string }
search: {
  search(params: PageParams & {
    query: string;
    filters?: Record<string, string[]>;
  }, ctx): Page<MediaItem>
  getFilters?(params: {}, ctx): Filter[]
  suggest?(params: { query: string }, ctx): string[]     // 输入联想
  trending?(params: {}, ctx): string[]                    // 热搜词
}

8. live

live: {
  getGroups(params: {}, ctx): LiveGroup[]
  listChannels(params: PageParams & { groupId?: string }, ctx): Page<Channel>
  resolve(params: {
    channelId: string; serverId?: string; codecs: ("h264" | "hevc" | "av1")[];
    maxHeight?: number; reason: "play" | "preload" | "refresh";
  }, ctx): PlaybackInfo
  getProgrammes?(params: { channelId: string; from: number; to: number }, ctx): Programme[]
}

interface LiveGroup { id: string; label: string }

interface Channel {
  id: string;
  name: string;
  logo?: ImageRef;
  number?: number;           // 频道号
  groupId?: string;
  now?: string;              // 正在播出的节目名
}

interface Programme {
  title: string;
  startAt: number;
  endAt: number;
  description?: string;
}

9. articles

articles: {
  list(params: PageParams, ctx): Page<ArticleItem>
  getDetail(params: { id: string }, ctx): ArticleDetail
}

interface ArticleItem {
  id: string;
  title: string;
  summary?: string;
  cover?: ImageRef;
  tags?: string[];
  author?: Author;
  stats?: { likes?: number; comments?: number; favorites?: number; hot?: number };
  publishedAt?: number;
  mediaId?: string;          // 关联的作品,宿主提供跳转
}

interface ArticleDetail extends ArticleItem {
  contentHtml?: string;      // 富文本正文,宿主清洗后渲染(05 §9)
  contentText?: string;      // 纯文本正文
  streams?: Stream[];        // 文内附带的视频
  interaction?: { liked?: boolean; favorited?: boolean };
}

10. comments

comments: {
  list(params: PageParams & { target: CommentTarget }, ctx): Page<Comment>
  send?(params: { target: CommentTarget; text: string }, ctx): Comment | void
  like?(params: { commentId: string; on: boolean }, ctx): void
}

type CommentTarget =
  | { kind: "media"; mediaId: string; episodeId?: string }
  | { kind: "article"; articleId: string }
  | { kind: "replies"; commentId: string };      // 某条评论的回复

interface Comment {
  id: string;
  author: { id?: string; name: string; avatar?: ImageRef };
  text: string;
  createdAt?: number;
  likes?: number;
  liked?: boolean;
  replyCount?: number;
  replies?: Comment[];       // 内联的前几条回复
  pinned?: boolean;
  region?: string;           // 上游提供的属地文本
}

11. interact

interact: {
  like?(params: { target: InteractTarget; on: boolean }, ctx): void
  favorite?(params: { target: InteractTarget; on: boolean; folderId?: string }, ctx): void
  follow?(params: { authorId: string; on: boolean }, ctx): void
}

type InteractTarget =
  | { kind: "media"; mediaId: string; episodeId?: string }
  | { kind: "article"; articleId: string };

这些是账号侧的操作,会改动上游的数据,需要登录;未登录时插件抛 AUTH_REQUIRED。 宿主的本地收藏与历史不依赖它们(设计原则"本地优先")。

12. auth

auth: {
  getMethods(params: {}, ctx): { methods: AuthMethod[]; registerUrl?: string }
  submit(params: { methodId: string; values: Record<string, string> }, ctx): AuthResult
  getProfile(params: {}, ctx): UserProfile | null
  logout(params: {}, ctx): void
}

interface AuthMethod {
  id: string;
  label: string;             // 如"账号密码登录""网页登录"
  form?: Form;               // 省略表示无需填表(如网页登录),宿主直接调用 submit
}

interface Form {
  title?: string;
  description?: string;
  fields: FormField[];
  submitLabel?: string;
}

interface FormField {
  key: string;
  type: "text" | "password" | "email" | "number" | "otp" | "select" | "hidden" | "captcha";
  label?: string;
  placeholder?: string;
  required?: boolean;
  value?: string;                                // 预填值
  options?: { value: string; label: string }[];  // select
  image?: ImageRef;                              // captcha 的题面图片
}

type AuthResult =
  | { status: "ok"; user: UserProfile }
  | { status: "next"; form: Form };              // 还需要一步(验证码、短信码等)

interface UserProfile {
  id: string;
  name: string;
  avatar?: ImageRef;
  description?: string;
  stats?: Record<string, number>;   // 展示用计数,键为展示标签,如 { "关注": 3 }
}

流程:

宿主调用 getMethods → 用户在插件页的登录表单里选一种方式并填写 → submit
   ├─ { status: "ok" }   → 登录完成,宿主记录登录态并展示 user
   ├─ { status: "next" } → 宿主渲染新表单,再次 submit(此前各步的值与本步合并后一起提交)
   └─ 抛 AUTH_INVALID    → 宿主在表单内展示错误消息

13. 账号侧数据

13.1 library

library: {
  listMedia(params: PageParams & {
    kind: "favorites" | "likes" | "history";
    folderId?: string;
  }, ctx): Page<MediaItem>
  listFolders?(params: {}, ctx): { id: string; label: string; count?: number }[]
  listFollowing?(params: PageParams, ctx): Page<Author>
}

上游账号里的收藏、点赞、历史。宿主在"我的"里把它与本地片库分开展示,并提供"导入到本地"。

13.2 progress

progress: {
  report(params: {
    mediaId: string;
    episodeId: string;
    positionSec: number;
    durationSec?: number;
    event: "start" | "heartbeat" | "pause" | "end";
  }, ctx): void
}

向上游回报播放进度。宿主限频调用(见 03 §3);失败静默,不影响播放。

13.3 updates

updates: {
  check(params: {
    items: { mediaId: string; episodeCount?: number; updatedAt?: number }[];   // ≤ 50
  }, ctx): { mediaId: string; episodeCount?: number; updatedAt?: number; latestLabel?: string }[]
}

宿主把片库里"追更中"的条目连同已知状态批量交给插件,插件返回有变化的条目。 只返回有更新的即可;宿主据此显示角标。未实现该能力时,宿主在用户打开详情页时顺带比对。

14. endpoints

endpoints: {
  list(params: { refresh?: boolean }, ctx): Endpoint[]
}

interface Endpoint {
  id: string;
  label?: string;
  url: string;          // 纯 origin,如 "https://www.example.com"
}

用于上游有多个入口域名(主站、备用站、镜像)的情况。

15. ui

插件不能绘制界面。需要用户输入或操作时(切换账号、填写自定义参数、选择偏好), 插件声明表单,由宿主渲染在插件页里(见 08 §2.4)。

ui: {
  getActions(params: {}, ctx): ActionDescriptor[]
  submit(params: { actionId: string; values: Record<string, string> }, ctx): ActionResult
}

interface ActionDescriptor {
  id: string;
  label: string;             // 按钮文字,如"切换账号""自定义线路"
  description?: string;      // 按钮下方的说明
  form: Form;                // 与 auth 的 Form 同构(§12)
}

type ActionResult =
  | { status: "ok"; message?: string }     // 完成后可在界面上提示一句话
  | { status: "next"; form: Form };        // 还需要一步,宿主渲染新表单后再次 submit

16. 顶层钩子

transformResource?(params: {
  kind: "image" | "playlist" | "segment" | "key";
  name: string;              // 来自 ResourceTransform.name
  state?: Json;              // 来自 ResourceTransform.state
  url: string;               // 资源的最终地址
  contentType?: string;
  data: Uint8Array;
}, ctx): { data: Uint8Array; contentType?: string }

dispose?(): void

17. 聚合搜索与换源

这是宿主功能,建立在各插件的 search 与 media 能力之上,不需要插件额外实现任何东西。 界面细节见 08。

17.1 聚合搜索

用户提交搜索后,宿主并行调用用户选中的插件的 search(单选、多选或全部)。 触发时机与并发上限见 03 §3。

综合模式下,结果先按来源分栏,另有一个"全部"栏把同一部作品合并成一张卡片:

  1. 归一化标题:去掉空格与标点、统一全角半角、忽略大小写。
  2. 归一化后标题相同、且 year 与 type 不冲突的条目合并。
  3. 合并后的卡片标注来源数量,点进去可以选择从哪个来源播放。

17.2 换源

当前来源无法播放(CONTENT_LOCKED、NOT_FOUND、UPSTREAM_CHANGED、取流失败)或用户主动选择时:

  1. 宿主用当前条目的 title(及 year、type)在其他插件中搜索。
  2. 按标题相似度排序,标题完全一致且年份、类型不冲突的排在前面。
  3. 由用户确认候选条目;宿主不自动替换。
  4. 确认后,宿主建立新来源关联并保留旧来源、旧收藏与旧进度;各来源仍按“插件 ID + 条目 ID”独立记录。
  5. 集数优先按明确的 season + episodeNumber 对齐;双方都没有季号时可按明确集号提出候选。 index、标题相似度只能作为备选提示,匹配缺失或有歧义时让用户选集。
  6. 新来源已有本地进度时优先使用该来源记录;没有时可以提议沿用旧播放位置, 但不能默认认为拆集版、剪辑版与完整版的秒数等价,由用户确认后再定位。

为了让换源可靠,插件应当: