IPTV 组播直播源搭建指南(以 IPv6 公网暴露为例)

本文是通用技术文档,把两套独立实践(四川联通家宽方案、四川电信家宽方案)里
被实测验证过的结论合并成一套可复现的流程。文中不包含任何真实域名、令牌、内网 IP,
全部用占位符,需要你自己替换。

目标:把运营商 IPTV 的组播直播转成 HLS,生成 m3u 播放列表,
通过公网 IPv6 直接对外提供,电视 / 手机 / 浏览器 / 飞牛影视都能看。

关键前提(换环境必改的 6 个量):

#参数说明
P1组播频道表与运营商/地区绑定,只能实测获得,不可照抄
P2组播域网卡名 + 地址所在二层段能收到组播的那块网卡(§2 判定)
P3服务监听地址与端口转流服务 / 网关
P4家庭局域网网段用于判定"客户端在不在家"
P5域名、端口、令牌、证书公网入口
P6公网 IPv6 前缀与本机全局地址IPv6 直连入口

目录


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 + DNATIPv6 直连
端口映射需要光猫 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
  • 抓 35 分钟,期间用机顶盒遥控器换台,每个台停 510 秒
    (换台是必须的——静止看电视时不会产生新的 Join 报文)
  • Wireshark 里按 Ctrl+M 打标记,方便事后对齐换台顺序

第 3 步:读出三个结论

结论怎么读影响
组播从哪个口出哪个口上出现组播地址的 UDP 流决定接线
带不带 tagWireshark 里看是否有 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 块一块进组播域,一块进服务域路由/防火墙默认不转发组播,跨不过去
组播域 ≠ 服务域,中间设备开了 igmpproxy1 块主机只接服务域路由器把组播桥进 LAN,主机在 LAN 侧就能 join
组播域是光猫 IPTV 专用口(独立段)2 块,或 1 块 + 旁路代理见下家用设备无法直连那个段
组播带 802.1Q tag1 块 + VLAN 子接口—见 §2.3 警告

「跨不过去」的三种解法(按推荐度):

  1. 主机双网卡(§2.4 方案 A)——最直接,服务与组播同机,无额外依赖
  2. 路由器开 igmpproxy(OpenWrt/iStoreOS 有 igmpproxy 包)——组播桥进家庭 LAN,
    主机只需 1 块网卡。注意:改完接口必须 service igmpproxy restart,否则不生效
  3. 组播域内放一台小主机跑 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.4075 10 秒只比 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(容器)或 root
  • setsockopt(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 秒窗口)
某卫视 4K38 Mbps0
另一卫视 4K37.9 Mbps1
某 4K 专区15.9 Mbps8

码率更低反而错得更多 → 与带宽、网卡、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.x5002社区列表
上海电信(旧段)239.45.0.1 ~ 239.45.3.2545140社区列表
上海电信(现用段)233.x.x.x、233.18.204.x5140社区列表(2025 年初新增 233 段)
江苏电信239.49.x.x8000社区列表
四川联通239.0.0.x5140本文实测(cucc)
成都电信239.93.x.x5140本文实测(ctcc)
广东电信239.77.x.x5146社区列表

上海电信的换段经过:2025 年初新增了 233.18.204.x 段,
2025 年底原 239.45.x 段停播、只剩 233 段可用。
这类整段迁移是运营商行为,你无法预知 —— 只能靠定期重扫发现(§11.2)。

结论:不要在任何工具、脚本或文档里写死 239. 前缀或 5140 端口。

三处必须做的检查:

  1. 抓包过滤器用整个组播空间,不要只匹配 239:

    igmp or (udp and net 224.0.0.0/4) or tcp port 80
    

    224.0.0.0/4 覆盖 224~239,包含 233/232。
    本文的 iptv_pcap2m3u.py 用 is_multicast() 判断
    0xE0000000 <= n <= 0xEFFFFFFF,同样覆盖整个 /4。

  2. 只剔除链路本地控制块(224.0.0.0/24:IGMP 自身、mDNS、SSDP),
    不要按 239. 前缀黑名单。239.255.255.250 等服务发现地址单列即可。

  3. 端口要逐频道处理。北京联通的 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 抓包。

抓包路径能给出完整的「地址 + 端口」候选集,缺的只是频道名。
频道名的来源有三个(按可靠性):

  1. 抓包时记录的换台顺序 + channels.csv 的「相对秒」列 → 人工对齐补名
  2. 运营商的 EPG/频道表接口(§4.1 的 http_urls.txt 里能抓到线索)
  3. 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.pyiptv-ctcc/(仓库根)抓包 → 频道候选表无(纯标准库)
