简介:本资源是面向安卓及智能电视盒子开发者的IPTV直播系统完整源码包,适用于希望掌握流媒体应用架构、进行二次开发或搭建私有电视平台的中高级开发者。压缩包包含后端服务代码与Android端APP源码两大核心模块,整体21.72MB,虽未提供具体文件总数与类型明细,但根据描述可明确涵盖服务器管理、数据库设计、RESTful API接口、流媒体分发(如RTMP/HLS支持)及TV/手机双端UI交互逻辑等关键实现。后端可能基于Python(Flask/Django)或Node.js构建,前端采用Java/Kotlin配合ExoPlayer实现播放控制与遥控器适配。已有4600人学习下载,说明其在IPTV定制化开发领域具备较强实践参考价值——读者可直接复用频道管理逻辑、认证授权机制、API通信范式及响应式布局方案,快速理解IPTV系统前后端协同原理并落地自有项目。
1. 这不是个“看电视App”,而是一套可落地的IPTV服务端+客户端协同架构:支持动态频道源热加载、EPG自动抓取、多终端会话隔离,适合中小团队快速搭建自有视频聚合平台
某开发者去年接手一个校园宿舍区的老旧电视信号改造项目,原计划用现成SaaS方案,结果发现:第三方服务对本地教育台、校内直播流支持极差,API限频严,频道列表改一次要等24小时审核。最后他扒出「新骆驼IPTV完美版」这套源码,三天内就跑通了从拉流、解析、转封装到APP端播放的全链路——关键不是它“能看台”,而是后端用Spring Boot + Netty做了轻量级流媒体网关,APP端用ExoPlayer深度定制了断连重试和缓冲策略。它不依赖任何云厂商PaaS,所有频道源、EPG、用户权限都走本地MySQL+Redis双写,配置改完实时生效。如果你正被“频道源格式不统一”“EPG更新慢”“多设备登录冲突”卡住,又不想碰FFmpeg底层编译或K8s部署,这套代码就是你该拆的第一份工程级IPTV参考实现。
2. 后端架构拆解:Spring Boot + Netty流媒体网关 + 定时任务调度中心
2.1 核心模块职责划分与数据流向
整个后端采用分层设计,避免把流媒体逻辑和业务逻辑揉在一起。application.yml里明确划出三类服务端口:
server.port=8080:HTTP业务接口(用户管理、频道CRUD、EPG同步状态)netty.port=9000:Netty流媒体网关(接收RTMP/HTTP-FLV拉流请求,做协议转换与负载分发)quartz.port=8081:定时任务管理端口(仅内网访问,用于手动触发EPG抓取或频道源校验)
提示:Netty网关不处理视频解码,只做“流路由”。当APP请求
http://ip:9000/live/cctv1.flv时,网关查MySQL里的channel_source表,拿到该频道当前有效的拉流URL(可能是rtmp://xxx/live/cctv1或https://xxx/xxx.m3u8),再用ChannelGroup复用连接池转发,全程零内存拷贝。
数据库设计上,channel表存频道基础信息(id, name, logo_url, sort_order),channel_source表存多源冗余(同一频道可配主源、备源、HTTP-FLV源、HLS源),字段priority决定故障切换顺序;epg_program表按channel_id + start_time联合索引,确保EPG查询<50ms。
2.2 Netty流媒体网关核心实现:协议适配与连接保活
com.xtliv.tv.gateway.NettyMediaServer是启动入口,关键在于ChannelInitializer中对不同协议的差异化处理:
// java public class MediaChannelInitializer extends ChannelInitializer<SocketChannel> { @Override protected void initChannel(SocketChannel ch) throws Exception { ChannelPipeline p = ch.pipeline(); // 所有连接先过认证过滤器(验证token或IP白名单) p.addLast("auth", new AuthHandler()); // 根据URL后缀动态选择解码器 String uri = ch.attr(ATTR_URI).get(); if (uri.endsWith(".flv")) { p.addLast("http-flv-decoder", new HttpFlvRequestDecoder()); p.addLast("flv-encoder", new FlvResponseEncoder()); } else if (uri.endsWith(".m3u8") || uri.endsWith(".ts")) { p.addLast("hls-decoder", new HlsRequestDecoder()); p.addLast("hls-encoder", new HlsResponseEncoder()); } p.addLast("business-handler", new MediaBusinessHandler()); } }MediaBusinessHandler是真正干活的类。它不做流数据缓存,而是用ConcurrentHashMap<String, Channel>维护“频道ID → 拉流连接”的映射。当第一个用户请求CCTV1时,它调用SourceManager.getLiveSource("cctv1")从DB查出主源地址,用Bootstrap新建一个SocketChannel去上游拉流;后续请求同一频道的用户,直接复用这个上游连接,通过Channel.writeAndFlush()分发数据帧。每个上游连接都绑定IdleStateHandler(60, 0, 0),60秒无数据自动重连,避免僵尸连接占资源。
2.3 EPG自动抓取模块:基于Jsoup的HTML解析 + 时间窗口去重
EPG数据来自多个公开网站(如某地方台官网节目单页),EpgTaskService用Quartz每15分钟触发一次:
// java @Scheduled(cron = "0 */15 * * * ?") public void fetchAllEpg() { List<Channel> channels = channelMapper.selectList(null); for (Channel c : channels) { // 每个频道单独线程抓取,避免单点失败影响全局 CompletableFuture.runAsync(() -> doFetchSingleChannel(c)); } }doFetchSingleChannel()核心逻辑是:
- 用Jsoup连接
http://epg.xxx.com/channel?id=cctv1,提取<div class="program">下的<span class="time">08:00</span><span class="name">新闻联播</span> - 将时间字符串转为
LocalDateTime,并强制对齐到整点(如"08:02"→"08:00"),解决网页发布时间不准问题 - 插入前执行去重SQL:
INSERT IGNORE INTO epg_program (...) VALUES (...),利用UNIQUE KEY(channel_id, start_time)跳过重复数据
注意:EPG抓取失败不抛异常,而是记录
error_log表,字段retry_count自增。连续3次失败后,该频道EPG自动降级为“静态默认节目单”(从resources/static/epg_fallback.json读取),保证APP端不显示空白。
2.4 用户会话与设备绑定机制:Redis分布式锁保障并发安全
APP端登录后,后端生成device_token并存入Redis,TTL设为7天:
// java String token = UUID.randomUUID().toString().replace("-", ""); String key = "device:" + deviceId; redisTemplate.opsForValue().set(key, token, Duration.ofDays(7)); // 同时写MySQL做持久化备份(防Redis宕机) deviceMapper.insert(new Device(deviceId, token, ip, userAgent));关键在“单设备登录”控制:当用户用新设备登录时,需踢掉旧设备。但MySQL UPDATE可能被并发请求覆盖,所以用Redis Lua脚本保证原子性:
-- lua local old_token = redis.call('GET', 'device:' .. KEYS[1]) if old_token then redis.call('DEL', 'session:' .. old_token) redis.call('PUBLISH', 'logout_channel', KEYS[1]) end redis.call('SET', 'device:' .. KEYS[1], ARGV[1]) redis.call('SETEX', 'session:' .. ARGV[1], 3600, KEYS[1]) return 1APP端订阅logout_channel消息,收到即主动退出登录。这种“Redis发令+APP响应”模式,比轮询数据库更实时,也避免了长连接心跳带来的服务器压力。
3. APP端源码解析:ExoPlayer深度定制 + 频道源热更新策略
3.1 播放器内核替换:从MediaPlayer到ExoPlayer 2.19的迁移路径
原始APK用系统MediaPlayer,遇到HLS切片丢失、DRM授权失败等问题无法自定义。源码中player/ExoPlayerWrapper.java完成了平滑过渡:
// java public class ExoPlayerWrapper { private SimpleExoPlayer player; private DefaultTrackSelector trackSelector; public void init(Context context) { // 关键:启用自适应带宽切换,但禁用自动分辨率降级(避免小屏手机播1080p卡顿) trackSelector = new DefaultTrackSelector(context); trackSelector.setParameters( trackSelector.buildUponParameters() .setMaxVideoSize(1280, 720) // 强制最高720p .setForceLowestBitrate(false) ); player = new SimpleExoPlayer.Builder(context) .setTrackSelector(trackSelector) .build(); // 注入自定义DataSource,支持带签名的URL(防盗链) player.setMediaSource(new ProgressiveMediaSource.Factory( new SignedDataSourceFactory(context) ).createMediaSource(MediaItem.fromUri(uri))); } }SignedDataSourceFactory重写了open方法,在发起HTTP请求前,拼接?sign=xxx&ts=171xxxxx参数,签名算法用SHA256+密钥(密钥存在BuildConfig.SIGN_KEY,打包时由Gradle注入),服务端校验通过才返回流数据。
3.2 频道列表热更新:本地JSON缓存 + 后端版本号比对
APP不每次启动都拉全量频道,而是用“版本号+增量更新”策略:
- 启动时请求
GET /api/v1/channels/version,返回{"version": "20240520_01"} - 对比本地
SharedPreferences存的last_version,若不同则请求GET /api/v1/channels/diff?from=20240519_02&to=20240520_01 - 后端返回JSON数组:
[{"op":"add","data":{...}},{"op":"delete","id":"cctv13"}] - APP端用
FastAdapter局部刷新RecyclerView,避免闪屏
血泪经验:早期用
Gson.fromJson()直接解析整个频道列表,遇到某个频道JSON字段缺失(如logo_url=null)会导致整个列表解析失败。后来改成JsonParser逐个读取对象,捕获JsonParseException后跳过该频道,日志打WARN - skip invalid channel: cctv13,保证其他频道不受影响。
3.3 断连重试与缓冲优化:三层重试机制设计
ExoPlayer默认重试只有2次,网络抖动时频繁黑屏。源码中RetryablePlayerView.java实现了三层兜底:
| 层级 | 触发条件 | 重试次数 | 动作 |
|---|---|---|---|
| L1(Player层) | PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED | 3次 | 延迟1s、2s、4s后player.retry() |
| L2(Source层) | L1失败后仍无法播放 | 2次 | 切换备用源(查channel_source表priority=2的URL) |
| L3(UI层) | L2失败后 | 1次 | 弹Toast“正在切换线路…”,同时后台请求/api/v1/channels/fallback获取兜底频道(如本地MP4测试流) |
缓冲策略也做了调整:DefaultLoadControl中minBufferMs设为2000ms(非默认15000ms),因为IPTV场景下用户容忍短暂卡顿,但不能忍受15秒启动延迟。
3.4 多语言与主题切换:资源目录分离 + 运行时AssetManager重载
APP支持简体中文、繁体中文、英文,但没用Android原生values-zh-rCN方案,因为EPG节目名、频道名需服务端下发,客户端只管渲染。所以语言包实际是JSON文件:
assets/i18n/ ├── zh-CN.json ├── zh-TW.json └── en-US.jsonLanguageManager.load(String langCode)方法用AssetManager.open()读取对应JSON,解析后存入ConcurrentHashMap<String, String>。切换语言时,不重启Activity,而是遍历当前ViewGroup所有TextView,调用setText(i18nMap.get(key))。主题色同理,theme_colors.json控制Toolbar背景、按钮边框等12个颜色值,修改后调用AppCompatDelegate.setDefaultNightMode()触发夜间模式切换。
4. 部署与运行:从本地调试到生产环境的完整流程
4.1 本地开发环境搭建:Docker一键启停依赖服务
项目根目录有docker-compose.yml,包含MySQL 8.0、Redis 7、Nginx(反向代理Netty网关)三个容器:
# docker-compose.yml version: '3.8' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: iptv_db ports: ["3306:3306"] volumes: ["./sql/init.sql:/docker-entrypoint-initdb.d/init.sql"] redis: image: redis:7-alpine ports: ["6379:6379"] nginx: image: nginx:alpine ports: ["80:80", "443:443"] volumes: ["./nginx.conf:/etc/nginx/nginx.conf"]init.sql预置了channel表结构和3条测试数据(CCTV1、湖南卫视、测试频道)。执行docker-compose up -d后,MySQL自动执行初始化脚本,无需手动建库。
提示:Nginx配置里
location /live/代理到http://netty:9000/,这样APP请求http://localhost/live/cctv1.flv实际走的是Nginx→Netty网关,方便后续加SSL或限流。
4.2 后端编译与启动:Maven Profile区分环境配置
pom.xml定义了dev、test、prod三个Profile:
<!-- pom.xml --> <profiles> <profile> <id>dev</id> <properties> <spring.profiles.active>dev</spring.profiles.active> </properties> </profile> </profiles>启动命令:
# 开发环境(H2内存库+控制台打印SQL) mvn spring-boot:run -Pdev # 生产环境(MySQL+关闭DEBUG日志) mvn clean package -Pprod java -jar target/iptv-backend-1.0.jar --spring.profiles.active=prodapplication-prod.yml中数据库密码用Jasypt加密,启动时传入密钥:
java -jar iptv-backend-1.0.jar --jasypt.encryptor.password=mySecretKey4.3 APP端构建:Gradle动态注入API地址与签名密钥
app/build.gradle中通过buildConfigField注入配置:
android { buildTypes { debug { buildConfigField "String", "API_BASE_URL", '"http://192.168.1.100:8080/"' buildConfigField "String", "SIGN_KEY", '"dev_sign_key_123"' } release { buildConfigField "String", "API_BASE_URL", '"https://api.yourdomain.com/"' buildConfigField "String", "SIGN_KEY", '"prod_sign_key_abc"' } } }这样Java代码中直接用BuildConfig.API_BASE_URL,无需硬编码,也避免了混淆后密钥泄露风险。
4.4 常见问题排查:5条真实踩坑记录与解决方案
现象1:APP播放时频繁卡顿,Logcat显示E/ExoPlayerImplInternal: Source error
原因:Netty网关上游拉流连接未设置超时,某些低质量源(如校园网RTMP流)握手耗时超30秒,阻塞整个EventLoop线程。
解决:在SourceManager.getLiveSource()中,为每个上游连接添加connectTimeoutMillis(5000)和readTimeoutMillis(10000)。
现象2:EPG抓取任务在Linux服务器上总失败,本地Windows正常
原因:Jsoup默认User-Agent含Windows NT 10.0,某地方台服务器识别为爬虫拒绝响应;Linux服务器时间未同步,导致HTTPS证书校验失败。
解决:Jsoup连接时显式设置header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36");执行sudo ntpdate -s time.nist.gov校准时间。
现象3:MySQL插入EPG时报错Incorrect datetime value: '0000-00-00 00:00:00'
原因:MySQL 5.7+默认sql_mode含NO_ZERO_DATE,而部分EPG网页用0000-00-00占位无效日期。
解决:修改MySQL配置my.cnf,在[mysqld]下添加sql_mode=STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION,去掉NO_ZERO_DATE。
现象4:APP切换WiFi/4G时播放中断,重连需手动点击
原因:ExoPlayer默认不监听网络变化,onNetworkEvent回调未注册。
解决:在ExoPlayerWrapper.init()后添加player.addAnalyticsListener(new AnalyticsListener() { ... }),重写onNetworkEvent()方法触发player.retry()。
现象5:Redis集群环境下设备踢出失效
原因:PUBLISH/subscribe在Redis Cluster中不支持跨slot,logout_channel被分配到不同节点。
解决:改用Redis Stream(XADD logout_stream * device_id cctv1),APP端用XREADGROUP消费,保证消息有序且不丢。
5. 二次开发实战:如何接入私有CDN、替换EPG源、增加会员分级
5.1 接入私有CDN:修改流媒体网关的URL重写规则
现有架构中,频道源URL直连上游,若想走CDN加速,需在Netty网关层做URL重写。以阿里云CDN为例,其回源鉴权要求URL带auth_key=xxx&auth_time=171xxxxx参数。
修改MediaBusinessHandler.channelRead0()方法,在获取源URL后插入重写逻辑:
// java private String rewriteToCdn(String originUrl) { if (!originUrl.contains("cdn.example.com")) return originUrl; try { URI uri = new URI(originUrl); String path = uri.getPath(); long expireTime = System.currentTimeMillis() / 1000 + 3600; // 1小时过期 String sign = DigestUtils.md5Hex(path + "your_cdn_secret" + expireTime); return String.format("%s?auth_key=%s&auth_time=%d", originUrl, sign, expireTime); } catch (URISyntaxException e) { log.warn("CDN rewrite failed for {}", originUrl, e); return originUrl; } }同时在application.yml中加开关:
cdn: enabled: true domain: "https://live.cdn.example.com"这样当channel_source.url为rtmp://origin/live/cctv1时,网关自动转为https://live.cdn.example.com/live/cctv1.flv?auth_key=xxx&auth_time=xxx,CDN节点校验通过后回源拉流。
5.2 替换EPG源:对接第三方EPG API(如TVGuide API)
假设新EPG服务商提供REST API:GET https://api.tvguide.com/programs?channel=cctv1&date=20240520,返回JSON格式:
{ "programs": [ {"start": "08:00", "title": "新闻联播", "duration": 30}, {"start": "08:30", "title": "天气预报", "duration": 5} ] }新建TvguideEpgFetcher.java,实现EpgFetcher接口:
// java @Component public class TvguideEpgFetcher implements EpgFetcher { private final RestTemplate restTemplate; @Override public List<EpgProgram> fetch(String channelId, LocalDate date) { String url = String.format( "https://api.tvguide.com/programs?channel=%s&date=%s", channelId, date.format(DateTimeFormatter.BASIC_ISO_DATE) ); ResponseEntity<TvguideResponse> resp = restTemplate.getForEntity(url, TvguideResponse.class); return resp.getBody().getPrograms().stream() .map(p -> { LocalDateTime start = LocalDateTime.parse( date + "T" + p.getStart(), DateTimeFormatter.ISO_LOCAL_DATE_TIME ); return new EpgProgram(channelId, start, p.getDuration(), p.getTitle()); }) .collect(Collectors.toList()); } }在EpgTaskService.fetchAllEpg()中,根据channel.epg_source_type字段动态选择实现类:
EpgFetcher fetcher = applicationContext.getBean(channel.getEpgSourceType() + "EpgFetcher"); List<EpgProgram> programs = fetcher.fetch(channel.getId(), today);5.3 增加会员分级:播放权限控制与广告插播
需求:免费用户只能看标清(480p),VIP用户可选高清(720p/1080p);免费用户每30分钟插播15秒广告。
在channel表新增字段quality_level(0=标清,1=高清,2=超清),is_vip_required(布尔);device表加vip_expire_time(时间戳)。
播放前校验逻辑加入PlayerAuthService:
// java public class PlayerAuthService { public boolean canPlay(String channelId, String deviceId) { Channel channel = channelMapper.selectById(channelId); if (!channel.getIsVipRequired()) return true; Device device = deviceMapper.selectById(deviceId); return device.getVipExpireTime() > System.currentTimeMillis(); } public List<VideoQuality> getAvailableQualities(String channelId, String deviceId) { Channel channel = channelMapper.selectById(channelId); int maxLevel = playerAuthService.canPlay(channelId, deviceId) ? 2 : 0; return qualityMapper.selectByLevelLessEqual(maxLevel); } }广告插播用ExoPlayer的ConcatenatingMediaSource实现:
// java private MediaSource buildAdaptiveSource(Uri videoUri, Uri adUri) { // 主视频源 MediaSource videoSource = new HlsMediaSource.Factory(dataSourceFactory) .createMediaSource(MediaItem.fromUri(videoUri)); // 广告源(15秒MP4) MediaSource adSource = new ProgressiveMediaSource.Factory(dataSourceFactory) .createMediaSource(MediaItem.fromUri(adUri)); // 每30分钟插入一次广告(用ClippingMediaSource截取30分钟片段) ClippingMediaSource clip1 = new ClippingMediaSource( videoSource, 0, TimeUnit.MINUTES.toMicros(30) ); ClippingMediaSource clip2 = new ClippingMediaSource( videoSource, TimeUnit.MINUTES.toMicros(30), C.TIME_UNSET ); return new ConcatenatingMediaSource(clip1, adSource, clip2); }5.4 改造总结:从“能跑”到“可维护”的关键习惯
这套源码最值得借鉴的不是功能多炫,而是它把“变化点”都设计成了可插拔模块:频道源、EPG抓取、播放器内核、CDN策略、会员逻辑……全部通过接口+Spring Bean注入解耦。我接手后做的第一件事,就是把所有硬编码的URL、密钥、超时时间,全挪到application.yml的custom.*节点下,并加了@ConfigurationProperties(prefix="custom")绑定类。这样运维改个CDN域名,不用重新编译,改配置重启即可。
从那以后我每次接流媒体项目,都强制走一遍“三问检查”:
- 这个功能(如EPG)是否可能被替换成另一个供应商?它的接口是否已抽象?
- 这个配置(如超时时间)是否可能因网络环境不同而调整?它是否暴露为可配置项?
- 这个错误(如拉流失败)是否会产生雪崩效应?它是否有降级方案(如兜底频道、静态EPG)?
这三问逼着我把每个模块都写成“可拔插单元”,而不是一坨粘连的胶水代码。希望帮到你。
本文还有配套的精品资源,点击获取