简介:新骆驼IPTV完美版后端源码与APP源码面向安卓手机与电视盒子开发者,提供一套电视直播应用的完整参考实现,可用于理解IPTV业务链路并进行二次开发或功能定制。压缩包以zip格式分发,整体约21.72MB;上游未标注文件总数与主要文件类型明细,包内实际围绕后端服务与安卓端APP源码展开。已有4600人学习,说明其在IPTV与安卓电视开发圈具有一定参考热度。后端部分覆盖频道与播放列表接口、数据库存储、登录鉴权、流媒体分发及RESTful API设计,前端则涉及频道列表与播放控制界面、播放器集成、网络请求解析以及遥控器事件适配。读者可据此梳理前后端协作方式,学习流媒体传输协议、接口设计与数据库查询优化等要点,并对照代码搭建自己的电视直播平台原型。
1. 这套 IPTV 双端源码解决的不是「播放」,而是「可运营的骨架」
很多人第一次拿到 IPTV 后端源码 + APP 源码的压缩包,第一反应是「装上就能看电视」。真跑一遍就会发现,难点从来不在播放器能不能解码,而在后端那层:频道怎么分类、EPG 怎么对齐、多端登录怎么鉴权、切片地址怎么拼、APP 请求超时怎么兜底。这套双端源码包的价值,是把「一套可运营的骨架」提前搭好了——后端负责任务调度与接口分发,APP 端负责拉列表、拿流地址、起播放器。它适合两类人:一类是想搭自有授权频道分发环境的开发者,另一类是手上有个流媒体 APP 需求、想省掉从零写接口的时间的团队。下面按「拆包 → 跑起来 → 对接 APP → 踩坑」的顺序,把这份 iptv 源相关的工程细节摊开讲。
2. 拆包先看骨架:目录结构、接口分层与数据表怎么读
拿到压缩包别急着找安装说明,先把目录树扫一遍。这类双端包的结构通常很规整:根目录一半是后端,一半是 APP 工程,中间夹着数据库脚本和一份写得比较随意的部署注释。花十分钟把这三块的位置确认清楚,后面能省掉几小时瞎找。
2.1 解压后先看什么:目录树与启动入口
Windows 下解压中文命名的压缩包经常出现乱码,建议直接在 Linux 或 macOS 上解,指定 UTF-8 编码。解完先看两层目录,再找入口文件。
# -O UTF-8 避免中文目录名乱码;解到独立目录方便对比原始包 unzip -O UTF-8 iptv-src.zip -d iptv-src cd iptv-src # 只看两层目录,先建立整体印象,不要一上来就 find 全量 find . -maxdepth 2 -type d | sort | head -40 # 找后端入口:PHP 系看 index.php,Node 系看 app.js/server.js,Go 系看 main.go ls -la find . -maxdepth 2 -name "index.php" -o -maxdepth 2 -name "app.js" -o -maxdepth 2 -name "main.go"这段命令的输出信息量很大。如果find命中的是index.php,基本可以确定后端是 PHP 系,常见的是 ThinkPHP 或自写的单入口路由,配置会落在config/或application/下;如果命中app.js且同级有package.json,那是 Node 系,重点看routes/和models/;如果是main.go,一般配config.yaml,router目录里能直接读到所有接口路径。
紧接着确认技术栈的「指纹」,别靠猜:
# 数据库访问层指纹:mysqli/PDO 是 PHP,sequelize/typeorm 是 Node,gorm 是 Go grep -rl "mysqli\|PDO\|sequelize\|typeorm\|gorm" \ --include=*.php --include=*.js --include=*.go . 2>/dev/null | head -10 # 从依赖文件反推版本要求,决定装哪个运行时 cat composer.json 2>/dev/null | head -30 cat package.json 2>/dev/null | head -30参数就两个要点:--include限定后缀是为了避开 vendor 与 node_modules 里几万个干扰文件;head截断是因为第一个命中往往就在核心目录里。跑完之后你会得到一份「运行时 + 数据库 + 依赖版本」的清单,后面装环境严格按这个清单来,别自作主张升级大版本——这类老工程对 PHP 7.4 和 PHP 8.1 的兼容性差异能差出半天时间。
还有一类文件必须提前看:*.sql和名字里带install、update的脚本。它们决定了你建库时字段名对不对得上,以及有没有「先跑 A 再跑 B」的顺序依赖。我一般会先wc -l看一眼这些脚本的行数,几百行的通常是完整建表,几十行的多半是增量补丁。
2.2 接口分层:客户端 API、管理后台 API、流媒体代理三层
这套包的接口不是一锅粥,粗分三层,搞混了会出大问题。
| 层 | 典型路径前缀 | 调用方 | 是否鉴权 | 主要职责 |
|---|---|---|---|---|
| 客户端 API | /api/、/v1/ | APP | 是,Token | 频道列表、分类、EPG、播放地址下发 |
| 管理后台 API | /admin/、/manage/ | 浏览器 | 是,会话 | 频道增删改、用户管理、日志查看 |
| 流媒体代理 | /live/、/proxy/ | 播放器 | 视配置 | 转发上游流、改写 m3u8、透传 UA |
三层里最容易出事的是第三层。客户端 API 挂了顶多列表空白,流媒体代理挂了直接黑屏,而且黑屏最难定位——因为 APP 只会告诉你「播放失败」,真正的错误在代理层的日志里。所以排查顺序永远是:先确认客户端 API 返回的流地址本身能不能用ffprobe拉起来,再确认代理层有没有改写坏。
接口清单不用手工扒,直接静态扫:
# 从前端/APP 侧反查接口:找 http 请求封装点 grep -rn "baseUrl\|BASE_URL\|apiHost" --include=*.js --include=*.java --include=*.kt . | head -20 # 从后端侧列出所有路由注册 grep -rn "Route::\|router.get\|router.post\|app.get\|app.post" \ --include=*.php --include=*.js . 2>/dev/null | head -40两边的结果对一遍,数量对不上就说明有接口只在一边存在——这种情况在拼装包里非常常见,通常是某个版本更新时漏了同步。发现之后先记下来,别急着改,等环境跑通再逐个验证。
2.3 数据表:频道、分类、EPG、用户、授权五张核心表
数据库结构决定了这套系统能扩展到什么程度。核心就五张表,字段名各家不同但语义一致:
-- 频道表:一条记录对应一个可播入口 CREATE TABLE `channel` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL COMMENT '频道名,APP 列表展示用', `cate_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '分类ID,关联 category.id', `stream_url` VARCHAR(512) NOT NULL COMMENT '上游流地址,m3u8/flv/mp4', `logo` VARCHAR(255) DEFAULT '' COMMENT '台标,建议本地存一份', `sort` INT NOT NULL DEFAULT 0 COMMENT '排序,越小越靠前', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', PRIMARY KEY (`id`), KEY `idx_cate` (`cate_id`, `status`, `sort`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- EPG 表:节目单,靠 channel_id + 时间区间关联 CREATE TABLE `epg` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `channel_id` INT UNSIGNED NOT NULL, `title` VARCHAR(128) NOT NULL, `start_ts` INT UNSIGNED NOT NULL COMMENT 'UTC 时间戳,别存格式化字符串', `end_ts` INT UNSIGNED NOT NULL, PRIMARY KEY (`id`), KEY `idx_ch_time` (`channel_id`, `start_ts`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;说明三个设计点。第一,stream_url长度给到 512,是因为带签名的流地址会很长,给 255 迟早截断,截断之后表现就是「地址看起来对,但播放器 403」,这个坑非常隐蔽。第二,epg用整型时间戳而不是datetime,原因是 EPG 数据源基本都是 UTC,存字符串会在时区转换上反复出错,统一存时间戳、展示时再转,逻辑只有一处。第三,idx_cate这个联合索引顺序别改,APP 首屏请求就是「某个分类下,上架状态,按 sort 排序」,索引顺序对不上就变成全表扫。
用户表和授权表通常长这样:user存账号密码与到期时间,device存设备指纹与绑定关系。授权这块的字段各家差异最大,有的用expire_time单字段,有的用套餐类型 + 剩余天数双字段。看的时候重点确认一件事:过期判断是在 SQL 里做还是在业务层做。如果是业务层,找一个if (expire < now)的写法,把它记下来,后面调试「明明没过期却提示到期」时能直接定位。
3. 让后端跑起来:运行环境、配置项与第一个可播频道
环境这一步,血泪经验是「别用你机器上现成的运行时」。老工程对版本极其敏感,尤其是 PHP 的mysqlnd扩展、Node 的node-sass这类,版本差一个小号就编译不过。老老实实按第 2 章扫出来的清单装。
3.1 运行环境与依赖安装
以最常见的 PHP 后端 + MySQL 组合为例,推荐用容器隔离,别污染宿主机:
# 用官方镜像起一个和后端要求匹配的 PHP 环境,扩展按需补 docker run -d --name iptv-php \ -p 8080:80 \ -v $(pwd)/backend:/var/www/html \ -e PHP_MEMORY_LIMIT=256M \ php:7.4-apache # 进容器补扩展:mysqli 基本必有,gd 用于台标处理,curl 用于拉上游流 docker exec -it iptv-php bash -c "docker-php-ext-install mysqli gd && a2enmod rewrite" # 依赖安装,优先用 lock 文件而不是 composer install 的最新解 cd backend && composer install --no-dev --optimize-autoloader参数说三处。-v挂载目录一定要挂到backend,别挂整个包,否则 Apache 会把 APP 工程目录也暴露成静态资源。PHP_MEMORY_LIMIT=256M是给后台批量导入频道留的余量,默认 128M 时批量导入几百条就会 500。--no-dev是为了跳过测试依赖,--optimize-autoloader生成类映射,能明显降低首屏接口耗时。
Node 系后端大同小异,区别只在于要在容器里跑npm ci。有一个容易忽略的点:如果package.json里锁定的是 Node 14,而你用 Node 18 跑,某些老中间件会在启动阶段直接抛ERR_REQUIRE_ESM,报错信息完全不提版本,只说什么模块找不到。遇到这类看不懂的启动错误,先怀疑版本,而不是代码。
数据库这边建议单独起一个实例,不要和宿主机上原有的 MySQL 共用:
docker run -d --name iptv-mysql \ -p 3307:3306 \ -e MYSQL_ROOT_PASSWORD=yourpass \ -e MYSQL_DATABASE=iptv \ -e MYSQL_CHARSET=utf8mb4 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_unicode_ci \ mysql:5.7端口故意用 3307,避免和宿主机 3306 打架——这是最容易后悔的一个小决定。字符集必须显式指定 utf8mb4,原因是频道名里有各种特殊字符,utf8 三字节存不下,插入时报错的同时后台还显示「保存成功」,数据其实没进去。
3.2 配置文件逐项说明:改错一个就是 500
配置文件通常是config/database.php或.env,里面真正需要你动手的就那么几项,其余保持默认。
| 配置项 | 常见默认值 | 建议值 | 改错的后果 |
|---|---|---|---|
| db.host | 127.0.0.1 | 容器名或宿主 IP | 报连接拒绝,页面白屏 |
| db.port | 3306 | 3307(按你起的端口) | 连接超时,日志无提示 |
| db.charset | utf8 | utf8mb4 | 中文频道名乱码或插入失败 |
| app.debug | false | 先 true 后 false | 出错只给 500,看不到栈 |
| app.url | 空 | 后端公网地址 | 下发的流地址拼成相对路径,APP 拉不到 |
| cache.driver | file | file | 改成 redis 但没装扩展,启动即崩 |
最有价值的是app.url。很多包默认留空,系统会用请求头里的 Host 拼地址。本地调试时 Host 是127.0.0.1:8080,拼出来的流地址 APP 在手机上根本访问不到,现象是「列表能出来,所有频道都播不了」。把app.url显式写成局域网 IP 加端口,这一类问题立刻消失。
改完配置先别急着开浏览器,用命令行验证数据库连通性,比看页面报错快得多:
# 容器内验证数据库可达,顺便确认字符集 docker exec -it iptv-php bash -c \ "php -r '\$p=new PDO(\"mysql:host=iptv-mysql;port=3306;dbname=iptv;charset=utf8mb4\",\"root\",\"yourpass\");echo \$p->query(\"select version()\")->fetchColumn();'"能打印出版本号,说明账号、端口、字符集三件事全对,后面所有数据库问题都可以排除掉这一层。这一步跳过的人,后面会在「页面白屏」上耗掉一整个下午。
3.3 建库导入与后台首次登录
导入 SQL 的顺序不能乱。通常包里会有一个install.sql建表,外加data.sql或init.sql灌初始数据。先建表再灌数据,反过来会报字段不存在。
# 建表与初始数据分两步,便于出错时只重跑其中一步 docker exec -i iptv-mysql mysql -uroot -pyourpass iptv < backend/sql/install.sql docker exec -i iptv-mysql mysql -uroot -pyourpass iptv < backend/sql/data.sql # 确认表数量与核心表是否为空 docker exec -it iptv-mysql mysql -uroot -pyourpass iptv -e \ "show tables; select count(*) as channels from channel; select count(*) as users from user;"-i不能省,省掉之后 mysql 客户端读不到标准输入,命令会静默返回成功但一张表都没建——这是最坑的一点,因为退出码是 0。
后台地址一般是http://你的IP:8080/admin,默认账号密码写在data.sql的 user 表插入语句里,直接查一下比到处翻文档快:
docker exec -it iptv-mysql mysql -uroot -pyourpass iptv -e \ "select id,username,left(password,12) as pwd_prefix,role from user limit 5;"left(password,12)只是看一眼是不是明文存储。如果是明文,说明这份包的密码处理很原始,上线前必须换成password_hash,否则后台账号一旦泄露就是全盘皆输。这类安全债在拼装包里很常见,拿到手就该在心里标记一笔。
登录进去之后,先别急着导频道。到系统设置里把「站点地址」「流媒体前缀」两项对齐到你的实际地址,再刷新一次列表页。这两个字段没对齐,后面所有流地址都是错的,而错在哪里不会有任何提示。
3.4 加一个频道并验证完整播放链路
只加一条频道,验证全链路通不通,再批量导。这一步的顺序是:后台加频道 → 接口取列表 → 取流地址 → ffprobe 验证 → APP 端播放。
后台加频道时填三项:频道名、分类、上游流地址。上游地址建议先用一个稳定可控的测试流,不要一上来就填生产地址,否则播放失败时分不清是代码问题还是流本身问题。
加完之后用命令行模拟 APP 的请求:
# 模拟 APP 拉频道列表,注意带上 Token,鉴权不过会返回 401 而不是空列表 curl -s -H "Authorization: Bearer YOUR_TOKEN" \ "http://192.168.1.10:8080/api/channel/list?cate_id=1&page=1&size=20" | head -c 800 # 拿到返回里的流地址后,直接验证这个地址能否解出音视频信息 ffprobe -v error -show_entries stream=codec_name,width,height \ -of default=noprint_wrappers=1 "http://上游地址/live/test.m3u8"curl那步如果返回 401,问题在鉴权,和流无关,别往下查了。返回 200 但data为空,去数据库确认status=1。拿到地址后ffprobe是关键一步:它能明确告诉你流的编码是 h264 还是 h265、分辨率多少、音频是不是 aac。如果 ffprobe 就报错,那这个上游地址本身有问题,APP 端再怎么调都没用。
ffprobe成功后,还差最后一环——经过流媒体代理层的那条地址。很多包的代理会改写 m3u8 里的分片路径,改写逻辑出错时,上游地址能播、代理地址播不了。验证方法一样,只是换成代理前缀的 URL:
# 对比直连与代理两条地址的 ffprobe 结果,差异就是代理层的问题 ffprobe -v error -show_entries format=duration \ -of default=noprint_wrappers=1 "http://192.168.1.10:8080/live/proxy?url=上游地址"两条都能出结果,才算后端这一侧通了。任何一条不通,问题范围就锁定在很小的区域,比在 APP 上盲试高效得多。
4. APP 端对接:接口协议、鉴权与播放器内核
后端通了之后,APP 侧的问题会集中到三处:请求链路对不上、Token 过期策略不一致、播放器解码不兼容。这三块占了实际调试时间的八成。
4.1 客户端请求链路与协议对齐
APP 工程里最先要改的是接口地址。改完之后要确认三件事:请求方法、参数名、返回结构。很多拼装包的 APP 与后端版本不同步,参数名一个用cate_id一个用categoryId,接口返回 200 但data为空,非常难查。
// 统一的请求封装:超时、Token 注入、错误码集中处理 const API_BASE = 'http://192.168.1.10:8080/api'; // 与后端 app.url 保持一致 async function request(path, params = {}, timeout = 8000) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeout); // 超时兜底,避免首页一直转圈 try { const qs = new URLSearchParams(params).toString(); const res = await fetch(`${API_BASE}${path}?${qs}`, { signal: ctrl.signal, headers: { 'Authorization': `Bearer ${getToken()}`, 'X-Device-Id': getDeviceId(), // 设备指纹,服务端做多端限制用 'Accept': 'application/json', }, }); if (res.status === 401) { clearToken(); jumpToLogin(); return null; } // 统一处理登录态失效 const json = await res.json(); return json.code === 0 ? json.data : null; } catch (e) { return null; // 网络异常静默降级,由调用方决定是否提示 } finally { clearTimeout(timer); } }这段代码有三个参数值得调。timeout给 8000ms,是因为首页列表接口在弱网下确实会慢,但超过 8 秒用户已经放弃,不如快速失败让用户能点重试。X-Device-Id不是可选项,服务端的授权表通常按设备绑定,不带这个头会返回「设备未授权」。code === 0里的成功码各家不同,有的是 200 有的是 0,必须和后端实际返回对一遍,这类低级错配能卡住半天。
API_BASE用明文 HTTP 在 Android 9 以上会被系统直接拦截,表现为所有请求都超时。要么上 HTTPS,要么在AndroidManifest.xml里对该域名开usesCleartextTraffic。后一种做法只适合内网调试,别带进正式包。
4.2 Token 与设备绑定:过期策略要对齐
后端和 APP 对 Token 生命周期的理解经常不一致。后端设的是 24 小时,APP 缓存写的是 7 天,结果就是第三天开始随机报「登录失效」,用户还以为是账号被封。
常见做法是「短 Token + 刷新」:访问 Token 15 分钟,刷新 Token 7 天,APP 在收到 401 时先尝试静默刷新,刷新失败才跳登录页。这套机制要前后端同时改,只改一边会更乱。如果不想动架构,退一步的做法是让 APP 每次启动都校验一次 Token 有效性,失效就登录,至少用户体验是可预期的。
设备绑定那块,思路是服务端记录「账号 + 设备指纹 + 最近登录时间」,同一账号在超过 N 台设备上使用时拒绝。参数 N 别设成 1,设成 1 会导致用户换手机就登不上,客服压力会很大。我一般设 2 到 3,再给后台留一个「解绑」按钮。
4.3 播放器内核选型与解码兼容
APP 端播放器这块,规律很明确:
| 内核 | 适用场景 | 优点 | 局限 |
|---|---|---|---|
| 系统 MediaPlayer | 纯 mp4 点播 | 无依赖、包体小 | 对 HLS 支持弱,错误码含糊 |
| ExoPlayer / Media3 | HLS 直播为主 | 支持软硬解切换、错误分类清晰 | 包体增加约 1MB,需处理混淆规则 |
| ijkplayer 系 | 流格式杂乱 | 兼容性最强 | 库老、新系统 ABI 适配麻烦 |
选 ExoPlayer 是当前更稳的选择,关键是它的错误码能区分「网络断了」「解码器不支持」「格式不对」,排查效率完全不是一个量级。要注意两点:一是混淆规则里要把androidx.media3相关类排除,否则 release 包会崩在播放器初始化;二是 h265 流在部分低端机上硬解不支持,要提前配好软解兜底,不然用户看到的是黑屏而不是错误提示。
判断流是不是 h265,用第 3 章那条ffprobe命令就能看出来,codec_name显示hevc就是 h265。上线前把自己频道库里 h265 的比例统计一遍,超过三成就必须做软解兜底,这是硬性要求。
4.4 改包名、改接口地址与打包验证
改包名这件事,只改build.gradle里的applicationId是不够的,还有三处:文件里声明的包路径、AndroidManifest.xml的package属性、以及第三方 SDK(推送、统计)的后台配置。漏掉任何一处,表现都是「能装但闪退」或者「推送收不到」,而后台不报任何错。
打包验证有个必做动作:在 release 包上跑一遍完整链路,而不是用 debug 包测。debug 包默认不混淆、不开签名校验,很多问题恰好在混淆后才暴露——尤其是 JSON 解析的实体类被混淆后字段名对不上,接口返回正常但解析出来全是 null。
# release 包安装后,用 adb 抓播放器相关日志,过滤掉系统噪声 adb logcat -s ExoPlayerImplInternal:* MediaCodec:* AndroidRuntime:E | grep -i "iptv\|player"这个过滤条件把播放器内部状态、解码器、崩溃三类日志拉到一起,播放失败时前两类会明确告诉你卡在哪一步。养成这个习惯之后,APP 端的排查速度会明显快过一个一个加日志。
5. 避坑与排查:黑屏、串台、改了不生效的五个典型现场
下面五条都是实测中反复出现的,按「现象 → 原因 → 解决」写,遇到对应现象直接照着查。
一、列表正常但所有频道黑屏,日志里没有任何错误。原因通常是app.url留空导致下发的流地址是相对路径,或者填了127.0.0.1而 APP 在另外一台设备上。解决方式是把app.url改成局域网 IP 或公网域名,改完清一次后端缓存再刷新列表,地址才能重新拼装。
二、单个频道拉流 403,其他频道都正常。原因是上游对User-Agent或Referer做了校验,而代理层透传的是后端服务自己的 UA。解决办法是在代理转发的请求头里显式带上播放器侧的 UA,并允许在频道表里给单条记录配置额外请求头。这类问题不要试图在 APP 端改,播放器发出的 UA 五花八门,统一在代理层处理最稳。
三、后台改了频道地址,APP 里还是老的。原因是有两层缓存:后端接口缓存和 APP 侧本地缓存。后端常见的是文件缓存,目录一般在runtime/cache或.cache下,删掉对应 key 或整个目录都可以;APP 侧通常在本地存了一份列表,要配上「下拉刷新强制绕缓存」的参数,比如加一个?nocache=1,后端识别到这个参数就跳过缓存读库。
四、EPG 节目单一整片空白,但接口有数据。原因九成是时区。EPG 源给的是 UTC 时间戳,前端按本地时间格式化却没做偏移,结果整份节目单错开 8 小时,显示成空白或错位。解决办法是后端统一按 UTC 存、下发时间戳,前端只做展示层转换。中间任何一层做了「格式化后存储」,后面都会反复出问题。
五、用户反馈「用一会儿就退出登录」。原因是 Token 有效期和 APP 缓存策略不一致,加上服务端多端登录限制在每次请求时都更新在线设备表,导致旧设备的会话被后来的请求顶掉。解决方式是登录时只在「设备新增」时写一次记录,不要每次请求都写;同时把 Token 过期时间与 APP 的本地缓存时间设成同一来源的配置项,而不是各写各的。
6. 进阶:把「能播」变成「稳播」的两个脚本
能播只是及格线。真正上线之后,问题都出在「昨天还好,今天突然不行」这类场景上,靠人盯着不现实,得有两个脚本常驻。
6.1 流地址健康巡检:提前发现挂掉的频道
思路很简单:把频道表里的地址全捞出来,逐个用ffprobe探一下,超时或解不出流信息的写入报告。放在定时任务里每天跑一次,比等用户投诉快。
import subprocess, pymysql, concurrent.futures as cf # 连接参数与后端保持一致;read_timeout 要小于 ffprobe 的超时,避免连接被服务端先断 conn = pymysql.connect(host="192.168.1.10", port=3307, user="root", password="yourpass", database="iptv", charset="utf8mb4", read_timeout=15) def probe(row): cid, name, url = row # -rw_timeout 单位微秒,8 秒足够判断一个 HLS 源是否存活; # -v error 只输出错误,减少解析量 cmd = ["ffprobe", "-v", "error", "-rw_timeout", "8000000", "-show_entries", "stream=codec_name", "-of", "csv=p=0", url] try: out = subprocess.run(cmd, capture_output=True, text=True, timeout=10) return (cid, name, "OK" if out.stdout.strip() else "EMPTY", out.stderr.strip()[:120]) except subprocess.TimeoutExpired: return (cid, name, "TIMEOUT", "ffprobe 超时") with conn.cursor() as cur: cur.execute("select id,name,stream_url from channel where status=1") rows = cur.fetchall() bad = [] # 并发 8 路:太高会打满出口带宽,反而把正常源探测成超时 with cf.ThreadPoolExecutor(max_workers=8) as pool: for r in pool.map(probe, rows): if r[2] != "OK": bad.append(r) for r in bad: print(f"[{r[2]}] {r[1]} ({r[0]}) -> {r[3]}") print(f"共 {len(rows)} 条,异常 {len(bad)} 条")两个参数决定这个脚本好不好用。-rw_timeout8 秒、外层timeout10 秒,外层必须比内层大,否则 ffprobe 还没自己退出就被 Python 强杀,拿到的错误信息是空的TIMEOUT,没法区分「源慢」和「源死」。并发数 8 是经验值,频道上千条时,并发调到 20 以上会把本机出口带宽占满,导致正常源也探测失败,误报一堆。
状态EMPTY和TIMEOUT要分开看:EMPTY通常意味着地址能连上但解不出流,多半是地址过期或返回了错误页;TIMEOUT是网络层不通。前者要换源,后者先查网络和 DNS。
6.2 EPG 时间对齐:一次把时区问题钉死
EPG 出问题几乎全是时区。写个脚本把入库前后的时间统一到 UTC,再做一次区间校验,能一次性解决「节目单错位」「节目显示在昨天」这类反复冒出来的问题。
from datetime import datetime, timezone, timedelta import xml.etree.ElementTree as ET CST = timezone(timedelta(hours=8)) def parse_xmltv(path): tree = ET.parse(path) out = [] for p in tree.getroot().findall("programme"): # XMLTV 时间格式形如 20240101120000 +0800,先解析再统一转 UTC start = datetime.strptime(p.get("start")[:14], "%Y%m%d%H%M%S") tz = p.get("start")[15:20] or "+0000" offset = int(tz[:3]) * 60 + (1 if tz[0] == "-" else -1) * int(tz[3:5]) start_utc = start - timedelta(minutes=-offset) # 转成 UTC 后入库 stop = datetime.strptime(p.get("stop")[:14], "%Y%m%d%H%M%S") stop_utc = stop - timedelta(minutes=-offset) # 校验:结束必须晚于开始,跨度超过 12 小时的多半是数据源错误 if stop_utc <= start_utc or (stop_utc - start_utc) > timedelta(hours=12): continue out.append((int(start_utc.timestamp()), int(stop_utc.timestamp()), p.get("channel"))) return out rows = parse_xmltv("epg.xml") print(f"有效节目 {len(rows)} 条,首条 {rows[0] if rows else '无'}")关键在最后那段校验。真实 EPG 数据源里总有几条时间反了或者跨度离谱的记录,直接入库会让前端展示出「一档节目占满全天」的效果,看起来像渲染 bug,实际是数据脏。在入库前过滤掉,比事后在前端做兜底省事得多。
这两个脚本跑顺之后,日常运维基本就剩「看报告、换源、补节目单」三件事。从那以后我每次接手这类双端包,都强制先跑一遍健康巡检再谈功能开发——因为功能再好,频道挂了就是零。希望帮到你。
本文还有配套的精品资源,点击获取