mc_scan.pyiptv-cucc/scripts/逐组实测有流方案 A(自建 IGMP)
probe_codecs.pyiptv-ctcc/hls/探测真实视频编码必须能访问 udpxy
iptv_hls_server.pyiptv-ctcc/hls/按需转封装(master + AAC/MP2 变体)必须能访问 udpxy
iptv_gateway.pyiptv-ctcc/hls/播放列表分发 + 客户端分流iptv_hls_server.py
app.pyiptv-ctcc/hls/周期扫描 + 生成 m3u + 状态页channels_baseline.csv
entrypoint.shiptv-ctcc/hls/容器内拉起三个进程同上
iptv_hls.pyiptv-cucc/scripts/精简转封装(直连 udp://)方案 A
build_channels.py / gen_logos.pyiptv-cucc/scripts/生成频道表 / 台标无
get_sichuan_list.pyiptv-cucc/scripts/抓四川联通公开清单仅示例,换运营商要重写
health_check.sh / rescan_channels.shiptv-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 分片与 master4023仅 127.0.0.1
iptv_gateway播放列表分发、CORS、按客户端分流、裸流直通4024内网 / 公网
iptv_scan周期扫描、生成 m3u、状态页9050仅 127.0.0.1
nginxHTTPS + 令牌校验 + 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 的档位不重编码,不需要强制关键帧。

参数值理由
-analyzeduration300000(0.3s)ffmpeg 默认 5s 探测,冷启动主因(首片 5.66s → 1.67s)
-probesize2000000(2MB)不能压更小。100KB(≈21ms)时 37.9Mbps 的 4K HEVC 连 PMT 都凑不齐,ffmpeg 直接 rc=234 / Output file does not contain any stream 退出,播放列表永不出现
-hls_time1目标分片时长
-force_key_framesexpr:gte(t,n_forced*1)必须有。HLS 只能在关键帧切分,x264 默认 GOP=250 帧(25fps 正好 10s),不强制关键帧分片会被拉成 6~10 秒,播放器要等一整个分片才起播
-sc_threshold0配合上面,关闭场景切分
-hls_flagsdelete_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(隔行),不处理会把梳齿伪影固化进画面

  • 不重编码的两种情况:

    1. 源码率已经低于目标(重编码只会更差)→ 视频 copy,只转音频
    2. HEVC(4K 频道,源是 BT.2020 10bit)→ 转成 BT.709 8bit 会改变色彩观感,宁可多花流量
  • 目标码率 5.5Mbps 的依据(FFV1 无损参考 + 帧对齐对比):

    目标码率SSIMPSNR(Y)
    3.5 Mbps0.992748.8 dB
    5.5 Mbps0.993949.9 dB
    8.0 Mbps0.994750.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,桌面浏览器解不了。
如果默认给原始流,浏览器会黑屏卡在错误态,播放器连"切换线路"的按钮都不出。

判定依据(按顺序):

  1. 移动端/播放器 UA(android / exoplayer / vlc / mpv / smarttv / ijkplayer …)→ 不转码
  2. 带 Sec-Fetch-Dest / Sec-Fetch-Mode(浏览器 fetch 元数据)→ 转码
  3. 桌面浏览器 UA(mozilla/ / chrome/ / safari/ / firefox/ / edg/ / opr/)→ 转码
  4. 无 UA → 转码(多半是服务端代理拉流,宁可转码也别黑屏)
  5. 其它未知 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>,且二进制里没有放行开关。

问题链:

  1. 应用要访问 NAS 自己的公网 IPv4 → 光猫/路由器通常不做 hairpin → 超时
  2. NAS 本地 DNS 可能把域名解析到别处的 A 记录(通配符 A)→ 那个 IP 上没开端口 → 连接被拒
  3. 改成 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,不受影响)。

三种应对,按推荐度:

  1. DDNS + DNS API:定时(每 10~30 分钟)读本机全局 IPv6,调 DNS API 更新 AAAA。
    外部客户端与内部应用都受益,hosts 方案可作为兜底。
  2. hosts 刷新定时器:只更新 hosts 一行。适合只关心媒体应用的场景。
  3. 路由器侧 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:

  1. 优先用 dig(直查 DNS,不看 hosts):
    IPV6=$(dig +short AAAA "$DOMAIN" | head -1)
  2. 没有 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 字符串
  • 播放列表里的每个 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. 媒体应用自己的源刷新周期(常见 1 小时,改数据库里的时间戳不会立即触发,
    它用内部定时器)
  2. 客户端 App 自身缓存 → 改完要完全退出重进
  3. 服务端 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 落在组播域那张网卡
流量源地址变成别的 IPVLAN 子接口劫持同网段路由删掉 VLAN 子接口,或显式覆盖 metric(§2.3)
看着看着卡住两个进程订阅同一组,一方退出发 Leave 打掉另一方统一到 udpxy 引用计数;或确保单一订阅者
开 IGMP snooping 后全挂光猫可能改按 MCV 过滤,不带 tag 的被丢记基线 → 打开后 30s 内验 3~5 个频道 → 任一为 0 立刻改回
机顶盒能开机、节目单正常,但黑屏光猫「公共组播 VLAN」配错核对并修正该值(§2.1 第 1 步)

冷启动 / 播放

