自建电视端影音系统LunaTV:从NAS到海报墙的全栈实践
家里那台电视买回来第一周就被系统里铺天盖地的雷剧推荐、开机广告和“连续包月”按钮搞得有点烦。一次不小心点到某个栏目后台莫名其妙开始下载东西。那时候我就在想既然平时看的内容大部分都是自己NAS里的电影和纪录片为什么不干脆自己做一套完全由自己掌控的电视端播放系统于是就有了LunaTV这个项目。LunaTV是我业余时间从零搭建的一套家庭影音播控台名字取自月亮的意象——希望它在客厅里保持安静、专注、按我说的播而不是反过来给我推销。核心能力很直接自动扫描NAS里的影片资源、抓取海报和简介生成海报墙支持电视端、手机端和浏览器三种入口播放播放进度三端同步手机还能当遥控器。本文会把从技术选型到部署落地的完整思路和踩坑记录都摊开来讲适合正在折腾自部署影音方案或者想用Flutter做TV端应用的开发者参考。1. 为什么做LunaTV传统电视端播放体验的三个死结1.1 聚合页推荐、会员套娃与“广告即内容”电视系统自带的那个桌面出发点从来不是“帮用户在最短时间内看到想看的片子”而是“在用户路径上塞尽可能多的广告位”。这背后是商业逻辑我理解但作为坐在沙发上的用户体验就是实实在在的崩坏。开机先来一段15秒广告首页往下翻十屏全是“VIP专享”和“猜你喜欢”你想看自己下载的4K纪录片得先退出这个聚合页打开第三方播放器手动找SMB/NFS设备输账号密码再一层层进目录。这个链路里每一个环节都不算大问题叠在一起就是灾难。我家老人打开电视经常就停在首页以为这个系统就是电视的全部。所以LunaTV的第一个目标是把“进入自己片库”这个动作缩短到两步以内开机进入App直接看到海报墙按方向键选中播放。1.2 一份清晰的需求清单和产品边界动手写代码之前我先列了一个需求清单也顺带列了“绝对不做”的清单。很多自部署项目死在功能膨胀上是因为作者做了一半什么都想加最后连自己都不想打开。LunaTV需要做的事情自动扫描本地/NAS媒体库识别影视文件的标题、年份、封面、简介。提供电视端、手机端、Web端三种入口UI风格统一操作逻辑各自贴合设备习惯。播放进度实时同步同一部电影手机上看了一半到客厅可以接着播。手机浏览器变身遥控器不需要额外安装App。支持外挂字幕、多音轨、4K HDR等常见家庭片源格式。明确不做的不集成任何在线版权视频源LunaTV只管理你合法拥有的本地文件。不做千人千面的推荐算法海报墙默认按最近添加排序用户可以手动切分类。不做服务端转码视频解码全部交给播放端的GPU/硬件。边界定得越清晰后期维护成本就越低。后来我每一次想“顺手加个功能”的时候都会拿这个清单去卡一下确实拦掉了很多高投入低回报的想法。1.3 为什么是“播控台”而不是“播放器”叫播放器会低估它做的事情。单论播放能力市面上优秀的开源播放器一抓一大把我用Xplayer、Kodi、mpv都很长时间。但LunaTV的角色更像一个“调度中心”它管理媒体库元数据负责外部显示海报墙接收遥控指令再把实际播放任务交给合适的播放内核去执行。这个类比可以帮助你理解系统的模块划分。播放器是“单车”LunaTV是“调度中心”单车只需要负责跑调度中心管车从哪出、去哪、回来停在哪。对应到代码里播放内核和UI是松耦合的UI层只发指令内核层通过抽象接口实现。这为后面替换播放内核留了空间——现在用的是libmpv哪天表现更好、更适合电视端的内核出现了替换成本也有限。2. 技术选型博弈哪些库能扛住“电视遥控器多端同步”这个组合拳2.1 播放器内核为什么选libmpv而不是ExoPlayerAndroid TV平台上大多数人会直接用Media3ExoPlayerGoogle官方维护硬件兼容性有保障。但我遇到过几个实际问题NAS里存着各种来源的老片源有外挂ASS特效字幕的有MKV内封PGS字幕的有10bit HDR的甚至还有DVDRip的古早MPG。ExoPlayer对其中一部分格式需要额外注册扩展组件部分特殊字幕效果还渲染不完整。而libmpv直接把FFmpeg的解码能力和mpv的渲染、字幕引擎整个带过来了开箱即用。桌面端就更有理由用libmpv了。Windows、Linux、macOS各有各的硬解APImpv的自动探测机制已经打磨了很多年。一套播放调参配置比如硬解模式、色彩管理、音频输出在所有设备上保持一致对维护来说省心太多。2.2 客户端框架Flutter 3.x 的取舍LunaTV客户端需要覆盖Android TV、手机、Windows、浏览器四个目标H5方案在电视盒子上最大的问题是WebView性能和遥控器按键兼容性参差不齐Electron的内存占用在电视盒子上也扛不住。Flutter可以用一套Dart代码编译到所有平台电视端的焦点管理、网格布局、DPR适配也有比较成熟的第三方库支撑。为什么不选Tauri它确实更轻WebView方案在桌面端很成熟但当时我身边没有熟悉Rust的队友电视盒子上的WebView兼容性风险也还没有被充分验证。现在回头看Tauri已经进步了很多如果你真重做LunaTV我会建议认真评估它作为桌面端候选方案。2.3 服务端为什么只需要轻量级方案很多人一听到“后端”就紧张觉得必须上K8s。实际上单家庭场景并发通常只有1-3个客户端瓶颈在磁盘IO和网络带宽根本不在CPU。所以LunaTV服务端选了Python FastAPI一个进程搞定媒体扫描、TMDB刮削、API服务和WebSocket推送。FastAPI的异步接口处理文件流很顺手FileResponse天然支持HTTP Range请求这对拖动播放进度条至关重要。数据库用SQLite加WAL模式整个片库千把部电影、几万条播放记录撑住完全没有问题。服务端不做转码所以没有FFmpeg的持续CPU开销只有扫描和刮削的时候会跑一会儿CPU。2.4 核心数据模型设计LunaTV的数据表比大多数人想象得要少核心就四张表表名关键字段作用media_itemsid, title, year, path, tmdb_id, overview, poster_path电影/剧集元数据postersid, media_id, file_path多尺寸海报文件缓存playback_recordsid, media_id, device_id, position_seconds, updated_at播放进度记录多端同步device_bindingsid, device_name, device_type, last_seen_at配对的手机/电视设备设计时的几个关键决策media_items.path是全局唯一的用文件路径做天然主键之一避免重复扫描tmdb_id单独存每次刮削只更新这个字段关联的远程信息不会因为TMDB侧改名而覆盖本地标题播放记录表用updated_at做冲突处理的依据——哪个端最后更新就采用哪个简单有效。3. 核心功能开发实录从NAS扫片到遥控器按下一部剧3.1 媒体库扫描文件名里的信息比TMDB更可靠扫描模块是服务端的重头戏。它需要遍历配置的媒体目录提取每个文件名的标题和年份再去TMDB搜索匹配元数据。文件命名规范会直接影响刮削成功率这是我开发过程中体会最深的一点。推荐的目录结构/mnt/media/ ├── Movies/ │ ├── The Matrix (1999)/ │ │ ├── The.Matrix.1999.2160p.BluRay.mkv │ │ └── The.Matrix.1999.cht.ass │ └── 功夫 (2004)/ │ └── 功夫2004.mkv解析逻辑是这样处理的先尝试匹配^(?Ptitle.?)[. ](?Pyear19\d{2}|20\d{2})这种带分隔符的格式比如The.Matrix.1999如果失败再扫描文本中的所有四连数字段作为年份候选标题则取年份之前的部分。TMDB搜索完成后保留tmdb_id、海报路径、简介、评分、演员列表等字段存进media_items。每次扫描都先查一遍现有库如果有相同的tmdb_id和路径就更新海报和简介不重复插入。这样用户手动整理文件后增量扫描能很快收敛。注意TMDB API有速率限制免费密钥大约每秒50次请求扫描上千部电影时一定要在代码里加节流否则很容易被临时封禁。3.2 海报墙渲染遥控器焦点管理比预想中难一个量级电视端UI和手机端最大的区别就是没有鼠标悬停这个交互维度。所有操作都靠遥控器的方向键和确认键焦点移动到哪个卡片上哪个卡片就必须有清晰可见的反馈。Flutter里我用GridView.builder配合FocusableActionDetector包裹海报卡片。方向键的默认焦点移动是按几何位置来的但在边角位置容易出问题焦点在第一行最后一个时按右键默认行为是跳到下一行第一个这不符合电视端的常规认知——用户期望的是焦点留在当前位置不动或者按行循环进入下一行而不是“跳”到看起来毫无逻辑的地方。解决办法是自定义FocusTraversalPolicy把左右键的移动边界限制在当前行内上下键才切换行。具体实现可以继承FocusTraversalPolicy重写nextFocus里的按键判断逻辑。这个细节不处理用户用起来的第一反应就是“遥控器坏了”。海报图片在电视端的加载也有讲究。不要直接Image.network(原始海报URL)那会让低端盒子同时解码几千像素宽的大图内存直接爆掉。我这里严格控制了所有海报缓存图片宽度不超过400像素用cacheWidth参数控制解码尺寸同时设定ImageCache.maximumSizeBytes为200MB上限滑出视口的海报及时释放。3.3 播放链路服务端只做搬运工LunaTV刻意不在服务端做转码因为转码需要CPU和GPU持续计算家庭服务器的功耗和风扇噪音都会上来。播放链路坚持“客户端解码”原则服务端用FileResponse处理媒体文件请求它原生支持Range Header。播放器拖动进度条时会向服务端发送Range: bytes...服务端返回对应的字节段seek体验和本地播放几乎没有区别。内封字幕完全交给播放内核自己处理libmpv的subtitle引擎很强。外挂字幕优先匹配同名文件。电视端直接传字幕文件路径给mpvWeb端因为没有本地文件访问权限我写了个转换脚本把ASS/SSA字幕转成WebVTT再通过HTTP推给浏览器。音频方面如果你家里的电视走HDMI ARC回传到功放直接把音频模式设为“直通”passthrough让功放解码AC3/DTS。这个LunaTV不做额外处理但需要在客户端播放设置里给出选项我在设置页放了三个选项自动、直通、PCM解码。3.4 局域网发现与手机当遥控器手机当遥控器最大的门槛是“电视端的IP地址用户记不住”。LunaTV启动时用mDNS协议注册了一个_lunatv._tcp.local.服务手机端和同网段的电视端会自动互相发现几乎不需要输入IP。配对流程是这样的手机打开http://{服务器IP}:8080页面显示一个二维码内容是{服务器IP}:{port}/pair?room客厅。电视端轮询服务器上的配对请求检测到后弹窗问用户“是否允许手机配对”确认后两端绑定同一个device_id。配对完成后手机端的遥控指令通过WebSocket发给服务端服务端广播给对应的电视端。指令内容就是一个简单的JSON结构{ action: play, media_id: 123, position_seconds: 300 }电视端收到后立即跳转到指定进度播放。实测从手机点击到电视端响应的延迟大约在50ms内完全感觉不到。这里我没有做手机到电视端的P2P直连因为多端同步播放进度时服务端本来就要在中间做一次消息路由再加一层P2P只会徒增复杂度。4. 踩坑实录四个差点让我弃坑的问题4.1 4K HDR片源在电视上整体偏绿第一个版本跑通后我拿了一部蓝光原盘试播画面一出来就傻眼了整个画面泛着诡异的绿。这不是片源问题在电脑上播是正常的。排查链路先怀疑是不是HDMI线材或电视设置换线、调电视的HDMI黑色级别都没用再怀疑是不是mpv色彩空间映射问题查日志发现解码器是VAAPI硬解但渲染管线在直通模式下没有做色彩管理。修复方式是让mpv走完整的色彩管理链路渲染后端换成vogpu-next开启target-colorspace-hintHDR转SDR时启用tone-mappingbt.2446a再给电视导入校色文件。一通操作之后画面终于正常了。如果你也遇到类似问题可以先用裸mpv命令试跑排除干扰mpv --vogpu-next --hwdecvaapi --target-colorspace-hintyes --tone-mappingbt.2446a file.mkv还有一个容易被忽略的坑电视HDMI输入的黑电平范围Limited vs Full跟播放设备不匹配时画面会发灰或者死黑这不是LunaTV能解决的需要你在电视的外部输入设置里手动调。4.2 中文文件名的NFC/NFD编码战争macOS上创建的文件夹默认用NFD分解形式保存中文Linux/NAS上常用NFC组合形式。两边差一个字节SMB挂载后就会显示成乱码。这个坑在自部署影音方案里极其常见。写了个小脚本把所有目录名和文件名统一转成NFC形式import os import unicodedata root /mnt/media for dirpath, dirnames, filenames in os.walk(root, topdownFalse): for name in filenames dirnames: new_name unicodedata.normalize(NFC, name) if new_name ! name: os.rename(os.path.join(dirpath, name), os.path.join(dirpath, new_name))跑完一遍之后FTP、SMB、NFS所有协议挂载的乱码问题都消失了。这件事给我一个教训做媒体库之前先把所有文件的编码规范化不然后续刮削和搜索会一直出各种诡异问题。4.3 TV端内存崩溃海报墙加载50张图直接OOM第一版海报墙上线后电视盒子播完一部电影返回首页时随机闪退。查崩溃日志定位到是图片加载导致的内存溢出。原因很简单Image.network默认按原始尺寸解码TMDB的海报原图虽然不大但同一屏有时候同时挂载了10张以上叠加图片缓存没做上限内存就爆了。修复策略分三层所有海报统一cacheWidth: 400解码时直接把位图缩小。全局配置ImageCache.maximumSizeBytes 200 * 1024 * 1024。给GridView.builder设置addAutomaticKeepAlives: false让不可见的Item可以被GC回收。经过这三层处理后即使在内存只有2GB的老盒子上连续翻动几十屏海报也没有再出现OOM。4.4 SQLite并发写锁播放记录偶尔存不上某天开始频繁出现手机端播放进度保存失败的日志错误是database is locked。原因很明确SQLite在默认rollback journal模式下写操作会锁整个数据库文件而手机端和电视端同时在写进度碰撞概率就高了。解决办法有两个一起用上数据库连接设置PRAGMA journal_modeWAL让读和写可以并发。服务端对写播放记录的接口加一个asyncio.Lock确保同一时刻只有一个写请求在跑。实际上WAL模式对大多数个人项目来说够用了。如果你并发冲突更严重还可以考虑把SQLite迁移到PostgreSQL但对我来说SQLite明显更合适——备份就一个文件不需要额外维护数据库服务。4.5 遥控器按键误触退出App有那么一阵评论区总有反馈“我按返回键只是想收起播放面板结果整个App退到电视桌面了。”这是因为Android TV默认的返回键行为是“返回上一个Activity”当播放页是全屏Activity时返回键直接触发Activity结束App就退出了。修复很简单但很关键在Activity级处理返回键事件判断当前是否有播放面板弹出。有面板就先收起面板没有面板才允许退出。更进一步在首页再按一次返回时先弹Toast提示3秒内按第二次才真正退出防止误触。5. 家庭部署完整步骤老笔记本也能当服务器5.1 硬件与环境准备LunaTV对服务器的要求很宽容。我最早跑在一台2014年的旧笔记本上i5 4200M加8GB内存被动散热垫着一本厚书塞在电视柜角落里稳定运行了好几个月。最低硬件建议CPU任何x86_64或ARM64双核以上内存4GB以上存储媒体文件所在目录挂载给容器系统盘剩余空间2GB以上就够网络需要支持局域网内组播普通家用路由默认都支持另外需要准备好两个东西一个TMDB API Key免费申请填一个邮箱就行以及存放媒体的目录。第一次部署前可以先想好目录结构尽量按前面推荐的命名规范整理不然后面扫描时会多花很多时间手工修正。5.2 Docker Compose编排部署LunaTV服务端和Web端都打包成了Docker镜像用Docker Compose一键拉起services: lunatv-server: image: lunatv/server:0.8.0 container_name: lunatv-server restart: unless-stopped ports: - 8080:8080 - 5353:5353/udp volumes: - /mnt/media:/data/media:ro - /opt/lunatv/data:/data/db environment: TMDB_API_KEY: 你的TMDB_KEY TMDB_LANGUAGE: zh-CN MEDIA_ROOT: /data/media DB_PATH: /data/db/lunatv.db ENABLE_MDNS: true启动之后打开http://{服务器IP}:8080服务端会给一个初始化引导页面。如果环境变量里已经配置了媒体目录会直接跳过配置点击“扫描媒体库”按钮就开始干活了。1000部电影大约需要10到20分钟完成全量扫描之后增量扫描基本分钟级。电视端APK的安装还是常规的三板斧电视设置里开启“允许安装未知来源应用”用U盘拷过去或adb install装完打开会自动做网络发现。如果自动发现失败进入设置页手动输入服务器IP即可这个兜底路径我留着因为总有人的路由器组播有点怪。5.3 手机端连接与多端同步手机完全不需要装独立App。在同一Wi-Fi下用浏览器打开http://{服务器IP}:8080首页左上角会显示一个二维码直接用手机相机扫一下电视端就会弹出配对请求。确认之后手机端就变成了一个全功能遥控器同时也可以直接在手机上浏览海报墙、播放视频。播放进度同步的机制是每个端在播放器暂停、停止、退出时把当前进度写到服务端。其他端在进入同一媒体详情页时会拉取最新进度并显示“上次看到 00:32:15是否继续播放”。三个端都遵循这个逻辑所以断点续播在任何设备上都能接上。5.4 备份与恢复自部署系统的备份主要是数据库和配置媒体文件本身不需要备份。整个/opt/lunatv/data目录就是一个SQLite文件加几个配置文件用tar打包或者直接cp到NAS上都可以。恢复的时候在新服务器上拉起Docker服务把备份的data目录挂回去再挂载同路径的媒体目录。如果媒体目录结构变了比如从/mnt/media挪到了/mnt/storage/media需要在管理后台重新指定MEDIA_ROOT并运行一次“路径映射修复”程序会按文件mtime做增量扫描通常一分钟内就把新的路径对应关系修正了。6. LunaTV后续扩展我已经在试的三个方向6.1 语音点播从智能音箱发起的请求家里有几台智能音箱平时随手一喊“放个歌”已经是习惯但想让它“放个电影”还做不到。我最近在尝试用Home Assistant做一层转发音箱听到指令后生成一个文本请求转发给LunaTV的HTTP接口比如/api/play?title星际穿越。服务端解析标题模糊匹配media_items表找到结果后直接推送给电视端播放。目前模糊匹配是用SQLite的LIKE加上简单的分词处理准确率大概在七成左右还在迭代。6.2 多用户观影记录与每周报告现在播放记录表里已经有device_id字段了继续扩展成user表并不复杂。我的计划是配对时不再只绑定设备而是给设备绑定一个家庭成员每周日晚推送一条通知告诉你“这周看了多少部电影、累计时长多少、哪个分类看得最多”。这些数据在现有的playback_records表里都有只是多几行聚合查询的事。6.3 儿童模式只有动画片的世界家里孩子会自己拿手机看视频但我不想让他直接接触到整个媒体库。LunaTV的儿童模式思路是启动后只显示指定分类动画片、纪录片的海报墙操作逻辑不变退出时需要输入PIN码。这个功能在UI层面主要是过滤和锁定后端只需要给media_items增加一个category字段不用改动播放链路。从立项到今天跑通LunaTV前后经历了大约四个月的业余时间。回头看真正花时间的不是播放器集成而是TV端焦点管理和中文媒体库的脏数据清洗。如果只给你两个建议一是在做媒体库之前先定好目录命名规范否则后续刮削和搜索都会跟着难受二是播放链路越早打通越好因为解码导致的颜色、声道问题等到UI做完再排查定位成本会高很多。希望这份记录能让你少走几步弯路。