danmaku-tool 开发杂记

/ dev

缘起

自建Emby媒体库之后,看剧总感觉少了点什么,后来才发现是弹幕。于是想着给Emby加个弹幕,发现了各种弹幕的实现,客户端的,服务端的。

基本上面的弹幕实现客户端以浏览器插件和播放器兼容dandan API为主,而服务端实现则是完整弹幕抓取和提供dandan兼容API为主。

客户端实现有个不好是没法跨客户端,比如web页面和播放器。服务端能够解决这个问题,但是现有的服务端实现基本都是先入库弹幕,过于麻烦了。

以上面 misaka_danmu_server 为例,需要部署后端,数据库,然后手动入库剧集弹幕(主要是和媒体库匹配然后下载弹幕到本地),然后使用dandan API观看弹幕。
整个流程不说复杂,但为了看个弹幕,整个流程还是过于繁琐了。因为弹幕是存在服务端的,想要看新弹幕,还需要通过手动或者定时任务去刷新。

个人想法

弹幕服务是轻量,开箱即用且实时的,可以提供复杂的扩展,但那是基于前面几点达成后的。

后端服务不需要复杂的外部依赖,比如像MySql这种比较重的数据库,毕竟全世界的剧集电影数据加起来也才什么量级,就算用,sqlite也随便应付了。尽量不要维护另外一套媒体信息,媒体库-弹幕媒体库-剧集网站媒体库,那将极大的增加整个系统的复杂度。

服务端完全没有存储弹幕的必要,除非是提供额外的功能来保存弹幕,但很多人只是想要看弹幕,且是最新的弹幕。除非有一天剧集或者电影被平台下架,服务端离线的弹幕才有用武之地。

服务端保存弹幕会增加很多不必要的复杂度,比如入库,不管是手动还是自动或者是Emby的webhook来入库,都极大的增加了整个系统的开发难度和使用成本。

对于用户来说,打开播放页面或者播放器就能看弹幕,不用繁杂的配置,由于各种三方播放器主动的兼容dandan API,对播放器用户来说基本配置个API即可,很方便了。

上面提到的几种弹幕服务,都难以让我满意,于是乎为了简单的给自己看剧加上弹幕,danmaku-tool 诞生了,目的就是为了观看实时的弹幕(我真的只是想要看个弹幕而已),也不用复杂的配置,后端服务基本以抓取弹幕和搜索匹配为主,不需要额外存储弹幕。
单个可执行文件打包,可以作为命令行工具,也可以作为服务端提供API服务。

dandan API

在记录开始之前必须要提一下这个被各种第三方播放器支持的弹幕服务API。

dandanplay 官网介绍只是个离线播放器,但是提供弹幕API,也就是说dandan本身维护了一个弹幕后端服务,然后开发的API来提供弹幕服务。说实话提供公开服务,这个风险还真是不小,毕竟弹幕这种数据,流媒体平台是没有提供合法渠道获取的。

dandan API 里最重要的就2个接口(在我看来):

正常来说,客户端适配上面两个接口即可最简单的实现弹幕功能,但并不是所有播放器都是这么做的,比如 Yamby,他是从dandan的媒体搜索接口开始,一步一步进行数据获取,最终匹配到弹幕。

开发记录

初期目标:

概念

国内平台基本以 series-episodes 这种形式来组织媒体,基本就是两个核心ID的对应。同一部剧的不同季,都是当作不同的剧集来处理,只是在流媒体平台通过其他方式展示在了一起,本质还是多个剧集ID。

服务端概念抽象还是尽量向 dandan那边靠拢,避免过多的转换。最终用于获取弹幕的ID要么是集数ID,要么是电影ID,所以服务端最终只抽象出 剧集 电影 这两个概念。至于纪录片,综艺一类的都归于剧集,具体展示的文本则使用流媒体平台的文本,服务端不做任何处理。

基于上面的设计,就形成了 流媒体 - 服务端 这么一个ID转换和绑定关系。

语言选择

轻量,能够分发足够小的二进制文件和docker镜像。有一定的web支持,刚好最近又在看Go,就干脆用这个来练手了。(没错就是为了练手才包的这个饺子)

架构:

中间dandan API就是核心需要实现的部分,同时为了提升匹配精准度,会从用户媒体库获取媒体信息,比如Emby。至于为什么,上面的 /match 接口已经提到了,一个标题能做的实在是太少了。

聚合接口只是作为参考,实际开发肯定是不会使用的,直接从流媒体平台获取数据。

接口抽象

