本文面向 atv-player 的 Python Spider 插件开发者,说明宿主当前真正支持的接口、数据结构、播放器扩展能力,以及哪些传统 TVBox/Atvp 习惯写法在这里并不会生效。
如果你手上已经有旧爬虫,这篇文档可以帮助你判断:
- 哪些写法可以直接复用
- 哪些字段会被
atv-player读取 - 哪些字段只是兼容保留、当前宿主并不接线
init(self, extend="")getName(self)homeContent(self, filter)categoryContent(self, tid, pg, filter, extend)searchContent(self, key, quick, pg=1, category="")detailContent(self, ids)playerContent(self, flag, id, vipFlags)danmaku(self)getManagerActions(self)runManagerAction(self, action_id, context)runPlayerAction(self, action_id, context)
playerContent(...) 返回值里,atv-player 当前会使用这些字段:
urlparseheadercoverqualitiessubtlyricactionsext
jxplayUrlplayerContent()["danmu"]localProxy(...)self.backend_parse = True(这是alist-tvbox后端提供的 Atvp.py 兼容运行层使用的特殊配置,atv-player直加载模式不读取)homeVideoContent(...)liveContent(...)isVideoFormat(...)manualVideoCheck(...)action(...)
这几个点非常重要:
playerContent()["danmu"]已经无效,是否启用弹幕能力只看danmaku()- 对 Spider 插件来源,
danmaku()不仅控制弹幕能力,也控制自动元数据增强和播放器内刮削能力是否挂载 localProxy(...)在atv-player当前实现里没有运行时入口,写了也不会被调用self.backend_parse = True不是通用传统字段,而是alist-tvbox后端提供的 Atvp.py 兼容运行层读取的特殊配置;如果 Spider 返回的是网盘资源或磁力资源,就必须配置这个参数,atv-player当前直加载模式不会读取它- 许多旧爬虫会返回
jx/playUrl,但atv-player当前只看parse、url、header
atv-player 的 Spider 插件加载流程是:
- 读取插件源码并实例化
Spider - 调用
init(extend)注入插件配置文本 - 用
homeContent(...)构建插件首页分类与筛选 - 用
categoryContent(...)和searchContent(...)拉列表 - 用
detailContent(...)构建详情页和播放列表 - 用户点击某个播放项后,再延迟调用
playerContent(flag, id, vipFlags)解析当前播放项
这意味着:
detailContent(...)负责“详情页和播放列表”playerContent(...)负责“某一个播放项最终怎么播”- 你的播放项
id设计必须稳定,因为宿主会把它保存到PlayItem.vod_id
下面是一个适合从零开始的最小骨架:
from base.spider import Spider as BaseSpider
class Spider(BaseSpider):
def __init__(self):
self.name = "示例来源"
self.host = "https://example.com"
self.extend = ""
def init(self, extend=""):
self.extend = extend or ""
return None
def getName(self):
return self.name
def danmaku(self):
return True
def homeContent(self, filter):
return {
"class": [
{"type_id": "movie", "type_name": "电影"},
{"type_id": "tv", "type_name": "剧集"},
],
"filters": {
"movie": [
{
"key": "year",
"name": "年份",
"init": "",
"value": [
{"n": "全部", "v": ""},
{"n": "2026", "v": "2026"},
],
}
]
},
"list": [],
}
def categoryContent(self, tid, pg, filter, extend):
return {
"page": int(pg),
"limit": 1,
"total": 1,
"list": [
{
"vod_id": "detail-1",
"vod_name": "示例影片",
"vod_pic": "https://img.example/poster.jpg",
"vod_remarks": "更新至第 1 集",
}
],
}
def searchContent(self, key, quick, pg=1, category=""):
return self.categoryContent(category or "movie", pg, False, {})
def detailContent(self, ids):
return {
"list": [
{
"vod_id": ids[0],
"vod_name": "示例影片",
"vod_pic": "https://img.example/poster.jpg",
"type_name": "剧情",
"vod_year": "2026",
"vod_area": "中国大陆",
"vod_actor": "演员A / 演员B",
"vod_content": "这里是简介",
"vod_play_from": "默认线路",
"vod_play_url": "第1集$play-1#第2集$play-2",
}
]
}
def playerContent(self, flag, id, vipFlags):
return {
"parse": 0,
"url": "https://media.example/video.m3u8",
"header": {"Referer": self.host + "/"},
}常用返回结构:
{
"class": [
{"type_id": "movie", "type_name": "电影"},
{"type_id": "tv", "type_name": "剧集"},
],
"filters": {
"movie": [
{
"key": "year",
"name": "年份",
"init": "",
"value": [{"n": "全部", "v": ""}, {"n": "2026", "v": "2026"}],
}
]
},
"list": [],
}说明:
class用来生成一级分类filters是可选项,键名通常对应type_idcategoryContent(...)收到的extend就是当前筛选值
常用字段:
pagelimittotallist
list 中每一项至少建议提供:
vod_idvod_namevod_picvod_remarks
atv-player 会优先尝试四参版本:
searchContent(key, quick, pg, category)如果你的插件只实现三参版本:
searchContent(key, quick, pg)宿主也会回退兼容。
如果你的来源天然有多种搜索域,例如按 song、album、playlist、singer 分流,建议直接使用第四个 category 参数。
宿主最关心的是:
- 详情元数据
vod_play_fromvod_play_url
典型结构:
{
"list": [
{
"vod_id": "detail-1",
"vod_name": "示例影片",
"vod_pic": "https://img.example/poster.jpg",
"type_name": "剧情",
"vod_year": "2026",
"vod_area": "中国",
"vod_director": "导演A",
"vod_actor": "演员A / 演员B",
"vod_content": "简介",
"vod_play_from": "线路1$$$线路2",
"vod_play_url": "第1集$play-1#第2集$play-2$$$备用1$play-1b#备用2$play-2b",
}
]
}规则:
$$$分隔不同播放源分组#分隔同一分组下的多个播放项- 每个播放项格式是
标题$id playerContent(flag, id, vipFlags)里的flag对应当前分组名,id对应这里的播放项值
atv-player 直接读取:
{
"parse": 0 or 1,
"url": "...",
"header": {...},
}当你已经拿到最终媒体地址时,返回:
return {
"parse": 0,
"url": "https://media.example/video.m3u8",
"header": {
"Referer": self.host + "/",
"User-Agent": "Mozilla/5.0 ...",
},
}适合:
- 直出
m3u8 - 直出
mp4 - 站内接口已经解出真实地址
如果你拿到的是播放页 URL、二次解析页 URL,或者必须交给宿主解析器处理的地址,返回:
return {
"parse": 1,
"url": "https://example.com/play/123.html",
"header": {"Referer": self.host + "/"},
}说明:
parse=1会让atv-player的内置解析服务接管- 这时当前播放项的“解析”下拉框会变为可用
url仍然必须是一个可交给解析器处理的字符串
这类站点型 Spider 的常见做法是:能直接解出媒体地址就返回 parse=0,不能稳定解出就降级为 parse=1
很多旧插件会返回:
{
"parse": 0,
"jx": 0,
"playUrl": "",
"url": "...",
"header": {},
}在 atv-player 里:
parse有效url有效header有效jx当前无效playUrl当前无效
所以不要把关键逻辑放在 jx 或 playUrl 上。
如果你当前无法解析出地址,优先返回空地址而不是伪造地址:
return {"parse": 0, "url": "", "header": {}}更推荐在必要时直接抛明确错误:
raise ValueError("播放地址解析失败")宿主会记录错误日志,并在播放器里显示失败信息。
这是 atv-player 相对传统宿主最特殊、也最实用的一块。
如果详情页本身就是网盘聚合或下载聚合,你可以直接把播放项值写成:
- 阿里云盘分享链接
- 夸克分享链接
- UC 分享链接
- 百度网盘分享链接
magnet:?ed2k://
常见做法是:
- 详情页把网盘资源和磁力资源组织成普通
vod_play_from/vod_play_url - 用户点击播放项时,宿主识别它是不是网盘分享链接或离线下载链接
- 如果是,宿主会走自己注入的后端解析流程,而不是把它当普通媒体 URL 直接播
你可以让分享链接出现在:
detailContent(...).vod_play_url的播放项值里playerContent(...).url的返回值里
宿主两边都会识别。
如果宿主已经配置了网盘解析能力,插件只需要诚实返回原始链接:
return {"parse": 0, "url": "https://pan.quark.cn/s/xxxx", "header": {}}或在 vod_play_url 里直接写:
夸克资源$https://pan.quark.cn/s/xxxx
推荐:
- 普通站内点播:插件自己在
playerContent(...)里解析 - 网盘分享链接:交给宿主
- 磁力/
ed2k:交给宿主
不推荐:
- 为了统一逻辑,把所有网盘链接都在插件里强行解析成站外中转地址
- 把本应作为网盘分享链接保留的信息提前抹平
如果你的 Spider 需要通过 Atvp.py 运行,可能会在 __init__ 里写:
self.backend_parse = True这个字段不是传统 Spider 约定字段,也不是随手遗留的兼容字段。它是专门给 alist-tvbox 后端提供的 Atvp.py 兼容运行层使用的特殊配置。
更准确地说,Atvp.py 不是普通内层 Spider,而是 alist-tvbox 后端提供的外层转换器:
- 它对外实现的是传统
TvBox风格Spider接口 - 它内部再去加载本项目 Spider
- 然后把本项目 Spider 的行为转换成传统
TvBox兼容运行时能理解的形态
backend_parse 就是这个外层转换器使用的模式开关之一,而且它的核心使用场景就是网盘资源和磁力资源。
Atvp.py 如何读取它
Atvp.py 会先加载并实例化本项目 Spider,然后用下面的逻辑读取:
def _category_mode_enabled(self):
if self._inner is None:
return False
return bool(getattr(self._inner, "backend_parse", False))也就是说,backend_parse 是本项目 Spider 向 alist-tvbox 后端提供的 Atvp.py 兼容运行层声明的一个模式开关。
Atvp.py 自身是怎么工作的
从代码看,Atvp.py 的工作方式大致是:
- 解析
extend - 读取
source,支持本地源码或远程源码 - 如果是
secspider包,就先验签、解密 - 动态加载内部
Spider类并实例化为self._inner - 对外暴露传统
TvBox风格的方法:homeContent(...)categoryContent(...)detailContent(...)searchContent(...)playerContent(...)localProxy(...)
- 在这些对外方法里,决定是直接转发给
self._inner,还是先做一层 Atvp 自己的重写、拆分、后端解析和 filter 处理
所以你可以把 Atvp.py 理解成:
- “由
alist-tvbox后端提供、把本项目 Spider 包装成传统 TvBox 兼容代码再运行”的桥接层
而不是:
- 一个普通业务 Spider
- 只做一点点辅助函数封装的工具类
如果 Spider 返回的是网盘资源或磁力资源,那么在 Atvp.py 链路下,self.backend_parse = True 不是“可选优化”,而是必需配置。
当 backend_parse=False 时:
categoryContent(...)直接走内层 SpidersearchContent(...)直接走内层 Spider- 结果项保持原样
当 backend_parse=True 时:
- 分类和搜索结果会先被 Atvp 改写
- 每个结果项的
vod_id会被包装成atvp_detail:<原始vod_id> - 每个结果项会被标记成
folder - 用户点开后,Atvp 会先调用内层
detailContent(...) - 然后把
vod_play_from/vod_play_url拆成一个“二级目录列表”
这说明 backend_parse 的本质不是“视频解析开关”,而是:
- 是否让 Atvp 把 Spider 的分类/搜索结果当作“目录入口”
- 是否让 Atvp 在详情阶段接管一部分资源展开逻辑
这里的“资源展开逻辑”主要就是网盘资源、磁力资源、分享链接这类不能直接按普通视频详情处理的目标。
换句话说:
- 返回普通站内视频详情时,可以不依赖这个字段
- 返回网盘资源或磁力资源时,必须开启这个字段
在 backend_parse=True 模式下,Atvp 会在后续链路里做两类事情:
- 如果某个
id看起来已经是http/https/magnet/ed2k,detailContent(...)会优先走 Atvp 的_parse(...) _parse(...)会请求 Atvp 后端的parse接口,再把返回结果过一遍 filter、缓存 detail、缓存 play context
虽然 _decode_parse(...) 代码层面也识别 http/https、magnet、ed2k,但按你的设计意图,这个模式开关主要针对的是网盘资源和磁力资源。
所以这个字段更准确的定义是:
alist-tvbox后端提供的 Atvp.py 兼容运行层面向网盘资源和磁力资源的“目录化后端解析模式开关”
而不是:
- 通用 Spider 规范字段
playerContent()的解析标记atv-player直加载模式的功能开关
当前不会读取这个字段。
也就是说,仅仅设置:
self.backend_parse = True不会给 atv-player 带来任何额外行为。
文档建议:
- 如果你的插件需要兼容 Atvp.py,并且返回的是网盘资源或磁力资源,就必须设置它
- 如果你的插件需要兼容 Atvp.py,但返回的是普通站内直链/播放页,再根据链路决定是否需要它
- 但不要把
atv-player直加载模式里的功能是否可用,建立在这个字段上
localProxy(...) 在 atv-player 当前实现里没有效果。
根因不是你的爬虫写法,而是宿主没有把这个接口接到运行时 HTTP 入口:
- 插件控制器不会调用
localProxy(...) - 本地 HLS 代理也不会转发到插件的
localProxy(...)
因此像下面这种传统写法:
def localProxy(self, params):
...
return [200, "application/vnd.apple.mpegurl", rewritten.encode("utf-8")]在当前 atv-player 中不会生效。
- 对
TVBox/Atvp风格宿主:这是可用接口 - 对当前
atv-player:这是未接线的兼容接口
如果你的目标宿主是当前 atv-player,优先选择:
- 在
playerContent(...)中直接返回最终媒体 URL - 必要时返回
parse=1交给宿主解析器 - 把网盘/磁力链接原样返回给宿主做后续处理
不要依赖 localProxy(...) 做:
m3u8重写- 分片代理
- 防盗链中转
- 动态签名转发
除非未来宿主明确把这条链路接上。
要让宿主把这个插件视为“支持弹幕来源推断”,实现:
def danmaku(self):
return True当前宿主不会再读取旧式的:
{"danmu": True}对 Spider 插件来源来说,这个开关当前还有额外含义:
danmaku() == True时,插件请求会挂载自动元数据增强能力danmaku() == True时,播放器里的元数据刮削服务也会挂载danmaku() == False时,这两条能力都不会为该插件来源启用
所以如果你希望这个插件来源支持:
- 自动元数据增强
- 弹幕自动解析
那就必须返回:
def danmaku(self):
return True只要:
danmaku()返回True- 当前播放项最终解析出了有效播放地址
宿主就会尝试根据当前内容推断弹幕搜索标题、集数和候选页面 URL。
建议尽量提供:
- 稳定的
vod_name - 清晰的剧集标题
- 尽量准确的
vod_year - 当播放项本身就是站内详情页 URL 或视频页 URL 时,直接把它保留在播放项
id或解析结果里
例如:
腾讯视频/v.qq.com页面 URL- 站内单集详情页 URL
宿主在很多情况下会拿这些信息去辅助弹幕匹配。
下面这些情况宿主很难自动推断:
- 纯资源聚合站,没有稳定的单集页面
- 你的播放项
id只是内部随机串,没有剧集信息 - 详情页标题和真实片名相差很大
这时更应该优先保证:
- 标题干净
- 集数标注明确
- 年份尽量准确
playerContent(...) 可以返回外挂字幕:
return {
"parse": 0,
"url": "https://media.example/video.mp4",
"subt": "https://cdn.example/subtitles/episode-1.srt",
}宿主支持这些形式:
- 绝对 URL
- 以
/开头的相对路径 - 本地绝对路径
- 内联文本负载
宿主会把它们归一化进 PlayItem.external_subtitles。
playerContent(...) 还可以返回原始歌词负载,宿主会把它转成可选字幕:
return {
"parse": 0,
"url": play_url,
"lyric": {
"format": "qqmusic-qrc",
"text": "...",
"translation": "...",
},
}当前宿主支持的典型歌词格式包括:
qqmusic-qrckugou-krcnetease-yrc
如果 lyric 成功转成逐字歌词,宿主会优先使用它;只有歌词无效时,才回退到 subt。
这一块的典型场景是音乐类或歌词类 Spider。
如果一个播放项存在多个清晰度,可以返回:
{
"parse": 0,
"url": "https://media.example/default.m3u8",
"qualities": [
{"id": "1080p", "name": "1080P", "url": "https://media.example/1080.m3u8"},
{"id": "720p", "name": "720P", "url": "https://media.example/720.m3u8"},
],
}宿主会把它映射到播放器的清晰度切换控件。
如果你希望播放开始后,用新的播放封面覆盖播放器里的视频占位图,可以返回:
{
"parse": 0,
"url": "https://media.example/video.mp4",
"cover": "https://img.example/resolved-cover.jpg",
}它只影响播放器里的视频封面,不会回写详情页海报。
这是当前宿主对插件扩展支持最完整的一部分。
每个动作都会被归一化成:
id: strlabel: stractive: bool = Falseenabled: bool = Truevisible: bool = Truetooltip: str = ""
规则:
id和label必填visible=False的动作会被丢弃- 非法动作会被忽略
- 原始顺序会被保留
播放器详情动作可以来自两处:
detailContent(...).list[0].actionsplayerContent(...).actions
适合容器级状态:
收藏歌单收藏专辑关注歌手
原因:
detailContent(...)拿得到完整容器上下文playerContent(...)只有flag和播放项id
示例:
"actions": [
{
"id": "favorite_playlist",
"label": "收藏歌单",
"active": False,
"tooltip": "",
}
]适合当前播放项状态:
收藏歌曲点赞不喜欢
示例:
return {
"parse": 0,
"url": self.get_play_url(id),
"actions": [
{"id": "favorite_track", "label": "收藏歌曲", "active": False},
{"id": "like_track", "label": "点赞", "enabled": True},
],
}行为:
- 当前播放项动作会合并到已有动作列表
- 如果
id相同,playerContent(...)的动作会覆盖detailContent(...)的同名动作
实现:
def runPlayerAction(self, action_id, context):
...宿主会传入:
context["action_id"]context["vod"]context["play_item"]context["playlist"]context["playlist_index"]context["play_index"]context["log"]
推荐:
- 用
vod处理专辑/歌单/歌手级动作 - 用
play_item处理单曲/单集级动作
动作执行后,返回刷新后的完整动作列表。
支持两种形式:
return {
"actions": [
{"id": "favorite_album", "label": "已收藏专辑", "active": True},
{"id": "favorite_track", "label": "已收藏歌曲", "active": True},
]
}或:
return [
{"id": "favorite_album", "label": "已收藏专辑", "active": True},
{"id": "favorite_track", "label": "已收藏歌曲", "active": True},
]推荐不要只返回被点击的那一个按钮,因为宿主会把返回结果当成当前播放项动作区的新状态。
这类模式常见于带收藏、关注、点赞能力的来源:
def runPlayerAction(self, action_id, context):
vod = context.get("vod") or {}
play_item = context.get("play_item") or {}
if action_id == "favorite_playlist":
self.favorite_playlist(vod.get("vod_id", ""))
elif action_id == "favorite_track":
self.favorite_track(play_item.get("vod_id", ""))
else:
raise ValueError(f"unsupported action: {action_id}")
return {"actions": self._build_actions_for_context(context)}你可以给播放器详情侧栏补充只读信息:
"ext": [
{"label": "播放", "value": "12万"},
{"label": "更新", "value": "2026-05-08"},
]规则:
detailContent(...).list[0].ext是整部作品级字段playerContent(...).ext是当前播放项级字段- 如果当前播放项有有效
ext,它会覆盖整部作品级字段显示 - 每一行必须同时有非空
label和value
value 不仅能是字符串,也能是数组或带动作对象:
{"label": "演员", "value": "演员1"}
{"label": "演员", "value": ["演员1", "演员2"]}
{
"label": "演员",
"value": [
{"label": "演员1", "action": {"type": "search", "value": "演员1"}},
{"label": "演员2", "action": {"type": "detail", "value": "actor-2"}},
],
}支持的动作类型:
categorysearchdetaillink
行为:
category:切回插件标签页并加载categoryContent(...)search:切回插件标签页并加载searchContent(...)detail:通过当前插件打开新的详情请求link:在系统浏览器打开 URL
如果你直接往 vod_actor、vod_director、vod_content 这类纯字符串字段里塞文本,也可以嵌入点击段:
[a=cr:{"type":"search","value":"周杰伦"}/]周杰伦[/a]
也支持指定:
[a=cr:{"target":"bilibili","type":"category","value":"up:378885845"}/]Harold[/a]
规则:
- 可见文本是
[a=cr:...]和[/a]中间的部分 type和value必填target可选target="bilibili"会路由到内置 Bilibili 标签
除了播放器里的详情动作,你还可以给“插件管理”对话框提供自定义按钮。
def getManagerActions(self):
return [
{"id": "qr_login", "label": "扫码登录"},
{
"id": "clear_login",
"label": "清除登录",
"enabled": True,
"tooltip": "",
},
]def runManagerAction(self, action_id, context):
if action_id == "clear_login":
context.set_config_text("")
context.log("info", "已清除登录信息")
context.refresh_plugin()
return
raise ValueError(f"unsupported action: {action_id}")当前上下文常用字段:
context.parentcontext.plugin_idcontext.plugin_namecontext.config_textcontext.set_config_text(text)context.refresh_plugin()context.log(level, message)
最实用的场景通常是:
- 扫码登录
- 清除登录态
- 写回 cookie/token 到配置文本
宿主会把插件配置文本原样传给 init(extend)。
建议:
- 简单场景直接把
extend当 cookie 文本 - 复杂场景优先支持 JSON
常见做法是:
- 如果
extend是 JSON,就解析成配置对象 - 如果
extend是普通文本且包含=,就当作 cookie
推荐写法:
def init(self, extend=""):
self.extend = extend or ""
try:
data = json.loads(self.extend) if self.extend.strip().startswith("{") else {}
except Exception:
data = {}
cookie = data.get("cookie", "") if isinstance(data, dict) else ""
if cookie:
self.headers["Cookie"] = cookie推荐:
- 把容器级动作放在
detailContent.actions - 把当前播放项动作放在
playerContent.actions - 把当前播放项详情补充字段放在
playerContent.ext - 直接返回最终媒体 URL 时使用
parse=0 - 需要交给宿主解析器时使用
parse=1 - 网盘分享链接、磁力链接原样返回给宿主
danmaku()单独返回True,不要再依赖playerContent()["danmu"]- 保持
vod_id、播放项id、动作id稳定
不推荐:
- 依赖
jx - 依赖
playUrl - 依赖
localProxy(...) - 把
self.backend_parse = True当成atv-player直加载模式的功能开关 - 只返回局部动作更新
- 把站点类型、专辑类型等状态硬编码进前端假设
先检查:
playerContent(...).url是否为空parse=0时,url是否真的是媒体 URL、网盘分享链接或磁力链接- 你是不是只返回了
playUrl,却没有返回url
先检查:
- 是否实现了
runPlayerAction(...) - 动作
id是否和声明一致 - 是否抛出了被宿主记录的异常
先检查:
danmaku()是否返回True- 标题是否过脏
- 集数是否可推断
- 播放项是否保留了有意义的页面 URL 或单集信息
这不是你一个插件的问题。当前宿主没有接这条链路。
如果你是在 atv-player 直加载模式下观察到这一点,这是当前宿主的预期行为,因为它不会直接读取这个字段。
如果你走的是 Atvp.py 适配链路,则要按“分类模式/后端解析适配开关”来理解它,而不是按播放器直加载模式理解。
如果你在 Atvp.py 链路下返回的是网盘资源或磁力资源,但没有设置这个参数,Atvp 就不会切到这套目录化后端解析模式。
结果通常会是:
- 分类/搜索结果不会被包装成
atvp_detail:...目录入口 - Atvp 不会按网盘资源/磁力资源的方式拆二级目录
- 后续详情和播放阶段不会按你为网盘资源设计的链路接管
所以对网盘资源和磁力资源来说,这个参数是必填的。