IPTV 组播直播源搭建指南
IPTV 组播直播源搭建指南(以 IPv6 公网暴露为例)
本文是通用技术文档,把两套独立实践(四川联通家宽方案、四川电信家宽方案)里
被实测验证过的结论合并成一套可复现的流程。文中不包含任何真实域名、令牌、内网 IP,
全部用占位符,需要你自己替换。目标:把运营商 IPTV 的组播直播转成 HLS,生成
m3u播放列表,
通过公网 IPv6 直接对外提供,电视 / 手机 / 浏览器 / 飞牛影视都能看。关键前提(换环境必改的 6 个量):
# 参数 说明 P1 组播频道表 与运营商/地区绑定,只能实测获得,不可照抄 P2 组播域网卡名 + 地址 所在二层段能收到组播的那块网卡(§2 判定) P3 服务监听地址与端口 转流服务 / 网关 P4 家庭局域网网段 用于判定"客户端在不在家" P5 域名、端口、令牌、证书 公网入口 P6 公网 IPv6 前缀与本机全局地址 IPv6 直连入口
目录
- 1. 目标架构
- 2. 第一步:判定组播落在哪里
- 3. 网络层事实(先搞懂再动手)
- 4. 频道发现
- 5. 服务实现
- 6. IPv6 公网暴露
- 7. 鉴权与令牌
- 8. 播放列表与台标
- 9. 客户端接入
- 10. 部署形态
- 11. 监控与自愈
- 12. 排障手册
- 13. 已知限制与风险
- 14. 附录
1. 目标架构
运营商 IPTV 组播 <组播地址>:<端口> (地址段/端口各省不同,见 §4.0)
│
│ (光猫 = IGMP querier + 代理;组播落到哪个口见 §2 判定)
▼
┌──────────────────────────────────────────┐
│ 光猫 ONT │
│ IPTV 业务(VID) / 公共组播 VLAN │
│ 端口绑定 / 组播绑定 / IGMP 代理 │
└───────────────┬──────────────────────────┘
│ 组播域(二层段 A)
┌────────────┴────────────┐
│ │
▼ ▼
┌────────────────┐ ┌──────────────────┐
│ 主机网卡① │ │ 中间设备 │
│ (组播域) │ │ 路由器/交换机 │
│ │ │ ├ 组播域==服务域 │→ 只需 1 块网卡
│ 方案 A │ │ └ 需 igmpproxy │→ 只需 1 块网卡
│ ffmpeg udp:// │ └──────────────────┘
│ 自发 IGMP │
│ │ ┌──────────────────┐
│ 方案 B │ │ 主机网卡② │
│ udpxy → HTTP │◄─────┤ (服务域) │
│ (引用计数) │ │ 转封装/网关/nginx│
└───────┬────────┘ └────────┬─────────┘
└──────────┬──────────────┘
▼
┌────────────────────────────┐
│ 转封装层 iptv_hls_server │
│ · aac 档:视频 copy + 音轨转 AAC │
│ · mp2 档:全 copy │
│ · mobile 档:去隔行 + 降码率 │
│ · master.m3u8 声明真实 CODECS │
│ 分片写 tmpfs(不落 SSD) │
└────────────┬───────────────┘
▼
┌────────────────────────────┐
│ 网关层 iptv_gateway │
│ /auto/ 按 UA + 来源网段分流 │
│ /stream/ 强制 HLS+AAC │
│ /rtp/ 裸流直通 │
│ /debug/reqs 请求头快照 │
└────────────┬───────────────┘
▼ 仅绑 127.0.0.1
┌────────────────────────────┐
│ 反代 nginx:HTTPS + 令牌校验 │
│ listen [::]:PORT ← IPv6 入口 │
└────────────┬───────────────┘
│ IPv6(无 NAT、无端口映射)
▼
客户端:电视 / 手机 / 浏览器 / 飞牛影视
为什么走 IPv6 而不是 IPv4 端口映射
| IPv4 + DNAT | IPv6 直连 | |
|---|---|---|
| 端口映射 | 需要光猫 DMZ + 软路由 DNAT 两条规则,方向错了就只连不回 | 不需要,内核直连 |
| 回包 | 必须经软路由解 conntrack NAT | 无状态,天然通 |
| 家用设备地址 | 动态公网 IP,运营商换 IP 就断 | 前缀可能变,但主机地址可保持(运营商 PD 下发 /64,前缀变则全变) |
| NAS 自身取源 | 缺 hairpin,NAS 访问自己公网 IP 通常不通 | hosts 钉到本机全局地址即可直连本机 |
| 防火墙 | 软路由 + 光猫两层 | 路由器 IPv6 入站过滤一层 |
IPv6 路径最大的坑不是网络,而是应用层 SSRF 守卫:飞牛影视等应用的图片/媒体下载器
会拒绝解析到私网地址的 URL。全局单播 IPv6(2000::/3)不在私网判定里,反而是
「让 NAS 自己取到自己的源」的最优解。详见 §6.4。
2. 第一步:判定组播落在哪里
搭建前只有一个必须回答的问题:
运营商的 IPTV 组播,最终到达你这边的哪个二层域?哪个物理口?带不带 802.1Q tag?
这个答案决定三件事:接几块网卡、怎么接线、要不要动光猫。
这一步判错,后面全部白工。
⚠️ 不要凭"光猫上有 IPTV 专用口"就认定必须接那个口。
组播落到哪个口由光猫的组播绑定 / 端口绑定配置决定,各家运营商、各型号光猫都不一样:
实测环境 组播实际落点 是否需要专用 IPTV 口 北京联通(中兴 F477 / 多款) 任意 LAN 口都能收到组播 否 四川联通(中兴 G7615V2,FTTR) untagged 的普通 LAN 口(配置里写的 MCV=4075 与实际不符) 否 成都电信 IPTV 专用口,且是独立二层段(与家庭 LAN 二层隔离) 是 深圳联通 需手工建 VLAN 45 + 组播绑定 VLAN 80 视配置 光猫配置里写的「组播落地 VLAN」不代表实际交付方式——必须实测。
2.1 判定步骤
第 1 步:看光猫(只读,不改)
登录光猫后台,找到 IPTV 业务配置,记下 5 个值:
| 记什么 | 在哪找 | 用途 |
|---|---|---|
| IPTV 业务的 VID(单播 VLAN) | 网络侧信息 → 宽带连接列表里的 VID_xxxx | 专网单播用 |
| 公共组播 VLAN | 高级配置 → IPTV → 「IPTV 对应连接的公共组播 VLAN」 | 组播下行用;填错 = 黑屏 |
| 端口绑定 | 基本配置 → 绑定设置 | IPTV 业务绑在哪个 LAN 口 |
| 组播绑定 / 组播落地 VLAN | 应用 → IGMP 设置 / 组播设置 | 组播复制到哪个 VLAN |
| IGMP 代理 / 嗅探开关 | 应用 → IGMP 设置 | 决定你发 report 后光猫会不会去上游拉流 |
只读不改。 记住这 5 个值即可,判定阶段不需要动任何配置。
第 2 步:物理试口
一台笔记本 + 一个傻瓜交换机(不需要端口镜像功能,组播在交换机上会被泛洪):
候选口 ── 傻瓜交换机 ──┬── 机顶盒(照常看电视)
└── 笔记本(抓包)
- 必须把机顶盒也挪到候选口上。组播是"有人 join 才下发",
机顶盒不在这一段,它的 IGMP 报告就到不了这里,光猫不会往这个口送流 - 依次试每个 LAN 口(含标着 ITV/IPTV 的那个),不要凭名字跳过
- 笔记本那个口拿不到 IP 是正常的(该段可能没有 DHCP),不影响抓包
- 抓包过滤器(用整个组播空间,不要只写 239,理由见 §4.0):
igmp or (udp and net 224.0.0.0/4) or tcp port 80
- 抓 3
5 分钟,期间用机顶盒遥控器换台,每个台停 510 秒
(换台是必须的——静止看电视时不会产生新的 Join 报文) - Wireshark 里按
Ctrl+M打标记,方便事后对齐换台顺序
第 3 步:读出三个结论
| 结论 | 怎么读 | 影响 |
|---|---|---|
| 组播从哪个口出 | 哪个口上出现组播地址的 UDP 流 | 决定接线 |
| 带不带 tag | Wireshark 里看是否有 802.1Q 标签 | 带 tag → 需要 VLAN 子接口 |
| 是不是独立二层段 | 笔记本能否与家庭 LAN 互 ping | 隔离 → 需要额外的桥接手段 |
同时你顺手拿到了第一个组播地址(从 IGMP Report 或 UDP 目的地址里读)。
若某个口只抓到 IGMP、看不到 UDP 组播:交换机开了 IGMP snooping,只往申请者泛洪。
地址照样能提取,端口显示"未观测到"——脚本会按占位端口处理,之后用实测修正。
第 4 步(可选):验证这台光猫的组播到底能不能通
用第 3 步拿到的地址,在笔记本上直接试:
# Linux
ffplay rtp://239.3.1.241:8000
# 或
ffplay "udp://@239.3.1.241:8000"
# Windows:VLC → 媒体 → 打开网络串流 → rtp://239.3.1.241:8000
能出画面 = 这个口可用,组播域确认。
出不来 → 换下一个候选口,回到第 2 步。
这也是
lelehub/udpxy-IPTV验证北京联通的标准做法:
「将电脑通过有线连接到光猫的任意接口,和光猫处于同一网段,用 ffplay 打开 rtp://… 尝试」。
2.2 判定结果 → 拓扑决策
设两个概念:
- 组播域:组播流实际存在的那个二层段
- 服务域:客户端访问你播放列表的那个二层段(家庭 LAN)
| 判定结果 | 需要的网卡 | 接线 | 说明 |
|---|---|---|---|
| 组播域 == 服务域 | 1 块 | 主机接家庭网即可 | 最省事。组播直接从光猫/交换机到你主机 |
| 组播域 ≠ 服务域,中间设备未开组播代理 | 2 块 | 一块进组播域,一块进服务域 | 路由/防火墙默认不转发组播,跨不过去 |
| 组播域 ≠ 服务域,中间设备开了 igmpproxy | 1 块 | 主机只接服务域 | 路由器把组播桥进 LAN,主机在 LAN 侧就能 join |
| 组播域是光猫 IPTV 专用口(独立段) | 2 块,或 1 块 + 旁路代理 | 见下 | 家用设备无法直连那个段 |
| 组播带 802.1Q tag | 1 块 + VLAN 子接口 | — | 见 §2.3 警告 |
「跨不过去」的三种解法(按推荐度):
- 主机双网卡(§2.4 方案 A)——最直接,服务与组播同机,无额外依赖
- 路由器开 igmpproxy(OpenWrt/iStoreOS 有
igmpproxy包)——组播桥进家庭 LAN,
主机只需 1 块网卡。注意:改完接口必须service igmpproxy restart,否则不生效 - 组播域内放一台小主机跑 udpxy(§2.4 方案 B)——udpxy 做组播→单播,
服务域内的客户端访问它的 HTTP 地址
双网卡不是为了"IPTV 业务需要一张专用网卡",而是因为组播域和你的服务域是两个二层段,
中间那台路由器不转发组播。这是同一个段就没这回事。
双网卡时,两块网卡各干什么(这张表决定后面所有配置):
| 网卡①(组播域) | 网卡②(服务域) | |
|---|---|---|
| 接哪 | 光猫 / 组播源那一侧 | 家庭 LAN / 路由器那一侧 |
| 用途 | 只用于收组播:发 IGMP report、收 ffmpeg 的流 | 对外提供服务:nginx、网关、扫描器 |
| 取流时 | ffmpeg 的 ?localaddr= 绑这张卡的 IP | — |
| 对外监听 | 不监听任何端口 | nginx :18088、网关 :4024 |
| 默认路由 | 不要(组播路由单独指定,见 §3.3) | 要(唯一出口) |
⚠️ 默认路由只能有一条,且必须指向服务域那张卡。
两张卡都设默认路由会导致路由抖动(表现为间歇性连不上公网)。
组播路由用 §3.3 的224.0.0.0/4单独指定到网卡①,不依赖默认路由。nginx 的
listen要绑服务域的地址(或0.0.0.0,但那样网卡②上的公网 IPv6
也会被暴露 —— 见 §10.1 里LISTEN_HOST的说明)。
2.3 VLAN 子接口的正确用法与警告
什么时候才需要建 VLAN 子接口:判定结果是「组播带 802.1Q tag」。
此时 VLAN 子接口是必需的,不是可选项。
但建之前必须知道(cucc 环境踩过的坑):
- 子接口会自动生成无 metric 的同网段路由,而系统给主网卡那条路由带 metric
→ 内核优先选子接口 → 你去光猫/机顶盒的流量源地址被改写,
ARP 邻居也全挂到子接口上 - 典型症状:
ip route get 192.168.1.1显示dev eth0.3300 src 192.168.1.240(地址不是你的) - 对策:
- 不要把 IPTV VLAN 建在承载默认路由的那块网卡上
- 确需同网卡时,显式覆盖 metric:
ip route replace <网段> dev eth0 src <IP> metric 10 - 清理残留:
ip link delete eth0.<vid>
反过来看:如果判定结果是「组播 untagged 落在普通口」(四川联通 cucc 环境实测),
不要建任何 VLAN 子接口——实测eth0.407510 秒只比 untagged 口多 67 字节,
组播根本不走它们,而它们却劫持了路由。
2.4 两种组播接入形态
方案 A:主机直接收组播(主机自己在组播域内)
- 主机有一块网卡在组播域,ffmpeg 直接
udp://<group>:<port>?localaddr=<P2_IP>
(localaddr不能省,见 §14.2 —— 缺了它源地址会走默认路由,
落到服务域网卡上,光猫只往发过 IGMP Report 的口送流 → 表现为"组播收不到") - 需要主动发 IGMP Membership Report(光猫是 querier+代理,见 §3.2)
- 优点:无额外组件、少一跳、延迟最低
- 前提:主机确实在组播域内(这就是需要那块网卡的原因)
方案 B:udpxy 代理(组播域与服务域分离,或要用它的引用计数)
- 组播域内跑 udpxy(容器/路由器均可),
http://<proxy>:4022/rtp/<group>:<port> - 主机在服务域,通过 HTTP 拉流,不用碰组播
- 优点:引用计数——多个客户端/扫描器同时订阅同一组,
断开时不会发 IGMP Leave 把别人的流掐断 - 注意:udpxy 必须指定组播接口(
-m <iface>),否则从错误网卡加入组 → 客户端拿到 0 字节;
且必须network_mode: host
选哪个:
| 情况 | 选 |
|---|---|
| 主机本来就在组播域(双网卡方案) | A |
| 主机与服务域分离 | B |
| 要同时跑扫描器 + 播放器 | B(A 会互相打断,§3.2) |
| 想省一台设备 | A(组播域 == 服务域时) |
2.5 其余前置条件
网络侧
- 运营商 IPTV 业务已开通
- 有一台常开的 Linux 主机(Debian / Ubuntu / fnOS 均可),有 root
- 主机或所在网段有可路由的公网 IPv6(电信/联通/移动家宽基本都有)
- 路由器 IPv6 已启用,且允许入站到你的主机(很多路由器默认开 IPv6 防火墙)
- 一个域名(可选但强烈建议),A 记录指向公网 IPv4,AAAA 记录指向你的 IPv6
- 一张覆盖该域名的 TLS 证书(完整链:leaf + 中间证书)
软件侧
python3 --version # 3.9+;所有脚本只用标准库
ffmpeg -version # 需要 libx264 + aac(静态包一般自带)
docker --version # 可选
平台无关性:本方案在 fnOS(Debian 12 衍生)、Debian、Ubuntu 上均验证过。
网卡数量按 §2.2 判定结果定,1 块是可行且常见。
3. 网络层事实(先搞懂再动手)
这一节的每一条都是实测结论,踩过的坑不要再踩。
3.1 「光猫配置的组播 VLAN」≠「实际交付方式」
这是最容易误判的一点。 光猫配置里写的组播落地 VLAN,与实际到达你网卡的帧,
可能完全不是一回事。
实测反例(四川联通 / 中兴 G7615V2,FTTR):
- 光猫配置写着「组播落地 VLAN(MCV)=4075」
- 但实际组播不带 tag 落在普通 LAN 口:untagged 网卡 10 秒进 3.75 MB,
而eth0.4075子接口只多 67 字节 - → 组播根本不走 VLAN 子接口
所以:不要照抄别人的结论,也不要照抄光猫页面上的数字。
按 §2.1 抓包实测,Wireshark 里看有没有 802.1Q 标签,那是唯一可信的判据。
如果实测确认带 tag,VLAN 子接口就是必需的,但建它有坑(§2.3):
子接口自动生成的路由没有 metric,会盖掉系统给主网卡那条带 metric 的路由 →
去光猫/机顶盒的流量源地址被改写,ARP 邻居全挂到子接口上。
典型症状:ip route get 192.168.1.1 显示 dev eth0.3300 src 192.168.1.240(地址不是你的)。
对策:不要把 IPTV VLAN 建在承载默认路由的网卡上;确需同卡时显式覆盖 metric:
ip route replace 192.168.1.0/24 dev eth0 src 192.168.1.89 metric 10
清理残留:ip link delete eth0.<vid>。
3.2 必须主动发 IGMP Membership Report
光猫同时是 querier + 代理:只有先看到 NAS 的 Membership Report,它才会把该组从
IPTV WAN 拉下来并下发到本口。ffmpeg 的 IP_ADD_MEMBERSHIP 也能让内核自动发 report,
但显式发更稳(尤其是按需拉流、需要保活的场景)。
实现要点(方案 A):
SOCK_RAW+IPPROTO_IGMP,需要CAP_NET_RAW(容器)或 rootsetsockopt(IPPROTO_IP, IP_MULTICAST_IF, <P2 地址>)指定发包网卡(P2 = 组播域那张网卡)- 报文发到
224.0.0.22(IGMPv2 all-members) - 保活:流存活期间每 30s 重发一次 report
- 释放:停流时发 Leave(类型
0x17),让上游收回组 - 单接口成员上限约 32,批量加组超了会
ENOBUFS→ 必须分批
⚠️ 两个进程不能同时订阅同一组。systemd 版与 Docker 版并行、或扫描器与播放器
同时 join 同一组时,一方退出会发 IGMP Leave,把另一方正在收的流一起打掉。
表现是「看着看着卡住」。方案 B(udpxy)用引用计数天然规避这一点。
3.3 组播路由要持久化
ip route replace 224.0.0.0/4 dev "$P2_IFACE" src "$P2_IP"
注意是 224.0.0.0/4(覆盖 224~239 整个组播空间),不是 239.0.0.0/8。
运营商换到 233 段时,这条路由无需改动(§4.0)。
手动 ip route add 的路由重启即丢。交给 systemd 持久化:
# /etc/systemd/system/iptv-mroute.service
[Unit]
Description=IPTV multicast route for group join
Before=iptv-hls.service
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'ip route replace 224.0.0.0/4 dev <P2_IFACE> src <P2_IP>'
[Install]
WantedBy=multi-user.target
这条路由不是绝对必需:转流服务用
?localaddr=<IP>绑源地址、IGMP 报告用
IP_MULTICAST_IF指定网卡,路由丢了流也能通(实测删掉后仍能取到分片)。
但没有它,内核查路由会回落到另一块网卡,属于带病运行,排障时极易误判成"组播收不到"。
3.4 冷启动有竞态
某个组被所有人放掉后,光猫要 ~15s 才重新从 IPTV WAN 拉下来。
若 ffmpeg 的 timeout 短于这个值,首次请求必然失败。
- ffmpeg 输入 URL 的
timeout参数设成 30000000(30s,单位 µs) - ffmpeg 的探测窗口压到
-analyzeduration 300000 -probesize 2000000(见 §5.2)
组是热的(刚有人看过)时首次请求即刻出流,实测首片 1.72.6s。
3.5 IGMP snooping 通常无害
开启 IGMP snooping 后,交换机按组播表过滤。本链路实测零影响:
组播 15 秒进 38.76 Mbps、多路端到端取流全通、机顶盒画面正常、
上级路由口未泛洪(只收 200 kbps)。若真出现挂掉:
先记基线 → 打开后 30 秒内验 3~5 个频道 → 任一为 0 立刻改回。
3.6 运营商侧可能存在并发组播频道数上限
这条不在光猫上,在运营商局端。
GPON 的 IPTV 承载网里,OLT 做 IGMP 终结(终止用户侧 IGMP,转成自己的组播订阅)。
据公开的 PON 口抓包分析,OLT 侧通常限定同一时刻最多转发的组播频道数
(该分析给出的量级是 8 路;各家/各期的具体阈值不保证相同)。
⚠️ 本文两套实测环境都没有撞到这个上限(cucc 侧实测到的是 4 路 4K 并发 ≈ 104 Mbps、
3 路端到端取流全通)。所以下面是机制说明,不是已复现的故障。
你若遇到「新台黑屏」,请先按 §3.4(冷启动竞态)和 §3.2(IGMP report 是否发出)排查,
排完这两项再怀疑并发上限。
机制要点(这部分是确定的):
- 组播 VLAN 是单向的:只有 OLT → ONT 下行,ONT 不会往组播 VLAN 发任何东西
- 不发 IGMP 请求时,光猫收不到组播 VLAN 的任何数据 → 不是"光猫收不到所以坏了",
而是上游根本没下发 - 若运营商侧确有并发上限,超过后新请求的组拉不下来
(表现为:开着的台正常,新开的台黑屏或一直转圈)
自查手段:
# 已经在看的组(看本地 IGMP 成员状态)
cat /proc/net/igmp
# 或
ip maddr show
3.7 源侧坏频道修不了
部分 4K 专区频道每秒一次块状斑块,是运营商推流侧的 TS 连续性错误:
| 频道 | 码率 | TS 连续性错误(6 秒窗口) |
|---|---|---|
| 某卫视 4K | 38 Mbps | 0 |
| 另一卫视 4K | 37.9 Mbps | 1 |
| 某 4K 专区 | 15.9 Mbps | 8 |
码率更低反而错得更多 → 与带宽、网卡、socket buffer 都无关。
处理:加进黑名单摘掉。转码遮丑只会把花屏变成轻微卡顿,还吃 CPU。
4. 频道发现
4.0 组播地址段:全国并不统一
这是最容易想当然的地方。 国内 IPTV 的组播地址绝大多数落在 239.0.0.0/8
(Administratively Scoped,私有组播),但:
- 不是全国统一:同一个运营商的不同省市,第二段就不同
- 甚至同一地区也会整体换段:
上海电信 2025-12 起 239 网段停播,改为 233 网段;
2025-02 又新增233.18.204.x 233.0.0.0/8(GLOP)和232.0.0.0/8(SSM)是完全合法的段,
上海电信用的就是 233
已公开的实测样本(均来自社区公开列表,非本文实测;请以自己抓包的结果为准):
| 地区 / 运营商 | 组播网段 | 端口 | 来源 |
|---|---|---|---|
| 北京联通 | 239.3.1.x | 每频道不同:8001 / 8108 / 8288 / 8300 … | 社区 m3u 列表 |
| 山东电信 | 239.21.1.x、239.21.2.x | 5002 | 社区列表 |
| 上海电信(旧段) | 239.45.0.1 ~ 239.45.3.254 | 5140 | 社区列表 |
| 上海电信(现用段) | 233.x.x.x、233.18.204.x | 5140 | 社区列表(2025 年初新增 233 段) |
| 江苏电信 | 239.49.x.x | 8000 | 社区列表 |
| 四川联通 | 239.0.0.x | 5140 | 本文实测(cucc) |
| 成都电信 | 239.93.x.x | 5140 | 本文实测(ctcc) |
| 广东电信 | 239.77.x.x | 5146 | 社区列表 |
上海电信的换段经过:2025 年初新增了
233.18.204.x段,
2025 年底原239.45.x段停播、只剩 233 段可用。
这类整段迁移是运营商行为,你无法预知 —— 只能靠定期重扫发现(§11.2)。
结论:不要在任何工具、脚本或文档里写死 239. 前缀或 5140 端口。
三处必须做的检查:
-
抓包过滤器用整个组播空间,不要只匹配 239:
igmp or (udp and net 224.0.0.0/4) or tcp port 80224.0.0.0/4覆盖 224~239,包含 233/232。
本文的iptv_pcap2m3u.py用is_multicast()判断
0xE0000000 <= n <= 0xEFFFFFFF,同样覆盖整个 /4。 -
只剔除链路本地控制块(
224.0.0.0/24:IGMP 自身、mDNS、SSDP),
不要按239.前缀黑名单。239.255.255.250等服务发现地址单列即可。 -
端口要逐频道处理。北京联通的
239.3.1.22:8001、239.3.1.26:8108、
239.3.1.29:8288—— 同一个网段,端口每个频道都不一样。
频道表的键必须是<组播地址>_<端口>组合,不能只用地址。
组播地址段的稳定性不由你决定。运营商调整网段是常态
(上海电信一次调整就作废了整份旧列表)。
这也是为什么频道表必须能重新扫描(§11.2)而不是写死。
4.1 路径一:抓包(通用,跨运营商)
组播地址扫不出来,因为组播地址不是机顶盒对外提供的单播服务,
而是它在 IPTV 专网里主动 join 的流。从 192.168.1.0/24 扫永远扫不到
(那不是方法问题,是物理隔离)。
但知道网段之后是可以枚举的——上海电信的做法就是直接扫
239.45.0.1 ~ 239.45.3.254 端口 5140。
抓包设备接入 IPTV 那条线,抓机顶盒的组播流量,解析 IGMP 报文得到组地址。
接线前先按 §2.1 判定组播落在哪个口——可能是光猫的任意 LAN 口,
不一定是标着 ITV/IPTV 的那个。用一个傻瓜交换机分线即可(组播会被泛洪),
交换机不需要端口镜像功能。
iptv_pcap2m3u.py(零第三方依赖,仅标准库)可直接复用,支持:
- 经典 pcap(微秒/纳秒)与 pcapng(Wireshark 默认格式)
- 链路层:Ethernet / Linux SLL / SLL2 / Raw IP,含 802.1Q VLAN
- IGMPv1/v2 Report、IGMPv3 Membership Report、MLD(IPv6 组播)
- UDP 组播流(推断 rtp 端口、包数、码率)
- 明文 HTTP / RTSP 请求(用于定位 EPG 与播放地址)
# 第一遍:生成候选表 + channels.csv(频道名是占位符)
python3 iptv_pcap2m3u.py iptv.pcapng
# 人工补频道名:编辑 channels.csv 的「频道名(待补)」列,
# 与你记录的换台顺序对齐(channels.csv 里有「相对秒」可对)
# 第二遍:带上频道名映射重跑,生成正式列表
python3 iptv_pcap2m3u.py iptv.pcapng --map channels.csv
# 同时生成经 udpxy 转单播的列表
python3 iptv_pcap2m3u.py iptv.pcapng --udpxy <NAS_IP>:4022
⚠️ 要跑两遍。
--map是输入参数(频道名映射表),
而channels.csv又是本工具的输出。第一遍跑出来的是「频道01/02/…」占位名,
补完名字后第二遍带上--map才得到有名字的列表。
产物:
| 文件 | 内容 |
|---|---|
channels.m3u | 直连组播播放列表(rtp://ip:port) |
channels_udpxy.m3u | 经 udpxy 转单播的播放列表 |
channels.csv | 首现时间 / 组播地址 / 端口 / 包数 / 码率(用于人工补频道名) |
http_urls.txt | 抓到的 HTTP 请求与 URL(找 EPG 用) |
抓包得到的是候选集,还需要逐个验证是否真有流、码率多少(§4.3)。
⚠️ 端口必须一起记。同一个组播地址在不同频道上可能用不同端口
(北京联通239.3.1.22:8001/239.3.1.29:8288)。
只按地址去重会漏频道。
4.2 路径二:运营商公开清单(快,但强绑定运营商)
部分运营商会公布组播/单播地址清单,可直接抓取解析。这一步需要一个针对
你运营商写的抓取脚本——没有通用脚本,因为清单页的格式各不相同:
# 用你自己(或按 §5.0 取来的)清单抓取脚本
python3 <清单抓取脚本>.py "<清单页 URL>" > /tmp/list.tsv
# 输出格式:名称 \t 组播地址 \t 端口
cucc 侧有一个现成的例子(iptv-cucc/scripts/get_sichuan_list.py),
但它写死了四川联通的页面与解析规则,换运营商不能直接用,只能当模板改。
⚠️ 不同运营商/地区的组播段完全不同,网上流传的多份清单属于不同地区,混用必然收不到流。
例:某清单用239.77.x.x,与另一运营商的239.0.x.x无关。⚠️ 公开清单还有个时效风险:运营商换段后清单不会立刻更新
(上海电信 239 → 233 的切换让整份旧列表作废)。
清单只能当初始候选集,最终仍要靠定期重扫纠正(§11.2)。
没有公开清单怎么办?→ 走 §4.1 抓包。
抓包路径能给出完整的「地址 + 端口」候选集,缺的只是频道名。
频道名的来源有三个(按可靠性):
- 抓包时记录的换台顺序 +
channels.csv的「相对秒」列 → 人工对齐补名 - 运营商的 EPG/频道表接口(§4.1 的
http_urls.txt里能抓到线索) - EPG 解析工具(
epg抓取器)按频道名反查组播地址
补完名字后,后续步骤(§4.3 逐组验证、§4.4 建表)完全不变。
换句话说:公开清单只是省掉"补名字"这一步,不是必需品。
4.3 逐组验证
方案 A(自建 IGMP):批量 join,用 IP_PKTINFO 读包的目的地址,
精确判定每个组是否有流。
# 用法:SRC_IP SECONDS_PER_GROUP BATCH_SIZE LIST_TSV
# SRC_IP = 组播那张网卡上的源 IP(不是网卡名!)
python3 mc_scan.py 192.168.1.89 3 20 /tmp/list.tsv
# → /tmp/live_channels.tsv 名称 \t 目的地址 \t 端口 \t 收到字节数
⚠️ 第一个参数是源 IP 地址,不是网卡名。脚本内部把它直接交给
IP_MULTICAST_IF的inet_aton(),传网卡名会直接抛异常退出。
先用ip -4 -o addr show dev "$IFACE"取到 IP 再传进去。
- 批大小 ≤ 20(内核单接口成员上限 ~32,留余量)
- 每组等待 3s 足够:光猫拉流 ~15s 是冷组的情况,组热时毫秒级就有包
- 收到 0 字节的组 = 空流/已下架,剔除
方案 B(udpxy):通过 udpxy 的 HTTP 接口收流。
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' --max-time 5 \
http://<NAS_IP>:4022/rtp/239.0.0.31:5140
# MB 级字节才算通;0 字节 = 源没推流或接口选错
用 udpxy 探测而不是直连 join 的原因:udpxy 对每个组做引用计数,
探测结束断开时不会发 IGMP Leave,因此不会打断正在播放的同组画面。
直连IP_ADD_MEMBERSHIP/leave 的写法会在扫到你正在看的台时把流掐断。
4.4 生成频道表
统一成 channels.json,供各服务读取:
{
"239.0.0.31_5140": {
"name": "CCTV-1",
"mcast": "239.0.0.31",
"port": 5140,
"group": "央视",
"kbps": 8700
}
}
键必须是 <组播地址>_<端口> 组合,不能只用地址:
- 端口逐频道不同(§4.0),只用地址会把不同频道合并成一个
- 键里带地址 → 运营商换段时,键自然变化,不会与旧频道混淆
- 键里带端口 → 同一地址不同端口是两个不同频道
同时生成母版 m3u(#EXTINF + tvg-id / tvg-name / tvg-logo / group-title + 流地址)。
5. 服务实现
三个组件,全部只用 Python 标准库 + ffmpeg。
5.0 脚本与源码清单
本文引用到的脚本全部来自两套已验证的实现,先确认你要用哪一套:
| 脚本 | 来自 | 用途 | 必须配套 |
|---|---|---|---|
iptv_pcap2m3u.py | iptv-ctcc/(仓库根) | 抓包 → 频道候选表 | 无(纯标准库) |
mc_scan.py | iptv-cucc/scripts/ | 逐组实测有流 | 方案 A(自建 IGMP) |
probe_codecs.py | iptv-ctcc/hls/ | 探测真实视频编码 | 必须能访问 udpxy |
iptv_hls_server.py | iptv-ctcc/hls/ | 按需转封装(master + AAC/MP2 变体) | 必须能访问 udpxy |
iptv_gateway.py | iptv-ctcc/hls/ | 播放列表分发 + 客户端分流 | iptv_hls_server.py |
app.py | iptv-ctcc/hls/ | 周期扫描 + 生成 m3u + 状态页 | channels_baseline.csv |
entrypoint.sh | iptv-ctcc/hls/ | 容器内拉起三个进程 | 同上 |
iptv_hls.py | iptv-cucc/scripts/ | 精简转封装(直连 udp://) | 方案 A |
build_channels.py / gen_logos.py | iptv-cucc/scripts/ | 生成频道表 / 台标 | 无 |
get_sichuan_list.py | iptv-cucc/scripts/ | 抓四川联通公开清单 | 仅示例,换运营商要重写 |
health_check.sh / rescan_channels.sh | iptv-cucc/scripts/ | 健康检查 / 频道表重扫 | 见 §11 的布局说明 |
本文的正文按「容器三件套」写(
iptv_hls_server.py+iptv_gateway.py+app.py),
因为它是功能更全的一套:master playlist + 真实 CODECS + AAC 变体 + 客户端分流。
其 URL 布局是/hls/<cid>/master.m3u8,§8.1 / §14.3 都按这个写。方案 A(自建 IGMP,直连 udp://)用的是另一套实现(
iptv_hls.py),
它的 URL 布局是/<cid>/index.m3u8,没有 master / 多变体 / CODECS。
选方案 A 就要接受这个差异,或自己改造。§5.6 有说明。
5.1 组件划分
| 组件 | 职责 | 端口 | 监听范围 |
|---|---|---|---|
iptv_hls_server | 按需拉流 → 转封装 → 提供 HLS 分片与 master | 4023 | 仅 127.0.0.1 |
iptv_gateway | 播放列表分发、CORS、按客户端分流、裸流直通 | 4024 | 内网 / 公网 |
iptv_scan | 周期扫描、生成 m3u、状态页 | 9050 | 仅 127.0.0.1 |
nginx | HTTPS + 令牌校验 + IPv6 入口 | 18088 | 公网 |
端口是全文一致的,改动请连带四处:
本文统一用 4023 / 4024 / 9050 / 18088。
nginx 的 upstream 必须指向 4023(转流服务),不是别的端口。
涉及这四个数字的位置:§5.1 本表、§6.3 的upstream块、
§10.1 compose 的环境变量、§14.3 验收清单。
三者可以是三个进程(推荐,便于独立重启与排障),也可以一个容器里跑三个进程。
若同容器,注意 entrypoint.sh 要给每个进程注入各自的端口变量
(HLS_PORT / GATEWAY_PORT / PORT,见 §14.1),
并且任一子进程退出就整体退出,交给 Docker restart 策略拉起,避免"半死"状态。
5.2 转封装参数
这一节的参数组合是实测调出来的,改动前先看理由。
ffmpeg -nostdin -loglevel error \
-fflags nobuffer -flags low_delay \
-analyzeduration 300000 -probesize 2000000 \
-i "$SRC" \
$VIDEO_AUDIO_ARGS \
-force_key_frames "expr:gte(t,n_forced*$HLS_TIME)" -sc_threshold 0 \
-f hls -hls_time "$HLS_TIME" -hls_list_size "$HLS_LIST" \
-hls_flags delete_segments \
-hls_segment_filename "$DIR/%s_seg_%05d.ts" "$DIR/index.m3u8"
⚠️
-force_key_frames和-sc_threshold 0不能省。
HLS 只能在关键帧处切分,x264 默认 GOP=250 帧(25fps 下正好 10 秒),
不强制关键帧 → 分片被拉成 6~10 秒 → 播放器要等一整个分片才起播。
这两个参数只在视频重编码时需要($VIDEO_AUDIO_ARGS里含-c:v libx264);
纯 copy 的档位不重编码,不需要强制关键帧。
| 参数 | 值 | 理由 |
|---|---|---|
-analyzeduration | 300000(0.3s) | ffmpeg 默认 5s 探测,冷启动主因(首片 5.66s → 1.67s) |
-probesize | 2000000(2MB) | 不能压更小。100KB(≈21ms)时 37.9Mbps 的 4K HEVC 连 PMT 都凑不齐,ffmpeg 直接 rc=234 / Output file does not contain any stream 退出,播放列表永不出现 |
-hls_time | 1 | 目标分片时长 |
-force_key_frames | expr:gte(t,n_forced*1) | 必须有。HLS 只能在关键帧切分,x264 默认 GOP=250 帧(25fps 正好 10s),不强制关键帧分片会被拉成 6~10 秒,播放器要等一整个分片才起播 |
-sc_threshold | 0 | 配合上面,关闭场景切分 |
-hls_flags | delete_segments | 淘汰旧片。旧片被淘汰后返回 404,播放列表用 EXT-X-MEDIA-SEQUENCE 正常前移,不会把坏片暴露给播放器 |
三档音视频策略
| 档 | 视频 | 音频 | 用途 |
|---|---|---|---|
aac | -c:v copy | -c:a aac -b:a 128k | 浏览器唯一能播的一档 |
mp2 | -c:v copy | -c:a copy | 移动端/电视播放器零转码,最快 |
mobile | 见下 | -c:a aac | 外网/手机省流量档 |
mobile 档的视频参数(两个例外都不重编码):
-vf "yadif,scale=-2:'min(1080,ih)'" \
-c:v libx264 -preset veryfast \
-b:v 5500k -maxrate 7150k -bufsize 11000k \
-force_key_frames "expr:gte(t,n_forced*1)" -sc_threshold 0
-
yadif必须:IPTV 普通频道是 1080i50(隔行),不处理会把梳齿伪影固化进画面 -
不重编码的两种情况:
- 源码率已经低于目标(重编码只会更差)→ 视频 copy,只转音频
- HEVC(4K 频道,源是 BT.2020 10bit)→ 转成 BT.709 8bit 会改变色彩观感,宁可多花流量
-
目标码率 5.5Mbps 的依据(FFV1 无损参考 + 帧对齐对比):
目标码率 SSIM PSNR(Y) 3.5 Mbps 0.9927 48.8 dB 5.5 Mbps 0.9939 49.9 dB 8.0 Mbps 0.9947 50.8 dB 5.5Mbps 已属视觉无损,流量省约 30%;再往上收益可忽略。成本
libx264 veryfast≈ 1.1 核/路。
5.3 master 播放列表与 CODECS
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=8800000,CODECS="avc1.640028,mp4a.40.2"
aac.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=8800000,CODECS="avc1.640028,mp4a.40.34"
mp2.m3u8
CODECS 是播放器判断"支不支持、要不要硬解"的唯一依据(hls.js 用它过滤 variant,
ExoPlayer 用它挑硬解器)。写错的后果:浏览器以为能播、选中、然后 append 失败或退软解。
- 音频:
mp4a.40.2= AAC,mp4a.40.34= MPEG-1 Audio Layer II (MP2) - 视频:
avc1.xxxx(H.264)/hvc1.x.x.Lxxx.B0(HEVC)
必须用工具探测真实编码,不能对所有频道写死 H.264:
# ⚠️ 必须显式给出 --udpxy:脚本的默认值指向别人的机器(192.168.1.5)
# 不改的话会去探测一台不存在的机器,得到全错的 codecs.json
python3 probe_codecs.py --channels channels.json --out codecs.json \
--udpxy http://<udpxy主机>:4022
⚠️
probe_codecs.py只能经 udpxy 探测(它拼%s/rtp/%s:%d去拉流)。
如果你走的是方案 A(自建 IGMP,没有 udpxy),这个脚本不可用。
方案 A 的等价做法是直接用 ffprobe 逐频道探测:
# 方案 A:直连组播探测编码
for cid in 239.0.0.31_5140 239.0.0.32_5140; do
g=${cid%_*}; p=${cid#*_}
echo "=== $cid"
ffprobe -v error -select_streams v:0 \
-show_entries stream=codec_name,profile,level,width,height \
-of default=nw=1 "udp://$g:$p?localaddr=<P2_IP>&timeout=5000000"
done
典型比例:153 个频道里 14 个 4K 是 HEVC(ctcc 环境实测)。
5.4 按客户端分流
流地址在播放列表里写内网 IP还是公网 IPv6,取决于客户端可达性。分流逻辑在网关层:
| 场景 | 路径 | 行为 |
|---|---|---|
| 局域网手机 / 电视 | /auto/ | 原始流直通,零转码、零冷启动 |
| 局域网桌面浏览器 | /auto/ | 302 到 HLS(音轨转 AAC,浏览器才播得动) |
| 外网 / WireGuard | /auto/ | 低码率档(5.5Mbps),省流量 |
| 强制兼容 | /stream/ | 一律 302 到 HLS(视频原画 + AAC 音轨) |
| 任意 | /rtp/ | 裸流直通(udpxy 风格) |
默认必须是
/auto/而不是裸流:约一半频道音轨是 MP2,桌面浏览器解不了。
如果默认给原始流,浏览器会黑屏卡在错误态,播放器连"切换线路"的按钮都不出。
判定依据(按顺序):
- 移动端/播放器 UA(
android/exoplayer/vlc/mpv/smarttv/ijkplayer…)→ 不转码 - 带
Sec-Fetch-Dest/Sec-Fetch-Mode(浏览器 fetch 元数据)→ 转码 - 桌面浏览器 UA(
mozilla//chrome//safari//firefox//edg//opr/)→ 转码 - 无 UA → 转码(多半是服务端代理拉流,宁可转码也别黑屏)
- 其它未知 UA → 不转码(默认省资源,播不了用户在播放器里切线路)
「在不在家」的判定用来源 IP 匹配 LAN_CIDRS:
LAN_CIDRS=192.168.1.0/24,127.0.0.1
⚠️ 不要把
10.0.0.0/8整段当内网。WireGuard 隧道段(常见10.20.0.0/24)恰好是
"人在外面、用手机流量"的场景,必须降码率。
已知副作用:在家同时开 WG 且把内网路由进隧道时,源 IP 也是隧道段地址 → 被判成远程。
彻底解决要读 WG peer 的endpoint自动区分,暂未实现;想看原画就切线路 2。
5.5 分片写 tmpfs
按 8.7Mbps 算,看 1 小时 = 3.9GB 写入(4K 频道 17GB/小时),持续写入磨 SSD。
tmpfs:
- /run/iptv-hls:size=512m,mode=1777
实测写 200MB 到 tmpfs → 宿主块设备写入 0MB(对照写 overlay 是 200MB)。
mode=1777 是为了让容器内非 root 用户也能写。
5.6 组播接入的两种方案
前提:已按 §2.2 判定组播域,并据此确定主机有几块网卡、各在哪一段。
| 方案 A:自建 IGMP | 方案 B:udpxy | |
|---|---|---|
| 依赖 | 无(自己发 IGMP report) | 外部容器 agrrh/udpxy 或路由器插件 |
| 权限 | 需要 CAP_NET_RAW | 不需要(容器自己处理) |
| 引用计数 | 无 → 并行订阅同一组会互相打断 | 有 → 天然安全 |
| 冷启延迟 | 直接 udp://,无中间层 | 多一跳 HTTP |
| 镜像体积 | 0 | +~10MB |
| 适合 | 单一服务、无并发订阅 | 有扫描器 + 多进程 + 播放器并存 |
若用方案 B,注意
-m <组播网卡>参数必填,否则会从错误网卡加入组播 → 客户端拿到 0 字节。
且容器必须network_mode: host,否则组播和端口都不通。
udpxy 必须跑在组播域内(它要 join 组),而对外的 HTTP 端口绑在服务域地址上。
方案 C:用 igmpproxy 把组播桥进家庭 LAN
如果判定结果是「组播域 ≠ 服务域」但不想多一根线、多一块网卡:
- 在中间设备(OpenWrt / iStoreOS)上装
igmpproxy - 它把组播从 WAN 侧桥到 LAN 侧,主机在家庭 LAN 里就能直接 join
- 主机侧改用
udp://(方案 A)即可,网卡只要 1 块
代价:
- 多一跳,延迟略增
- 改完接口必须
service igmpproxy restart,否则不生效 - 家用设备若在 WiFi 上收组播,注意组播在 802.11 网络里的转发行为
选型建议:单场景用方案 A(少一个依赖);有扫描器并存用方案 B;
「不想加网卡」用方案 C。
6. IPv6 公网暴露
6.1 前提检查
# 1) 主机拿到全局单播 IPv6?
ip -6 addr show scope global
# 期望看到 2000::/3 的地址(不是 fe80:: 链路本地,不是 fd00::/8 ULA)
# 2) 有可路由的前缀?
ip -6 route show default
# 期望有 default via fe80::xxx dev eth0 proto ra
# 3) 出网通?
ping -6 -c3 2400:3200::1
# 4) 路由器是否放行入站?(关键,见下)
路由器侧:
- 确认 IPv6 防火墙 允许入站到你的主机(OpenWrt:
uci set firewall.@zone[0].input='ACCEPT'之类) - 部分光猫自身也做 IPv6 入站过滤,需在光猫 Web 后台放行对应端口
- IPv6 不需要端口映射,但需要「放行」
6.2 域名与 DNS
live.example.com. A <公网 IPv4> # 兼容 IPv4 客户端
live.example.com. AAAA <主机全局 IPv6> # 主路径
AAAA 必须指向你的主机地址,不是网关。运营商给的 /64 前缀下,主机地址通常由 DHCPv6-PD
或 SLAAC 分配后固定,可以直接写;但前缀可能变(见 §6.5)。
DNS 托管建议用支持 API 的托管商(Cloudflare / 阿里云 / DNSPod),
以便程序自动更新 AAAA。
6.3 nginx 入口
# 独立的 upstream 文件(必须!)
# 没有这个块,nginx 会把 proxy_pass 里的 "iptv_hls" 当域名去做 DNS 解析,
# 请求全部打去公网 IP,表现为 m3u8 等 60s 后 502 / upstream timed out
#
# ⚠️ 端口必须是转流服务的监听端口(本文统一 4023,见 §5.1)。
# 写成 8088 等其它值 → 每一个播放请求都 502 / upstream timed out。
upstream iptv_hls {
server 127.0.0.1:4023;
keepalive 16;
}
map $arg_t $iptv_tok_ok {
default 0;
"<你的40位令牌>" 1;
}
# 令牌不落日志:用 $uri 代替 $request
# ⚠️ log_format 只能写在 http 层(conf.d 顶层即可),放 server 块里会报
# "log_format" directive is not allowed here
log_format iptv_sanitized '$remote_addr - [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent';
server {
# ★ IPv6 直连入口:必须显式 listen [::]
listen 18088 ssl;
listen [::]:18088 ssl;
http2 on;
server_name live.example.com;
ssl_certificate /etc/nginx/cert/fullchain.pem; # 必须是完整链
ssl_certificate_key /etc/nginx/cert/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# 无令牌一律 403(覆盖 m3u / m3u8 / 分片 / 台标 / channels.json)
if ($iptv_tok_ok = 0) { return 403; }
access_log /var/log/nginx/iptv-access.log iptv_sanitized;
location = /live.m3u {
alias /usr/share/nginx/html/iptv/live.m3u;
default_type application/vnd.apple.mpegurl;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "no-cache" always;
}
location /logo/ {
alias /usr/share/nginx/html/iptv/logo/;
expires 7d;
add_header Access-Control-Allow-Origin "*" always;
}
location = /channels.json {
proxy_pass http://iptv_hls/channels.json;
proxy_set_header Host $host;
add_header Access-Control-Allow-Origin "*" always;
}
location / {
proxy_pass http://iptv_hls;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off; # HLS 不能缓冲
proxy_cache off;
proxy_read_timeout 3600s; # 长连接流
proxy_send_timeout 3600s;
add_header Access-Control-Allow-Origin "*" always;
}
}
要点:
listen [::]:PORT不能省。只写listen 18088在多数配置下只监听 IPv4。- 服务端(转流服务)只绑
127.0.0.1,公网只暴露 nginx 一层 → 鉴权、限流、日志集中在一处。 proxy_pass用 upstream 名字,不要直接写127.0.0.1:8088(写对了也行,
但 upstream 形式能配 keepalive)。upstream块必须独立成文件放在conf.d/(只有*.conf会被 include)。
改名的.bak.*文件仍留在目录里反而危险,建议归档到conf.d/之外。
IPv6 URL 写法:地址带方括号,端口在括号后
https://[2408:xxxx:xxxx::1234]:18088/live.m3u?t=<令牌>
但播放列表里尽量写域名而不是裸 IPv6 —— 裸 IPv6 换前缀即失效,域名可以靠 DNS/hosts 跟进。
6.4 让 NAS 自己取到自己的源
很多媒体应用(飞牛影视等)会自己去下载播放列表和台标。它们的下载器有 SSRF 守卫:
URL 解析到 127.x / 10.x / 172.16-31.x / 192.168.x 一律拒绝,错误形如
unsafe url: <ip>,且二进制里没有放行开关。
问题链:
- 应用要访问 NAS 自己的公网 IPv4 → 光猫/路由器通常不做 hairpin → 超时
- NAS 本地 DNS 可能把域名解析到别处的 A 记录(通配符 A)→ 那个 IP 上没开端口 → 连接被拒
- 改成
127.0.1/ 内网 IP → SSRF 守卫拒绝
唯一稳妥解:/etc/hosts 把域名钉到本机的公网 IPv6 全局地址。
- 全局单播地址不在 SSRF 私网判定里 → 放行
- 直连本机 nginx,不经路由器、不做 hairpin → 不超时
- nginx 已
listen [::]:PORT→ 通
#!/bin/sh
# 让媒体应用能自己取到 IPTV 直播源(绕开 SSRF 守卫 + hairpin 限制)
# 用法:sh fix_hosts_v6.sh [公网IPv6] 回滚:sh fix_hosts_v6.sh --remove
set -e
HOSTS=/etc/hosts
TAG='# IPTV 自取源: 指向本机公网 IPv6'
DOMAIN='live.example.com'
if [ "$1" = "--remove" ]; then
sed -i "\|^${TAG}|d" "$HOSTS"
sed -i "/[0-9a-fA-F:]* ${DOMAIN}\$/d" "$HOSTS"
echo "已移除 $DOMAIN 的 hosts 映射"; exit 0
fi
IPV6="$1"
if [ -z "$IPV6" ]; then
IPV6=$(getent ahostsv6 "$DOMAIN" 2>/dev/null | awk 'NR==1{print $1}')
[ -z "$IPV6" ] && { echo "拿不到 $DOMAIN 的 AAAA,请手动传入公网 IPv6" >&2; exit 1; }
fi
cp -a "$HOSTS" "$HOSTS.bak.iptv.$(date +%H%M%S)"
sed -i "/ ${DOMAIN}\$/d" "$HOSTS"
printf '\n%s\n%s %s\n' "$TAG" "$IPV6" "$DOMAIN" >> "$HOSTS"
echo "已写入:$(grep " $DOMAIN\$" "$HOSTS")"
验证应用能自己取源:把一张台标文件挪走 + 把下载任务置 pending,
观察应用是否自己下载成功(文件 mtime 更新、任务状态 success、无 error)。
走过的弯路(别再试):给路由器加 hairpin(DNAT + masq)→ 应用仍报
unsafe url: <私网 IP>;
hosts 指192.168.x.x(NAS 自己的内网地址)→ 同样unsafe url。
6.5 IPv6 前缀变化的自愈
运营商 PD 下发的 /64 前缀可能变化。hosts 里写死旧地址 → 应用取源失败(外部客户端走公共 DNS,不受影响)。
三种应对,按推荐度:
- DDNS + DNS API:定时(每 10~30 分钟)读本机全局 IPv6,调 DNS API 更新 AAAA。
外部客户端与内部应用都受益,hosts 方案可作为兜底。 - hosts 刷新定时器:只更新 hosts 一行。适合只关心媒体应用的场景。
- 路由器侧 DDNS:由路由器更新自己的 AAAA,再由主机侧解决"本机取自己"的 hosts 问题。
推荐的 systemd timer(每 15 分钟):
# /etc/systemd/system/iptv-hosts6.service
[Unit]
Description=Refresh /etc/hosts for IPTV self-fetch
[Service]
Type=oneshot
ExecStart=/data/iptv/fix_hosts_v6.sh --refresh
# /etc/systemd/system/iptv-hosts6.timer
[Unit]
Description=Refresh IPTV hosts every 15 min
[Timer]
OnBootSec=2min
OnUnitActiveSec=15min
[Install]
WantedBy=timers.target
⚠️
--refresh模式必须先清掉旧行再取 AAAA。
hosts 的优先级高于 DNS,脚本若用getent ahostsv6 <域名>取地址,
而 hosts 里正躺着上一轮写的旧值,就会每次读回旧值、永远修不好前缀变化。
正确的取法是绕过 hosts 查 DNS:
- 优先用
dig(直查 DNS,不看 hosts):
IPV6=$(dig +short AAAA "$DOMAIN" | head -1)- 没有
dig时,先删旧行再getent:
sed -i "/ ${DOMAIN}\$/d" /etc/hosts然后
IPV6=$(getent ahostsv6 "$DOMAIN" | awk 'NR==1{print $1}')脚本落地路径与 §10.2 的
IPTV_BASE保持一致(/data/iptv/fix_hosts_v6.sh)。
6.6 IPv6 下的客户端注意事项
- 双栈拨号(happy eyeballs):IPv6 优先客户端会先试 AAAA,若光猫没放行 IPv6 入站,
会先卡几秒再回落 IPv4。确保 IPv6 路径真通,否则体验反而变差。 - IPv6 地址会变:客户端连的是域名,DNS 更新后自动跟随;写死 IP 的客户端会断。
- 部分家用设备只支持 IPv4:保留 A 记录 + IPv4 端口映射作为兜底(见 §10.3)。
7. 鉴权与令牌
公网入口必须鉴权。推荐 nginx 层校验令牌:
openssl rand -hex 20 # → 40 位 hex / 160 bit
nginx:
map $arg_t $iptv_tok_ok {
default 0;
"<40位令牌>" 1;
}
server {
...
if ($iptv_tok_ok = 0) { return 403; }
}
要点:
- 令牌只在 nginx 校验,转流服务只负责把请求里的
?t=回写到分片 URL 上
(否则分片请求不带令牌 → nginx 一律 403 → 播不出来) - 换令牌只改 nginx 一处 + 重生成 m3u,服务端不用动
- 令牌不落日志:
- nginx 用自定义
log_format($uri代替$request),只给 IPTV 这个 vhost 用 - 转流服务日志里去掉 query 字符串
- nginx 用自定义
- 播放列表里的每个 URL(流地址、台标地址)都要带
?t= - 内网直连入口另开一个 vhost,明文、无令牌、URL 指向内网 IP
8. 播放列表与台标
8.1 m3u 结构
#EXTM3U
#EXTINF:-1 tvg-id="239.0.0.31_5140" tvg-name="CCTV-1" tvg-logo="https://live.example.com:18088/logo/239.0.0.31_5140.png?t=<令牌>" group-title="央视",CCTV-1
https://live.example.com:18088/hls/239.0.0.31_5140/master.m3u8?t=<令牌>
⚠️ URL 必须带端口
:18088。nginx 只监听 18088,
漏掉端口的地址会连到 443 → 客户端直接连不上。
tvg-id用稳定标识(组播地址_端口),不要用会变的序号group-title分组:央视 → 卫视 → 地方 → 专区 → 其它 → 4K- 建议同时产出一个「仅 AAC」版本(每频道一条
/stream/),供兼容性优先的客户端
谁生成这个文件? 扫描器(app.py,9050)负责生成播放列表,nginx 只负责静态分发它。
两者之间必须有一步「把文件放到 nginx 能读到的目录」:
# 1) 扫描器输出到数据目录(compose 里 OUT_DIR / OUT_NAME 指定)
# 默认文件名见 §14.1;这里假设输出为 /data/iptv.m3u
# 2) 复制/软链到 nginx 的静态目录
ln -sf /data/iptv.m3u /usr/share/nginx/html/iptv/live.m3u
对应关系:
| 项 | 值 |
|---|---|
| 扫描器输出 | $OUT_DIR/$OUT_NAME(compose 环境变量,见 §14.1) |
| nginx 静态目录 | /usr/share/nginx/html/iptv/ |
| nginx 里的文件名 | live.m3u(§6.3 的 location = /live.m3u 决定) |
扫描器也可以直接把列表写进 nginx 目录(把
OUT_DIR指过去),
但那样扫描器与 nginx 共享目录,nginx 容器重建时容易丢文件 —— 建议用软链。
8.2 台标
三条路线,各有取舍:
| 方式 | 优点 | 缺点 |
|---|---|---|
| 公共 CDN(按频道名取) | 真实台标,命中率高 | 必须公网 URL(SSRF 守卫);名字对不上就取不到 |
| ffmpeg drawtext 生成 | 完全自控,无外部依赖 | 只是文字牌,不是真实台标 |
| 手动落位到应用缓存目录 | 绕过应用的下载器 | 需要知道它的缓存路径规则 |
公共 CDN 的坑:
- CDN 上的文件名就是频道名(
CCTV1.png、浙江卫视.png),
而 iptv-org / tv-logos 那类用英文名(Anhui TV)→ 匹配率可能 0/153 - 命中不足时用别名表补:
{ "东方卫视4K": "东方卫视", "CDTV1": "成都新闻综合", "SCTV2": "四川文化旅游" }
tvg-logo必须是公网 URL:飞牛把tvg-logo当异步下载任务,
下载器有 SSRF 防护(内部字符串unsafe external image url),无放行开关。
查证方法:
-- 应用数据库
select source_url, status, last_error from resource_download;
select title, posters from item where type='LiveChannel' and title='CCTV1';
- 台标只在频道入库/刷新时抓一次。改了
tvg-logo必须让源刷新(或删源重加)。
手动落位(已知缓存路径规则):
<appdata 根目录>/trim.media/img/<xx>/<yy>/resource_<sha256(完整source_url)>.png
怎么找到 `appdata 根目录:** 两套实测环境的位置不同,先搜再猜:
# 常见两个根目录,各搜一次
ls -d /vol1/@appmeta/trim.media/img 2>/dev/null
ls -d /vol4/trim.media/img 2>/dev/null
# 兜底:全盘找
find / -maxdepth 4 -type d -name "trim.media" 2>/dev/null
<xx>/<yy> 的取法:sha256 的前 2 位 → <xx>,第 3~4 位 → <yy>
(<xx> 是 2 个十六进制字符,<yy> 也是 2 个)。
最稳的做法是直接查应用自己的数据库,它记着确切路径:
select hash_path from resource_download where source_url like '%live.m3u%' limit 3;
做法:把台标拷过去 + 把 resource_download.status 置 success。
应用有「managed image file missing, retry download」自愈逻辑,会在下一次刷新时补下载。
应用还可能忽略的字段(实测):
- 不解析
tvg-id(二进制里 0 命中) - 不用
logos字段,只认tvg-logo下载 +posters
8.3 客户端缓存
改完 m3u 客户端不变,是三处缓存叠加:
- 媒体应用自己的源刷新周期(常见 1 小时,改数据库里的时间戳不会立即触发,
它用内部定时器) - 客户端 App 自身缓存 → 改完要完全退出重进
- 服务端 SSR 缓存
直接验证服务端返回(走 nginx 入口,因为 live.m3u 是 nginx 侧的静态文件):
curl -s "http://<NAS>:18088/live.m3u?t=<令牌>" | grep -c tvg-logo
9. 客户端接入
飞牛影视 / trim.media
设置 → IPTV 直播源 → https://live.example.com:18088/live.m3u?t=<令牌>
对应数据库字段 mediadb_config.iptv_source_url,同行还有
iptv_refresh_interval(秒)、iptv_refresh_enabled、iptv_channel_count。
TiviMate / IPTV Pro / Kodi / VLC:填同一个 URL(这些客户端会读 tvg-logo)。
浏览器:必须走 HLS + AAC 档(/auto/ 会自动判断)。裸 IPTV 流的 MP2 音轨浏览器解不了。
电视 / 盒子:多数能软解 MP2,直接吃裸流最快。
10. 部署形态
10.1 容器(推荐)
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod 755 /app/entrypoint.sh # 必须 755:容器以非 root 跑,只有 --x 时解释器打不开(exit 126)
ENV TZ=Asia/Shanghai PYTHONUNBUFFERED=1
ENTRYPOINT ["/app/entrypoint.sh"]
services:
iptv-stack:
build: .
image: iptv-stack:latest
container_name: iptv-stack
restart: unless-stopped
network_mode: host # 必须:组播 + 直接对外端口
cap_add: [NET_RAW] # 必须:发 IGMP Membership Report 要 SOCK_RAW
user: "1000:1000"
environment:
TZ: Asia/Shanghai
# ---- 三个进程的监听端口(entrypoint.sh 分别注入 LISTEN_PORT)----
HLS_PORT: "4023"
GATEWAY_PORT: "4024"
PORT: "9050"
# ---- 转流服务 ----
CHANNELS_FILE: /app/channels.json
CODECS_FILE: /app/codecs.json
HLS_ROOT: /run/iptv-hls
HLS_PORT: "4023"
HLS_TIME: "1"
HLS_LIST: "10"
AUDIO_BITRATE: "128k"
# ⚠️ 必须显式设成 127.0.0.1:默认值是 0.0.0.0,
# host 网络下会直接监听宿主所有地址(含公网 IPv6),
# 绕过了「公网只暴露 nginx 一层」的设计(§6.3)
LISTEN_HOST: "127.0.0.1"
# ---- udpxy(转流服务经它取流,方案 B 必需)----
# ⚠️ 必须改成你自己的地址:默认值指向作者的另一台机器
UDPXY: "http://127.0.0.1:4022"
UDPXY_HOST: "127.0.0.1"
UDPXY_PORT: "4022"
# ---- 网关 ----
GATEWAY_PORT: "4024"
LISTEN_HOST_GATEWAY: "0.0.0.0" # 网关要对内网/公网开放
HLS_HOST: "127.0.0.1" # ⚠️ 网关反代转流服务的目标
HLS_PORT: "4023"
M3U_FILE: /data/live.m3u
M3U_AAC_FILE: /data/live-aac.m3u
# ---- 扫描器 ----
DATA_DIR: /data
OUT_DIR: /data
OUT_NAME: "live.m3u" # nginx 静态分发这个文件(§8.1)
OUT_AAC: "live-aac.m3u"
BASELINE_FILE: /app/channels_baseline.csv
# ⚠️ 扫描时用来加入组播的本机地址 = 组播网卡上的源 IP
SCAN_IFACE_IP: "<P2_IP>"
RUN_AT: "06:30"
RUN_EVERY_DAYS: "15"
# ---- 对外基地址(拼播放列表 URL)----
IMPORT_BASE: "https://live.example.com:18088"
# ---- 客户端分流 ----
LAN_CIDRS: "192.168.1.0/24,127.0.0.1"
MOBILE_BITRATE_KBPS: "5500"
MOBILE_MAX_HEIGHT: "1080"
MOBILE_VCODEC: "libx264"
MOBILE_PRESET: "veryfast"
MOBILE_COPY_UNDER_KBPS: "6000"
# ---- 台标 ----
LOGO_DIR: /data/logos
LOGO_BASE: "https://live.fanmingming.com/tv"
volumes:
- ./iptv_hls_server.py:/app/iptv_hls_server.py:ro
- ./iptv_gateway.py:/app/iptv_gateway.py:ro
- ./app.py:/app/app.py:ro
- ./probe_codecs.py:/app/probe_codecs.py:ro
- ./channels.json:/app/channels.json:ro
- ./codecs.json:/app/codecs.json:ro
- ./channels_baseline.csv:/app/channels_baseline.csv:ro
- ./data:/data
tmpfs:
- /run/iptv-hls:size=512m,mode=1777
⚠️ 上表里带 ⚠️ 的几项如果照抄默认值,服务能起来但行为是错的:
LISTEN_HOST会把转流服务暴露到公网、UDPXY/HLS_HOST会指向一台不存在的机器、
SCAN_IFACE_IP不填则扫描器加不进组、OUT_NAME与 nginx 期望的文件名对不上
(live.m3u↔ nginx 的location = /live.m3u)。
要点:
network_mode: host:组播加入和端口暴露都要求它- 代码用挂载进来:改
.py只需docker restart,不必重建镜像;
只有改entrypoint.sh/ 依赖才需要docker compose build - 挂载文件权限要可读:root 创建的 600 权限
.py,非 root 容器用户读不到 → 起不来 - 容器内多个进程:给每个进程注入各自的
LISTEN_PORT;
wait -n+ trap,任一子进程退出即整体退出交给 restart 策略
10.2 systemd(无 Docker 时)
⚠️ systemd 路线用的是
iptv_hls.py(cucc 那套精简实现),不是容器那套三件套。
差别必须知道:
systemd 版( iptv_hls.py)容器版(三件套) URL 布局 /<cid>/index.m3u8/hls/<cid>/master.m3u8音轨 全 copy(MP2 原样) AAC / MP2 双变体 + master CODECS 声明 无 有( probe_codecs.py)客户端分流 无 有( /auto/、/stream/)取流方式 直连 udp://(方案 A) 经 udpxy(方案 B) 本文的播放列表示例与验收命令按容器版写(§8.1、§14.3)。
走 systemd 版时,播放列表里的 URL 要换成/<cid>/index.m3u8,
且浏览器会因 MP2 音轨播不了(§5.3)。
# /etc/systemd/system/iptv-hls.service
[Unit]
Description=IPTV HLS transcoder
After=network-online.target iptv-mroute.service
Wants=network-online.target
[Service]
Type=simple
Restart=always
RestartSec=5
Environment=IPTV_BASE=/data/iptv
Environment=IPTV_PORT=4023
Environment=IPTV_IFACE=<P2_IFACE>
Environment=IPTV_LOCALADDR=<P2_IP>
Environment=IPTV_IDLE=120
Environment=IPTV_HLS_TIME=2
Environment=IPTV_HLS_LIST=6
ExecStart=/usr/bin/python3 /data/iptv/iptv_hls.py
StandardOutput=append:/data/iptv/service.log
StandardError=append:/data/iptv/service.log
[Install]
WantedBy=multi-user.target
# 配套的组播路由 unit(iptv-mroute.service)见 §3.3
IPTV_PORT=4023与 §6.3 的 nginx upstream、§5.1 的端口表一致。
改端口要连带改这三处。
10.3 IPv4 兜底
保留一条 IPv4 路径给只支持 IPv4 的设备:
- 路由器上做 DNAT 到主机内网地址
- DNAT 目标必须是「路由器侧视角的 NAS 地址」:主机默认网关是路由器时,
回包必须经路由器解 conntrack NAT,再由上级设备还原成公网 IP。
填错方向 → 公网能连上但收不到回包(表现为 connect 超时) - 有多层 NAT(光猫 + 路由器)时,上一级也要 DMZ / 端口映射
11. 监控与自愈
11.1 健康检查
轻量版(不拉流、不占带宽):检查进程、端口、组播路由、频道表完整性、m3u 可解析。
深度版:真的拉一个频道,验分片首字节是 0x47(MPEG-TS 同步字节)。
# 轻量
sh health_check.sh
# 深度
sh health_check.sh --deep
⚠️
health_check.sh是按 systemd 布局写的(iptv-cucc/scripts/):
它检查systemctl is-active iptv-hls、/data/iptv/iptv_hls.py是否存在、
/data/software/nginx/conf.d/iptv-{upstream,nas,lan}.conf三个文件是否齐全、
端口默认8088、金丝雀频道默认cctv-4。走容器部署(§10.1)的话,这个脚本的 7 项里至少 4 项会必然 FAIL ——
容器里没有 systemd unit、没有/data/software/nginx/、端口也不是 8088。
用容器版时要么改脚本里的这几处常量,要么自己写一个针对容器的检查。
systemd 定时器:
# iptv-health.timer 每 10 分钟
# iptv-health-deep.timer 每天 04:20
11.2 频道表重扫
运营商会改组播地址,静态频道表会过期。
sh rescan_channels.sh # 只报告差异,不改线上
sh rescan_channels.sh --apply # 落地(有 80% 安全闸 + 自动备份)
80% 安全闸:新结果与旧结果差异超过 20% 时拒绝自动应用(防止一次扫崩就清空频道表),
需要人工确认。自动应用前先备份旧表。
定时器:每周一次。
现有脚本的能力边界
rescan_channels.sh 的实际流程是:抓公开清单 → 逐组实测 → 与线上对比。
它的差异报告输出「新增 / 消失 / 组播地址变化」三类,不输出网段。
所以,整段迁移这种变化它检测不到:
| 现象 | 现有脚本能否发现 | 处理 |
|---|---|---|
| 少了几个频道(频道下架) | ✅ 报「消失」 | 正常,直接 --apply |
| 某个频道换了组播地址 | ✅ 报「组播地址变化」 | 正常 |
| 频道数骤降 >20% | ✅ 被 80% 闸门挡住 | 人工确认后 --apply |
| 整段迁移(239 → 233) | ❌ 检测不到 | 必须人工重抓包重建候选集 |
整段迁移是运营商行为,你无法预知(上海电信 239 → 233 就是先例,§4.0)。
检测手段只有一个:频道数骤降 + 抓包发现新段。
所以除了定时重扫,还要定期(比如每月)手动跑一次 §4.1 的抓包流程,
对比新出现的网段。
改进方向(现有脚本未实现):差异报告里单独列出「出现的组播网段」,
段变了就显式告警。
11.3 状态页(可选)
一个只给内网看的 Web 页:
- 当前频道数、上次/下次扫描时间
- 每次扫描的明细与日志(IGMP 原始记录可预览可下载)
- 播放列表在线预览 / 下载
- 手动触发「立即扫描」「仅重生成列表」
11.4 排障接口
网关层保留一个「最近 N 个请求的头快照」接口,用来回答「到底是谁在拉流」:
curl -s http://<NAS>:4024/debug/reqs | head -40
# 输出:UA / X-Forwarded-For / Host / 是否被判为需转码
这是排查「为什么走了转码」「为什么走了低码率」最快的手段。
12. 排障手册
组播 / IGMP
| 现象 | 根因 | 处理 |
|---|---|---|
| 所有频道突然全黑 | 运营商整段迁移(如 239 → 233) | 重扫重建频道表(§11.2);重扫要重新抓包,别只重试旧清单 |
| 频道少了几个,端口也不对 | 逐频道改端口 | 键用 <地址>_<端口>,重扫时按组合比对 |
| 组播完全收不到 | 没发 IGMP Membership Report,光猫不会把组从 WAN 拉下来 | 主动发 report(方案 A)或用 udpxy(方案 B) |
| 换了个口就收不到 | 组播只落在特定口 / 特定 VLAN | 回到 §2.1 实测判定,别猜 |
| 主机接在家庭 LAN 上收不到 | 组播域 ≠ 服务域,中间路由器不转发组播 | 双网卡(§2.2)、开 igmpproxy、或旁路 udpxy |
| 已有台正常,新开的台黑屏/转圈 | ①冷启动竞态(§3.4)②IGMP report 没发出(§3.2)③运营商侧并发上限(§3.6) | 按 ①②③ 顺序排除;③的确认手段是关掉几个台再试 |
| 组播 VLAN 里的数据完全不来 | 组播 VLAN 单向下行,OLT 未收到 IGMP 就不下发 | 必须先发 IGMP report |
| 重启后组播时好时坏 | 224.0.0.0/4 是手动加的路由,重启即丢 | systemd oneshot 持久化(§3.3) |
| 批量加组报 ENOBUFS | 内核单接口组播成员上限约 32 | 分批(每批 ≤20) |
| 组播收得到但不稳定 | 走错网卡 | 确认 ip route get 239.x.x.x 落在组播域那张网卡 |
| 流量源地址变成别的 IP | VLAN 子接口劫持同网段路由 | 删掉 VLAN 子接口,或显式覆盖 metric(§2.3) |
| 看着看着卡住 | 两个进程订阅同一组,一方退出发 Leave 打掉另一方 | 统一到 udpxy 引用计数;或确保单一订阅者 |
| 开 IGMP snooping 后全挂 | 光猫可能改按 MCV 过滤,不带 tag 的被丢 | 记基线 → 打开后 30s 内验 3~5 个频道 → 任一为 0 立刻改回 |
| 机顶盒能开机、节目单正常,但黑屏 | 光猫「公共组播 VLAN」配错 | 核对并修正该值(§2.1 第 1 步) |
冷启动 / 播放
| 现象 | 根因 | 处理 |
|---|---|---|
| 首次请求 502 / 播放列表不出现 | 冷组重拉 ~15s 超过 ffmpeg timeout | timeout 提到 30s;重试一次即可 |
| 4K 频道一直转圈 | probesize 太小,ffmpeg rc=234 退出 | -probesize 2000000 |
| 分片时长 6~10 秒而不是 1 秒 | x264 默认 GOP=250 帧 | -force_key_frames expr:gte(t,n_forced*1) + -sc_threshold 0 |
| 画面有横向锯齿 | 1080i 隔行没处理 | 转码时加 yadif |
| 浏览器黑屏,播放器连切线路按钮都没有 | 给了裸流但浏览器解不了 MP2 | 默认走 /auto/(HLS+AAC) |
| 浏览器"以为能播"然后失败 | CODECS 写错(HEVC 写成 avc1) | 用 probe_codecs.py 探测真实编码 |
| 播放中途卡顿 502 | 代理缓冲 | proxy_buffering off + proxy_read_timeout 3600s |
| 老设备解不了 | 4K 是 HEVC | 该类频道单独处理或从列表摘除 |
nginx / 反代
| 现象 | 根因 | 处理 |
|---|---|---|
| m3u8 60s 后 502 / upstream timed out | upstream 定义被删,nginx 把 proxy_pass 里的名字当域名做 DNS 解析 | upstream 独立成文件(§6.3) |
conf.d/*.conf 里配置不生效 | 只有 *.conf 会被 include | 归档到 conf.d/ 之外,别留 .bak.* |
容器 docker port 为空 | 容器是 host 网络,端口直接在宿主机 | 宿主机上 ss -lntp |
| IPv6 客户端连不上 | 只写了 listen 18088,没写 listen [::]:18088 | 两个都写 |
log_format directive is not allowed here | 放进了 server 块 | 移到 conf.d/ 顶层(http 上下文) |
IPv6 / DNS
| 现象 | 根因 | 处理 |
|---|---|---|
| IPv6 地址 ping 得通但外部连不上 | 路由器 IPv6 入站过滤 | 路由器放行入站 + 检查光猫 |
应用报 unable to connect to <域名> | 本地 DNS 把域名解析到别的 A 记录,那个 IP 上没开端口 | hosts 钉 AAAA(§6.4) |
应用报 unsafe url: <私网 IP> | SSRF 守卫 | hosts 钉公网 IPv6,不要指私网/回环 |
应用报 unable to connect 但 curl 正常 | Go 客户端优先用 A 记录,没走 AAAA | 同上(hosts 钉 AAAA) |
| 外部客户端 IPv6 卡几秒才出画面 | IPv6 优先 + IPv6 路径不通回落 IPv4 | 打通 IPv6 入站,或调整客户端策略 |
| 一段时间后全部取源失败 | 运营商换了 IPv6 前缀,hosts 是旧的 | DDNS 定时更新 + hosts 刷新 timer(§6.5) |
媒体应用
| 现象 | 根因 | 处理 |
|---|---|---|
台标任务全 terminal / unsafe url | 触发 SSRF 守卫 | 台标 URL 必须是公网地址(§8.2) |
| 改了 m3u 但客户端没变 | 三处缓存 | 触发源刷新 + App 完全退出重进(§8.3) |
改了 iptv_refresh_interval 不触发刷新 | 应用用内部定时器 | 等它自己的周期(常见 1 小时) |
| 应用不代理播放流 | 播放是客户端直连公网入口 | 应用能否取到源只影响刷新列表和下台标,不影响播放 |
| 台标是灰图 | CDN 上没有对应文件名 | 别名表补齐 |
杂项
| 现象 | 根因 | 处理 |
|---|---|---|
sh: not found | 从 Windows 传脚本带 CRLF | tr -d '\r' < s.sh > s2.sh && mv s2.sh s.sh |
| PowerShell 里参数被吃掉 | $(...)、<、> 被 PS 解释 | 复杂命令写成 .sh 再 base64 传 |
busybox nc -z 不存在 | 镜像里没这命令 | 用 python 自检代替 |
| check-host 报"境外不可达" | check-tcp 对非常规端口返回全 null | 改用 check-http 打真实 URL,API 带 Accept: application/json |
| 光猫登录后浏览器被挤掉 | 光猫单会话 | 全程浏览器 或 全程脚本,二选一 |
13. 已知限制与风险
网络侧
- 家宽公网 IP 的自定义端口在境外可能不可达(运营商/线路策略)
- 4K 频道多为 HEVC(3840×2160),老设备解不了
- 4 路 4K 频道并发 ≈ 100+ Mbps,家宽上下行和 WiFi 承载要够
- IPv6 前缀变化需要自动化处理,否则媒体应用的 hosts 指向会失效
- 某些路由器对 IPv6 入站过滤默认开启,需手动放行
服务侧
- 组播源质量不可控:源侧带损的频道只能摘掉,无法修复
- 运营商侧可能存在并发组播频道数上限(§3.6)→ 若你撞到了,同时能开的台数有硬顶。
本文两套环境均未实测到该阈值 - 组播网段和端口不归你管(§4.0):运营商随时可能整段迁移
(上海电信 239 → 233 就是先例),或逐频道改端口。
频道表必须能重扫重建,不能写死 - 冷启一个频道约 2~5s(等 ffmpeg 首片)
- 空闲超过
IPTV_IDLE秒自动停流并发 IGMP Leave;组被放掉后需 ~15s 重新拉流 - GPU 转码不一定划算:软编
libx264 veryfast已达 4.7x 实时 / 1.1 核每路,
而 VAAPI/QSV 的滤镜链在真实组播流上很难一次调通 → 先用 CPU,确有需要再上 GPU - 频道表必须定期重扫(运营商换地址后要等下一次扫描)
安全侧
- 公网入口必须有令牌。媒体应用的 SSRF 守卫是应用层的,
不保护你的直播源本身;直连 m3u 的攻击者不会经过任何应用层守卫 - 令牌会出现在播放列表里 → 不要把带令牌的 m3u 提交到任何 git 仓库
- 令牌不落日志:nginx 用
$uri、服务端日志去掉 query - 只暴露 nginx 一个入口,转流服务绑
127.0.0.1 - 检查宿主机上其它对外暴露的服务,别让直播源的开放端口成为绕过入口的跳板
14. 附录
14.1 环境变量速查
两套实现用不同的变量名,不要混用。
容器版(三件套,§10.1)
| 变量 | 默认 | 作用 |
|---|---|---|
HLS_PORT | 4023 | 转流服务端口(entrypoint.sh 把它注入子进程的 LISTEN_PORT) |
GATEWAY_PORT | 4024 | 网关端口(同上) |
PORT | 9050 | 扫描器/状态页端口 |
LISTEN_HOST | 0.0.0.0 | ⚠️ 必须改成 127.0.0.1(转流服务,见 §10.1) |
HLS_PORT | 4023 | 网关反代转流服务时用的端口 |
HLS_HOST | 192.168.1.5 | ⚠️ 必须改(网关反代目标,见 §10.1) |
HLS_ROOT | /run/iptv-hls | 分片目录(容器内 tmpfs) |
HLS_TIME / HLS_LIST | 4 / 6 | 分片时长 / 播放列表窗口(代码默认值;compose 里设为 1 / 10) |
AUDIO_BITRATE | 128k | AAC 音轨码率 |
KINDS | aac | 提供的音轨变体 |
IDLE_SECONDS | 300 | 空闲多少秒停流并发 IGMP Leave |
CHANNELS_FILE | — | 频道表路径 |
CODECS_FILE | — | 真实编码表路径(probe_codecs.py 产出) |
UDPXY | http://192.168.1.5:4022 | ⚠️ 必须改(转流服务取流上游) |
UDPXY_HOST / UDPXY_PORT | 192.168.1.5 / 4022 | ⚠️ 必须改(网关裸流直通) |
GATEWAY_URL | — | 网关自引用地址(触发媒体应用刷新时用) |
DATA_DIR | /data | 报告、日志、state 目录 |
OUT_DIR | $DATA_DIR | 播放列表输出目录 |
OUT_NAME / OUT_AAC | iptv.m3u / iptv-aac.m3u | ⚠️ 改成 live.m3u(与 nginx 一致,见 §8.1) |
M3U_FILE / M3U_AAC_FILE | — | 网关读取的播放列表路径 |
BASELINE_FILE | /app/channels_baseline.csv | 扫描候选集(必须先生成,见 §4.1) |
SCAN_IFACE_IP | 10.100.0.2 | ⚠️ 必须改(扫描时加入组播用的本机地址 = 组播网卡源 IP) |
RUN_AT / RUN_EVERY_DAYS | 06:30 / 15 | 定时全量重扫 |
KEEP_BACKUPS | 7 | 保留的历史版本数 |
EXCLUDE_CHANNELS / EXCLUDE_ADDRS | 空 | 黑名单(按频道名 / 组播地址),重扫后依然生效 |
IMPORT_BASE | — | 对外基地址(拼播放列表 URL) |
LAN_CIDRS | 192.168.1.0/24,127.0.0.1 | 哪些来源算"在家" |
MOBILE_KIND | mobile | 低码率档名 |
MOBILE_BITRATE_KBPS | 5500 | 低码率目标 |
MOBILE_MAX_HEIGHT | 1080 | 低码率最大高度 |
MOBILE_PRESET | veryfast | x264 preset |
MOBILE_VCODEC | libx264 | 可换 h264_vaapi(需映射 /dev/dri) |
MOBILE_COPY_UNDER_KBPS | 6000 | 源码率低于它就只转音频、不重编码 |
LOGO_DIR / LOGO_BASE | /data/logos / 公网 CDN | 台标目录与 URL 前缀 |
PROBE_CSV | /data/last_probe.csv | 扫描结果(码率估算来源) |
systemd 版(iptv_hls.py,§10.2)
| 变量 | 默认 | 作用 |
|---|---|---|
IPTV_BASE | /data/iptv | 工作目录(频道表、分片) |
IPTV_PORT | 4023 | 转流服务监听端口(只绑 127.0.0.1) |
IPTV_IFACE | — | 发 IGMP 报告的网卡(方案 A) |
IPTV_LOCALADDR | — | 发 IGMP 报告的源 IP(方案 A) |
IPTV_IDLE | 120 | 空闲多少秒停流并发 IGMP Leave |
IPTV_IGMP_KEEPALIVE | 30 | IGMP 报告保活间隔(秒) |
IPTV_HLS_TIME / IPTV_HLS_LIST | 2 / 6 | 分片时长 / 播放列表窗口(代码默认值) |
14.2 最小 IGMP 实现(方案 A 核心)
import socket, struct
def _igmp_pkt(group, mtype):
grp = socket.inet_aton(group)
pkt = struct.pack("!BBH", mtype, 0, 0) + grp
s = 0
for i in range(0, len(pkt), 2):
s += (pkt[i] << 8) + pkt[i + 1]
while s >> 16:
s = (s & 0xFFFF) + (s >> 16)
return struct.pack("!BBH", mtype, 0, (~s) & 0xFFFF) + grp
def igmp_send(group, mtype=0x16, src_ip=None):
"""mtype 0x16 = IGMPv2 Membership Report(join),0x17 = Leave"""
try:
r = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_IGMP)
r.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(src_ip))
r.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1)
r.sendto(_igmp_pkt(group, mtype), ("224.0.0.22", 0))
r.close()
return True
except Exception as e:
sys.stderr.write("igmp_send %s failed: %s\n" % (group, e))
return False
配合 ffmpeg 输入 URL 绑源地址(不依赖组播路由也能收):
udp://<group>:<port>?localaddr=<P2_IP>&fifo_size=1000000&overrun_nonfatal=1&timeout=30000000
| 参数 | 作用 |
|---|---|
localaddr | 绑定源地址,避免从错误网卡发出 |
fifo_size | 加大接收缓冲,容忍突发 |
overrun_nonfatal | 丢包不致命,避免高码率流直接断 |
timeout | 30s,覆盖光猫重拉组的 ~15s |
14.3 验收清单
阶段零:拓扑判定
- 光猫 IPTV VID / 公共组播 VLAN / 端口绑定 / 组播绑定 已记录
- 抓包确认:组播从哪个口出来
- 抓包确认:帧带不带 802.1Q tag
- 组播域 == 服务域?还是两个段?(决定网卡数,见 §2.2)
-
ffplay rtp://<已知组播地址>在组播域那台机器上能出画面
阶段一:数据通路
# 组播与路由
ip route get 239.0.0.195 # 应落在组播域那张网卡
ip maddr show # 已加入的组(确认 join 成功)
cat /proc/net/igmp # 本地 IGMP 成员状态
# IPv6
ip -6 addr show scope global # 应有全局单播地址
# 服务与端口
ss -lntp | grep -E ':4023|:4024|:9050' # 应在听
curl -s http://127.0.0.1:4024/status # 网关状态
# 取流(冷启约 2~5s,热启毫秒级)
# 第 1 步:取播放列表
curl -s -o /tmp/v.m3u8 -w "%{http_code}\n" \
http://127.0.0.1:4024/hls/239.0.0.31_5140/master.m3u8
# 第 2 步:从列表里取出分片 URL(列表本身不是分片)
SEG=$(grep -v '^#' /tmp/v.m3u8 | tail -1)
echo "分片: $SEG"
# 第 3 步:下载分片并验首字节
curl -s -o /tmp/v.ts -w "%{http_code} %{size_download}\n" "http://127.0.0.1:4024$SEG"
# 分片首字节必须是 0x47(MPEG-TS 同步字节)
head -c1 /tmp/v.ts | xxd | head -1
⚠️ 别直接去 curl
aac.m3u8验首字节 —— 那是播放列表,首字节是#(0x23)。
必须先从列表里取分片 URL 再下载。
阶段二:暴露
# IPv6 入口
curl -6 -s -o /dev/null -w '%{http_code}\n' "https://live.example.com:18088/live.m3u?t=<令牌>" # 200
curl -6 -s -o /dev/null -w '%{http_code}\n' "https://live.example.com:18088/live.m3u" # 403
# 外网可达:用 check-http,不要用 check-tcp
客户端侧:
- 直播源已填,频道数正确
- 界面能看到台标
- 播一个频道,出画
- 桌面浏览器播一个频道(验 AAC 档与 CODECS)
- 外网(手机流量,非 WiFi)播一个频道(验低码率档与 IPv6 路径)
14.4 实施顺序建议
阶段零:判定拓扑(不做这一步,后面全是白工)
- 读光猫,记下 IPTV VID / 公共组播 VLAN / 端口绑定 / 组播绑定 / IGMP 代理开关(§2.1)
- 逐口试接 + 抓包,确定组播落在哪个口、带不带 tag(§2.1 第 2~3 步)
- 判定组播域 vs 服务域,决定网卡数量与接线(§2.2)
- 用
ffplay rtp://<已知组播地址>在候选口所在的那台机器上验证能出画面
如果这一步判定出「组播域 == 服务域」,恭喜,后面不需要多网卡。
如果判定出「IPTV 专用口是独立二层段」,那才需要额外手段(双网卡 / igmpproxy / 旁路 udpxy)。
阶段一:打通数据通路
- 拿到候选组地址(抓包 / 公开清单,§4.1、§4.2)
- 逐组验证有流 + 码率(§4.3)
- 跑通最小链路:单频道、单档、裸流直通 → 客户端能看
- 加 HLS 档 → 浏览器能看
- 补 master + 真实 CODECS → 多端自适应
- 生成全量频道表与 m3u
阶段二:暴露与运营
- 套 nginx + 令牌 → 公网可达
- 接 IPv6:放行入站 → 配 AAAA →
listen [::]→ 验证 - 接媒体应用 → 处理 SSRF(hosts 钉 IPv6)→ 台标
- 补监控(health / rescan / DDNS)
第 0~3 步不通,后面全是白工。
Comments