现象根因处理
首次请求 502 / 播放列表不出现冷组重拉 ~15s 超过 ffmpeg timeouttimeout 提到 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 outupstream 定义被删,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 传脚本带 CRLFtr -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_PORT4023转流服务端口(entrypoint.sh 把它注入子进程的 LISTEN_PORT)
GATEWAY_PORT4024网关端口(同上)
PORT9050扫描器/状态页端口
LISTEN_HOST0.0.0.0⚠️ 必须改成 127.0.0.1(转流服务,见 §10.1)
HLS_PORT4023网关反代转流服务时用的端口
HLS_HOST192.168.1.5⚠️ 必须改(网关反代目标,见 §10.1)
HLS_ROOT/run/iptv-hls分片目录(容器内 tmpfs)
HLS_TIME / HLS_LIST4 / 6分片时长 / 播放列表窗口(代码默认值;compose 里设为 1 / 10)
AUDIO_BITRATE128kAAC 音轨码率
KINDSaac提供的音轨变体
IDLE_SECONDS300空闲多少秒停流并发 IGMP Leave
CHANNELS_FILE—频道表路径
CODECS_FILE—真实编码表路径(probe_codecs.py 产出)
UDPXYhttp://192.168.1.5:4022⚠️ 必须改(转流服务取流上游)
UDPXY_HOST / UDPXY_PORT192.168.1.5 / 4022⚠️ 必须改(网关裸流直通)
GATEWAY_URL—网关自引用地址(触发媒体应用刷新时用)
DATA_DIR/data报告、日志、state 目录
OUT_DIR$DATA_DIR播放列表输出目录
OUT_NAME / OUT_AACiptv.m3u / iptv-aac.m3u⚠️ 改成 live.m3u(与 nginx 一致,见 §8.1)
M3U_FILE / M3U_AAC_FILE—网关读取的播放列表路径
BASELINE_FILE/app/channels_baseline.csv扫描候选集(必须先生成,见 §4.1)
SCAN_IFACE_IP10.100.0.2⚠️ 必须改(扫描时加入组播用的本机地址 = 组播网卡源 IP)
RUN_AT / RUN_EVERY_DAYS06:30 / 15定时全量重扫
KEEP_BACKUPS7保留的历史版本数
EXCLUDE_CHANNELS / EXCLUDE_ADDRS空黑名单(按频道名 / 组播地址),重扫后依然生效
IMPORT_BASE—对外基地址(拼播放列表 URL)
LAN_CIDRS192.168.1.0/24,127.0.0.1哪些来源算"在家"
MOBILE_KINDmobile低码率档名
MOBILE_BITRATE_KBPS5500低码率目标
MOBILE_MAX_HEIGHT1080低码率最大高度
MOBILE_PRESETveryfastx264 preset
MOBILE_VCODEClibx264可换 h264_vaapi(需映射 /dev/dri)
MOBILE_COPY_UNDER_KBPS6000源码率低于它就只转音频、不重编码
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_PORT4023转流服务监听端口(只绑 127.0.0.1)
IPTV_IFACE—发 IGMP 报告的网卡(方案 A)
IPTV_LOCALADDR—发 IGMP 报告的源 IP(方案 A)
IPTV_IDLE120空闲多少秒停流并发 IGMP Leave
IPTV_IGMP_KEEPALIVE30IGMP 报告保活间隔(秒)
IPTV_HLS_TIME / IPTV_HLS_LIST2 / 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丢包不致命,避免高码率流直接断
timeout30s,覆盖光猫重拉组的 ~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 实施顺序建议

阶段零:判定拓扑(不做这一步,后面全是白工)

  1. 读光猫,记下 IPTV VID / 公共组播 VLAN / 端口绑定 / 组播绑定 / IGMP 代理开关(§2.1)
  2. 逐口试接 + 抓包,确定组播落在哪个口、带不带 tag(§2.1 第 2~3 步)
  3. 判定组播域 vs 服务域,决定网卡数量与接线(§2.2)
  4. 用 ffplay rtp://<已知组播地址> 在候选口所在的那台机器上验证能出画面

如果这一步判定出「组播域 == 服务域」,恭喜,后面不需要多网卡。
如果判定出「IPTV 专用口是独立二层段」,那才需要额外手段(双网卡 / igmpproxy / 旁路 udpxy)。

阶段一:打通数据通路

  1. 拿到候选组地址(抓包 / 公开清单,§4.1、§4.2)
  2. 逐组验证有流 + 码率(§4.3)
  3. 跑通最小链路:单频道、单档、裸流直通 → 客户端能看
  4. 加 HLS 档 → 浏览器能看
  5. 补 master + 真实 CODECS → 多端自适应
  6. 生成全量频道表与 m3u

阶段二:暴露与运营

  1. 套 nginx + 令牌 → 公网可达
  2. 接 IPv6:放行入站 → 配 AAAA → listen [::] → 验证
  3. 接媒体应用 → 处理 SSRF(hosts 钉 IPv6)→ 台标
  4. 补监控(health / rescan / DDNS)

第 0~3 步不通,后面全是白工。