Scrapper 核心抽象接口,用于弹幕抓取和媒体信息获取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Scraper interface {  
// Scrape 抓取并保存弹幕 各个平台视频id/剧集id 看各自实现
Scrape(id string) error
// GetDanmaku 实时获取平台弹幕 id是各自平台的视频id
GetDanmaku(id string) ([]*StandardDanmaku, error)
// 媒体信息
Media(id string) (*Media, error)
// Match 匹配剧集信息,如果是剧集,会获取ep信息同时返回
Match(param MatchParam) ([]*Media, error)
// CheckEm 是否检查搜索结果em标签
CheckEm() bool
// 平台声明
Platform() Platform
}

MetadataService 元数据接口,用于获取媒体的元数据,可以来自于用户媒体库(Emby等),也可以来自第三方(TMDB等)。

1
2
3
4
5
6
7
8
9
type MetadataService interface {  
// 源声明
Source() Source
// Episodes 获取所有ep
Episodes(id string) ([]*MediaEpisode, error)
// Year 搜索获取媒体年份 如果传入了季id则获取对应季的年份 季节只有多余1季的才获取季节年份
// 默认只返回第一个搜索结果的年份
Year(name string, seasonId string) (int, error)
}

由于获取的元数据较少,该接口也是用于提升匹配精准度,所以还处于设计初,后续变动可能较大,也只实现了Emby元数据。

DataSerializer 弹幕序列化工具,主要是用于CLI工具对弹幕的保存,目前就考虑了 xmlass

1
2
3
4
5
type DataSerializer interface {  
Serialize(data *SerializerData) error
Deserialize(file string) ([]*StandardDanmaku, error)
Type() string
}

同时还设计了一些用于作为服务端时候的资源初始化(异步)接口,资源释放接口等。

API 提供

本身还是对dandan API的实现。服务端提供了两种模式,一种是实时模式,一种是本地弹幕文件模式。前者是实时从流媒体网站获取弹幕,后者则是优先从本地弹幕文件获取弹幕。流媒体的检索和匹配则都是直接从流媒体网站进行实时搜索。

实时模式

尽管是并发从流媒体站点获取弹幕,但为了提升性能和防止被流控,接口上还是设计了内存缓存,在短时间内不重复获取同一个视频的弹幕。

由于dandan API将弹幕ID设计为数字ID,该模式也不得不将流媒体平台的 series-episodes ID在内存中使用平台前缀进行映射:

1
episodeId -> memory_cache -> [platform]\x00[id]\x00[id] -> platform scraper

由于不同媒体客户端在处理弹幕时会有缓存操作,不得不将该映射关系持久化到文件。

为什么不使用数据库?

最终还是轻量化的考虑,如果将整个映射关系全部保存到数据库,那又是额外的复杂度了,基本上可以等同于将流媒体信息又复制了一份到系统再次映射。
整个系统设计本身是保留并使用原流媒体平台的ID的,这里不持久化这个映射关系也是出于这点考虑。

本地弹幕文件模式

本地文件模式和实时模式差异不大,只不过会将弹幕保存到本地文件,获取时候优先从文件读取。同时让弹幕文件过期时间可配置,满足不同需求。

核心和难点

整个系统其实设计并没有太多的难点,核心在于流媒体平台数据的分析和获取。
最繁杂的部分就是对剧集的匹配,这个反而是最消耗精力的。

不同流媒体平台的搜索方式不同,series-episodes 的对应关系也有差异,同时还需要考虑搜索词黑名单(比如刺客),过滤推广和垃圾数据等。
不用想,这部分代码必然十分抽象,不同平台的各种规则实在是难品,这部分的开发和调试占用了很多时间。

针对匹配,做了三种不同的匹配规则:

  1. 搜索词正则替换,用于处理平台黑名单。同时也可以用于强制匹配特定结果。
  2. 年份匹配,由于国内平台年份和一些元数据平台的年份经常对不上,这里提供规则用于设置剧集年份或者不匹配年份。
  3. 剧集重映射,同样是因为流媒体剧集和元数据平台无法对应,比如腾子的火影,是几百集放整个一季。这里提供重新映射剧集的规则,能将流媒体的剧集信息转换成和用户媒体一致的对应关系。

分发

最后选择了分发二进制文件和docker镜像。
平台就选择了三大常用平台 windows macOS linuxarmx86 架构。
不得不说的Go的交叉编译实在是好用,前提是别开CGO。

待续……

2026/02/13 更新

近闻那个搜集B站API的仓库已经被 DMCA,实是惋惜,开发时候还参考了,省去不少时间。

自用的镜像已经跑了几个月,非常稳定,空载内存占用也就20M出头,从打开视频到出现弹幕2s左右的时间,电影可能时间更长,毕竟分片更多。

最后还是实现了dandan媒体搜索的两个相关API,为了适配Yamby。