构建我自己的流媒体电视台
作者:Ben Tasker / 开发者
2024年10月08日
导航至
最近一位同事提到,他看到一位 YouTuber 在家设置了自己的有线电视频道。
这让我觉得是个好主意:我有时想让电视在后台播放(背景噪音有助于狗狗安静下来),但经常很难从无数可用的选项中选择一些东西(其中包括我们大部分 DVD 的翻录副本)。
直播电视和 Prime Video 并不是真正可行的选择:目的是有一些背景娱乐,而不是被我永远不会购买的东西的烦人广告分心。
拥有一个只播放我喜欢的东西的无广告频道,将使随便播放一些东西变得容易得多;这又不是说我通常打算观看它。
我们家里有一系列设备,所以我希望我的实现使用常用的支持协议(HLS 和/或 RTMP),并且理想情况下,能够在我的 Kubernetes 集群上运行。
在这篇文章中,我将讨论我构建的容器镜像的一些更有趣的方面,以便使用我收集的翻录 DVD 来运行我自己的流媒体电视台。
概述
该系统只是一个 docker 容器,它从本地库中随机选择媒体,并通过 RTMP 和 HLS 提供给播放器。
就像电视频道一样,它播放自己的节目表。
不可避免地,我有点得意忘形,所以功能集还包括
- 媒体的允许和阻止列表:限制频道可以流式传输的内容
- 设置广播窗口的能力:限制频道可以流式传输的时间
- 动态广播触发:如果实际上没有人观看,则减少计算使用量
- 将播放历史记录和其他统计信息写入 InfluxDB
- 用于跳到下一集的 API 端点(以防播放问题或“我看过这个”)
我选择使用 InfluxDB 而不是(例如)编写文本日志,因为写入时间序列数据库可以更轻松地探索和分析播放历史记录。它还允许将历史记录与 [性能和使用情况统计信息] 轻松关联(例如,识别某些电视剧是否容易导致帧丢失率增加)。
其他 InfluxDB 功能(例如对存储桶保留期的支持)也允许轻松管理该数据的生命周期(例如,通过在数据足够旧以至于不太可能引起兴趣时使其过期)。
运行
通过 docker 调用很简单
不可避免地,在 Kubernetes 上运行的 YAML 有点冗长,但作为 K8s 配置来说仍然非常简单
RTMP & HLS
感谢我之前的一些工作,我在视频交付方面有很多先前的经验,包括 RTMP。
事实上,在之前的职位中,我拥有并运营了 nginx-rtmp 的私有分支,该分支必须进行大量定制(换句话说,我对它做了一些难以言喻的事情),以满足极其多样化的客户需求。
这种经验是相关的,因为从拥有、运营和支持自定义 RTMP 应用程序(尤其是在 CDN 规模上)中获得的一件事是,您会明白这件事情您只想做一次(如果可以的话……)。
因此,虽然 nginx-rtmp 位于此构建的核心,但我根本没有对其进行自定义,而是围绕它构建了工具。
虽然我过去构建过 HLS 工具,但这次没有必要:nginx-rtmp 内置了 HLS 支持(它也支持 DASH,但我目前没有使用它)。
视频处理
该项目涉及读取任意视频文件,因此,显然,我选择了视频转换的瑞士军刀:FFmpeg
使用 ffmpeg 将文件流式传输到 RTMP 服务器是一个已解决的问题
此命令中的关键标志是
-re
,它指示 ffmpeg
以其原始帧速率读取文件,确保文件实时流式传输,而不是一次性全部传输。
发布脚本
我用 BASH 编写了自动发布。
在使用 RTMP 堆栈的工作中,我有一位同事偶尔会开玩笑地在 shell 脚本上留下代码审查评论:“行数表明这应该使用 .py 扩展名。”
我认为发布脚本会引起类似的审查,而且……他不会错。当我意识到我需要遍历文件中的行,然后在它们上执行相当于 '|'.join() 的操作时,我开始后悔选择 BASH。
但是,我已经投入了……也许我会在某个时候重写它。
视频选择
发布脚本选择一个随机剧集,然后选择一个随机剧集,并使用从阻止列表构建的正则表达式过滤每个剧集。
过滤器在两个阶段都应用,这样我可以包含针对剧集以及剧集的过滤器。
例如,如果我想确保不包含特辑,我可以过滤掉任何包含字符串 s00e
的文件。
动态发布触发
容器的第一个版本非常简单
- 启动
nginx-rtmp
- 循环,将随机剧集发布到
nginx-rtmp
中
结果是一个始终开启的 IPTV 频道。
但是……它始终开启。即使我没有观看,ffmpeg
也在尽职尽责地运行,占用宝贵的内核并消耗电力
因此,我决定更改它,以便仅当播放器连接时,发布脚本才开始新剧集。
nginx-rtmp
模块有一系列事件挂钩,包括播放事件。每个挂钩都会向任意端点发出简单的 HTTP 请求。
因此,只需一行配置
Nginx 将播放请求的详细信息发送到目标地址
我拼凑了一个小型控制服务器(用 Python 编写——我没有重复我在入口点犯的错误),从而实现了有点简陋但功能正常的流程
- 播放器连接到
nginx-rtmp
nginx-rtmp
将播放事件发送到控制服务器- 控制服务器将播放(和播放器计数)写入控制文件
- 发布脚本检测到状态更改并启动
ffmpeg
- 播放开始
如果播放器稍后断开连接,则存在类似的工作流程
- 播放器断开连接
nginx-rtmp
将play_done
事件发送到控制服务器- 控制服务器从播放器计数中减去 1
- 如果播放器计数 < 1,则写入停止状态
ffmpeg
继续当前流,但在状态为停止时不会启动下一个流
我决定在断开连接时不停止 ffmpeg
,因为我可能想要重新连接(可能是由于更换房间)。
在日志中,它看起来像这样
从技术上讲,此过程会延长视频启动时间,但为了帮助确保流畅播放,RTMP 播放器倾向于先坐下来填充缓冲区,因此额外的延迟并不是那么明显。
引入此功能带来了一些好处,包括
- CPU 和电力不会浪费在处理无人观看的内容上
- 我不会错过剧集的开头,因为系统在有人观看之前不会启动流
唯一的真正缺点是它仅适用于 RTMP 流媒体。HLS 通过标准 HTTP 连接提供服务,因此没有简单的等效项(有一些解决方法,但它们都有我不希望做出的权衡)。
广播窗口
如果我说实话,实现这一点更多的是出于怀旧而非效率。
广播窗口声明系统应仅在设定的时间内广播视频
在这些时间之外,播放器将收到旧的 BBC 测试卡,而不是开始新剧集。
不过,它与原始版本略有不同,因为该卡片以静音方式流式传输。怀旧之情仅此而已,我并不真的喜欢重温被蜂鸣声吵醒的体验。
避免剧集之间卡顿
系统的设计意味着 ffmpeg
必须消耗一系列任意视频文件。由于这些文件是在过去几年中翻录的,因此它们之间存在很多差异,包括
- 视频和音频编解码器
- 帧速率
- 分辨率
- 像素格式(较少见,但仍然)
nginx-rtmp
几乎只是转发管道输入的内容,因此当剧集更改时,下游播放器最终可能会收到与之前收到的帧完全不同的帧。
一些播放器(如 ffplay
)可以很好地处理这个问题。但是,大多数播放器最终会冻结响应。
为了避免这些意外更改,我调整了 ffmpeg
调用,以便它可以标准化其输出
为了确保通用分辨率,使用缩放过滤器以 720p 为目标。如果输入文件的分辨率较低,则使用信箱模式。
做出更改后,剧集之间的过渡变得更加可靠。
我还实现了一个可选机制,该机制可用于在剧集切换时将播放器从流重定向走(然后再返回)。但是,Kodi 的 Simple IPTV Player 插件似乎不太喜欢这样。
播放历史记录和统计信息
多年来,我们积累了相当多的剧集,以至于有时我可能认不出我们正在观看的内容(或者也许我只是老了)。
最初,我实现了一个小型 API 端点,可以调用该端点来检查当前正在播放的内容。
但是,如果我太投入而忘记检查,直到结束后才检查怎么办?那样我就永远不会知道那部精彩的节目是什么了!
相反,我决定让容器将播放历史记录写入 InfluxDB。
结论
从本质上讲,这只是一个有趣的玩耍项目,可以让自己在几天内摆脱困境。
但是,它也很好地满足了其目标用例,即使它并不总是成功地只在后台运行:前天晚上我坐下来,打开它,迎接我的是一集《兔八哥》!
最棒的是,没有广告中断。诚然,我确实考虑添加一个广告,以便我可以起诉马斯克“扣留数十亿美元的广告收入”(现在就是这样运作的,对吧?)。
pod 的资源需求因流式传输的剧集而异:处理较高分辨率的视频显然比处理较低分辨率的视频需要更多资源。我目前将 pod 限制为 2 个内核,这对于大多数媒体来说都很好。
并不是说我要添加那么多播放器,但大部分计算费用都在初始发布中——实际的流交付非常便宜(我可能会先遇到网络争用,然后 CPU 才会成为这方面的问题)。