├── logos ├── 乐游.png ├── 梨园.png ├── 汽摩.png ├── 三沙卫视.png ├── 东南卫视.png ├── 东方卫视.png ├── 东方财经.png ├── 东风卫视.png ├── 中国交通.png ├── 中国天气.png ├── 书画频道.png ├── 云南卫视.png ├── 人间卫视.png ├── 优漫卡通.png ├── 先锋乒羽.png ├── 兵团卫视.png ├── 农林卫视.png ├── 动漫秀场.png ├── 北京卫视.png ├── 南方卫视.png ├── 卡酷动画.png ├── 厦门卫视.png ├── 吉林卫视.png ├── 嘉佳卡通.png ├── 四川乡村.png ├── 四川卫视.png ├── 四海钓鱼.png ├── 天元围棋.png ├── 天津卫视.png ├── 宁夏卫视.png ├── 安多卫视.png ├── 安徽卫视.png ├── 家庭理财.png ├── 家政频道.png ├── 山东卫视.png ├── 山西卫视.png ├── 峨眉电影.png ├── 广东卫视.png ├── 广西卫视.png ├── 康巴卫视.png ├── 延边卫视.png ├── 快乐垂钓.png ├── 新疆卫视.png ├── 星空卫视.png ├── 欢笑剧场.png ├── 求索纪录.png ├── 求索频道.png ├── 江苏卫视.png ├── 江西卫视.png ├── 河北卫视.png ├── 河南卫视.png ├── 法治天地.png ├── 浙江卫视.png ├── 海南卫视.png ├── 海峡卫视.png ├── 深圳卫视.png ├── 游戏风云.png ├── 湖北卫视.png ├── 湖南卫视.png ├── 澳亚卫视.png ├── 环球旅游.png ├── 甘肃卫视.png ├── 生活时尚.png ├── 精彩影视.png ├── 茶频道.png ├── 莲花卫视.png ├── 西藏卫视.png ├── 财富天下.png ├── 贵州卫视.png ├── 辽宁卫视.png ├── 都市剧场.png ├── 重庆卫视.png ├── 金色学堂.png ├── 金鹰卡通.png ├── 金鹰纪实.png ├── 陕西农林.png ├── 陕西卫视.png ├── 青海卫视.png ├── 香港卫视.png ├── 魅力足球.png ├── CCTV1.png ├── CCTV10.png ├── CCTV11.png ├── CCTV12.png ├── CCTV13.png ├── CCTV14.png ├── CCTV15.png ├── CCTV16.png ├── CCTV17.png ├── CCTV2.png ├── CCTV3.png ├── CCTV3D.png ├── CCTV4.png ├── CCTV4K.png ├── CCTV4欧洲.png ├── CCTV4美洲.png ├── CCTV5+.png ├── CCTV5.png ├── CCTV6.png ├── CCTV7.png ├── CCTV8.png ├── CCTV8K.png ├── CCTV9.png ├── CDTV1.png ├── CDTV2.png ├── CDTV3.png ├── CDTV4.png ├── CDTV5.png ├── CDTV6.png ├── CDTV8.png ├── CETV1.png ├── CETV2.png ├── CETV3.png ├── CETV4.png ├── CGTN俄语.png ├── CGTN法语.png ├── CGTN纪录.png ├── CGTN英语.png ├── CHC动作电影.png ├── CHC家庭影院.png ├── CHC影迷电影.png ├── CHC高清电影.png ├── SCTV2.png ├── SCTV3.png ├── SCTV4.png ├── SCTV5.png ├── SCTV6.png ├── SCTV7.png ├── SCTV8.png ├── SCTV9.png ├── 内蒙古卫视.png ├── 内蒙古蒙语卫视.png ├── 北京卫视4K.png ├── 北京纪实科教.png ├── 大湾区卫视.png ├── 山东教育卫视.png ├── 欢笑剧场4K.png ├── 西藏藏语卫视.png ├── 黑龙江卫视.png ├── CGTN西班牙语.png └── CGTN阿拉伯语.png ├── docs ├── images │ ├── openwrt_startup_web.png │ └── openwrt_startup_script.png └── autostart.md ├── internal ├── app │ ├── iptv │ │ ├── iptv.go │ │ ├── channel_group.go │ │ ├── logo.go │ │ ├── epg.go │ │ ├── crypto.go │ │ ├── hwctc │ │ │ ├── hwctc.go │ │ │ ├── hwctc_config.go │ │ │ ├── epg_gdhdpublic.go │ │ │ ├── epg_liveplay.go │ │ │ ├── epg.go │ │ │ ├── channel.go │ │ │ ├── epg_defaulttrans2.go │ │ │ ├── authenticator.go │ │ │ ├── epg_vsp.go │ │ │ └── epg_stbepg2023group.go │ │ └── channel.go │ ├── router │ │ ├── config_controller.go │ │ ├── scheduler.go │ │ ├── router.go │ │ ├── channel_controller.go │ │ └── epg_controller.go │ └── config │ │ └── config.go └── pkg │ ├── util │ └── util.go │ └── logging │ └── logger.go ├── scripts └── iptv ├── .gitignore ├── cmd └── iptv │ ├── main.go │ └── cmds │ ├── root.go │ ├── serve.go │ ├── key.go │ └── channel.go ├── .github └── workflows │ └── release.yml ├── LICENSE ├── .goreleaser.yml ├── go.mod ├── config.yml ├── README.md └── go.sum /logos/乐游.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/乐游.png -------------------------------------------------------------------------------- /logos/梨园.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/梨园.png -------------------------------------------------------------------------------- /logos/汽摩.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/汽摩.png -------------------------------------------------------------------------------- /logos/三沙卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/三沙卫视.png -------------------------------------------------------------------------------- /logos/东南卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/东南卫视.png -------------------------------------------------------------------------------- /logos/东方卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/东方卫视.png -------------------------------------------------------------------------------- /logos/东方财经.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/东方财经.png -------------------------------------------------------------------------------- /logos/东风卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/东风卫视.png -------------------------------------------------------------------------------- /logos/中国交通.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/中国交通.png -------------------------------------------------------------------------------- /logos/中国天气.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/中国天气.png -------------------------------------------------------------------------------- /logos/书画频道.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/书画频道.png -------------------------------------------------------------------------------- /logos/云南卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/云南卫视.png -------------------------------------------------------------------------------- /logos/人间卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/人间卫视.png -------------------------------------------------------------------------------- /logos/优漫卡通.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/优漫卡通.png -------------------------------------------------------------------------------- /logos/先锋乒羽.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/先锋乒羽.png -------------------------------------------------------------------------------- /logos/兵团卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/兵团卫视.png -------------------------------------------------------------------------------- /logos/农林卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/农林卫视.png -------------------------------------------------------------------------------- /logos/动漫秀场.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/动漫秀场.png -------------------------------------------------------------------------------- /logos/北京卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/北京卫视.png -------------------------------------------------------------------------------- /logos/南方卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/南方卫视.png -------------------------------------------------------------------------------- /logos/卡酷动画.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/卡酷动画.png -------------------------------------------------------------------------------- /logos/厦门卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/厦门卫视.png -------------------------------------------------------------------------------- /logos/吉林卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/吉林卫视.png -------------------------------------------------------------------------------- /logos/嘉佳卡通.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/嘉佳卡通.png -------------------------------------------------------------------------------- /logos/四川乡村.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/四川乡村.png -------------------------------------------------------------------------------- /logos/四川卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/四川卫视.png -------------------------------------------------------------------------------- /logos/四海钓鱼.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/四海钓鱼.png -------------------------------------------------------------------------------- /logos/天元围棋.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/天元围棋.png -------------------------------------------------------------------------------- /logos/天津卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/天津卫视.png -------------------------------------------------------------------------------- /logos/宁夏卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/宁夏卫视.png -------------------------------------------------------------------------------- /logos/安多卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/安多卫视.png -------------------------------------------------------------------------------- /logos/安徽卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/安徽卫视.png -------------------------------------------------------------------------------- /logos/家庭理财.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/家庭理财.png -------------------------------------------------------------------------------- /logos/家政频道.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/家政频道.png -------------------------------------------------------------------------------- /logos/山东卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/山东卫视.png -------------------------------------------------------------------------------- /logos/山西卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/山西卫视.png -------------------------------------------------------------------------------- /logos/峨眉电影.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/峨眉电影.png -------------------------------------------------------------------------------- /logos/广东卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/广东卫视.png -------------------------------------------------------------------------------- /logos/广西卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/广西卫视.png -------------------------------------------------------------------------------- /logos/康巴卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/康巴卫视.png -------------------------------------------------------------------------------- /logos/延边卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/延边卫视.png -------------------------------------------------------------------------------- /logos/快乐垂钓.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/快乐垂钓.png -------------------------------------------------------------------------------- /logos/新疆卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/新疆卫视.png -------------------------------------------------------------------------------- /logos/星空卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/星空卫视.png -------------------------------------------------------------------------------- /logos/欢笑剧场.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/欢笑剧场.png -------------------------------------------------------------------------------- /logos/求索纪录.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/求索纪录.png -------------------------------------------------------------------------------- /logos/求索频道.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/求索频道.png -------------------------------------------------------------------------------- /logos/江苏卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/江苏卫视.png -------------------------------------------------------------------------------- /logos/江西卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/江西卫视.png -------------------------------------------------------------------------------- /logos/河北卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/河北卫视.png -------------------------------------------------------------------------------- /logos/河南卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/河南卫视.png -------------------------------------------------------------------------------- /logos/法治天地.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/法治天地.png -------------------------------------------------------------------------------- /logos/浙江卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/浙江卫视.png -------------------------------------------------------------------------------- /logos/海南卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/海南卫视.png -------------------------------------------------------------------------------- /logos/海峡卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/海峡卫视.png -------------------------------------------------------------------------------- /logos/深圳卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/深圳卫视.png -------------------------------------------------------------------------------- /logos/游戏风云.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/游戏风云.png -------------------------------------------------------------------------------- /logos/湖北卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/湖北卫视.png -------------------------------------------------------------------------------- /logos/湖南卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/湖南卫视.png -------------------------------------------------------------------------------- /logos/澳亚卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/澳亚卫视.png -------------------------------------------------------------------------------- /logos/环球旅游.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/环球旅游.png -------------------------------------------------------------------------------- /logos/甘肃卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/甘肃卫视.png -------------------------------------------------------------------------------- /logos/生活时尚.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/生活时尚.png -------------------------------------------------------------------------------- /logos/精彩影视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/精彩影视.png -------------------------------------------------------------------------------- /logos/茶频道.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/茶频道.png -------------------------------------------------------------------------------- /logos/莲花卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/莲花卫视.png -------------------------------------------------------------------------------- /logos/西藏卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/西藏卫视.png -------------------------------------------------------------------------------- /logos/财富天下.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/财富天下.png -------------------------------------------------------------------------------- /logos/贵州卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/贵州卫视.png -------------------------------------------------------------------------------- /logos/辽宁卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/辽宁卫视.png -------------------------------------------------------------------------------- /logos/都市剧场.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/都市剧场.png -------------------------------------------------------------------------------- /logos/重庆卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/重庆卫视.png -------------------------------------------------------------------------------- /logos/金色学堂.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/金色学堂.png -------------------------------------------------------------------------------- /logos/金鹰卡通.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/金鹰卡通.png -------------------------------------------------------------------------------- /logos/金鹰纪实.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/金鹰纪实.png -------------------------------------------------------------------------------- /logos/陕西农林.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/陕西农林.png -------------------------------------------------------------------------------- /logos/陕西卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/陕西卫视.png -------------------------------------------------------------------------------- /logos/青海卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/青海卫视.png -------------------------------------------------------------------------------- /logos/香港卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/香港卫视.png -------------------------------------------------------------------------------- /logos/魅力足球.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/魅力足球.png -------------------------------------------------------------------------------- /logos/CCTV1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV1.png -------------------------------------------------------------------------------- /logos/CCTV10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV10.png -------------------------------------------------------------------------------- /logos/CCTV11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV11.png -------------------------------------------------------------------------------- /logos/CCTV12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV12.png -------------------------------------------------------------------------------- /logos/CCTV13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV13.png -------------------------------------------------------------------------------- /logos/CCTV14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV14.png -------------------------------------------------------------------------------- /logos/CCTV15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV15.png -------------------------------------------------------------------------------- /logos/CCTV16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV16.png -------------------------------------------------------------------------------- /logos/CCTV17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV17.png -------------------------------------------------------------------------------- /logos/CCTV2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV2.png -------------------------------------------------------------------------------- /logos/CCTV3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV3.png -------------------------------------------------------------------------------- /logos/CCTV3D.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV3D.png -------------------------------------------------------------------------------- /logos/CCTV4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV4.png -------------------------------------------------------------------------------- /logos/CCTV4K.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV4K.png -------------------------------------------------------------------------------- /logos/CCTV4欧洲.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV4欧洲.png -------------------------------------------------------------------------------- /logos/CCTV4美洲.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV4美洲.png -------------------------------------------------------------------------------- /logos/CCTV5+.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV5+.png -------------------------------------------------------------------------------- /logos/CCTV5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV5.png -------------------------------------------------------------------------------- /logos/CCTV6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV6.png -------------------------------------------------------------------------------- /logos/CCTV7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV7.png -------------------------------------------------------------------------------- /logos/CCTV8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV8.png -------------------------------------------------------------------------------- /logos/CCTV8K.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV8K.png -------------------------------------------------------------------------------- /logos/CCTV9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CCTV9.png -------------------------------------------------------------------------------- /logos/CDTV1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CDTV1.png -------------------------------------------------------------------------------- /logos/CDTV2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CDTV2.png -------------------------------------------------------------------------------- /logos/CDTV3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CDTV3.png -------------------------------------------------------------------------------- /logos/CDTV4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CDTV4.png -------------------------------------------------------------------------------- /logos/CDTV5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CDTV5.png -------------------------------------------------------------------------------- /logos/CDTV6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CDTV6.png -------------------------------------------------------------------------------- /logos/CDTV8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CDTV8.png -------------------------------------------------------------------------------- /logos/CETV1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CETV1.png -------------------------------------------------------------------------------- /logos/CETV2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CETV2.png -------------------------------------------------------------------------------- /logos/CETV3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CETV3.png -------------------------------------------------------------------------------- /logos/CETV4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CETV4.png -------------------------------------------------------------------------------- /logos/CGTN俄语.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CGTN俄语.png -------------------------------------------------------------------------------- /logos/CGTN法语.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CGTN法语.png -------------------------------------------------------------------------------- /logos/CGTN纪录.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CGTN纪录.png -------------------------------------------------------------------------------- /logos/CGTN英语.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CGTN英语.png -------------------------------------------------------------------------------- /logos/CHC动作电影.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CHC动作电影.png -------------------------------------------------------------------------------- /logos/CHC家庭影院.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CHC家庭影院.png -------------------------------------------------------------------------------- /logos/CHC影迷电影.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CHC影迷电影.png -------------------------------------------------------------------------------- /logos/CHC高清电影.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CHC高清电影.png -------------------------------------------------------------------------------- /logos/SCTV2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/SCTV2.png -------------------------------------------------------------------------------- /logos/SCTV3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/SCTV3.png -------------------------------------------------------------------------------- /logos/SCTV4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/SCTV4.png -------------------------------------------------------------------------------- /logos/SCTV5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/SCTV5.png -------------------------------------------------------------------------------- /logos/SCTV6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/SCTV6.png -------------------------------------------------------------------------------- /logos/SCTV7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/SCTV7.png -------------------------------------------------------------------------------- /logos/SCTV8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/SCTV8.png -------------------------------------------------------------------------------- /logos/SCTV9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/SCTV9.png -------------------------------------------------------------------------------- /logos/内蒙古卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/内蒙古卫视.png -------------------------------------------------------------------------------- /logos/内蒙古蒙语卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/内蒙古蒙语卫视.png -------------------------------------------------------------------------------- /logos/北京卫视4K.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/北京卫视4K.png -------------------------------------------------------------------------------- /logos/北京纪实科教.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/北京纪实科教.png -------------------------------------------------------------------------------- /logos/大湾区卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/大湾区卫视.png -------------------------------------------------------------------------------- /logos/山东教育卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/山东教育卫视.png -------------------------------------------------------------------------------- /logos/欢笑剧场4K.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/欢笑剧场4K.png -------------------------------------------------------------------------------- /logos/西藏藏语卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/西藏藏语卫视.png -------------------------------------------------------------------------------- /logos/黑龙江卫视.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/黑龙江卫视.png -------------------------------------------------------------------------------- /logos/CGTN西班牙语.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CGTN西班牙语.png -------------------------------------------------------------------------------- /logos/CGTN阿拉伯语.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/logos/CGTN阿拉伯语.png -------------------------------------------------------------------------------- /docs/images/openwrt_startup_web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/docs/images/openwrt_startup_web.png -------------------------------------------------------------------------------- /docs/images/openwrt_startup_script.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super321/iptv-tool/HEAD/docs/images/openwrt_startup_script.png -------------------------------------------------------------------------------- /internal/app/iptv/iptv.go: -------------------------------------------------------------------------------- 1 | package iptv 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type Client interface { 8 | // GetAllChannelList 获取频道列表 9 | GetAllChannelList(ctx context.Context) ([]Channel, error) 10 | 11 | // GetAllChannelProgramList 获取所有频道的节目单列表 12 | GetAllChannelProgramList(ctx context.Context, channels []Channel) ([]ChannelProgramList, error) 13 | } 14 | -------------------------------------------------------------------------------- /internal/app/router/config_controller.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | type Lives struct { 10 | Lives []Live `json:"lives"` 11 | } 12 | 13 | // Live 直播配置 14 | type Live map[string]any 15 | 16 | var lives *Lives 17 | 18 | // LoadLivesConfig 加载直播配置 19 | func LoadLivesConfig(livesCfg *Lives) { 20 | lives = livesCfg 21 | } 22 | 23 | // GetLivesConfig 查询直播配置 24 | func GetLivesConfig(c *gin.Context) { 25 | if lives == nil { 26 | c.Status(http.StatusNotFound) 27 | return 28 | } 29 | 30 | // 返回响应 31 | c.PureJSON(http.StatusOK, lives) 32 | } 33 | -------------------------------------------------------------------------------- /scripts/iptv: -------------------------------------------------------------------------------- 1 | #!/bin/sh /etc/rc.common 2 | START=99 3 | STOP=20 4 | 5 | IPTV_HOME=/opt/iptv 6 | 7 | start(){ 8 | # Example 9 | nohup $IPTV_HOME/iptv serve -i 24h -p 8088 -u inner=http://192.168.3.1:4022 > /dev/null 2>&1 & 10 | } 11 | 12 | stop(){ 13 | # kill your pid 14 | kill -9 `ps | grep "$IPTV_HOME/iptv" | grep -v 'grep' | awk '{print $1}'` 15 | } 16 | 17 | restart(){ 18 | kill -9 `ps | grep "$IPTV_HOME/iptv" | grep -v 'grep' | awk '{print $1}'` 19 | # Example 20 | nohup $IPTV_HOME/iptv serve -i 24h -p 8088 -u inner=http://192.168.3.1:4022 > /dev/null 2>&1 & 21 | } -------------------------------------------------------------------------------- /internal/app/iptv/channel_group.go: -------------------------------------------------------------------------------- 1 | package iptv 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | const otherChGroupName = "其他" 8 | 9 | type ChannelGroupRules struct { 10 | Name string // 分组名称 11 | Rules []*regexp.Regexp // 分组规则 12 | } 13 | 14 | // GetChannelGroupName 根据频道名称自动获取分组名称 15 | func GetChannelGroupName(chGroupRulesList []ChannelGroupRules, channelName string) string { 16 | // 自动识别频道的分类 17 | for _, chGroupRules := range chGroupRulesList { 18 | for _, groupRule := range chGroupRules.Rules { 19 | if groupRule.MatchString(channelName) { 20 | return chGroupRules.Name 21 | } 22 | } 23 | } 24 | return otherChGroupName 25 | } 26 | -------------------------------------------------------------------------------- /docs/autostart.md: -------------------------------------------------------------------------------- 1 | # OpenWrt自启动设置 2 | 3 | ## 步骤 4 | 5 | 1. 通过SSH客户端登录OpenWrt,执行以下命令安装依赖并下载自启动脚本。 6 | ```bash 7 | opkg update && opkg install coreutils-nohup 8 | cd /etc/init.d/ 9 | curl -OJL https://raw.githubusercontent.com/super321/iptv-tool/main/scripts/iptv 10 | chmod 755 iptv 11 | ``` 12 | 13 | 2. 根据自己的实际环境修改自启动脚本`iptv`,例如:IPTV工具的运行目录和启动命令。 14 | ![在OpenWrt上的IPTV工具自启动脚本](./images/openwrt_startup_script.png) 15 | 16 | 3. 执行以下命令启用并立即启动自启动脚本。 17 | ```bash 18 | /etc/init.d/iptv enable 19 | /etc/init.d/iptv start 20 | ``` 21 | 22 | 4. 在OpenWrt“启动项”界面,可查看自启动状态 23 | ![在OpenWrt上的IPTV工具自启动项](./images/openwrt_startup_web.png) -------------------------------------------------------------------------------- /internal/pkg/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "sort" 7 | ) 8 | 9 | // GetCurrentAbPathByExecutable 获取当前执行程序所在的绝对路径 10 | func GetCurrentAbPathByExecutable() (string, error) { 11 | exePath, err := os.Executable() 12 | if err != nil { 13 | return "", err 14 | } 15 | res, _ := filepath.EvalSymlinks(filepath.Dir(exePath)) 16 | return res, nil 17 | } 18 | 19 | // SortedMapKeys 对Map的Key进行排序 20 | func SortedMapKeys[T any](maps map[string]T) []string { 21 | ret := make([]string, len(maps)) 22 | i := 0 23 | for name := range maps { 24 | ret[i] = name 25 | i++ 26 | } 27 | sort.Strings(ret) 28 | return ret 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | 27 | ### IDE 28 | .idea 29 | .idea/** 30 | 31 | ### Action 32 | dist 33 | -------------------------------------------------------------------------------- /internal/app/iptv/logo.go: -------------------------------------------------------------------------------- 1 | package iptv 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | const logoDirName = "logos" 10 | 11 | type ChannelLogoRule struct { 12 | Name string 13 | Rule *regexp.Regexp 14 | } 15 | 16 | func (l *ChannelLogoRule) ResolveName(matches []string) string { 17 | s := l.Name 18 | if len(matches) > 1 { 19 | for i, ma := range matches[1:] { 20 | s = strings.ReplaceAll(s, "$G"+strconv.FormatInt(int64(i+1), 10), ma) 21 | } 22 | } 23 | return s 24 | } 25 | 26 | // GetChannelLogoName 根据频道名称识别频道台标logo 27 | func GetChannelLogoName(chLogoRuleList []ChannelLogoRule, channelName string) string { 28 | for _, chLogoRule := range chLogoRuleList { 29 | matches := chLogoRule.Rule.FindStringSubmatch(channelName) 30 | if len(matches) > 0 { 31 | return chLogoRule.ResolveName(matches) 32 | } 33 | } 34 | return channelName 35 | } 36 | -------------------------------------------------------------------------------- /cmd/iptv/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "iptv/cmd/iptv/cmds" 6 | "iptv/internal/pkg/logging" 7 | "iptv/internal/pkg/util" 8 | "path" 9 | 10 | "github.com/spf13/cobra" 11 | "go.uber.org/zap" 12 | "go.uber.org/zap/zapcore" 13 | ) 14 | 15 | func init() { 16 | currPath, err := util.GetCurrentAbPathByExecutable() 17 | if err != nil { 18 | panic(err) 19 | } 20 | logFile := path.Join(currPath, "iptv.log") 21 | 22 | // 初始化日志 23 | err = logging.InitLogger(&logging.LogConfig{ 24 | Level: zapcore.InfoLevel, 25 | FileName: logFile, 26 | MaxSize: 30, 27 | MaxBackups: 3, 28 | IsStdout: true, 29 | }) 30 | if err != nil { 31 | panic(err) 32 | } 33 | } 34 | 35 | func main() { 36 | // L():获取全局logger 37 | logger := zap.L() 38 | defer logger.Sync() 39 | 40 | cobra.CheckErr(cmds.NewRootCLI().ExecuteContext(context.Background())) 41 | } 42 | -------------------------------------------------------------------------------- /internal/app/iptv/epg.go: -------------------------------------------------------------------------------- 1 | package iptv 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // ChannelProgramList 频道节目单列表 8 | type ChannelProgramList struct { 9 | ChannelId string `json:"channelId"` // 频道Id 10 | ChannelName string `json:"channelName,omitempty"` // 频道名称 11 | DateProgramList []DateProgram `json:"dateProgramList"` // 不同日期的频道列表 12 | } 13 | 14 | // DateProgram 一天的节目单列表 15 | type DateProgram struct { 16 | Date time.Time `json:"date"` 17 | ProgramList []Program `json:"programList"` 18 | } 19 | 20 | // Program 节目单 21 | type Program struct { 22 | ProgramName string `json:"programName"` // 节目名称 23 | BeginTimeFormat string `json:"beginTimeFormat"` // 格式化的开始时间,例如:20241122205700 24 | EndTimeFormat string `json:"endTimeFormat"` // 格式化的结束时间,例如:20241122210100 25 | StartTime string `json:"startTime"` // 开始时间,例如:20:57 26 | EndTime string `json:"endTime"` // 结束时间,例如:21:01 27 | } 28 | -------------------------------------------------------------------------------- /internal/app/router/scheduler.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "context" 5 | "iptv/internal/app/iptv" 6 | "time" 7 | 8 | "go.uber.org/zap" 9 | ) 10 | 11 | const waitSeconds = 30 12 | 13 | // Schedule 定时调度更新缓存数据 14 | func Schedule(ctx context.Context, iptvClient iptv.Client, duration time.Duration) { 15 | // 创建定时任务 16 | ticker := time.NewTicker(duration) 17 | go func() { 18 | for { 19 | select { 20 | case <-ctx.Done(): 21 | logger.Info("The scheduling task has been stopped.") 22 | return 23 | case <-ticker.C: 24 | logger.Info("Start executing the scheduling task.") 25 | 26 | // 更新频道列表数据 27 | if err := updateChannelsWithRetry(ctx, iptvClient, 3); err != nil { 28 | logger.Error("Failed to update channel list.", zap.Error(err)) 29 | } 30 | 31 | // 更新节目单数据 32 | if err := updateEPG(ctx, iptvClient); err != nil { 33 | logger.Error("Failed to update EPG.", zap.Error(err)) 34 | } 35 | 36 | logger.Info("The scheduling task has been completed.") 37 | } 38 | } 39 | }() 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | # id-token: write 11 | # packages: write 12 | 13 | jobs: 14 | goreleaser: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version-file: 'go.mod' 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v6 27 | with: 28 | # either 'goreleaser' (default) or 'goreleaser-pro' 29 | distribution: goreleaser 30 | # 'latest', 'nightly', or a semver 31 | version: '~> v2' 32 | args: release --clean 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution 36 | # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 superant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/app/iptv/crypto.go: -------------------------------------------------------------------------------- 1 | package iptv 2 | 3 | import ( 4 | "encoding/hex" 5 | "strings" 6 | 7 | "github.com/forgoer/openssl" 8 | ) 9 | 10 | type TripleDESCrypto struct { 11 | key []byte 12 | } 13 | 14 | // NewTripleDESCrypto 创建新的3DES加密对象 15 | func NewTripleDESCrypto(key string) *TripleDESCrypto { 16 | // 补齐密钥长度到24字节 17 | if len(key) < 24 { 18 | key += strings.Repeat("0", 24-len(key)) 19 | } else if len(key) > 24 { 20 | key = key[:24] 21 | } 22 | 23 | return &TripleDESCrypto{ 24 | key: []byte(key), 25 | } 26 | } 27 | 28 | // ECBEncrypt 加密函数,返回十六进制字符串 29 | func (c *TripleDESCrypto) ECBEncrypt(plainText string) (string, error) { 30 | encrypted, err := openssl.Des3ECBEncrypt([]byte(plainText), c.key, openssl.PKCS7_PADDING) 31 | if err != nil { 32 | return "", err 33 | } 34 | 35 | return hex.EncodeToString(encrypted), nil 36 | } 37 | 38 | // ECBDecrypt 解密函数,输入十六进制字符串,返回明文 39 | func (c *TripleDESCrypto) ECBDecrypt(cipherText string) (string, error) { 40 | data, err := hex.DecodeString(cipherText) 41 | if err != nil { 42 | return "", err 43 | } 44 | 45 | decrypted, err := openssl.Des3ECBDecrypt(data, c.key, openssl.PKCS7_PADDING) 46 | if err != nil { 47 | return "", err 48 | } 49 | 50 | return string(decrypted), nil 51 | } 52 | -------------------------------------------------------------------------------- /cmd/iptv/cmds/root.go: -------------------------------------------------------------------------------- 1 | package cmds 2 | 3 | import ( 4 | "iptv/internal/app/config" 5 | "iptv/internal/pkg/util" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var ( 13 | cfgFile string 14 | 15 | conf *config.Config 16 | ) 17 | 18 | func init() { 19 | cobra.OnInitialize(initConfig) 20 | } 21 | 22 | func NewRootCLI() *cobra.Command { 23 | rootCmd := &cobra.Command{ 24 | Use: "iptv", 25 | Short: "IPTV工具", 26 | SilenceUsage: true, 27 | SilenceErrors: true, 28 | CompletionOptions: cobra.CompletionOptions{ 29 | DisableDefaultCmd: true, 30 | }, 31 | } 32 | 33 | rootCmd.AddCommand(NewKeyCLI()) 34 | rootCmd.AddCommand(NewChannelCLI()) 35 | rootCmd.AddCommand(NewServeCLI()) 36 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "YAML配置文件的路径") 37 | 38 | return rootCmd 39 | } 40 | 41 | // initConfig 初始化配置文件 42 | func initConfig() { 43 | var err error 44 | var fPath string 45 | 46 | if cfgFile != "" { 47 | // 使用命令参数中的配置文件 48 | fPath = cfgFile 49 | } else { 50 | cfgHome, err := util.GetCurrentAbPathByExecutable() 51 | cobra.CheckErr(err) 52 | 53 | fPath = filepath.Join(cfgHome, "config.yml") 54 | 55 | // 写入缺省配置文件 56 | if _, err = os.Stat(fPath); os.IsNotExist(err) { 57 | err = config.CreateDefaultCfg(fPath) 58 | cobra.CheckErr(err) 59 | } 60 | } 61 | 62 | // 读取配置文件 63 | conf, err = config.Load(fPath) 64 | cobra.CheckErr(err) 65 | } 66 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: iptv 3 | 4 | before: 5 | hooks: 6 | - go mod tidy 7 | 8 | builds: 9 | - binary: iptv 10 | main: ./cmd/iptv/main.go 11 | env: 12 | - CGO_ENABLED=0 13 | goos: 14 | - windows 15 | - darwin 16 | - linux 17 | - freebsd 18 | goarch: 19 | - amd64 20 | - arm 21 | - arm64 22 | - mips 23 | - mipsle 24 | - mips64 25 | - mips64le 26 | goarm: 27 | - '5' 28 | - '6' 29 | - '7' 30 | gomips: 31 | - hardfloat 32 | - softfloat 33 | ignore: 34 | - goos: windows 35 | goarch: arm 36 | - goos: windows 37 | goarch: arm64 38 | - goos: darwin 39 | goarch: arm 40 | 41 | checksum: 42 | name_template: "checksums.txt" 43 | 44 | archives: 45 | - name_template: >- 46 | {{ .ProjectName }}_ 47 | {{- title .Os }}_ 48 | {{- if eq .Arch "amd64" }}x86_64 49 | {{- else if eq .Arch "386" }}i386 50 | {{- else }}{{ .Arch }}{{ end }} 51 | {{- if .Mips }}_{{ .Mips }}{{ end }} 52 | {{- if .Arm }}v{{ .Arm }}{{ end }} 53 | wrap_in_directory: true 54 | format_overrides: 55 | - goos: windows 56 | formats: 57 | - "zip" 58 | builds_info: 59 | group: root 60 | owner: root 61 | files: 62 | - config.yml 63 | - src: "logos/*.png" 64 | dst: logos 65 | changelog: 66 | sort: asc 67 | groups: 68 | - title: Features 69 | regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' 70 | order: 0 71 | - title: "Bug fixes" 72 | regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' 73 | order: 1 74 | - title: Others 75 | order: 999 76 | filters: 77 | exclude: 78 | - "^docs:" 79 | - "^style:" 80 | - "^test:" 81 | - "^chore:" -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module iptv 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/forgoer/openssl v1.6.0 7 | github.com/gin-contrib/zap v1.1.5 8 | github.com/gin-gonic/gin v1.10.0 9 | github.com/spf13/cobra v1.9.1 10 | go.uber.org/zap v1.27.0 11 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 12 | gopkg.in/yaml.v3 v3.0.1 13 | ) 14 | 15 | require ( 16 | github.com/bytedance/sonic v1.13.2 // indirect 17 | github.com/bytedance/sonic/loader v0.2.4 // indirect 18 | github.com/cloudwego/base64x v0.1.5 // indirect 19 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 20 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 21 | github.com/gin-contrib/sse v1.1.0 // indirect 22 | github.com/go-playground/locales v0.14.1 // indirect 23 | github.com/go-playground/universal-translator v0.18.1 // indirect 24 | github.com/go-playground/validator/v10 v10.26.0 // indirect 25 | github.com/goccy/go-json v0.10.5 // indirect 26 | github.com/google/go-cmp v0.7.0 // indirect 27 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 28 | github.com/json-iterator/go v1.1.12 // indirect 29 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 30 | github.com/kr/pretty v0.3.1 // indirect 31 | github.com/leodido/go-urn v1.4.0 // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 34 | github.com/modern-go/reflect2 v1.0.2 // indirect 35 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 36 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 37 | github.com/spf13/pflag v1.0.6 // indirect 38 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 39 | github.com/ugorji/go/codec v1.2.12 // indirect 40 | go.uber.org/multierr v1.11.0 // indirect 41 | golang.org/x/arch v0.16.0 // indirect 42 | golang.org/x/crypto v0.37.0 // indirect 43 | golang.org/x/net v0.39.0 // indirect 44 | golang.org/x/sys v0.32.0 // indirect 45 | golang.org/x/text v0.24.0 // indirect 46 | google.golang.org/protobuf v1.36.6 // indirect 47 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 48 | ) 49 | -------------------------------------------------------------------------------- /internal/app/iptv/hwctc/hwctc.go: -------------------------------------------------------------------------------- 1 | package hwctc 2 | 3 | import ( 4 | "fmt" 5 | "iptv/internal/app/iptv" 6 | "net/http" 7 | "regexp" 8 | 9 | "go.uber.org/zap" 10 | ) 11 | 12 | type Client struct { 13 | httpClient *http.Client // HTTP客户端 14 | config *Config // hwctc相关配置 15 | key string // 加密Authenticator的秘钥 16 | originHost string // HTTP请求的服务器地址端口 17 | headers map[string]string // 自定义HTTP请求头 18 | chExcludeRule *regexp.Regexp // 频道的过滤规则 19 | chGroupRulesList []iptv.ChannelGroupRules // 频道分组的规则 20 | chLogoRuleList []iptv.ChannelLogoRule // 频道台标的匹配规则 21 | 22 | host string // 缓存最新重定向的服务器地址和端口 23 | 24 | logger *zap.Logger // 日志 25 | } 26 | 27 | var _ iptv.Client = (*Client)(nil) 28 | 29 | func NewClient(httpClient *http.Client, config *Config, key, serverHost string, headers map[string]string, 30 | chExcludeRule *regexp.Regexp, chGroupRulesList []iptv.ChannelGroupRules, chLogoRuleList []iptv.ChannelLogoRule) (iptv.Client, error) { 31 | // config不能为空 32 | if config == nil { 33 | return nil, fmt.Errorf("client config is nil") 34 | } else if err := config.Validate(); err != nil { // 校验config配置 35 | return nil, err 36 | } 37 | 38 | // 密钥和服务器地址必须配置 39 | if key == "" { 40 | return nil, fmt.Errorf("key is empty") 41 | } else if serverHost == "" { 42 | return nil, fmt.Errorf("serverHost is empty") 43 | } 44 | 45 | i := Client{ 46 | httpClient: httpClient, 47 | config: config, 48 | key: key, 49 | originHost: serverHost, 50 | headers: headers, 51 | chExcludeRule: chExcludeRule, 52 | chGroupRulesList: chGroupRulesList, 53 | chLogoRuleList: chLogoRuleList, 54 | host: serverHost, 55 | logger: zap.L(), 56 | } 57 | if i.httpClient == nil { 58 | i.httpClient = http.DefaultClient 59 | } 60 | return &i, nil 61 | } 62 | 63 | func (c *Client) setCommonHeaders(req *http.Request) { 64 | req.Header.Set("Host", c.host) 65 | // 设置自定义HTTP请求头 66 | if len(c.headers) > 0 { 67 | for k, v := range c.headers { 68 | req.Header.Set(k, v) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /cmd/iptv/cmds/serve.go: -------------------------------------------------------------------------------- 1 | package cmds 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "iptv/internal/app/router" 8 | "os" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/spf13/cobra" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | var httpConfig HttpConfig 17 | 18 | type HttpConfig struct { 19 | Port int `json:"port"` 20 | UdpxyURL string `json:"udpxyURL"` 21 | Interval time.Duration `json:"interval"` 22 | LiveFile string `json:"liveFile"` 23 | } 24 | 25 | func NewServeCLI() *cobra.Command { 26 | serveCmd := &cobra.Command{ 27 | Use: "serve", 28 | Short: "启动HTTP服务,提供直播源、EPG等查询接口。", 29 | RunE: func(cmd *cobra.Command, args []string) error { 30 | // 读取直播配置 31 | if httpConfig.LiveFile != "" { 32 | content, err := os.ReadFile(httpConfig.LiveFile) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | var lives router.Lives 38 | err = json.Unmarshal(content, &lives) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | // 加载配置内容 44 | router.LoadLivesConfig(&lives) 45 | } 46 | 47 | // 检查自动更新间隔不能太短 48 | if httpConfig.Interval < 15*time.Minute { 49 | return errors.New("interval cannot be less than 15 minutes") 50 | } 51 | 52 | // 创建并启动HTTP服务 53 | r, err := router.NewEngine(cmd.Context(), conf, httpConfig.Interval, httpConfig.UdpxyURL) 54 | if err != nil { 55 | return err 56 | } 57 | // L():获取全局logger 58 | logger := zap.L() 59 | logger.Info("Start the http service.", zap.String("port", strconv.Itoa(httpConfig.Port))) 60 | if err = r.Run(fmt.Sprintf(":%d", httpConfig.Port)); err != nil { 61 | return err 62 | } 63 | 64 | return nil 65 | }, 66 | } 67 | 68 | serveCmd.Flags().IntVarP(&httpConfig.Port, "port", "p", 8080, "HTTP服务的监听端口。") 69 | serveCmd.Flags().StringVarP(&httpConfig.UdpxyURL, "udpxy", "u", "", "如果有安装udpxy进行组播转单播,则请配置HTTP地址。支持同时配置内外网对应的多个udpxy的地址。e.g `http://192.168.1.1:4022或inner=http://192.168.1.1:4022,outer=http://udpxy.iptv.com:4022`。") 70 | serveCmd.Flags().DurationVarP(&httpConfig.Interval, "interval", "i", 24*time.Hour, "自动刷新频道列表和节目单的间隔时间,e.g `24h或15m`。") 71 | serveCmd.Flags().StringVarP(&httpConfig.LiveFile, "livefile", "l", "", "加载FongMi的直播配置json文件,并提供查询接口。") 72 | 73 | return serveCmd 74 | } 75 | -------------------------------------------------------------------------------- /internal/app/iptv/hwctc/hwctc_config.go: -------------------------------------------------------------------------------- 1 | package hwctc 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | const ( 8 | providerSuffixCTC = "CTC" 9 | providerSuffixCU = "CU" 10 | ) 11 | 12 | type Config struct { 13 | ProviderSuffix string `json:"providerSuffix" yaml:"providerSuffix"` // 配置IPTV的供应商后缀 14 | InterfaceName string `json:"interfaceName" yaml:"interfaceName"` // 网络接口的名称。若配置则生成Authenticator时,优先使用该接口对应的IPv4地址,而不使用`ip`字段的值。 15 | // 以下信息均可通过抓包获取 16 | IP string `json:"ip" yaml:"ip"` // 生成Authenticator所需的IP地址。可随便一个地址,或者通过配置`interfaceName`动态获取 17 | ChannelProgramAPI string `json:"channelProgramAPI,omitempty" yaml:"channelProgramAPI,omitempty"` // 请求频道节目信息(EPG)的API接口,目前只支持两种:liveplay_30或者gdhdpublic。 18 | // 以下信息均可通过抓包请求ValidAuthenticationHWCTC.jsp的参数拿到 19 | UserID string `json:"userID" yaml:"userID"` 20 | Lang string `json:"lang,omitempty" yaml:"lang,omitempty"` // 如果没有可以不填 21 | NetUserID string `json:"netUserID,omitempty" yaml:"netUserID,omitempty"` // 如果没有可以不填 22 | STBType string `json:"stbType" yaml:"stbType"` 23 | STBVersion string `json:"stbVersion" yaml:"stbVersion"` 24 | Conntype string `json:"conntype" yaml:"conntype"` 25 | STBID string `json:"stbID" yaml:"stbID"` // 机顶盒背面也可查 26 | TemplateName string `json:"templateName" yaml:"templateName"` 27 | AreaId string `json:"areaId" yaml:"areaId"` 28 | UserGroupId string `json:"userGroupId,omitempty" yaml:"userGroupId,omitempty"` 29 | ProductPackageId string `json:"productPackageId,omitempty" yaml:"productPackageId,omitempty"` 30 | MAC string `json:"mac" yaml:"mac"` // 机顶盒背面也可查 31 | UserField string `json:"userField,omitempty" yaml:"userField,omitempty"` 32 | SoftwareVersion string `json:"softwareVersion" yaml:"softwareVersion"` 33 | IsSmartStb string `json:"isSmartStb,omitempty" yaml:"isSmartStb,omitempty"` 34 | Vip string `json:"vip,omitempty" yaml:"vip,omitempty"` 35 | } 36 | 37 | func (c *Config) Validate() error { 38 | // 校验config配置 39 | if (c.IP == "" && c.InterfaceName == "") || 40 | c.UserID == "" || 41 | c.STBType == "" || 42 | c.STBVersion == "" || 43 | c.STBID == "" || 44 | c.MAC == "" { 45 | return errors.New("invalid HWCTC IPTV client config") 46 | } 47 | 48 | // 设置默认的供应商 49 | if c.ProviderSuffix != providerSuffixCTC && c.ProviderSuffix != providerSuffixCU { 50 | c.ProviderSuffix = providerSuffixCTC 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /internal/pkg/logging/logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | "gopkg.in/natefinch/lumberjack.v2" 10 | ) 11 | 12 | type LogConfig struct { 13 | Level zapcore.Level `json:"level"` // Level 最低日志等级,DEBUG收集info等级以上的日志 14 | FileName string `json:"file_name"` // FileName 日志文件位置 15 | MaxSize int `json:"max_size"` // MaxSize 进行切割之前,日志文件的最大大小(MB为单位),默认为100MB 16 | MaxAge int `json:"max_age"` // MaxAge 是根据文件名中编码的时间戳保留旧日志文件的最大天数。 17 | MaxBackups int `json:"max_backups"` // MaxBackups 是要保留的旧日志文件的最大数量。默认是保留所有旧的日志文件(尽管 MaxAge 可能仍会导致它们被删除。) 18 | IsStdout bool `json:"is_stdout"` // IsStdout 是否输出到控制台 19 | IsStackTrace bool `json:"is_stack_trace"` // IsStackTrace 是否输出堆栈信息 20 | } 21 | 22 | // InitLogger 初始化Logger 23 | func InitLogger(lCfg *LogConfig) (err error) { 24 | writeSyncer := getLogWriter(lCfg.FileName, lCfg.MaxSize, lCfg.MaxBackups, lCfg.MaxAge, lCfg.IsStdout) 25 | encoder := getEncoder() 26 | 27 | core := zapcore.NewCore(encoder, writeSyncer, lCfg.Level) 28 | var logger *zap.Logger 29 | if lCfg.IsStackTrace { 30 | logger = zap.New(core, zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel)) 31 | } else { 32 | logger = zap.New(core, zap.AddCaller()) 33 | } 34 | zap.ReplaceGlobals(logger) 35 | return 36 | } 37 | 38 | // getEncoder 负责设置 encoding 的日志格式 39 | func getEncoder() zapcore.Encoder { 40 | encodeConfig := zap.NewProductionEncoderConfig() 41 | encodeConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) { 42 | enc.AppendString(t.Format("2006-01-02 15:04:05.000")) 43 | } 44 | encodeConfig.TimeKey = "time" 45 | encodeConfig.EncodeLevel = zapcore.CapitalLevelEncoder 46 | encodeConfig.EncodeCaller = zapcore.ShortCallerEncoder 47 | return zapcore.NewJSONEncoder(encodeConfig) 48 | } 49 | 50 | // getLogWriter 负责日志写入的位置 51 | func getLogWriter(filename string, maxsize, maxBackup, maxAge int, isStdout bool) zapcore.WriteSyncer { 52 | lumberJackLogger := &lumberjack.Logger{ 53 | Filename: filename, // 文件位置 54 | MaxSize: maxsize, // 进行切割之前,日志文件的最大大小(MB为单位) 55 | MaxAge: maxAge, // 保留旧文件的最大天数 56 | MaxBackups: maxBackup, // 保留旧文件的最大个数 57 | Compress: true, // 是否压缩/归档旧文件 58 | } 59 | if isStdout { 60 | return zapcore.NewMultiWriteSyncer(zapcore.AddSync(lumberJackLogger), zapcore.AddSync(os.Stdout)) 61 | } else { 62 | return zapcore.AddSync(lumberJackLogger) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /cmd/iptv/cmds/key.go: -------------------------------------------------------------------------------- 1 | package cmds 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "iptv/internal/app/iptv" 7 | "iptv/internal/pkg/util" 8 | "os" 9 | "path" 10 | "strings" 11 | 12 | "github.com/spf13/cobra" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | const keyFileName = "key.txt" 17 | 18 | var authenticator string 19 | 20 | func NewKeyCLI() *cobra.Command { 21 | keyCmd := &cobra.Command{ 22 | Use: "key", 23 | Short: "暴力破解IPTV的密钥", 24 | RunE: func(cmd *cobra.Command, args []string) error { 25 | // 检查 Authenticator 长度是否小于 10 26 | if len(authenticator) < 10 { 27 | return errors.New("invalid authenticator") 28 | } 29 | 30 | // 获取当前目录 31 | currDir, err := util.GetCurrentAbPathByExecutable() 32 | if err != nil { 33 | return err 34 | } 35 | // 将结果写入文件 36 | filePath := path.Join(currDir, keyFileName) 37 | file, err := os.Create(filePath) 38 | if err != nil { 39 | return err 40 | } 41 | defer file.Close() 42 | 43 | // L():获取全局logger 44 | logger := zap.L() 45 | 46 | var keys []string 47 | logger.Info("Start testing 00000000-99999999 all eight digits.") 48 | // 暴力破解从 00000000 到 99999999 的所有八位数字 49 | for x := 0; x < 100000000; x++ { 50 | key := fmt.Sprintf("%08d", x) 51 | 52 | // 每尝试 500,000 次输出一次进度 53 | if x%500000 == 0 { 54 | logger.Sugar().Infof("Tried to: -- %s --", key) 55 | } 56 | 57 | // 创建 3DES 解密器 58 | crypto := iptv.NewTripleDESCrypto(key) 59 | 60 | // 尝试解密 Authenticator 61 | decodedText, err := crypto.ECBDecrypt(authenticator) 62 | if err != nil { 63 | continue 64 | } 65 | 66 | // 解析解密后的文本 67 | infos := strings.Split(decodedText, "$") 68 | if len(infos) <= 7 { 69 | continue 70 | } 71 | 72 | // 写入文件 73 | var infoText = fmt.Sprintf(" Random: %s\n EncryptToken: %s\n UserID: %s\n STBID: %s\n IP: %s\n MAC: %s\n Reserved: %s\n CTC: %s", 74 | infos[0], infos[1], infos[2], infos[3], infos[4], infos[5], infos[6], infos[7]) 75 | line := fmt.Sprintf("Find key: %s, Plaintext: %s\nDetails:\n%s\n\n", key, decodedText, infoText) 76 | logger.Info("Find a key.", zap.String("key", key)) 77 | if _, err = file.WriteString(line); err != nil { 78 | logger.Error("Failed to write to file.", zap.Error(err)) 79 | return err 80 | } 81 | 82 | keys = append(keys, key) 83 | } 84 | 85 | logger.Sugar().Infof("Crack complete! A total of %d keys were found, see file: %s.", len(keys), keyFileName) 86 | return nil 87 | }, 88 | } 89 | 90 | keyCmd.Flags().StringVarP(&authenticator, "authenticator", "a", "", "请输入Authenticator值,可通过抓包获取。") 91 | 92 | // 必填参数 93 | _ = keyCmd.MarkFlagRequired("authenticator") 94 | 95 | return keyCmd 96 | } 97 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | ############################################### 2 | # 全局设置 3 | ############################################### 4 | 5 | # 8位数字,生成Authenticator的秘钥 6 | # 并不是Authenticator,而是生成Authenticator的秘钥 7 | # 必填 8 | key: 9 | # HTTP请求的服务器地址端口 10 | # 注意需要走IPTV专用网络才能访问通。 11 | # 必填 12 | serverHost: 182.138.3.142:8082 13 | # 自定义HTTP请求头 14 | headers: 15 | Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8' 16 | User-Agent: 'Mozilla/5.0 (X11; Linux x86_64; Fhbw2.0) AppleWebKit' 17 | Accept-Language: 'zh-CN,en-US;q=0.8' 18 | X-Requested-With: 'com.fiberhome.iptv' 19 | # 频道的过滤规则,仅支持正则表达式 20 | # 获取频道列表时,匹配该规则的频道会被过滤掉 21 | chExcludeRule: '^.*?(画中画|单音轨|-体验|\(测试\)|直播室\d+)' 22 | # 频道分组规则 23 | # 依照顺序识别频道分组,且仅支持正则表达式 24 | chGroupRules: 25 | - name: 央视 26 | rules: 27 | - '^(CCTV|中央).+?$' 28 | - name: 卫视 29 | rules: 30 | - '^[^(热门)].+?卫视.*?$' 31 | - name: 国际 32 | rules: 33 | - '^(CGTN|凤凰).+?$' 34 | - name: 地方 35 | rules: 36 | - '^(SCTV|CDTV|四川乡村|峨眉电影).*?$' # 四川地方频道 37 | - '^(浙江|杭州|民生|钱江|教科影视|好易购|西湖|青少体育).+?$' # 浙江地方频道 38 | - '^(福建|福州|厦门|漳州|泉州|三明|莆田|南平|龙岩|宁德).+?$' # 福建地方频道 39 | - name: 付费 40 | rules: 41 | - '.+?\(VIP\)$' 42 | - name: 专区 43 | rules: 44 | - '.+?专区$' 45 | # 频道台标匹配规则 46 | # 依照顺序识别频道台标,且仅支持正则表达式 47 | # 根据匹配转换后的名称(name),从./logos目录中查询对应的台标图片 48 | # 若频道名称不匹配以下任意台标规则,则将根据频道本身的名称来查询对应的台标图片 49 | logos: 50 | - rule: '^(.+?)-(.+?)(\(?标清\)?|\(?高清\)?|\(?超清\)?)?$' # 匹配规则 51 | # 使用$G1, $G2等,可自动替换为正则表达式的对应分组。 52 | name: '$G1$G2' # 转换后的台标名称 53 | - rule: '^([^(热门)].+?)卫视(\(?标清\)?|\(?高清\)?|\(?超清\)?)?$' 54 | name: '$G1卫视' 55 | - rule: '^(.+?)(\(?标清\)?|\(?高清\)?|\(?超清\)?|\(?VIP\)?)?$' # 通用规则,去掉多余内容 56 | name: '$G1' 57 | # 回看请求参数配置 58 | catchup: 59 | # 自定义配置回看请求的参数 60 | sources: 61 | 0: 'playseek=${(b)yyyyMMddHHmmss}-${(e)yyyyMMddHHmmss}' 62 | 1: 'playseek={utc:YmdHMS}-{utcend:YmdHMS}' 63 | 64 | ############################################### 65 | # hw平台相关设置 66 | hwctc: 67 | # 配置IPTV的供应商后缀 68 | # 可选值:CTC(电信), CU(联通) 69 | # 未设置时,默认设置为CTC 70 | providerSuffix: 71 | 72 | # "interfaceName"和"ip"至少填写一个,若都填写则优先使用"interfaceName"指定的接口对应的IPv4地址 73 | # 生成Authenticator所需的网络接口名称,可通过配置自动获取指定接口的IPv4地址。用于获取软路由上某接口被自动分配的IPTV线路的IP地址。 74 | interfaceName: 75 | # 生成Authenticator所需的客户端的ip,可任意配置 76 | ip: 77 | 78 | # 认证接口ValidAuthenticationHWCTC.jsp的相关参数 79 | # 必填 80 | userID: 81 | lang: 82 | netUserID: 83 | # 必填 84 | stbType: 85 | # 必填 86 | stbVersion: 87 | conntype: 88 | # 必填 89 | stbID: 90 | templateName: 91 | areaId: 92 | userGroupId: 93 | productPackageId: 94 | # 必填 95 | mac: 96 | userField: 97 | softwareVersion: 98 | isSmartStb: 99 | vip: 100 | 101 | # 获取EPG信息的API 102 | # 可选值:liveplay_30, gdhdpublic, vsp, StbEpg2023Group, defaulttrans2 103 | # 未设置时,将自动进行尝试。 104 | channelProgramAPI: -------------------------------------------------------------------------------- /internal/app/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "context" 5 | "iptv/internal/app/config" 6 | "iptv/internal/app/iptv" 7 | "iptv/internal/app/iptv/hwctc" 8 | "iptv/internal/pkg/util" 9 | "net/http" 10 | "path" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | ginzap "github.com/gin-contrib/zap" 16 | "github.com/gin-gonic/gin" 17 | "go.uber.org/zap" 18 | ) 19 | 20 | var ( 21 | logger *zap.Logger 22 | 23 | udpxyURLs map[string]string 24 | catchupSources map[string]string 25 | ) 26 | 27 | func NewEngine(ctx context.Context, conf *config.Config, interval time.Duration, udpxyURLCfg string) (*gin.Engine, error) { 28 | // L():获取全局logger 29 | logger = zap.L() 30 | 31 | gin.SetMode(gin.ReleaseMode) 32 | 33 | // 获取程序运行的当前路径 34 | currDir, err := util.GetCurrentAbPathByExecutable() 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | // 创建IPTV客户端 40 | iptvClient, err := newIPTVClient(conf) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | // 执行初始化操作 46 | err = initData(ctx, iptvClient) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | // 执行定时任务 52 | Schedule(ctx, iptvClient, interval) 53 | 54 | // 缓存udpxy配置 55 | udpxyURLs = parseUdpxyURLs(udpxyURLCfg) 56 | 57 | // 缓存回看请求参数配置 58 | catchupSources = conf.Catchup.Sources 59 | 60 | // 创建 Gin 路由引擎 61 | r := gin.New() 62 | 63 | // 日志记录 64 | r.Use(ginzap.Ginzap(logger, "", false)) 65 | r.Use(ginzap.RecoveryWithZap(logger, true)) 66 | 67 | // 查询直播源-m3u格式 68 | r.GET("/channel/m3u", GetM3UData) 69 | // 查询直播源-txt格式 70 | r.GET("/channel/txt", GetTXTData) 71 | // 查询直播源-pls格式 72 | r.GET("/channel/pls", GetPLSData) 73 | 74 | // 查询EPG-json格式 75 | r.GET("/epg/json", GetJsonEPG) 76 | // 查询EPG-xml格式 77 | r.GET("/epg/xml", GetXmlEPG) 78 | r.GET("/epg/xml.gz", GetXmlEPGWithGzip) 79 | 80 | // 查询频道logo 81 | r.Static("/logo", path.Join(currDir, "logos")) 82 | 83 | // 查询直播配置接口 84 | r.GET("/config/lives", GetLivesConfig) 85 | 86 | return r, nil 87 | } 88 | 89 | // parseUdpxyURLs 解析多个udpxy的URL 90 | func parseUdpxyURLs(udpxyURLCfg string) map[string]string { 91 | result := make(map[string]string) 92 | 93 | if udpxyURLCfg == "" { 94 | return result 95 | } 96 | 97 | // 解析多个udpxy地址 98 | tmpUdpxyURLs := strings.Split(udpxyURLCfg, ",") 99 | for i, tmpUdpxyURL := range tmpUdpxyURLs { 100 | // 获取每个udpxy的名称和URL 101 | udpxyNameAndURL := strings.Split(tmpUdpxyURL, "=") 102 | if len(udpxyNameAndURL) != 2 { 103 | // 找不到名称则用序号代替 104 | result[strconv.Itoa(i)] = tmpUdpxyURL 105 | } else { 106 | result[udpxyNameAndURL[0]] = udpxyNameAndURL[1] 107 | } 108 | } 109 | return result 110 | } 111 | 112 | // initData 初始化数据 113 | func initData(ctx context.Context, iptvClient iptv.Client) error { 114 | // 更新频道列表数据 115 | if err := updateChannelsWithRetry(ctx, iptvClient, 3); err != nil { 116 | return err 117 | } 118 | 119 | // 更新节目单 120 | if err := updateEPG(ctx, iptvClient); err != nil { 121 | logger.Error("Failed to update EPG.", zap.Error(err)) 122 | } 123 | return nil 124 | } 125 | 126 | // newIPTVClient 读取配置文件并创建IPTV客户端 127 | func newIPTVClient(conf *config.Config) (iptv.Client, error) { 128 | // 校验配置文件 129 | if err := conf.Validate(); err != nil { 130 | return nil, err 131 | } 132 | 133 | // 创建IPTV客户端 134 | return hwctc.NewClient(&http.Client{ 135 | Timeout: 10 * time.Second, 136 | }, conf.HWCTC, conf.Key, conf.ServerHost, conf.Headers, 137 | conf.ChExcludeRule, conf.ChGroupRulesList, conf.ChLogoRuleList) 138 | } 139 | -------------------------------------------------------------------------------- /cmd/iptv/cmds/channel.go: -------------------------------------------------------------------------------- 1 | package cmds 2 | 3 | import ( 4 | "errors" 5 | "iptv/internal/app/iptv" 6 | "iptv/internal/app/iptv/hwctc" 7 | "iptv/internal/pkg/util" 8 | "net/http" 9 | "os" 10 | "path" 11 | "slices" 12 | "time" 13 | 14 | "github.com/spf13/cobra" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | const ( 19 | fileName = "iptv" 20 | ) 21 | 22 | var ( 23 | supportFileFormat = []string{"txt", "m3u", "pls"} 24 | udpxyURL string 25 | format string 26 | catchupSource string 27 | multicastFirst bool 28 | ) 29 | 30 | func NewChannelCLI() *cobra.Command { 31 | channelCmd := &cobra.Command{ 32 | Use: "channel", 33 | Short: "获取频道列表,并按指定格式生成直播源文件。", 34 | RunE: func(cmd *cobra.Command, args []string) error { 35 | // L():获取全局logger 36 | logger := zap.L() 37 | 38 | // 校验配置文件 39 | if err := conf.Validate(); err != nil { 40 | return err 41 | } 42 | 43 | // 创建IPTV客户端 44 | i, err := hwctc.NewClient(&http.Client{ 45 | Timeout: 10 * time.Second, 46 | }, conf.HWCTC, conf.Key, conf.ServerHost, conf.Headers, 47 | conf.ChExcludeRule, conf.ChGroupRulesList, conf.ChLogoRuleList) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | // 获取频道列表 53 | channels, err := i.GetAllChannelList(cmd.Context()) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | if len(channels) == 0 { 59 | return errors.New("no channels found") 60 | } 61 | 62 | if !slices.Contains(supportFileFormat, format) { 63 | return errors.New("file format not support") 64 | } 65 | 66 | // 在当前目录中创建频道文件 67 | outFileName := fileName + "." + format 68 | currDir, err := util.GetCurrentAbPathByExecutable() 69 | if err != nil { 70 | return err 71 | } 72 | filePath := path.Join(currDir, outFileName) 73 | file, err := os.Create(filePath) 74 | if err != nil { 75 | logger.Error("Failed to create a file.", zap.Error(err)) 76 | return err 77 | } 78 | defer file.Close() 79 | 80 | var content string 81 | switch format { 82 | case supportFileFormat[0]: 83 | // 将获取到的频道列表转换为TXT格式 84 | content, err = iptv.ToTxtFormat(channels, udpxyURL, multicastFirst) 85 | if err != nil { 86 | return err 87 | } 88 | case supportFileFormat[1]: 89 | // 将获取到的频道列表转换为M3U格式 90 | content, err = iptv.ToM3UFormat(channels, udpxyURL, catchupSource, multicastFirst, "") 91 | if err != nil { 92 | return err 93 | } 94 | case supportFileFormat[2]: 95 | // 将获取到的频道列表转换为PLS(playlist)格式 96 | content, err = iptv.ToPLSFormat(channels, udpxyURL, multicastFirst) 97 | if err != nil { 98 | return err 99 | } 100 | } 101 | 102 | // 将结果写入文件 103 | if _, err = file.WriteString(content); err != nil { 104 | logger.Error("Failed to write to file.", zap.Error(err)) 105 | return err 106 | } 107 | 108 | logger.Sugar().Infof("A total of %d channels have been found, all of which have been written to the file %s.", len(channels), outFileName) 109 | 110 | return nil 111 | }, 112 | } 113 | 114 | channelCmd.Flags().StringVarP(&udpxyURL, "udpxy", "u", "", "如果有安装udpxy进行组播转单播,请配置HTTP地址,e.g `http://192.168.1.1:4022`。") 115 | channelCmd.Flags().StringVarP(&format, "format", "f", "m3u", "生成的直播源文件格式,e.g `m3u,txt或pls`。") 116 | channelCmd.Flags().StringVarP(&catchupSource, "catchup-source", "s", "playseek=${(b)yyyyMMddHHmmss}-${(e)yyyyMMddHHmmss}", "回看的请求格式字符串,会追加在时移地址后面。") 117 | channelCmd.Flags().BoolVarP(&multicastFirst, "multicast-first", "m", false, "当频道存在多个URL地址时,是否优先使用组播地址。缺省为false。") 118 | 119 | return channelCmd 120 | } 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iptv-tool 2 | 3 | [![GitHub Release](https://img.shields.io/github/v/release/super321/iptv-tool?logo=github)](https://github.com/super321/iptv-tool/releases/latest) 4 | [![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/super321/iptv-tool/total?logo=github)](https://github.com/super321/iptv-tool/releases/latest) 5 | 6 | IPTV工具,功能列表如下: 7 | 8 | * 自动更新频道列表和EPG信息。 9 | * 提供m3u、txt和pls格式直播源在线接口。 10 | * 支持频道黑名单过滤、频道分组以及频道台标配置 11 | * 支持m3u的catchup回看参数配置 12 | * 提供EPG在线接口,支持xmltv和json两种格式。 13 | 14 | ## 配置说明 15 | 16 | 详细说明参见配置文件[config.yml](./config.yml) 17 | 18 | ## 使用介绍 19 | 20 | 将config.yml配置文件与工具放在一起,然后运行工具即可,具体运行命令如下: 21 | 22 | * 根据某次抓包获取的Authenticator反向破解key 23 | 24 | ``` 25 | ./iptv key -a xxxxx 26 | ``` 27 | 28 | 说明:-a后面指定Authenticator,待运行完毕后会在当前目录下生成key.txt文件,其中可能找到很多key,任意一个均可使用(文件中Find 29 | Key后面的即是)。 30 | 更多参数说明可通过命令`./iptv key -h`查看。 31 | 32 | * 直接生成m3u直播源文件 33 | 34 | ``` 35 | ./iptv channel -f m3u -u http://192.168.3.1:4022 36 | ``` 37 | 38 | 说明:运行完毕后会在当前目录下生成iptv.m3u文件,通过-u参数指定软路由的udpxy的http地址。 39 | 更多参数说明可通过命令`./iptv channel -h`查看。 40 | 41 | * 启动HTTP服务,提供在线m3u和epg接口: 42 | 43 | ``` 44 | ./iptv serve -i 24h -p 8088 -u http://192.168.3.1:4022 45 | ``` 46 | 47 | 或 48 | 49 | ``` 50 | ./iptv serve -i 24h -p 8088 -u inner=http://192.168.3.1:4022 51 | ``` 52 | 53 | 说明:-i指定频道和EPG更新间隔时间,-p指定启动的http服务的端口,-u指定udpxy的http地址。 54 | 更多参数说明可通过命令`./iptv serve -h`查看。 55 | 56 | ## HTTP API 57 | 58 | * [m3u格式直播源](#m3u格式直播源) 59 | * [txt格式直播源](#txt格式直播源) 60 | * [pls格式直播源](#pls格式直播源) 61 | * [json格式EPG](#json格式EPG) 62 | * [xmltv格式EPG](#xmltv格式EPG) 63 | * [xmltv格式EPG(gzip压缩)](#xmltv格式epggzip压缩) 64 | 65 | ### m3u格式直播源 66 | 67 | ``` 68 | http://IP:PORT/channel/m3u?csFormat={format}&multiFirst={multiFirst}&udpxy={udpxy} 69 | ``` 70 | 71 | #### 参数说明 72 | 73 | * csFormat:可指定回看catchup-source的请求格式,支持通过配置文件[config.yml](./config.yml)中的`catchup.sources`进行自定义配置。 74 | **非必填,缺省为其中任意一个**。 75 | 76 | > 例如,若config.yml部分内容为:
77 | > ``` 78 | > catchup: 79 | > sources: 80 | > diyp: "playseek=${(b)yyyyMMddHHmmss}-${(e)yyyyMMddHHmmss}" 81 | > kodi: "playseek={utc:YmdHMS}-{utcend:YmdHMS}" 82 | > ``` 83 | > * `/channel/m3u?csFormat=diyp`则使用`playseek=${(b)yyyyMMddHHmmss}-${(e)yyyyMMddHHmmss}`。 84 | > * `/channel/m3u?csFormat=kodi`则使用`playseek={utc:YmdHMS}-{utcend:YmdHMS}`。 85 | > * `/channel/m3u?csFormat=notexist`若指定的名称不存在,则不生成catchup相关内容。 86 | 87 | 若未填写配置文件[config.yml](./config.yml)中的`catchup.sources`内容,则缺省使用以下内容: 88 | 89 | | 值 | 是否缺省 | 说明 | 90 | |---|------|-----------------------------------------------------| 91 | | 0 | 是 | `playseek=${(b)yyyyMMddHHmmss}-${(e)yyyyMMddHHmmss}` | 92 | | 1 | 否 | `playseek={utc:YmdHMS}-{utcend:YmdHMS}` | 93 | 94 | * multiFirst:当频道存在多个URL地址时,是否优先使用组播地址。可选值:`true`或`false`。**非必填,缺省为`true`**。 95 | 96 | * udpxy:当通过启动参数`-u`或`--udpxy`配置了包含内外网的多个udpxy的URL地址时,可通过该参数指定当前m3u所使用的地址。 97 | **非必填,缺省为其中任意一个URL地址**
98 | 99 | > 例如,若启动参数配置为:
100 | > `./iptv serve -u inner=http://192.168.1.1:4022,outer=http://udpxy.iptv.com:4022` 101 | > * `/channel/m3u?udpxy=inner`则使用udpxy的内网地址。 102 | > * `/channel/m3u?udpxy=outer`则使用udpxy的外网地址。 103 | > * `/channel/m3u?udpxy=notexist`若指定的名称不存在,则使用频道的原始地址。 104 | 105 | ### txt格式直播源 106 | 107 | ``` 108 | http://IP:PORT/channel/txt?multiFirst={multiFirst}&udpxy={udpxy} 109 | ``` 110 | 111 | #### 参数说明 112 | 113 | * multiFirst:参数说明同上。 114 | * udpxy:参数说明同上。 115 | 116 | ### pls格式直播源 117 | 118 | ``` 119 | http://IP:PORT/channel/pls?multiFirst={multiFirst}&udpxy={udpxy} 120 | ``` 121 | 122 | #### 参数说明 123 | 124 | * multiFirst:参数说明同上。 125 | * udpxy:参数说明同上。 126 | 127 | ### json格式EPG 128 | 129 | ``` 130 | http://IP:PORT/epg/json?ch={name}&date={date} 131 | ``` 132 | 133 | ### xmltv格式EPG 134 | 135 | ``` 136 | http://IP:PORT/epg/xml?backDay={backDay} 137 | ``` 138 | 139 | #### 参数说明 140 | 141 | * backDay:可选保留最近多少天的节目单,**非必填,缺省为查全部**。 142 | 143 | ### xmltv格式EPG(gzip压缩) 144 | 145 | ``` 146 | http://IP:PORT/epg/xml.gz?backDay={backDay} 147 | ``` 148 | 149 | #### 参数说明 150 | 151 | * backDay:参数说明同上。 152 | 153 | ## 帮助 154 | 155 | * [在OpenWrt中设置自启动](./docs/autostart.md) 156 | 157 | # 免责声明 158 | 159 | 在使用本项目之前,请仔细阅读以下免责声明: 160 | 161 | * 本项目的初衷是为研究、学习和技术交流提供帮助,未对其作任何特殊用途的适配。您在使用本项目时,必须遵守适用的法律法规和道德规范。 162 | * 本项目不得用于任何违法或不正当的目的,包括但不限于商业用途、侵权行为或破坏性操作。 163 | * 使用本项目产生的任何后果,由使用者自行承担全部风险和责任。开发者对因使用本项目引发的任何直接或间接损失,不承担任何责任。 164 | * 本免责声明的解释权归项目开发者所有。 165 | 166 | **注意:如果您无法接受以上条款,请勿使用或分发本项目。** -------------------------------------------------------------------------------- /internal/app/iptv/hwctc/epg_gdhdpublic.go: -------------------------------------------------------------------------------- 1 | package hwctc 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "iptv/internal/app/iptv" 10 | "net/http" 11 | "time" 12 | ) 13 | 14 | type gdhdpublicChannelProgramListResult struct { 15 | Result []gdhdpublicChannelProgramList `json:"result"` 16 | } 17 | 18 | type gdhdpublicChannelProgramList struct { 19 | Code string `json:"code"` 20 | ProID string `json:"proID"` 21 | ProFlag string `json:"proflag"` 22 | Name string `json:"name"` 23 | Time string `json:"time"` 24 | Endtime string `json:"endtime"` 25 | Day string `json:"day"` 26 | } 27 | 28 | // getGdhdpublicChannelProgramList 获取指定频道的节目单列表(zj) 29 | func (c *Client) getGdhdpublicChannelProgramList(ctx context.Context, token *Token, channel *iptv.Channel) (*iptv.ChannelProgramList, error) { 30 | // 获取未来一天的日期 31 | tomorrow := time.Now().AddDate(0, 0, 1) 32 | tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, tomorrow.Location()) 33 | 34 | // 根据当前频道的时移范围,预估EPG的查询时间范围(加上未来一天) 35 | epgBackDay := int(channel.TimeShiftLength.Hours()/24) + 1 36 | // 限制EPG查询的最大时间范围 37 | if epgBackDay > maxBackDay { 38 | epgBackDay = maxBackDay 39 | } 40 | 41 | // 从未来一天开始往前,倒查多个日期的节目单 42 | dateProgramList := make([]iptv.DateProgram, 0, epgBackDay+1) 43 | for i := 0; i <= epgBackDay; i++ { 44 | date := tomorrow.AddDate(0, 0, -i) 45 | dateStr := date.Format("20060102") 46 | 47 | // 获取指定日期的节目单列表 48 | programList, err := c.getGdhdpublicChannelDateProgram(ctx, token, channel.ChannelID, dateStr) 49 | if err != nil { 50 | if errors.Is(err, ErrEPGApiNotFound) { 51 | return nil, err 52 | } 53 | c.logger.Sugar().Warnf("Failed to get the program list for channel %s on %s. Error: %v", channel.ChannelName, dateStr, err) 54 | continue 55 | } 56 | 57 | dateProgramList = append(dateProgramList, iptv.DateProgram{ 58 | Date: date, 59 | ProgramList: programList, 60 | }) 61 | } 62 | 63 | return &iptv.ChannelProgramList{ 64 | ChannelId: channel.ChannelID, 65 | ChannelName: channel.ChannelName, 66 | DateProgramList: dateProgramList, 67 | }, nil 68 | } 69 | 70 | // getGdhdpublicChannelDateProgram 获取指定频道的某日期的节目单列表 71 | func (c *Client) getGdhdpublicChannelDateProgram(ctx context.Context, token *Token, channelId string, dateStr string) ([]iptv.Program, error) { 72 | // 创建请求 73 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, 74 | fmt.Sprintf("http://%s/EPG/jsp/gdhdpublic/Ver.3/common/data.jsp", c.host), nil) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | // 增加请求参数 80 | params := req.URL.Query() 81 | params.Add("Action", "channelProgramList") 82 | params.Add("channelId", channelId) 83 | params.Add("date", dateStr) 84 | req.URL.RawQuery = params.Encode() 85 | 86 | // 设置请求头 87 | c.setCommonHeaders(req) 88 | 89 | // 设置Cookie 90 | req.AddCookie(&http.Cookie{ 91 | Name: "JSESSIONID", 92 | Value: token.JSESSIONID, 93 | }) 94 | req.AddCookie(&http.Cookie{ 95 | Name: "telecomToken", 96 | Value: token.UserToken, 97 | }) 98 | 99 | // 执行请求 100 | resp, err := c.httpClient.Do(req) 101 | if err != nil { 102 | return nil, err 103 | } 104 | defer resp.Body.Close() 105 | 106 | if resp.StatusCode == http.StatusNotFound || resp.StatusCode >= http.StatusInternalServerError { 107 | return nil, ErrEPGApiNotFound 108 | } else if resp.StatusCode != http.StatusOK { 109 | return nil, fmt.Errorf("http status code: %d", resp.StatusCode) 110 | } 111 | 112 | // 解析响应内容 113 | result, err := io.ReadAll(resp.Body) 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | return parseGdhdpublicChannelDateProgram(result) 119 | } 120 | 121 | // parseGdhdpublicChannelDateProgram 解析频道节目单列表 122 | func parseGdhdpublicChannelDateProgram(rawData []byte) ([]iptv.Program, error) { 123 | // 解析json 124 | var resp gdhdpublicChannelProgramListResult 125 | if err := json.Unmarshal(rawData, &resp); err != nil { 126 | return nil, err 127 | } 128 | 129 | if len(resp.Result) == 0 { 130 | return nil, ErrChProgListIsEmpty 131 | } 132 | 133 | // 遍历单个日期中的节目单 134 | programList := make([]iptv.Program, 0, len(resp.Result)) 135 | for _, rawProg := range resp.Result { 136 | bTime, err := time.ParseInLocation(time.DateTime, rawProg.Day+" "+rawProg.Time, time.Local) 137 | if err != nil { 138 | return nil, err 139 | } 140 | eTime, err := time.ParseInLocation(time.DateTime, rawProg.Day+" "+rawProg.Endtime, time.Local) 141 | if err != nil { 142 | return nil, err 143 | } 144 | 145 | programList = append(programList, iptv.Program{ 146 | ProgramName: rawProg.Name, 147 | BeginTimeFormat: bTime.Format("20060102150405"), 148 | EndTimeFormat: eTime.Format("20060102150405"), 149 | StartTime: bTime.Format("15:04"), 150 | EndTime: eTime.Format("15:04"), 151 | }) 152 | } 153 | return programList, nil 154 | } 155 | -------------------------------------------------------------------------------- /internal/app/router/channel_controller.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "iptv/internal/app/iptv" 8 | "iptv/internal/pkg/util" 9 | "net/http" 10 | "strconv" 11 | "sync/atomic" 12 | "time" 13 | 14 | "github.com/gin-gonic/gin" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | var ( 19 | // 缓存最新的频道列表数据 20 | channelsPtr atomic.Pointer[[]iptv.Channel] 21 | ) 22 | 23 | // GetM3UData 查询直播源m3u 24 | func GetM3UData(c *gin.Context) { 25 | // 获取catchup-source格式 26 | var catchupSource string 27 | csFormat := c.Query("csFormat") 28 | if csFormat != "" { 29 | // 如果取不到对应的catchup-source,则不生成catchup相关内容 30 | catchupSource = catchupSources[csFormat] 31 | } else { 32 | // 若未指定,则默认随机取其中一个 33 | for _, k := range util.SortedMapKeys(catchupSources) { 34 | catchupSource = catchupSources[k] 35 | break 36 | } 37 | } 38 | 39 | // 是否优先是由组播地址 40 | multiFirstStr := c.DefaultQuery("multiFirst", "true") 41 | multicastFirst, err := strconv.ParseBool(multiFirstStr) 42 | if err != nil { 43 | multicastFirst = true 44 | } 45 | 46 | // 获取指定的udpxy 47 | udpxyName := c.Query("udpxy") 48 | udpxyURL := getUdpxyURL(udpxyName) 49 | 50 | channels := *channelsPtr.Load() 51 | if len(channels) == 0 { 52 | c.Status(http.StatusNotFound) 53 | return 54 | } 55 | 56 | // 设置台标的统一Base URL 57 | logoBaseUrl := fmt.Sprintf("http://%s/logo", c.Request.Host) 58 | 59 | // 将获取到的频道列表转换为m3u格式 60 | m3uContent, err := iptv.ToM3UFormat(channels, udpxyURL, catchupSource, multicastFirst, logoBaseUrl) 61 | if err != nil { 62 | logger.Error("Failed to convert channel list to m3u format.", zap.Error(err)) 63 | // 返回响应 64 | c.Status(http.StatusOK) 65 | return 66 | } 67 | 68 | // 返回响应 69 | c.String(http.StatusOK, m3uContent) 70 | } 71 | 72 | // GetTXTData 查询直播源txt 73 | func GetTXTData(c *gin.Context) { 74 | // 是否优先是由组播地址 75 | multiFirstStr := c.DefaultQuery("multiFirst", "true") 76 | multicastFirst, err := strconv.ParseBool(multiFirstStr) 77 | if err != nil { 78 | multicastFirst = true 79 | } 80 | 81 | // 获取指定的udpxy 82 | udpxyName := c.Query("udpxy") 83 | udpxyURL := getUdpxyURL(udpxyName) 84 | 85 | channels := *channelsPtr.Load() 86 | if len(channels) == 0 { 87 | c.Status(http.StatusNotFound) 88 | return 89 | } 90 | 91 | // 将获取到的频道列表转换为txt格式 92 | txtContent, err := iptv.ToTxtFormat(channels, udpxyURL, multicastFirst) 93 | if err != nil { 94 | logger.Error("Failed to convert channel list to txt format.", zap.Error(err)) 95 | // 返回响应 96 | c.Status(http.StatusOK) 97 | return 98 | } 99 | 100 | // 返回响应 101 | c.String(http.StatusOK, txtContent) 102 | } 103 | 104 | // GetPLSData 查询直播源pls 105 | func GetPLSData(c *gin.Context) { 106 | // 是否优先是由组播地址 107 | multiFirstStr := c.DefaultQuery("multiFirst", "true") 108 | multicastFirst, err := strconv.ParseBool(multiFirstStr) 109 | if err != nil { 110 | multicastFirst = true 111 | } 112 | 113 | // 获取指定的udpxy 114 | udpxyName := c.Query("udpxy") 115 | udpxyURL := getUdpxyURL(udpxyName) 116 | 117 | channels := *channelsPtr.Load() 118 | if len(channels) == 0 { 119 | c.Status(http.StatusNotFound) 120 | return 121 | } 122 | 123 | // 将获取到的频道列表转换为pls格式 124 | content, err := iptv.ToPLSFormat(channels, udpxyURL, multicastFirst) 125 | if err != nil { 126 | logger.Error("Failed to convert channel list to pls format.", zap.Error(err)) 127 | // 返回响应 128 | c.Status(http.StatusOK) 129 | return 130 | } 131 | 132 | // 返回响应 133 | c.String(http.StatusOK, content) 134 | } 135 | 136 | // getUdpxyURL 通过udpxy的名称来获取指定的URL地址 137 | func getUdpxyURL(udpxyName string) string { 138 | var udpxyURL string 139 | if udpxyName != "" { 140 | // 获取指定名称的udpxy的URL 141 | udpxyURL = udpxyURLs[udpxyName] 142 | } else { 143 | // 若未指定名称,则默认随机取其中一个udpxy的URL 144 | for _, k := range util.SortedMapKeys(udpxyURLs) { 145 | udpxyURL = udpxyURLs[k] 146 | break 147 | } 148 | } 149 | return udpxyURL 150 | } 151 | 152 | // updateChannelsWithRetry 更新缓存的频道数据(失败重试) 153 | func updateChannelsWithRetry(ctx context.Context, iptvClient iptv.Client, maxRetries int) error { 154 | var err error 155 | for i := 0; i < maxRetries; i++ { 156 | if err = updateChannels(ctx, iptvClient); err != nil { 157 | logger.Sugar().Errorf("Failed to update channel list, will try again after waiting %d seconds. Error: %v, number of retries: %d.", waitSeconds, err, i) 158 | time.Sleep(waitSeconds * time.Second) 159 | } else { 160 | break 161 | } 162 | } 163 | return err 164 | } 165 | 166 | // updateChannels 更新缓存的频道数据 167 | func updateChannels(ctx context.Context, iptvClient iptv.Client) error { 168 | // 查询最新的频道列表 169 | channels, err := iptvClient.GetAllChannelList(ctx) 170 | if err != nil { 171 | return err 172 | } 173 | 174 | if len(channels) == 0 { 175 | return errors.New("no channels found") 176 | } 177 | 178 | logger.Sugar().Infof("The channel list has been updated, rows: %d.", len(channels)) 179 | // 更新缓存的频道列表 180 | channelsPtr.Store(&channels) 181 | 182 | return nil 183 | } 184 | -------------------------------------------------------------------------------- /internal/app/iptv/hwctc/epg_liveplay.go: -------------------------------------------------------------------------------- 1 | package hwctc 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "iptv/internal/app/iptv" 9 | "net/http" 10 | "regexp" 11 | "time" 12 | ) 13 | 14 | // getLiveplayChannelProgramList 获取指定频道的节目单列表(sc) 15 | func (c *Client) getLiveplayChannelProgramList(ctx context.Context, token *Token, channel *iptv.Channel) (*iptv.ChannelProgramList, error) { 16 | // 创建请求 17 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, 18 | fmt.Sprintf("http://%s/EPG/jsp/liveplay_30/en/getTvodData.jsp", c.host), nil) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | // 增加请求参数 24 | params := req.URL.Query() 25 | params.Add("channelId", channel.ChannelID) 26 | req.URL.RawQuery = params.Encode() 27 | 28 | // 设置请求头 29 | c.setCommonHeaders(req) 30 | 31 | // 设置Cookie 32 | req.AddCookie(&http.Cookie{ 33 | Name: "JSESSIONID", 34 | Value: token.JSESSIONID, 35 | }) 36 | 37 | // 执行请求 38 | resp, err := c.httpClient.Do(req) 39 | if err != nil { 40 | return nil, err 41 | } 42 | defer resp.Body.Close() 43 | 44 | if resp.StatusCode == http.StatusNotFound || resp.StatusCode >= http.StatusInternalServerError { 45 | return nil, ErrEPGApiNotFound 46 | } else if resp.StatusCode != http.StatusOK { 47 | return nil, fmt.Errorf("http status code: %d", resp.StatusCode) 48 | } 49 | 50 | // 解析响应内容 51 | result, err := io.ReadAll(resp.Body) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | // 正则提取JSON配置 57 | regex := regexp.MustCompile("parent.jsonBackLookStr = (.+?);") 58 | matches := regex.FindSubmatch(result) 59 | if len(matches) != 2 { 60 | return nil, ErrParseChProgList 61 | } 62 | 63 | // 解析节目单 64 | dateProgramList, err := parseLiveplayChannelProgramList(matches[1]) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | return &iptv.ChannelProgramList{ 70 | ChannelId: channel.ChannelID, 71 | ChannelName: channel.ChannelName, 72 | DateProgramList: dateProgramList, 73 | }, nil 74 | } 75 | 76 | // parseLiveplayChannelProgramList 解析频道节目单列表 77 | func parseLiveplayChannelProgramList(rawData []byte) ([]iptv.DateProgram, error) { 78 | // 动态解析Json 79 | var rawArray []any 80 | err := json.Unmarshal(rawData, &rawArray) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | if len(rawArray) != 2 { 86 | return nil, ErrParseChProgList 87 | } 88 | 89 | dateProgList, ok := rawArray[1].([]any) 90 | if !ok { 91 | return nil, ErrParseChProgList 92 | } else if len(dateProgList) == 0 { 93 | return nil, ErrChProgListIsEmpty 94 | } 95 | 96 | // 遍历多个日期的节目单 97 | dateProgramList := make([]iptv.DateProgram, 0, len(dateProgList)) 98 | for _, rawProgList := range dateProgList { 99 | progList, ok := rawProgList.([]any) 100 | if !ok { 101 | return nil, ErrParseChProgList 102 | } else if len(progList) == 0 { 103 | continue 104 | } 105 | 106 | // 遍历单个日期中的节目单 107 | programList := make([]iptv.Program, 0, len(progList)) 108 | for _, rawProg := range progList { 109 | prog, ok := rawProg.(map[string]any) 110 | if !ok { 111 | return nil, ErrParseChProgList 112 | } else if len(prog) == 0 { 113 | continue 114 | } 115 | 116 | programName := prog["programName"].(string) 117 | beginTimeFormatStr := prog["beginTimeFormat"].(string) 118 | endTimeFormatStr := prog["endTimeFormat"].(string) 119 | startTimeStr := prog["startTime"].(string) 120 | endTimeStr := prog["endTime"].(string) 121 | 122 | if endTimeStr == "00:00" { 123 | // 临界值特殊处理 124 | endTimeStr = "23:59" 125 | 126 | // IPTV返回的结束时间为0点的节目单存在BUG,endTimeFormat错误设置为了当天的零点而不是第二天的零点 127 | // BUG数据示例:{"beginTimeFormat":"20241130232400","isPlayable":"0","programName":"典籍里的中国Ⅱ(6)","contentId":"755597800","index":"335","startTime":"23:24","endTime":"00:00","channelId":"658582938","endTimeFormat":"20241130000000"} 128 | if (beginTimeFormatStr[:8] + "000000") == endTimeFormatStr { 129 | endTimeFormat, err := time.ParseInLocation("20060102150405", endTimeFormatStr, time.Local) 130 | if err != nil { 131 | return nil, err 132 | } 133 | endTimeFormat = endTimeFormat.Add(24 * time.Hour) 134 | endTimeFormatStr = endTimeFormat.Format("20060102150405") 135 | } 136 | } 137 | 138 | programList = append(programList, iptv.Program{ 139 | ProgramName: programName, 140 | BeginTimeFormat: beginTimeFormatStr, 141 | EndTimeFormat: endTimeFormatStr, 142 | StartTime: startTimeStr, 143 | EndTime: endTimeStr, 144 | }) 145 | } 146 | 147 | beginTime, err := time.ParseInLocation("20060102150405", programList[0].BeginTimeFormat, time.Local) 148 | if err != nil { 149 | return nil, err 150 | } 151 | // 时间取整到天 152 | date := time.Date(beginTime.Year(), beginTime.Month(), beginTime.Day(), 0, 0, 0, 0, beginTime.Location()) 153 | dateProgramList = append(dateProgramList, iptv.DateProgram{ 154 | Date: date, 155 | ProgramList: programList, 156 | }) 157 | } 158 | return dateProgramList, nil 159 | } 160 | -------------------------------------------------------------------------------- /internal/app/iptv/hwctc/epg.go: -------------------------------------------------------------------------------- 1 | package hwctc 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "iptv/internal/app/iptv" 7 | "slices" 8 | 9 | "go.uber.org/zap" 10 | ) 11 | 12 | var ( 13 | ErrParseChProgList = errors.New("failed to parse channel program list") 14 | ErrChProgListIsEmpty = errors.New("the list of programs is empty") 15 | ErrEPGApiNotFound = errors.New("epg api not found") 16 | ) 17 | 18 | const ( 19 | maxBackDay = 8 20 | 21 | chProgAPILiveplay = "liveplay_30" 22 | chProgAPIGdhdpublic = "gdhdpublic" 23 | chProgAPIVsp = "vsp" 24 | chProgAPIStbEpg2023Group = "StbEpg2023Group" 25 | chProgAPIDefaulttrans2 = "defaulttrans2" 26 | ) 27 | 28 | type getChannelProgramListFunc func(ctx context.Context, token *Token, channel *iptv.Channel) (*iptv.ChannelProgramList, error) 29 | 30 | // GetAllChannelProgramList 获取所有频道的节目单列表 31 | func (c *Client) GetAllChannelProgramList(ctx context.Context, channels []iptv.Channel) ([]iptv.ChannelProgramList, error) { 32 | // 请求认证的Token 33 | token, err := c.requestToken(ctx) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | var result []iptv.ChannelProgramList 39 | switch c.config.ChannelProgramAPI { 40 | case chProgAPILiveplay: 41 | result, err = c.getAllChannelProgramList(ctx, channels, token, c.getLiveplayChannelProgramList) 42 | case chProgAPIGdhdpublic: 43 | result, err = c.getAllChannelProgramList(ctx, channels, token, c.getGdhdpublicChannelProgramList) 44 | case chProgAPIVsp: 45 | result, err = c.getAllChannelProgramList(ctx, channels, token, c.getVspChannelProgramList) 46 | case chProgAPIStbEpg2023Group: 47 | result, err = c.getStbEpg2023GroupAllChannelProgramList(ctx, channels, token) 48 | case chProgAPIDefaulttrans2: 49 | result, err = c.getAllChannelProgramList(ctx, channels, token, c.getDefaulttrans2ChannelProgramList) 50 | default: 51 | // 自动选择调用EPG的API接口 52 | result, err = c.getAllChannelProgramListByAuto(ctx, channels, token) 53 | } 54 | 55 | return result, err 56 | } 57 | 58 | // getAllChannelProgramList 获取所有频道的节目单列表 59 | func (c *Client) getAllChannelProgramList(ctx context.Context, channels []iptv.Channel, token *Token, getChProgFunc getChannelProgramListFunc) ([]iptv.ChannelProgramList, error) { 60 | epg := make([]iptv.ChannelProgramList, 0, len(channels)) 61 | for _, channel := range channels { 62 | // 跳过不支持回看的频道 63 | if channel.TimeShift != "1" || channel.TimeShiftLength <= 0 { 64 | continue 65 | } 66 | 67 | progList, err := getChProgFunc(ctx, token, &channel) 68 | if err != nil { 69 | if errors.Is(err, ErrEPGApiNotFound) { 70 | return nil, err 71 | } 72 | c.logger.Sugar().Warnf("Failed to get the program list for channel %s. Error: %v", channel.ChannelName, err) 73 | continue 74 | } 75 | 76 | if progList != nil && len(progList.DateProgramList) > 0 { 77 | // 对频道的节目单按日期升序排序 78 | slices.SortFunc(progList.DateProgramList, func(a, b iptv.DateProgram) int { 79 | return a.Date.Compare(b.Date) 80 | }) 81 | 82 | epg = append(epg, *progList) 83 | } 84 | } 85 | return epg, nil 86 | } 87 | 88 | // getAllChannelProgramListByAuto 自动选择调用EPG的API接口 89 | func (c *Client) getAllChannelProgramListByAuto(ctx context.Context, channels []iptv.Channel, token *Token) ([]iptv.ChannelProgramList, error) { 90 | result, err := c.getAllChannelProgramList(ctx, channels, token, c.getLiveplayChannelProgramList) 91 | if !errors.Is(err, ErrEPGApiNotFound) { 92 | c.logger.Info("An available EPG API was found.", zap.String("channelProgramAPI", chProgAPILiveplay)) 93 | c.config.ChannelProgramAPI = chProgAPILiveplay 94 | return result, err 95 | } 96 | 97 | result, err = c.getAllChannelProgramList(ctx, channels, token, c.getGdhdpublicChannelProgramList) 98 | if !errors.Is(err, ErrEPGApiNotFound) { 99 | c.logger.Info("An available EPG API was found.", zap.String("channelProgramAPI", chProgAPIGdhdpublic)) 100 | c.config.ChannelProgramAPI = chProgAPIGdhdpublic 101 | return result, err 102 | } 103 | 104 | result, err = c.getAllChannelProgramList(ctx, channels, token, c.getVspChannelProgramList) 105 | if !errors.Is(err, ErrEPGApiNotFound) { 106 | c.logger.Info("An available EPG API was found.", zap.String("channelProgramAPI", chProgAPIVsp)) 107 | c.config.ChannelProgramAPI = chProgAPIVsp 108 | return result, err 109 | } 110 | 111 | result, err = c.getStbEpg2023GroupAllChannelProgramList(ctx, channels, token) 112 | if !errors.Is(err, ErrEPGApiNotFound) { 113 | c.logger.Info("An available EPG API was found.", zap.String("channelProgramAPI", chProgAPIStbEpg2023Group)) 114 | c.config.ChannelProgramAPI = chProgAPIStbEpg2023Group 115 | return result, err 116 | } 117 | 118 | result, err = c.getAllChannelProgramList(ctx, channels, token, c.getDefaulttrans2ChannelProgramList) 119 | if !errors.Is(err, ErrEPGApiNotFound) { 120 | c.logger.Info("An available EPG API was found.", zap.String("channelProgramAPI", chProgAPIDefaulttrans2)) 121 | c.config.ChannelProgramAPI = chProgAPIDefaulttrans2 122 | return result, err 123 | } 124 | 125 | c.logger.Warn("No suitable EPG API found.") 126 | return nil, err 127 | } 128 | -------------------------------------------------------------------------------- /internal/app/iptv/hwctc/channel.go: -------------------------------------------------------------------------------- 1 | package hwctc 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "fmt" 8 | "io" 9 | "iptv/internal/app/iptv" 10 | "net/http" 11 | "net/url" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "go.uber.org/zap" 18 | ) 19 | 20 | // GetAllChannelList 获取所有频道列表 21 | func (c *Client) GetAllChannelList(ctx context.Context) ([]iptv.Channel, error) { 22 | // 请求认证的Token 23 | token, err := c.requestToken(ctx) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | // 计算JSESSIONID的MD5 29 | hash := md5.Sum([]byte(token.JSESSIONID)) 30 | // 转换为16进制字符串并转换为大写,即为tempKey 31 | tempKey := strings.ToUpper(hex.EncodeToString(hash[:])) 32 | 33 | // 组装请求数据 34 | data := map[string]string{ 35 | "conntype": c.config.Conntype, 36 | "UserToken": token.UserToken, 37 | "tempKey": tempKey, 38 | "stbid": token.Stbid, 39 | "SupportHD": "1", 40 | "UserID": c.config.UserID, 41 | "Lang": c.config.Lang, 42 | } 43 | body := url.Values{} 44 | for k, v := range data { 45 | body.Add(k, v) 46 | } 47 | 48 | // 创建请求 49 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, 50 | fmt.Sprintf("http://%s/EPG/jsp/getchannellistHW%s.jsp", c.host, c.config.ProviderSuffix), strings.NewReader(body.Encode())) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | // 设置请求头 56 | c.setCommonHeaders(req) 57 | req.Header.Set("Referer", fmt.Sprintf("http://%s/EPG/jsp/ValidAuthenticationHW%s.jsp", c.host, c.config.ProviderSuffix)) 58 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 59 | 60 | // 设置Cookie 61 | req.AddCookie(&http.Cookie{ 62 | Name: "JSESSIONID", 63 | Value: token.JSESSIONID, 64 | }) 65 | 66 | // 执行请求 67 | resp, err := c.httpClient.Do(req) 68 | if err != nil { 69 | return nil, err 70 | } 71 | defer resp.Body.Close() 72 | 73 | if resp.StatusCode != http.StatusOK { 74 | return nil, fmt.Errorf("http status code: %d", resp.StatusCode) 75 | } 76 | 77 | // 解析响应内容 78 | result, err := io.ReadAll(resp.Body) 79 | if err != nil { 80 | return nil, err 81 | } 82 | chRegex := regexp.MustCompile("ChannelID=\"(.+?)\",ChannelName=\"(.+?)\",UserChannelID=\"(.+?)\",ChannelURL=\"(.+?)\",TimeShift=\"(.+?)\",TimeShiftLength=\"(\\d+?)\".+?,TimeShiftURL=\"(.+?)\"") 83 | matchesList := chRegex.FindAllSubmatch(result, -1) 84 | if matchesList == nil { 85 | return nil, fmt.Errorf("failed to extract channel list") 86 | } 87 | 88 | channels := make([]iptv.Channel, 0, len(matchesList)) 89 | for _, matches := range matchesList { 90 | if len(matches) != 8 { 91 | continue 92 | } 93 | 94 | channelName := string(matches[2]) 95 | // 过滤掉特殊频道 96 | if c.chExcludeRule != nil && c.chExcludeRule.MatchString(channelName) { 97 | c.logger.Warn("This is not a normal channel, skip it.", zap.String("channelName", channelName)) 98 | continue 99 | } 100 | 101 | // channelURL类型转换 102 | // channelURL可能同时返回组播和单播多个地址(通过|分割) 103 | channelURLStrList := strings.Split(string(matches[4]), "|") 104 | channelURLs := make([]url.URL, 0, len(channelURLStrList)) 105 | for _, channelURLStr := range channelURLStrList { 106 | channelURL, err := url.Parse(channelURLStr) 107 | if err != nil { 108 | continue 109 | } 110 | 111 | channelURLs = append(channelURLs, *channelURL) 112 | } 113 | 114 | if len(channelURLs) == 0 { 115 | c.logger.Warn("The channelURL of this channel is illegal, skip it.", zap.String("channelName", channelName), zap.String("channelURL", string(matches[4]))) 116 | continue 117 | } 118 | 119 | // TimeShiftLength类型转换 120 | timeShiftLength, err := strconv.ParseInt(string(matches[6]), 10, 64) 121 | if err != nil { 122 | c.logger.Warn("The timeShiftLength of this channel is illegal. Use the default value: 0.", zap.String("channelName", channelName), zap.String("timeShiftLength", string(matches[6]))) 123 | timeShiftLength = 0 124 | } 125 | 126 | // 解析时移地址 127 | timeShiftURL, err := url.Parse(string(matches[7])) 128 | if err != nil { 129 | c.logger.Warn("The timeShiftURL of this channel is illegal. Use the default value: nil.", zap.String("channelName", channelName), zap.String("timeShiftURL", string(matches[7]))) 130 | } 131 | // 如果ChannelURL只返回了一个组播地址,则考虑将回看地址同时作为单播地址进行记录 132 | if timeShiftURL != nil && 133 | len(channelURLs) == 1 && channelURLs[0].Scheme == iptv.SCHEME_IGMP { 134 | channelURLs = append(channelURLs, *timeShiftURL) 135 | } 136 | 137 | // 自动识别频道的分类 138 | groupName := iptv.GetChannelGroupName(c.chGroupRulesList, channelName) 139 | 140 | // 识别频道台标logo 141 | logoName := iptv.GetChannelLogoName(c.chLogoRuleList, channelName) 142 | 143 | channels = append(channels, iptv.Channel{ 144 | ChannelID: string(matches[1]), 145 | ChannelName: channelName, 146 | UserChannelID: string(matches[3]), 147 | ChannelURLs: channelURLs, 148 | TimeShift: string(matches[5]), 149 | TimeShiftLength: time.Duration(timeShiftLength) * time.Minute, 150 | TimeShiftURL: timeShiftURL, 151 | GroupName: groupName, 152 | LogoName: logoName, 153 | }) 154 | } 155 | return channels, nil 156 | } 157 | -------------------------------------------------------------------------------- /internal/app/iptv/hwctc/epg_defaulttrans2.go: -------------------------------------------------------------------------------- 1 | package hwctc 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "iptv/internal/app/iptv" 9 | "net/http" 10 | "net/url" 11 | "strconv" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | type defaulttrans2Respone struct { 17 | Data []defaulttrans2ChannelProg `json:"data"` 18 | Title []string `json:"title"` 19 | } 20 | 21 | type defaulttrans2ChannelProg struct { 22 | ProgName string `json:"progName"` 23 | ScrollFlag int `json:"scrollFlag"` 24 | StartTime string `json:"startTime"` 25 | EndTime string `json:"endTime"` 26 | SubProgName string `json:"subProgName"` 27 | State string `json:"state"` 28 | ProgId string `json:"progId"` 29 | } 30 | 31 | // getDefaulttrans2ChannelProgramList 获取指定频道的节目单列表(sd) 32 | func (c *Client) getDefaulttrans2ChannelProgramList(ctx context.Context, token *Token, channel *iptv.Channel) (*iptv.ChannelProgramList, error) { 33 | now := time.Now() 34 | now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) 35 | 36 | // 从当天开始往前,倒查多个日期的节目单 37 | dateSize := 7 38 | dateProgramList := make([]iptv.DateProgram, 0, dateSize) 39 | for i := 0; i < dateSize; i++ { 40 | date := now.AddDate(0, 0, -i) 41 | 42 | // 获取指定日期的节目单列表 43 | programList, chDateSize, err := c.getDefaulttrans2ChannelDateProgram(ctx, token, channel, date, -i) 44 | if err != nil { 45 | if errors.Is(err, ErrEPGApiNotFound) { 46 | return nil, err 47 | } 48 | c.logger.Sugar().Warnf("Failed to get the program list for channel %s on %s (index: %d). Error: %v", channel.ChannelName, date.Format("20060102"), -i, err) 49 | continue 50 | } 51 | 52 | if i == 0 { 53 | dateSize = chDateSize 54 | } 55 | dateProgramList = append(dateProgramList, iptv.DateProgram{ 56 | Date: date, 57 | ProgramList: programList, 58 | }) 59 | } 60 | 61 | return &iptv.ChannelProgramList{ 62 | ChannelId: channel.ChannelID, 63 | ChannelName: channel.ChannelName, 64 | DateProgramList: dateProgramList, 65 | }, nil 66 | } 67 | 68 | // getVspChannelDateProgram 获取指定频道的某日期的节目单列表 69 | func (c *Client) getDefaulttrans2ChannelDateProgram(ctx context.Context, token *Token, channel *iptv.Channel, date time.Time, index int) ([]iptv.Program, int, error) { 70 | // 创建请求 71 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, 72 | fmt.Sprintf("http://%s/EPG/jsp/defaulttrans2/en/datajsp/getTvodProgListByIndex.jsp", c.host), nil) 73 | if err != nil { 74 | return nil, 0, err 75 | } 76 | 77 | // 增加请求参数 78 | params := req.URL.Query() 79 | params.Add("CHANNELID", channel.ChannelID) 80 | params.Add("index", strconv.Itoa(index)) 81 | req.URL.RawQuery = params.Encode() 82 | 83 | // 设置请求头 84 | c.setCommonHeaders(req) 85 | req.Header.Set("Referer", fmt.Sprintf("http://%s/EPG/jsp/defaulttrans2/en/chanMiniList.html", c.host)) 86 | 87 | // 设置Cookie 88 | cookies := []*http.Cookie{ 89 | {Name: "maidianFlag", Value: "1"}, 90 | {Name: "navNameFocus", Value: "3"}, 91 | {Name: "jumpTime", Value: "0"}, 92 | {Name: "channelTip", Value: "1"}, 93 | {Name: "lastChanNum", Value: "1"}, 94 | {Name: "STARV_TIMESHFTCID", Value: channel.ChannelID}, 95 | {Name: "STARV_TIMESHFTCNAME", Value: url.QueryEscape(channel.ChannelName)}, 96 | {Name: "JSESSIONID", Value: token.JSESSIONID}, 97 | } 98 | for _, cookie := range cookies { 99 | req.AddCookie(cookie) 100 | } 101 | 102 | // 执行请求 103 | resp, err := c.httpClient.Do(req) 104 | if err != nil { 105 | return nil, 0, err 106 | } 107 | defer resp.Body.Close() 108 | 109 | if resp.StatusCode == http.StatusNotFound || resp.StatusCode >= http.StatusInternalServerError { 110 | return nil, 0, ErrEPGApiNotFound 111 | } else if resp.StatusCode != http.StatusOK { 112 | return nil, 0, fmt.Errorf("http status code: %d", resp.StatusCode) 113 | } 114 | 115 | // 解析响应内容 116 | var response defaulttrans2Respone 117 | if err = json.NewDecoder(resp.Body).Decode(&response); err != nil { 118 | return nil, 0, fmt.Errorf("parse response failed: %w", err) 119 | } 120 | 121 | // 解析节目单信息 122 | return parseDefaulttrans2ChannelDateProgram(response, date, index) 123 | } 124 | 125 | // parseDefaulttrans2ChannelDateProgram 解析频道节目单列表 126 | func parseDefaulttrans2ChannelDateProgram(response defaulttrans2Respone, date time.Time, index int) ([]iptv.Program, int, error) { 127 | if len(response.Data) == 0 { 128 | return nil, 0, ErrChProgListIsEmpty 129 | } else if len(response.Title) == 0 { 130 | return nil, 0, fmt.Errorf("no date title list") 131 | } 132 | 133 | // 比较日期是否正确 134 | datePos := len(response.Title) - 1 + index 135 | if datePos >= len(response.Title) || datePos < 0 { 136 | return nil, 0, fmt.Errorf("invalid date position: %d", datePos) 137 | } else if !strings.HasPrefix(response.Title[datePos], date.Format("02")) { 138 | return nil, 0, fmt.Errorf("the program date does not match the query date") 139 | } 140 | 141 | dateStr := date.Format("20060102") 142 | // 遍历单个日期中的节目单 143 | programList := make([]iptv.Program, 0, len(response.Data)) 144 | for i, prog := range response.Data { 145 | // 处理节目单的开始和结束时间 146 | startTimeStr := prog.StartTime 147 | if i == 0 { 148 | // 将第一个节目单的开始时间统一设置为零点 149 | startTimeStr = "00:00" 150 | } else if len(startTimeStr) > 5 { 151 | startTimeStr = startTimeStr[:5] 152 | } 153 | endTimeStr := prog.EndTime 154 | if len(endTimeStr) > 5 { 155 | endTimeStr = endTimeStr[:5] 156 | } 157 | 158 | bTime, err := time.ParseInLocation("20060102 15:04", dateStr+" "+startTimeStr, time.Local) 159 | if err != nil { 160 | return nil, 0, err 161 | } 162 | eTime, err := time.ParseInLocation("20060102 15:04", dateStr+" "+endTimeStr, time.Local) 163 | if err != nil { 164 | return nil, 0, err 165 | } 166 | // 处理跨天的节目单数据,将结束时间改为第二天的零点 167 | if bTime.After(eTime) { 168 | tempDate := date.AddDate(0, 0, 1) 169 | eTime = time.Date(tempDate.Year(), tempDate.Month(), tempDate.Day(), 0, 0, 0, 0, tempDate.Location()) 170 | endTimeStr = "23:59" 171 | } 172 | 173 | // 组装节目单对象 174 | programList = append(programList, iptv.Program{ 175 | ProgramName: prog.ProgName, 176 | BeginTimeFormat: bTime.Format("20060102150405"), 177 | EndTimeFormat: eTime.Format("20060102150405"), 178 | StartTime: startTimeStr, 179 | EndTime: endTimeStr, 180 | }) 181 | // 丢弃后续第二天的节目单数据,如果存在的话 182 | if endTimeStr == "23:59" { 183 | break 184 | } 185 | } 186 | return programList, len(response.Title), nil 187 | } 188 | -------------------------------------------------------------------------------- /internal/app/iptv/channel.go: -------------------------------------------------------------------------------- 1 | package iptv 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "iptv/internal/pkg/util" 7 | "net/url" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | const SCHEME_IGMP = "igmp" 15 | 16 | // Channel 频道信息 17 | type Channel struct { 18 | ChannelID string `json:"channelID"` // 频道ID 19 | ChannelName string `json:"channelName"` // 频道名称 20 | UserChannelID string `json:"userChannelID"` // 频道号 21 | ChannelURLs []url.URL `json:"channelURLs"` // 频道URL列表 22 | TimeShift string `json:"timeShift"` // 时移类型 23 | TimeShiftLength time.Duration `json:"timeShiftLength"` // 支持的时移长度 24 | TimeShiftURL *url.URL `json:"timeShiftURL"` // 时移地址(回放地址) 25 | 26 | GroupName string `json:"groupName"` // 程序识别的频道分类 27 | LogoName string `json:"logoName"` // 频道台标名称 28 | } 29 | 30 | // ToM3UFormat 转换为M3U格式内容 31 | func ToM3UFormat(channels []Channel, udpxyURL, catchupSource string, multicastFirst bool, logoBaseUrl string) (string, error) { 32 | if len(channels) == 0 { 33 | return "", errors.New("no channels found") 34 | } 35 | 36 | catchupSource = strings.TrimLeft(catchupSource, "?&") 37 | 38 | currDir, err := util.GetCurrentAbPathByExecutable() 39 | if err != nil { 40 | return "", err 41 | } 42 | 43 | var sb strings.Builder 44 | sb.WriteString("#EXTM3U\n") 45 | for _, channel := range channels { 46 | // 根据指定条件,获取频道URL地址 47 | channelURLStr, isMulticastCh, err := getChannelURLStr(channel.ChannelURLs, udpxyURL, multicastFirst) 48 | if err != nil { 49 | return "", err 50 | } 51 | 52 | var m3uLineSb strings.Builder 53 | 54 | // 设置频道ID和序号 55 | m3uLineSb.WriteString(fmt.Sprintf("#EXTINF:-1 tvg-id=\"%s\" tvg-chno=\"%s\"", 56 | channel.ChannelID, channel.UserChannelID)) 57 | // 设置频道的台标URL 58 | if logoBaseUrl != "" && channel.LogoName != "" { 59 | logoFile := channel.LogoName + ".png" 60 | if _, err = os.Stat(filepath.Join(currDir, logoDirName, logoFile)); !os.IsNotExist(err) { 61 | if logoUrl, err := url.JoinPath(logoBaseUrl, logoFile); err == nil { 62 | m3uLineSb.WriteString(fmt.Sprintf(" tvg-logo=\"%s\"", 63 | logoUrl)) 64 | } 65 | } 66 | } 67 | // 设置频道回看参数 68 | if catchupSource != "" && 69 | channel.TimeShift == "1" && channel.TimeShiftLength > 0 && channel.TimeShiftURL != nil { 70 | var chCatchup, chCatchupSource string 71 | if isMulticastCh { 72 | chCatchup = "default" 73 | chCatchupSource = channel.TimeShiftURL.String() 74 | if channel.TimeShiftURL.RawQuery != "" { 75 | chCatchupSource += "&" + catchupSource 76 | } else { 77 | chCatchupSource += "?" + catchupSource 78 | } 79 | } else { 80 | chCatchup = "append" 81 | chCatchupSource = "?" + catchupSource 82 | } 83 | 84 | m3uLineSb.WriteString(fmt.Sprintf(" catchup=\"%s\" catchup-source=\"%s\" catchup-days=\"%d\"", 85 | chCatchup, chCatchupSource, int64(channel.TimeShiftLength.Hours()/24))) 86 | } 87 | // 设置频道分组和名称 88 | m3uLineSb.WriteString(fmt.Sprintf(" group-title=\"%s\",%s\n%s\n", 89 | channel.GroupName, channel.ChannelName, channelURLStr)) 90 | sb.WriteString(m3uLineSb.String()) 91 | } 92 | return sb.String(), nil 93 | } 94 | 95 | // ToTxtFormat 转换为txt格式内容 96 | func ToTxtFormat(channels []Channel, udpxyURL string, multicastFirst bool) (string, error) { 97 | if len(channels) == 0 { 98 | return "", errors.New("no channels found") 99 | } 100 | 101 | // 对频道列表,按分组名称进行分组 102 | groupNames := make([]string, 0) 103 | groupChannelMap := make(map[string][]Channel) 104 | for _, channel := range channels { 105 | groupChannels, ok := groupChannelMap[channel.GroupName] 106 | if !ok { 107 | groupNames = append(groupNames, channel.GroupName) 108 | groupChannelMap[channel.GroupName] = []Channel{channel} 109 | continue 110 | } 111 | 112 | groupChannels = append(groupChannels, channel) 113 | groupChannelMap[channel.GroupName] = groupChannels 114 | } 115 | 116 | var sb strings.Builder 117 | // 为保证顺序,单独遍历分组名称的slices 118 | for _, groupName := range groupNames { 119 | groupChannels := groupChannelMap[groupName] 120 | 121 | // 输出分组信息 122 | groupLine := fmt.Sprintf("%s,#genre#\n", groupName) 123 | sb.WriteString(groupLine) 124 | 125 | // 输出频道信息 126 | for _, channel := range groupChannels { 127 | // 根据指定条件,获取频道URL地址 128 | channelURLStr, _, err := getChannelURLStr(channel.ChannelURLs, udpxyURL, multicastFirst) 129 | if err != nil { 130 | return "", err 131 | } 132 | 133 | txtLine := fmt.Sprintf("%s,%s\n", 134 | channel.ChannelName, channelURLStr) 135 | sb.WriteString(txtLine) 136 | } 137 | } 138 | return sb.String(), nil 139 | } 140 | 141 | // ToPLSFormat 转换为pls(playlist)格式内容 142 | func ToPLSFormat(channels []Channel, udpxyURL string, multicastFirst bool) (string, error) { 143 | if len(channels) == 0 { 144 | return "", errors.New("no channels found") 145 | } 146 | 147 | var sb strings.Builder 148 | sb.WriteString("[playlist]\n\n") 149 | for i, channel := range channels { 150 | // 根据指定条件,获取频道URL地址 151 | channelURLStr, _, err := getChannelURLStr(channel.ChannelURLs, udpxyURL, multicastFirst) 152 | if err != nil { 153 | return "", err 154 | } 155 | 156 | entryIndex := i + 1 157 | // 设置频道URL 158 | sb.WriteString(fmt.Sprintf("File%d=%s\n", entryIndex, channelURLStr)) 159 | // 设置频道名称 160 | sb.WriteString(fmt.Sprintf("Title%d=%s\n\n", entryIndex, channel.ChannelName)) 161 | } 162 | sb.WriteString(fmt.Sprintf("NumberOfEntries=%d\n", len(channels))) 163 | sb.WriteString("Version=2\n") 164 | return sb.String(), nil 165 | } 166 | 167 | // getChannelURLStr 根据指定条件,获取频道URL地址 168 | func getChannelURLStr(channelURLs []url.URL, udpxyURL string, multicastFirst bool) (string, bool, error) { 169 | if len(channelURLs) == 0 { 170 | return "", false, errors.New("no channel urls found") 171 | } 172 | 173 | var channelURL url.URL 174 | if len(channelURLs) == 1 { 175 | channelURL = channelURLs[0] 176 | } else { 177 | for _, channelURL = range channelURLs { 178 | if (multicastFirst && channelURL.Scheme == SCHEME_IGMP) || 179 | (!multicastFirst && channelURL.Scheme != SCHEME_IGMP) { 180 | break 181 | } 182 | } 183 | } 184 | 185 | isMulticastCh := channelURL.Scheme == SCHEME_IGMP 186 | if udpxyURL != "" && isMulticastCh { 187 | result, err := url.JoinPath(udpxyURL, fmt.Sprintf("/rtp/%s", channelURL.Host)) 188 | return result, isMulticastCh, err 189 | } else { 190 | return channelURL.String(), isMulticastCh, nil 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /internal/app/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "iptv/internal/app/iptv" 6 | "iptv/internal/app/iptv/hwctc" 7 | "os" 8 | "regexp" 9 | 10 | "go.uber.org/zap" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | type OptionChannelGroupRules struct { 15 | Name string `json:"name" yaml:"name"` // 分组名称 16 | Rules []string `json:"rules" yaml:"rules"` // 分组规则 17 | } 18 | 19 | type OptionChannelLogoRule struct { 20 | Name string `json:"name" yaml:"name"` // 频道台标名称 21 | Rule string `json:"rule" yaml:"rule"` // 台标匹配规则 22 | } 23 | 24 | type CatchupConfig struct { 25 | Sources map[string]string `json:"sources" yaml:"sources"` // 回看请求的参数 26 | } 27 | 28 | type Config struct { 29 | Key string `json:"key" yaml:"key"` // 必填,8位数字,生成Authenticator的秘钥 30 | ServerHost string `json:"serverHost" yaml:"serverHost"` // 必填,HTTP请求的IPTV服务器地址端口 31 | Headers map[string]string `json:"headers" yaml:"headers"` // 自定义HTTP请求头 32 | 33 | OptionChExcludeRule string `json:"chExcludeRule" yaml:"chExcludeRule"` // 频道的过滤规则 34 | ChExcludeRule *regexp.Regexp `json:"-" yaml:"-"` // Validate()时进行填充 35 | 36 | OptionChGroupRulesList []OptionChannelGroupRules `json:"chGroupRules" yaml:"chGroupRules"` // 自定义频道分组规则 37 | ChGroupRulesList []iptv.ChannelGroupRules `json:"-" yaml:"-"` // Validate()时进行填充 38 | 39 | OptionChLogoRuleList []OptionChannelLogoRule `json:"logos" yaml:"logos"` // 自定义台标匹配规则 40 | ChLogoRuleList []iptv.ChannelLogoRule `json:"-" yaml:"-"` // Validate()时进行填充 41 | 42 | Catchup *CatchupConfig `json:"catchup" yaml:"catchup"` // 回看请求参数配置 43 | 44 | HWCTC *hwctc.Config `json:"hwctc,omitempty" yaml:"hwctc,omitempty"` // hw平台相关设置 45 | } 46 | 47 | func (c *Config) Validate() error { 48 | // 校验config配置 49 | if c.Key == "" || 50 | c.ServerHost == "" { 51 | return errors.New("invalid IPTV-Tool config") 52 | } 53 | 54 | // L():获取全局logger 55 | logger := zap.L() 56 | 57 | // 填充频道的过滤规则 58 | if c.OptionChExcludeRule != "" { 59 | rule, err := regexp.Compile(c.OptionChExcludeRule) 60 | if err != nil { 61 | logger.Warn("The channel exclusion rule is incorrect. Skip it.", zap.String("chExcludeRule", c.OptionChExcludeRule), zap.Error(err)) 62 | } else { 63 | c.ChExcludeRule = rule 64 | } 65 | } 66 | 67 | // 填充频道分组的正则表达式规则 68 | c.ChGroupRulesList = make([]iptv.ChannelGroupRules, 0, len(c.OptionChGroupRulesList)) 69 | for _, opChGroupRules := range c.OptionChGroupRulesList { 70 | if opChGroupRules.Name == "" { 71 | logger.Warn("The channel group name is empty. Skip it.") 72 | continue 73 | } else if len(opChGroupRules.Rules) == 0 { 74 | logger.Warn("The channel group rule is empty. Skip it.", zap.String("name", opChGroupRules.Name)) 75 | continue 76 | } 77 | 78 | rules := make([]*regexp.Regexp, 0, len(opChGroupRules.Rules)) 79 | for _, ruleStr := range opChGroupRules.Rules { 80 | rule, err := regexp.Compile(ruleStr) 81 | if err != nil { 82 | logger.Warn("The channel group rule is incorrect. Skip it.", zap.String("name", opChGroupRules.Name), zap.String("rule", ruleStr), zap.Error(err)) 83 | continue 84 | } 85 | 86 | rules = append(rules, rule) 87 | } 88 | if len(rules) > 0 { 89 | c.ChGroupRulesList = append(c.ChGroupRulesList, iptv.ChannelGroupRules{ 90 | Name: opChGroupRules.Name, 91 | Rules: rules, 92 | }) 93 | } 94 | } 95 | 96 | // 填充频道台标的匹配规则 97 | c.ChLogoRuleList = make([]iptv.ChannelLogoRule, 0, len(c.OptionChLogoRuleList)) 98 | for _, opLogoRule := range c.OptionChLogoRuleList { 99 | if opLogoRule.Name == "" { 100 | logger.Warn("The channel logo name is empty. Skip it.") 101 | continue 102 | } else if opLogoRule.Rule == "" { 103 | logger.Warn("The channel logo rule is empty. Skip it.", zap.String("name", opLogoRule.Name)) 104 | continue 105 | } 106 | 107 | rule, err := regexp.Compile(opLogoRule.Rule) 108 | if err != nil { 109 | logger.Warn("The channel logo rule is incorrect. Skip it.", zap.String("name", opLogoRule.Name), zap.String("rule", opLogoRule.Rule), zap.Error(err)) 110 | continue 111 | } 112 | 113 | c.ChLogoRuleList = append(c.ChLogoRuleList, iptv.ChannelLogoRule{ 114 | Name: opLogoRule.Name, 115 | Rule: rule, 116 | }) 117 | } 118 | 119 | // 回看请求参数 120 | if c.Catchup == nil { 121 | c.Catchup = &CatchupConfig{ 122 | Sources: map[string]string{ 123 | "0": "playseek=${(b)yyyyMMddHHmmss}-${(e)yyyyMMddHHmmss}", 124 | "1": "playseek={utc:YmdHMS}-{utcend:YmdHMS}", 125 | }, 126 | } 127 | } else if len(c.Catchup.Sources) == 0 { 128 | c.Catchup.Sources = map[string]string{ 129 | "0": "playseek=${(b)yyyyMMddHHmmss}-${(e)yyyyMMddHHmmss}", 130 | "1": "playseek={utc:YmdHMS}-{utcend:YmdHMS}", 131 | } 132 | } 133 | 134 | return nil 135 | } 136 | 137 | func Load(fPath string) (*Config, error) { 138 | // 读取配置文件 139 | data, err := os.ReadFile(fPath) 140 | if err != nil { 141 | return nil, err 142 | } 143 | var config Config 144 | if err = yaml.Unmarshal(data, &config); err != nil { 145 | return nil, err 146 | } 147 | 148 | return &config, nil 149 | } 150 | 151 | func CreateDefaultCfg(fPath string) error { 152 | // 写入默认配置 153 | f, err := os.Create(fPath) 154 | if err != nil { 155 | return err 156 | } 157 | defer f.Close() 158 | 159 | // 创建编码器 160 | encoder := yaml.NewEncoder(f) 161 | 162 | // 缺省配置 163 | defaultCfg := Config{ 164 | ServerHost: "127.0.0.1", 165 | Headers: map[string]string{ 166 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 167 | "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; Fhbw2.0) AppleWebKit", 168 | "Accept-Language": "zh-CN,en-US;q=0.8", 169 | "X-Requested-With": "com.fiberhome.iptv", 170 | }, 171 | OptionChExcludeRule: "^.*?(画中画|单音轨|-体验|\\(测试\\)|直播室\\d+)", 172 | OptionChGroupRulesList: []OptionChannelGroupRules{ 173 | { 174 | Name: "央视", 175 | Rules: []string{ 176 | "^(CCTV|中央).+?$", 177 | }, 178 | }, 179 | { 180 | Name: "卫视", 181 | Rules: []string{ 182 | "^[^(热门)].+?卫视.*?$", 183 | }, 184 | }, 185 | { 186 | Name: "国际", 187 | Rules: []string{ 188 | "^(CGTN|凤凰).+?$", 189 | }, 190 | }, 191 | { 192 | Name: "地方", 193 | Rules: []string{ 194 | "^(SCTV|CDTV|四川乡村|峨眉电影).*?$", 195 | "^(浙江|杭州|民生|钱江|教科影视|好易购|西湖|青少体育).+?$", 196 | "^(福建|福州|厦门|漳州|泉州|三明|莆田|南平|龙岩|宁德).+?$", 197 | }, 198 | }, 199 | { 200 | Name: "付费", 201 | Rules: []string{ 202 | ".+?\\(VIP\\)$", 203 | }, 204 | }, 205 | { 206 | Name: "专区", 207 | Rules: []string{ 208 | ".+?专区$", 209 | }, 210 | }, 211 | }, 212 | OptionChLogoRuleList: []OptionChannelLogoRule{ 213 | { 214 | Rule: "^(.+?)-(.+?)(\\(?标清\\)?|\\(?高清\\)?|\\(?超清\\)?)$", 215 | Name: "$G1$G2", 216 | }, 217 | { 218 | Rule: "^([^(热门)].+?)卫视(\\(?标清\\)?|\\(?高清\\)?|\\(?超清\\)?)$", 219 | Name: "$G1卫视", 220 | }, 221 | { 222 | Rule: "^(.+?)(\\(?标清\\)?|\\(?高清\\)?|\\(?超清\\)?|\\(?VIP\\)?)$", 223 | Name: "$G1", 224 | }, 225 | }, 226 | Catchup: &CatchupConfig{ 227 | Sources: map[string]string{ 228 | "0": "playseek=${(b)yyyyMMddHHmmss}-${(e)yyyyMMddHHmmss}", 229 | "1": "playseek={utc:YmdHMS}-{utcend:YmdHMS}", 230 | }, 231 | }, 232 | HWCTC: &hwctc.Config{}, 233 | } 234 | 235 | return encoder.Encode(&defaultCfg) 236 | } 237 | -------------------------------------------------------------------------------- /internal/app/iptv/hwctc/authenticator.go: -------------------------------------------------------------------------------- 1 | package hwctc 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "iptv/internal/app/iptv" 9 | "math/rand" 10 | "net" 11 | "net/http" 12 | "net/url" 13 | "regexp" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | type Token struct { 19 | UserToken string `json:"userToken"` 20 | Stbid string `json:"stbid"` 21 | JSESSIONID string `json:"jsessionid"` 22 | } 23 | 24 | // requestToken 请求认证的Token 25 | func (c *Client) requestToken(ctx context.Context) (*Token, error) { 26 | // 访问登录页面 27 | referer, err := c.authenticationURL(ctx, true) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | // 获取EncryptToken 33 | encryptToken, err := c.authLoginHWCTC(ctx, referer) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | // 认证并获取Token和JSESSIONID 39 | return c.validAuthenticationHWCTC(ctx, encryptToken) 40 | } 41 | 42 | // authenticationURL 认证第一步 43 | func (c *Client) authenticationURL(ctx context.Context, FCCSupport bool) (string, error) { 44 | // 创建请求 45 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, 46 | fmt.Sprintf("http://%s/EDS/jsp/AuthenticationURL", c.originHost), nil) 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | // 增加请求参数 52 | params := req.URL.Query() 53 | params.Add("UserID", c.config.UserID) 54 | params.Add("Action", "Login") 55 | if FCCSupport { 56 | params.Add("FCCSupport", "1") 57 | } 58 | req.URL.RawQuery = params.Encode() 59 | 60 | // 设置请求头 61 | c.setCommonHeaders(req) 62 | 63 | // 执行请求 64 | resp, err := c.httpClient.Do(req) 65 | if err != nil { 66 | return "", err 67 | } 68 | defer resp.Body.Close() 69 | 70 | if resp.StatusCode != http.StatusOK { 71 | return "", fmt.Errorf("http status code: %d", resp.StatusCode) 72 | } 73 | 74 | // 服务器会302重定向,这里缓存最新的服务器地址和端口 75 | c.host = resp.Request.URL.Host 76 | 77 | return resp.Request.URL.String(), nil 78 | } 79 | 80 | // authLoginHWCTC 认证第二步 81 | func (c *Client) authLoginHWCTC(ctx context.Context, referer string) (string, error) { 82 | // 组装请求数据 83 | data := map[string]string{ 84 | "UserID": c.config.UserID, 85 | "VIP": c.config.Vip, 86 | } 87 | body := url.Values{} 88 | for k, v := range data { 89 | body.Add(k, v) 90 | } 91 | 92 | // 创建请求 93 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, 94 | fmt.Sprintf("http://%s/EPG/jsp/authLoginHW%s.jsp", c.host, c.config.ProviderSuffix), strings.NewReader(body.Encode())) 95 | if err != nil { 96 | return "", err 97 | } 98 | 99 | // 设置请求头 100 | c.setCommonHeaders(req) 101 | req.Header.Set("Referer", referer) 102 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 103 | 104 | // 执行请求 105 | resp, err := c.httpClient.Do(req) 106 | if err != nil { 107 | return "", err 108 | } 109 | defer resp.Body.Close() 110 | 111 | if resp.StatusCode != http.StatusOK { 112 | return "", fmt.Errorf("http status code: %d", resp.StatusCode) 113 | } 114 | 115 | // 解析响应内容 116 | result, err := io.ReadAll(resp.Body) 117 | if err != nil { 118 | return "", err 119 | } 120 | regex := regexp.MustCompile("EncryptToken = \"(.+?)\";") 121 | matches := regex.FindSubmatch(result) 122 | if len(matches) != 2 { 123 | return "", errors.New("failed to parse EncryptToken") 124 | } 125 | return string(matches[1]), nil 126 | } 127 | 128 | // validAuthenticationHWCTC 认证第三步,获取UserToken和cookie中的JSESSIONID 129 | func (c *Client) validAuthenticationHWCTC(ctx context.Context, encryptToken string) (*Token, error) { 130 | // 生成随机的8位数字 131 | random := c.generate8DigitNumber() 132 | 133 | var err error 134 | // 获取IPv4地址 135 | var ipv4Addr string 136 | if c.config.InterfaceName != "" { 137 | ipv4Addr, err = c.getInterfaceIPv4Addr(c.config.InterfaceName) 138 | if err != nil { 139 | return nil, err 140 | } 141 | } 142 | if ipv4Addr == "" { 143 | ipv4Addr = c.config.IP 144 | } 145 | 146 | // 输入的格式:random + "$" + EncryptToken + "$" + UserID + "$" + STBID + "$" + IP + "$" + MAC + "$" + Reserved + "$" + CTC 147 | input := fmt.Sprintf("%d$%s$%s$%s$%s$%s$$CTC", 148 | random, encryptToken, c.config.UserID, c.config.STBID, ipv4Addr, c.config.MAC) 149 | // 使用3DES加密生成Authenticator 150 | crypto := iptv.NewTripleDESCrypto(c.key) 151 | authenticator, err := crypto.ECBEncrypt(input) 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | // 组装请求数据 157 | data := map[string]string{ 158 | "UserID": c.config.UserID, 159 | "Lang": c.config.Lang, 160 | "SupportHD": "1", 161 | "NetUserID": c.config.NetUserID, 162 | "Authenticator": strings.ToUpper(authenticator), 163 | "STBType": c.config.STBType, 164 | "STBVersion": c.config.STBVersion, 165 | "conntype": c.config.Conntype, 166 | "STBID": c.config.STBID, 167 | "templateName": c.config.TemplateName, 168 | "areaId": c.config.AreaId, 169 | "userToken": encryptToken, 170 | "userGroupId": c.config.UserGroupId, 171 | "productPackageId": c.config.ProductPackageId, 172 | "mac": c.config.MAC, 173 | "UserField": c.config.UserField, 174 | "SoftwareVersion": c.config.SoftwareVersion, 175 | "IsSmartStb": c.config.IsSmartStb, 176 | "desktopId": "", 177 | "stbmaker": "", 178 | "XMPPCapability": "", 179 | "ChipID": "", 180 | "VIP": c.config.Vip, 181 | } 182 | body := url.Values{} 183 | for k, v := range data { 184 | body.Add(k, v) 185 | } 186 | 187 | // 创建请求 188 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, 189 | fmt.Sprintf("http://%s/EPG/jsp/ValidAuthenticationHW%s.jsp", c.host, c.config.ProviderSuffix), strings.NewReader(body.Encode())) 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | // 设置请求头 195 | c.setCommonHeaders(req) 196 | referer := fmt.Sprintf("http://%s/EPG/jsp/authLoginHW%s.jsp", c.host, c.config.ProviderSuffix) 197 | req.Header.Set("Referer", referer) 198 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 199 | 200 | // 执行请求 201 | resp, err := c.httpClient.Do(req) 202 | if err != nil { 203 | return nil, err 204 | } 205 | defer resp.Body.Close() 206 | 207 | if resp.StatusCode != http.StatusOK { 208 | return nil, fmt.Errorf("http status code: %d", resp.StatusCode) 209 | } 210 | 211 | // 从Cookie中获取JSESSIONID 212 | var jsessionID string 213 | for _, cookie := range resp.Cookies() { 214 | if cookie.Name == "JSESSIONID" { 215 | jsessionID = cookie.Value 216 | break 217 | } 218 | } 219 | if jsessionID == "" { 220 | return nil, errors.New("failed to find JSESSIONID in response") 221 | } 222 | 223 | // 解析响应内容 224 | result, err := io.ReadAll(resp.Body) 225 | if err != nil { 226 | return nil, err 227 | } 228 | regex := regexp.MustCompile("(?s)\"UserToken\" value=\"(.+?)\".+?\"stbid\" value=\"(.*?)\"") 229 | matches := regex.FindSubmatch(result) 230 | if len(matches) != 3 { 231 | return nil, errors.New("failed to parse userToken") 232 | } 233 | return &Token{ 234 | UserToken: string(matches[1]), 235 | Stbid: string(matches[2]), 236 | JSESSIONID: jsessionID, 237 | }, nil 238 | } 239 | 240 | // generate8DigitNumber 生成随机8位数字 241 | func (c *Client) generate8DigitNumber() int { 242 | // 设置随机数种子 243 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 244 | // 生成一个8位数字 (范围:10000000 - 99999999) 245 | number := r.Intn(90000000) + 10000000 246 | 247 | return number 248 | } 249 | 250 | // getInterfaceIPv4Addr 获取指定网络接口的IPv4地址 251 | func (c *Client) getInterfaceIPv4Addr(interfaceName string) (string, error) { 252 | iface, err := net.InterfaceByName(interfaceName) 253 | if err != nil { 254 | return "", err 255 | } 256 | 257 | // 获取网络接口的所有地址 258 | addrs, err := iface.Addrs() 259 | if err != nil { 260 | return "", err 261 | } 262 | 263 | var ipv4Addr string 264 | // 遍历所有地址 265 | for _, addr := range addrs { 266 | // 检查地址类型是否是IPv4 267 | if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil { 268 | ipv4Addr = ipnet.IP.String() 269 | // 输出IPv4地址 270 | c.logger.Sugar().Infof("Use network interface %s, IPv4 address: %s", iface.Name, ipv4Addr) 271 | break 272 | } 273 | } 274 | 275 | if ipv4Addr == "" { 276 | return "", errors.New("address of the specified interface could not found") 277 | } 278 | return ipv4Addr, nil 279 | } 280 | -------------------------------------------------------------------------------- /internal/app/iptv/hwctc/epg_vsp.go: -------------------------------------------------------------------------------- 1 | package hwctc 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "iptv/internal/app/iptv" 10 | "net/http" 11 | "slices" 12 | "strconv" 13 | "time" 14 | ) 15 | 16 | type vspQueryChannel struct { 17 | ChannelIDs []int64 `json:"channelIDs"` 18 | } 19 | 20 | type vspQueryPlaybill struct { 21 | Type string `json:"type"` 22 | StartTime string `json:"startTime"` 23 | EndTime string `json:"endTime"` 24 | Count string `json:"count"` 25 | Offset string `json:"offset"` 26 | IsFillProgram string `json:"isFillProgram"` 27 | MustIncluded string `json:"mustIncluded"` 28 | } 29 | 30 | // vspQueryPayload 请求体 31 | type vspQueryPayload struct { 32 | QueryChannel *vspQueryChannel `json:"queryChannel"` 33 | QueryPlaybill *vspQueryPlaybill `json:"queryPlaybill"` 34 | NeedChannel string `json:"needChannel"` 35 | } 36 | 37 | type vspResponseResult struct { 38 | RetMsg string `json:"retMsg"` 39 | RetCode string `json:"retCode"` 40 | } 41 | 42 | type vspResponsePlaybillLiteRating struct { 43 | Name string `json:"name"` 44 | ID string `json:"ID"` 45 | } 46 | 47 | type vspResponsePlaybillLite struct { 48 | Rating *vspResponsePlaybillLiteRating `json:"rating"` 49 | IsNPVR string `json:"isNPVR"` 50 | StartTime string `json:"startTime"` 51 | ID string `json:"ID"` 52 | ChannelID string `json:"channelID"` 53 | CUTVStatus string `json:"CUTVStatus"` 54 | IsFillProgram string `json:"isFillProgram"` 55 | IsCPVR string `json:"isCPVR"` 56 | Name string `json:"name"` 57 | ReminderStatus string `json:"reminderStatus"` 58 | EndTime string `json:"endTime"` 59 | IsCUTV string `json:"isCUTV"` 60 | } 61 | 62 | type vspResponseChannelPlaybills struct { 63 | PlaybillCount string `json:"playbillCount"` 64 | PlaybillLites []vspResponsePlaybillLite `json:"playbillLites"` 65 | } 66 | 67 | // vspResponse 响应体 68 | type vspResponse struct { 69 | Result *vspResponseResult `json:"result"` 70 | Total string `json:"total"` 71 | ChannelPlaybills []vspResponseChannelPlaybills `json:"channelPlaybills"` 72 | } 73 | 74 | // getVspChannelProgramList 获取指定频道的节目单列表(hb) 75 | func (c *Client) getVspChannelProgramList(ctx context.Context, token *Token, channel *iptv.Channel) (*iptv.ChannelProgramList, error) { 76 | // 获取未来一天的日期 77 | tomorrow := time.Now().AddDate(0, 0, 1) 78 | tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, tomorrow.Location()) 79 | 80 | // 根据当前频道的时移范围,预估EPG的查询时间范围(加上未来一天) 81 | epgBackDay := int(channel.TimeShiftLength.Hours()/24) + 1 82 | // 限制EPG查询的最大时间范围 83 | if epgBackDay > maxBackDay { 84 | epgBackDay = maxBackDay 85 | } 86 | 87 | // 从未来一天开始往前,倒查多个日期的节目单 88 | dateProgramList := make([]iptv.DateProgram, 0, epgBackDay+1) 89 | for i := 0; i <= epgBackDay; i++ { 90 | // 获取起止时间 91 | startDate := tomorrow.AddDate(0, 0, -i) 92 | endDate := startDate.AddDate(0, 0, 1) 93 | 94 | // 获取指定日期的节目单列表 95 | programList, err := c.getVspChannelDateProgram(ctx, token, channel.ChannelID, startDate.UnixMilli(), endDate.UnixMilli(), 0) 96 | if err != nil { 97 | if errors.Is(err, ErrEPGApiNotFound) { 98 | return nil, err 99 | } 100 | c.logger.Sugar().Warnf("Failed to get the program list for channel %s on %s. Error: %v", channel.ChannelName, startDate.Format("20060102"), err) 101 | continue 102 | } 103 | 104 | dateProgramList = append(dateProgramList, iptv.DateProgram{ 105 | Date: startDate, 106 | ProgramList: programList, 107 | }) 108 | } 109 | 110 | return &iptv.ChannelProgramList{ 111 | ChannelId: channel.ChannelID, 112 | ChannelName: channel.ChannelName, 113 | DateProgramList: dateProgramList, 114 | }, nil 115 | } 116 | 117 | // getVspChannelDateProgram 获取指定频道的某日期的节目单列表 118 | func (c *Client) getVspChannelDateProgram(ctx context.Context, token *Token, channelId string, startTime, endTime int64, offset int) ([]iptv.Program, error) { 119 | channelIdInt, err := strconv.ParseInt(channelId, 10, 64) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | // 创建请求体 125 | payload := &vspQueryPayload{ 126 | QueryChannel: &vspQueryChannel{ 127 | ChannelIDs: []int64{channelIdInt}, 128 | }, 129 | QueryPlaybill: &vspQueryPlaybill{ 130 | Type: "0", 131 | StartTime: strconv.FormatInt(startTime, 10), 132 | EndTime: strconv.FormatInt(endTime, 10), 133 | Count: "100", 134 | Offset: strconv.Itoa(offset), 135 | IsFillProgram: "0", 136 | MustIncluded: "0", 137 | }, 138 | NeedChannel: "0", 139 | } 140 | // 创建请求体bytes 141 | payloadBytes, err := json.Marshal(payload) 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | // 创建请求 147 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, 148 | fmt.Sprintf("http://%s/VSP/V3/QueryPlaybillList", c.host), bytes.NewReader(payloadBytes)) 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | // 设置请求头 154 | c.setCommonHeaders(req) 155 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") 156 | req.Header.Set("X-Requested-With", "XMLHttpRequest") 157 | 158 | // 设置Cookie 159 | req.AddCookie(&http.Cookie{ 160 | Name: "JSESSIONID", 161 | Value: token.JSESSIONID, 162 | }) 163 | 164 | // 执行请求 165 | resp, err := c.httpClient.Do(req) 166 | if err != nil { 167 | return nil, err 168 | } 169 | defer resp.Body.Close() 170 | 171 | if resp.StatusCode == http.StatusNotFound || resp.StatusCode >= http.StatusInternalServerError { 172 | return nil, ErrEPGApiNotFound 173 | } else if resp.StatusCode != http.StatusOK { 174 | return nil, fmt.Errorf("http status code: %d", resp.StatusCode) 175 | } 176 | 177 | // 解析响应内容 178 | var response vspResponse 179 | if err = json.NewDecoder(resp.Body).Decode(&response); err != nil { 180 | return nil, fmt.Errorf("parse response failed: %w", err) 181 | } else if response.Result == nil || response.Result.RetCode != "000000000" || len(response.ChannelPlaybills) == 0 { 182 | // 调用失败 183 | return nil, fmt.Errorf("the API returned failed, response: %+v", response) 184 | } 185 | 186 | // 解析节目单 187 | channelPlaybills := response.ChannelPlaybills[0] 188 | programList, err := parseVspChannelDateProgram(channelPlaybills.PlaybillLites) 189 | if err != nil { 190 | return nil, err 191 | } 192 | 193 | // 若有分页则进行递归调用 194 | count, err := strconv.Atoi(channelPlaybills.PlaybillCount) 195 | if err != nil { 196 | return nil, err 197 | } 198 | if count > (offset + 100) { 199 | nextProgramList, err := c.getVspChannelDateProgram(ctx, token, channelId, startTime, endTime, offset+100) 200 | if err != nil { 201 | return nil, err 202 | } 203 | programList = slices.Concat(programList, nextProgramList) 204 | } 205 | 206 | return programList, nil 207 | } 208 | 209 | // parseVspChannelDateProgram 解析频道节目单列表 210 | func parseVspChannelDateProgram(playbillLites []vspResponsePlaybillLite) ([]iptv.Program, error) { 211 | if len(playbillLites) == 0 { 212 | return nil, ErrChProgListIsEmpty 213 | } 214 | 215 | // 遍历单个日期中的节目单 216 | programList := make([]iptv.Program, 0, len(playbillLites)) 217 | for _, playbillLite := range playbillLites { 218 | startTimeInt, err := strconv.ParseInt(playbillLite.StartTime, 10, 64) 219 | if err != nil { 220 | return nil, err 221 | } 222 | endTimeInt, err := strconv.ParseInt(playbillLite.EndTime, 10, 64) 223 | if err != nil { 224 | return nil, err 225 | } 226 | 227 | // 时间戳转换 228 | bTime := time.UnixMilli(startTimeInt) 229 | eTime := time.UnixMilli(endTimeInt) 230 | 231 | // 临界值特殊处理 232 | endTimeStr := eTime.Format("15:04") 233 | if endTimeStr == "00:00" { 234 | endTimeStr = "23:59" 235 | } 236 | 237 | programList = append(programList, iptv.Program{ 238 | ProgramName: playbillLite.Name, 239 | BeginTimeFormat: bTime.Format("20060102150405"), 240 | EndTimeFormat: eTime.Format("20060102150405"), 241 | StartTime: bTime.Format("15:04"), 242 | EndTime: endTimeStr, 243 | }) 244 | } 245 | return programList, nil 246 | } 247 | -------------------------------------------------------------------------------- /internal/app/router/epg_controller.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "compress/gzip" 5 | "context" 6 | "encoding/xml" 7 | "errors" 8 | "fmt" 9 | "iptv/internal/app/iptv" 10 | "net/http" 11 | "strconv" 12 | "sync/atomic" 13 | "time" 14 | 15 | "github.com/gin-gonic/gin" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | const ( 20 | xmltvGenInfoName = "iptv-tool" 21 | xmltvGenInfoUrl = "https://github.com/super321/iptv-tool" 22 | 23 | xmltvGzipFilename = "epg.xml.gz" 24 | ) 25 | 26 | var ( 27 | // 缓存最新的节目单数据 28 | epgPtr atomic.Pointer[[]iptv.ChannelProgramList] 29 | ) 30 | 31 | // ChannelDateJsonEPG 频道的JSON格式EPG 32 | type ChannelDateJsonEPG struct { 33 | ChannelName string `json:"channel_name"` 34 | Date string `json:"date"` 35 | EPGData []JsonEPG `json:"epg_data"` 36 | } 37 | 38 | // JsonEPG JSON格式EPG 39 | type JsonEPG struct { 40 | Title string `json:"title"` // 标题 41 | Desc string `json:"desc"` // 描述 42 | Start string `json:"start"` // 开始时间 43 | End string `json:"end"` // 结束时间 44 | } 45 | 46 | // GetJsonEPG 获取JSON格式的EPG 47 | func GetJsonEPG(c *gin.Context) { 48 | // 获取频道名称 49 | chName := c.Query("ch") 50 | // 获取日期 51 | dateStr := c.DefaultQuery("date", time.Now().Format("2006-01-02")) 52 | 53 | // 校验频道名称是否为空 54 | if chName == "" { 55 | logger.Warn("The name of the channel is null.") 56 | // 返回响应 57 | c.Status(http.StatusBadRequest) 58 | return 59 | } 60 | 61 | // 解析日期 62 | date, err := time.ParseInLocation("2006-01-02", dateStr, time.Local) 63 | if err != nil { 64 | logger.Error("Date format error", zap.Error(err)) 65 | c.Status(http.StatusBadRequest) 66 | return 67 | } 68 | 69 | // 空响应 70 | emptyResp := ChannelDateJsonEPG{ 71 | ChannelName: chName, 72 | Date: dateStr, 73 | EPGData: []JsonEPG{}, 74 | } 75 | 76 | // 如果缓存的节目单列表为空则直接返回空数据 77 | chProgLists := *epgPtr.Load() 78 | if len(chProgLists) == 0 { 79 | c.PureJSON(http.StatusOK, &emptyResp) 80 | return 81 | } 82 | 83 | // 根据频道名称查询到该频道所有日期的节目单列表 84 | var tagerChProgList *iptv.ChannelProgramList 85 | for _, chProgList := range chProgLists { 86 | if chProgList.ChannelName == chName { 87 | tagerChProgList = &chProgList 88 | break 89 | } 90 | } 91 | if tagerChProgList == nil || len(tagerChProgList.DateProgramList) == 0 { 92 | c.PureJSON(http.StatusOK, &emptyResp) 93 | return 94 | } 95 | 96 | // 查询该频道指定日期的节目单列表 97 | dateEPGData := make([]JsonEPG, 0) 98 | for _, dateProgList := range tagerChProgList.DateProgramList { 99 | if dateProgList.Date.Equal(date) { 100 | if len(dateProgList.ProgramList) > 0 { 101 | for _, program := range dateProgList.ProgramList { 102 | dateEPGData = append(dateEPGData, JsonEPG{ 103 | Title: program.ProgramName, 104 | Start: program.StartTime, 105 | End: program.EndTime, 106 | }) 107 | } 108 | } 109 | break 110 | } 111 | } 112 | 113 | // 返回最终响应 114 | c.PureJSON(http.StatusOK, &ChannelDateJsonEPG{ 115 | ChannelName: chName, 116 | Date: dateStr, 117 | EPGData: dateEPGData, 118 | }) 119 | } 120 | 121 | // XmlEPG XMLTV格式的EPG 122 | type XmlEPG struct { 123 | XMLName xml.Name `xml:"tv"` 124 | SourceInfoUrl string `xml:"source-info-url,attr,omitempty"` 125 | SourceInfoName string `xml:"source-info-name,attr,omitempty"` 126 | SourceDataUrl string `xml:"source-data-url,attr,omitempty"` 127 | GeneratorInfoName string `xml:"generator-info-name,attr,omitempty"` 128 | GeneratorInfoUrl string `xml:"generator-info-url,attr,omitempty"` 129 | Channels []XmlEPGChannel `xml:"channel,omitempty"` 130 | Programmes []XmlEPGProgramme `xml:"programme,omitempty"` 131 | } 132 | 133 | type XmlEPGChannel struct { 134 | Id string `xml:"id,attr"` 135 | DisplayName *XmlEPGDisplay `xml:"display-name"` 136 | } 137 | 138 | type XmlEPGProgramme struct { 139 | Start string `xml:"start,attr"` 140 | Stop string `xml:"stop,attr"` 141 | Channel string `xml:"channel,attr"` 142 | Title *XmlEPGDisplay `xml:"title"` 143 | Desc *XmlEPGDisplay `xml:"desc,omitempty"` 144 | } 145 | 146 | type XmlEPGDisplay struct { 147 | Lang string `xml:"lang,attr"` 148 | Value string `xml:",chardata"` 149 | } 150 | 151 | // GetXmlEPG 返回XMLTV格式的EPG 152 | func GetXmlEPG(c *gin.Context) { 153 | var err error 154 | 155 | // 保留过去几天的节目单 156 | backDay := 0 157 | backDayStr := c.Query("backDay") 158 | if backDayStr != "" { 159 | if backDay, err = strconv.Atoi(backDayStr); err != nil { 160 | backDay = 0 161 | } 162 | } 163 | 164 | // 如果缓存的节目单列表为空则直接返回空数据 165 | chProgLists := *epgPtr.Load() 166 | if len(chProgLists) == 0 { 167 | c.XML(http.StatusOK, &XmlEPG{ 168 | GeneratorInfoName: xmltvGenInfoName, 169 | GeneratorInfoUrl: xmltvGenInfoUrl, 170 | }) 171 | return 172 | } 173 | 174 | xmlEPG := getXmlEPG(chProgLists, backDay) 175 | 176 | c.XML(http.StatusOK, xmlEPG) 177 | } 178 | 179 | func GetXmlEPGWithGzip(c *gin.Context) { 180 | var err error 181 | 182 | // 保留过去几天的节目单 183 | backDay := 0 184 | backDayStr := c.Query("backDay") 185 | if backDayStr != "" { 186 | if backDay, err = strconv.Atoi(backDayStr); err != nil { 187 | backDay = 0 188 | } 189 | } 190 | 191 | var xmlEPG *XmlEPG 192 | // 如果缓存的节目单列表为空则直接返回空数据 193 | chProgLists := *epgPtr.Load() 194 | if len(chProgLists) == 0 { 195 | xmlEPG = &XmlEPG{ 196 | GeneratorInfoName: xmltvGenInfoName, 197 | GeneratorInfoUrl: xmltvGenInfoUrl, 198 | } 199 | } else { 200 | xmlEPG = getXmlEPG(chProgLists, backDay) 201 | } 202 | 203 | // 将结构体数据转换为XML,并进行格式化 204 | xmlData, err := xml.MarshalIndent(xmlEPG, "", " ") 205 | if err != nil { 206 | logger.Error("Failed to marshal xml.", zap.Error(err)) 207 | c.Status(http.StatusInternalServerError) 208 | return 209 | } 210 | 211 | // 设置HTTP头,通知浏览器这是一个二进制流文件 212 | c.Header("Transfer-Encoding", "gzip") // 说明文件是gzip压缩格式 213 | c.Header("Content-Type", "application/octet-stream") // 说明是二进制文件 214 | c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", xmltvGzipFilename)) // 指定下载文件名 215 | 216 | // 创建一个gzip压缩的Writer,并将XML数据写入其中 217 | gzipWriter := gzip.NewWriter(c.Writer) 218 | defer gzipWriter.Close() 219 | 220 | // 写入xml头 221 | if _, err = gzipWriter.Write([]byte("\n")); err != nil { 222 | logger.Error("Failed to write xml header.", zap.Error(err)) 223 | c.Status(http.StatusInternalServerError) 224 | return 225 | } 226 | 227 | // 写入xml内容 228 | if _, err = gzipWriter.Write(xmlData); err != nil { 229 | logger.Error("Failed to write xml data.", zap.Error(err)) 230 | c.Status(http.StatusInternalServerError) 231 | return 232 | } 233 | } 234 | 235 | // getXmlEPG 将频道节目单转为xmltv格式 236 | func getXmlEPG(chProgLists []iptv.ChannelProgramList, backDay int) *XmlEPG { 237 | backTime := time.Now().AddDate(0, 0, -backDay) 238 | backTime = time.Date(backTime.Year(), backTime.Month(), backTime.Day(), 0, 0, 0, 0, backTime.Location()) 239 | 240 | channels := make([]XmlEPGChannel, 0, len(chProgLists)) 241 | programmes := make([]XmlEPGProgramme, 0) 242 | for _, chProgList := range chProgLists { 243 | // 获取频道的相关信息 244 | channels = append(channels, XmlEPGChannel{ 245 | Id: chProgList.ChannelId, 246 | DisplayName: &XmlEPGDisplay{ 247 | Lang: "zh", 248 | Value: chProgList.ChannelName, 249 | }, 250 | }) 251 | 252 | if len(chProgList.DateProgramList) == 0 { 253 | continue 254 | } 255 | 256 | for _, dateProgList := range chProgList.DateProgramList { 257 | if len(dateProgList.ProgramList) == 0 || 258 | (backDay > 0 && !backTime.Before(dateProgList.Date)) { 259 | continue 260 | } 261 | for _, program := range dateProgList.ProgramList { 262 | // 获取节目的相关信息 263 | programmes = append(programmes, XmlEPGProgramme{ 264 | Start: program.BeginTimeFormat + " +0800", 265 | Stop: program.EndTimeFormat + " +0800", 266 | Channel: chProgList.ChannelId, 267 | Title: &XmlEPGDisplay{ 268 | Lang: "zh", 269 | Value: program.ProgramName, 270 | }, 271 | }) 272 | } 273 | } 274 | } 275 | 276 | return &XmlEPG{ 277 | GeneratorInfoName: xmltvGenInfoName, 278 | GeneratorInfoUrl: xmltvGenInfoUrl, 279 | Channels: channels, 280 | Programmes: programmes, 281 | } 282 | } 283 | 284 | // updateEPG 更新缓存的节目单数据 285 | func updateEPG(ctx context.Context, iptvClient iptv.Client) error { 286 | // 获取缓存的所有频道列表 287 | channels := *channelsPtr.Load() 288 | if len(channels) == 0 { 289 | return errors.New("no channels") 290 | } 291 | 292 | // 获取所有频道的节目单列表 293 | allChProgramList, err := iptvClient.GetAllChannelProgramList(ctx, channels) 294 | if err != nil { 295 | return err 296 | } 297 | 298 | logger.Sugar().Infof("EPG data updated, total: %d.", len(allChProgramList)) 299 | // 更新缓存的频道列表 300 | epgPtr.Store(&allChProgramList) 301 | 302 | return nil 303 | } 304 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= 2 | github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= 3 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 4 | github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= 5 | github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 6 | github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= 7 | github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 8 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 9 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 10 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 14 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/forgoer/openssl v1.6.0 h1:IueL+UfH0hKo99xFPojHLlO3QzRBQqFY+Cht0WwtOC0= 16 | github.com/forgoer/openssl v1.6.0/go.mod h1:9DZ4yOsQmveP0aXC/BpQ++Y5TKaz5yR9+emcxmIZNZs= 17 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 18 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 19 | github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= 20 | github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= 21 | github.com/gin-contrib/zap v1.1.5 h1:qKwhWb4DQgPriCl1AHLLob6hav/KUIctKXIjTmWIN3I= 22 | github.com/gin-contrib/zap v1.1.5/go.mod h1:lAchUtGz9M2K6xDr1rwtczyDrThmSx6c9F384T45iOE= 23 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 24 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 25 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 26 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 27 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 28 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 29 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 30 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 31 | github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= 32 | github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 33 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 34 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 35 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 36 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 37 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 38 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 39 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 40 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 41 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 42 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 43 | github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= 44 | github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 45 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 46 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 47 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 48 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 49 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 50 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 51 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 52 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 53 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 54 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 55 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 56 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 57 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 58 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 59 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 60 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 61 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 62 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 63 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 64 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 65 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 66 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 67 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 68 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 69 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 70 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 71 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 72 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 73 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 74 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 75 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 76 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 77 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 78 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 79 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 80 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 81 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 82 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 83 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 84 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 85 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 86 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 87 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 88 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 89 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 90 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 91 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 92 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 93 | golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U= 94 | golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= 95 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 96 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 97 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 98 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 99 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 100 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 101 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 102 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 103 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 104 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 105 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 106 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 107 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 108 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 109 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= 110 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 111 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 112 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 113 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 114 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 115 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 116 | -------------------------------------------------------------------------------- /internal/app/iptv/hwctc/epg_stbepg2023group.go: -------------------------------------------------------------------------------- 1 | package hwctc 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "iptv/internal/app/iptv" 8 | "iptv/internal/pkg/util" 9 | "net/http" 10 | "net/url" 11 | "slices" 12 | "strconv" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | type stbEpg2023GroupResponse[T any] struct { 18 | Data T `json:"data"` 19 | ErrCode string `json:"errCode"` 20 | ErrMsg string `json:"errMsg"` 21 | Status string `json:"status"` 22 | } 23 | 24 | type stbEpg2023GroupCategory struct { 25 | Name string `json:"name"` 26 | ID string `json:"id"` 27 | } 28 | 29 | type stbEpg2023GroupChannel struct { 30 | AuthCode string `json:"authCode"` 31 | Code string `json:"code"` 32 | Name string `json:"name"` 33 | IsCharge string `json:"isCharge"` 34 | ID string `json:"ID"` 35 | MixNo string `json:"mixNo"` 36 | MediaID string `json:"mediaID"` 37 | } 38 | 39 | type stbEpg2023GroupChannelProg struct { 40 | Name string `json:"name"` 41 | StartTime int64 `json:"startTime"` 42 | ID string `json:"ID"` 43 | EndTime int64 `json:"endTime"` 44 | ChannelID string `json:"channelID"` 45 | Status string `json:"status"` 46 | } 47 | 48 | // getStbEpg2023GroupAllChannelProgramList 获取全部频道的节目单列表(fj) 49 | func (c *Client) getStbEpg2023GroupAllChannelProgramList(ctx context.Context, channels []iptv.Channel, token *Token) ([]iptv.ChannelProgramList, error) { 50 | // 获取“全部”类别的ID 51 | categoryID, err := c.getStbEpg2023GroupChannelCategoryID(ctx, "全部", token) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | // 获取所有频道列表的code 57 | stbEpg2023GrouChList, err := c.getStbEpg2023GroupChannelList(ctx, categoryID, token) 58 | if err != nil { 59 | return nil, err 60 | } 61 | // 获取频道ID和频道Code的映射关系 62 | chIdCodeMap := make(map[string]string, len(stbEpg2023GrouChList)) 63 | for _, stbEpg2023GrouCh := range stbEpg2023GrouChList { 64 | chIdCodeMap[stbEpg2023GrouCh.ID] = stbEpg2023GrouCh.Code 65 | } 66 | 67 | epg := make([]iptv.ChannelProgramList, 0, len(channels)) 68 | for _, channel := range channels { 69 | // 跳过不支持回看的频道 70 | if channel.TimeShift != "1" || channel.TimeShiftLength <= 0 { 71 | continue 72 | } 73 | 74 | chCode, ok := chIdCodeMap[channel.ChannelID] 75 | if !ok { 76 | c.logger.Sugar().Warnf("Failed to get the code for channel %s.", channel.ChannelName) 77 | continue 78 | } 79 | 80 | // 获取单个频道的全部节目单列表 81 | progList, err := c.getStbEpg2023GroupChannelProgramList(ctx, token, &channel, chCode) 82 | if err != nil { 83 | c.logger.Sugar().Warnf("Failed to get the program list for channel %s. Error: %v", channel.ChannelName, err) 84 | continue 85 | } 86 | 87 | if progList != nil && len(progList.DateProgramList) > 0 { 88 | // 对频道的节目单按日期升序排序 89 | slices.SortFunc(progList.DateProgramList, func(a, b iptv.DateProgram) int { 90 | return a.Date.Compare(b.Date) 91 | }) 92 | 93 | epg = append(epg, *progList) 94 | } 95 | } 96 | return epg, nil 97 | } 98 | 99 | // getChannelCate 获取指定频道类别的ID 100 | func (c *Client) getStbEpg2023GroupChannelCategoryID(ctx context.Context, categoryName string, token *Token) (string, error) { 101 | // 组装请求数据 102 | data := map[string]string{ 103 | "action": "getChannelCate", 104 | } 105 | body := url.Values{} 106 | for k, v := range data { 107 | body.Add(k, v) 108 | } 109 | 110 | // 创建请求 111 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, 112 | fmt.Sprintf("http://%s/EPG/jsp/StbEpg2023Group/en/function/ajax/epg7getProperties.jsp", c.host), strings.NewReader(body.Encode())) 113 | if err != nil { 114 | return "", err 115 | } 116 | 117 | // 设置请求头 118 | c.setCommonHeaders(req) 119 | req.Header.Set("VIS-AJAX", "AjaxHttpRequest") 120 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 121 | 122 | // 设置Cookie 123 | req.AddCookie(&http.Cookie{ 124 | Name: "JSESSIONID", 125 | Value: token.JSESSIONID, 126 | }) 127 | 128 | // 执行请求 129 | resp, err := c.httpClient.Do(req) 130 | if err != nil { 131 | return "", err 132 | } 133 | defer resp.Body.Close() 134 | 135 | if resp.StatusCode == http.StatusNotFound || resp.StatusCode >= http.StatusInternalServerError { 136 | return "", ErrEPGApiNotFound 137 | } else if resp.StatusCode != http.StatusOK { 138 | return "", fmt.Errorf("http status code: %d", resp.StatusCode) 139 | } 140 | 141 | // 解析响应内容 142 | var response stbEpg2023GroupResponse[[]stbEpg2023GroupCategory] 143 | if err = json.NewDecoder(resp.Body).Decode(&response); err != nil { 144 | return "", fmt.Errorf("parse response failed: %w", err) 145 | } else if response.Status != "1" { 146 | // 调用失败 147 | return "", fmt.Errorf("the API returned failed, errMsg: %s", response.ErrMsg) 148 | } else if len(response.Data) == 0 { 149 | // 未获取到频道分类信息 150 | return "", fmt.Errorf("no channel categories") 151 | } 152 | 153 | var categoryID string 154 | for _, category := range response.Data { 155 | if categoryName == category.Name { 156 | categoryID = category.ID 157 | break 158 | } 159 | } 160 | if categoryID == "" { 161 | return "", fmt.Errorf("channel category not found") 162 | } 163 | return categoryID, nil 164 | } 165 | 166 | // getStbEpg2023GroupChannelList 获取指定频道类别的频道列表 167 | func (c *Client) getStbEpg2023GroupChannelList(ctx context.Context, categoryID string, token *Token) ([]stbEpg2023GroupChannel, error) { 168 | // 组装请求数据 169 | data := map[string]string{ 170 | "action": "getChannelList", 171 | "cateID": categoryID, 172 | "type": "", 173 | } 174 | body := url.Values{} 175 | for k, v := range data { 176 | body.Add(k, v) 177 | } 178 | 179 | // 创建请求 180 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, 181 | fmt.Sprintf("http://%s/EPG/jsp/StbEpg2023Group/en/function/ajax/epg7getChannelByAjax.jsp", c.host), strings.NewReader(body.Encode())) 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | // 设置请求头 187 | c.setCommonHeaders(req) 188 | req.Header.Set("VIS-AJAX", "AjaxHttpRequest") 189 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 190 | 191 | // 设置Cookie 192 | req.AddCookie(&http.Cookie{ 193 | Name: "JSESSIONID", 194 | Value: token.JSESSIONID, 195 | }) 196 | 197 | // 执行请求 198 | resp, err := c.httpClient.Do(req) 199 | if err != nil { 200 | return nil, err 201 | } 202 | defer resp.Body.Close() 203 | 204 | if resp.StatusCode != http.StatusOK { 205 | return nil, fmt.Errorf("http status code: %d", resp.StatusCode) 206 | } 207 | 208 | // 解析响应内容 209 | var response stbEpg2023GroupResponse[[]stbEpg2023GroupChannel] 210 | if err = json.NewDecoder(resp.Body).Decode(&response); err != nil { 211 | return nil, fmt.Errorf("parse response failed: %w", err) 212 | } else if response.Status != "1" { 213 | // 调用失败 214 | return nil, fmt.Errorf("the API returned failed, errMsg: %s", response.ErrMsg) 215 | } else if len(response.Data) == 0 { 216 | // 未找到频道列表 217 | return nil, fmt.Errorf("no channel list") 218 | } 219 | 220 | return response.Data, nil 221 | } 222 | 223 | // getStbEpg2023GroupChannelProgramList 获取指定频道的节目单列表 224 | func (c *Client) getStbEpg2023GroupChannelProgramList(ctx context.Context, token *Token, channel *iptv.Channel, chCode string) (*iptv.ChannelProgramList, error) { 225 | // 根据当前频道的时移范围,预估EPG的查询时间范围(加上未来一天) 226 | epgBackDay := int(channel.TimeShiftLength.Hours()/24) + 1 227 | // 限制EPG查询的最大时间范围 228 | if epgBackDay > maxBackDay { 229 | epgBackDay = maxBackDay 230 | } 231 | 232 | // 计算开始、结束时间 233 | tomorrow := time.Now().AddDate(0, 0, 1) 234 | old := tomorrow.AddDate(0, 0, -epgBackDay) 235 | startTime := time.Date(old.Year(), old.Month(), old.Day(), 0, 0, 1, 534, tomorrow.Location()).UnixMilli() 236 | endTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 23, 59, 59, 534, tomorrow.Location()).UnixMilli() 237 | 238 | // 组装请求数据 239 | data := map[string]string{ 240 | "action": "getChannelProg", 241 | "code": chCode, 242 | "channelID": channel.ChannelID, 243 | "endTime": strconv.FormatInt(endTime, 10), 244 | "startTime": strconv.FormatInt(startTime, 10), 245 | "offset": "0", 246 | "limit": "2000", 247 | } 248 | body := url.Values{} 249 | for k, v := range data { 250 | body.Add(k, v) 251 | } 252 | 253 | // 创建请求 254 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, 255 | fmt.Sprintf("http://%s/EPG/jsp/StbEpg2023Group/en/function/ajax/epg7getChannelByAjax.jsp", c.host), strings.NewReader(body.Encode())) 256 | if err != nil { 257 | return nil, err 258 | } 259 | 260 | // 设置请求头 261 | c.setCommonHeaders(req) 262 | req.Header.Set("VIS-AJAX", "AjaxHttpRequest") 263 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 264 | 265 | // 设置Cookie 266 | req.AddCookie(&http.Cookie{ 267 | Name: "JSESSIONID", 268 | Value: token.JSESSIONID, 269 | }) 270 | 271 | // 执行请求 272 | resp, err := c.httpClient.Do(req) 273 | if err != nil { 274 | return nil, err 275 | } 276 | defer resp.Body.Close() 277 | 278 | if resp.StatusCode != http.StatusOK { 279 | return nil, fmt.Errorf("http status code: %d", resp.StatusCode) 280 | } 281 | 282 | // 解析响应内容 283 | var response stbEpg2023GroupResponse[[]stbEpg2023GroupChannelProg] 284 | if err = json.NewDecoder(resp.Body).Decode(&response); err != nil { 285 | return nil, fmt.Errorf("parse response failed: %w", err) 286 | } else if response.Status != "1" { 287 | // 调用失败 288 | return nil, fmt.Errorf("the API returned failed, errMsg: %s", response.ErrMsg) 289 | } 290 | 291 | // 解析节目单 292 | dateProgramList, err := parseStbEpg2023GroupDateProgramList(response.Data) 293 | if err != nil { 294 | return nil, err 295 | } 296 | 297 | return &iptv.ChannelProgramList{ 298 | ChannelId: channel.ChannelID, 299 | ChannelName: channel.ChannelName, 300 | DateProgramList: dateProgramList, 301 | }, nil 302 | } 303 | 304 | // parseStbEpg2023GroupDateProgramList 解析频道节目单列表 305 | func parseStbEpg2023GroupDateProgramList(channelProgList []stbEpg2023GroupChannelProg) ([]iptv.DateProgram, error) { 306 | if len(channelProgList) == 0 { 307 | return nil, ErrChProgListIsEmpty 308 | } 309 | 310 | // 遍历频道节目单列表 311 | progMap := make(map[string][]iptv.Program) 312 | for _, channelProg := range channelProgList { 313 | // 时间戳转换 314 | bTime := time.UnixMilli(channelProg.StartTime) 315 | eTime := time.UnixMilli(channelProg.EndTime) 316 | 317 | // 临界值特殊处理 318 | endTimeStr := eTime.Format("15:04") 319 | if endTimeStr == "00:00" { 320 | endTimeStr = "23:59" 321 | } 322 | 323 | dateStr := bTime.Format("20060102") 324 | programList, ok := progMap[dateStr] 325 | if !ok { 326 | programList = make([]iptv.Program, 0) 327 | } 328 | programList = append(programList, iptv.Program{ 329 | ProgramName: channelProg.Name, 330 | BeginTimeFormat: bTime.Format("20060102150405"), 331 | EndTimeFormat: eTime.Format("20060102150405"), 332 | StartTime: bTime.Format("15:04"), 333 | EndTime: endTimeStr, 334 | }) 335 | progMap[dateStr] = programList 336 | } 337 | 338 | // 组装结果 339 | dateProgramList := make([]iptv.DateProgram, 0) 340 | for _, dateStr := range util.SortedMapKeys(progMap) { 341 | programList := progMap[dateStr] 342 | 343 | date, err := time.ParseInLocation("20060102", dateStr, time.Local) 344 | if err != nil { 345 | return nil, err 346 | } 347 | dateProgramList = append(dateProgramList, iptv.DateProgram{ 348 | Date: date, 349 | ProgramList: programList, 350 | }) 351 | } 352 | return dateProgramList, nil 353 | } 354 | --------------------------------------------------------------------------------