一个轻量的 Objective-C HLS(m3u8)离线下载库,面向“业务侧可落地”的多任务管理与本地播放:
- 统一的多任务管理入口:
BszM3u8DownloadManager(推荐业务侧只使用 manager) - 统一事件回调:只需要实现一个 delegate 方法即可驱动 UI(回调在主线程)
- 默认落盘目录稳定可观察:
Documents/BszM3u8Downloader/(并做目录自愈) - 默认不进 iCloud 备份(对离线大文件更安全)
- 可选本地 HTTP Server:用于 WKWebView/AVPlayer/三方播放器播放本地 m3u8(支持 Range/CORS/OPTIONS 等常见兼容点)
- 支持
#EXT-X-KEY:会尝试把 key 下载到本地并改写 m3u8 引用(不等同于 DRM)
如果你只想“能跑起来”:跳到「快速上手」。
默认会在沙盒 Documents 下创建:
Documents/
BszM3u8Downloader/
tasks.plist
downloads/
<stableDirName_1>/
index.m3u8
0001.ts
...
<stableDirName_2>/
index.m3u8
...
tasks.plist:任务索引/持久化记录downloads/:每个任务一个稳定子目录(由 taskId/url 生成,避免目录名过长且尽量可读)
默认只包含下载功能(不含本地播放 server):
pod 'BszM3u8Downloader'需要本地 HTTP Server 播放(依赖 GCDWebServer):
pod 'BszM3u8Downloader/LocalServer'- 最低 iOS:12.0
例如你的 App 与本仓库中的 BszM3u8Downloader 同级:
pod 'BszM3u8Downloader', :path => '../BszM3u8Downloader'仓库内自带示例工程,包含:单任务/多任务/文件浏览(用于验证落盘与重启恢复)。
- 进入
BszM3u8Downloader/Example/ - 执行
pod install - 打开
m3u8Example.xcworkspace运行
业务侧推荐只 import 入口文件(默认只暴露 manager):
#import <BszM3u8Downloader/BszM3u8Downloader.h>如需本地 HTTP 播放,再额外引入:
#import <BszM3u8Downloader/BszM3u8LocalServer.h>建议在 App 启动早期设置(例如 AppDelegate / SceneDelegate / 根 VC)。
@interface ViewController () <BszM3u8DownloadManagerDelegate>
@end
- (void)viewDidLoad {
[super viewDidLoad];
[BszM3u8DownloadManager sharedManager].delegate = self;
}
- (void)downloadManager:(BszM3u8DownloadManager *)manager
didUpdateTaskInfo:(BszM3u8DownloadTaskInfo *)taskInfo
error:(NSError *)error {
// 回调在主线程(便于直接更新 UI)
NSLog(@"task=%@ status=%ld progress=%.3f speed=%.0f dir=%@ err=%@",
taskInfo.taskId,
(long)taskInfo.status,
taskInfo.progress,
taskInfo.speedBytesPerSecond,
taskInfo.outputDir,
error.localizedDescription);
}NSError *error = nil;
BOOL ok = [[BszM3u8DownloadManager sharedManager] createAndStartTaskWithTaskId:@"movie_001"
urlString:@"https://example.com/a/index.m3u8"
ext:@{ @"name": @"测试影片" }
error:&error];
if (!ok) {
NSLog(@"start failed: %@", error.localizedDescription);
}[[BszM3u8DownloadManager sharedManager] pauseTaskForTaskId:@"movie_001"];
NSError *err = nil;
BOOL resumed = [[BszM3u8DownloadManager sharedManager] resumeTaskForTaskId:@"movie_001" error:&err];
if (!resumed) {
NSLog(@"resume failed: %@", err.localizedDescription);
}
[[BszM3u8DownloadManager sharedManager] deleteTaskForTaskId:@"movie_001"];NSArray<BszM3u8DownloadTaskInfo *> *all = [[BszM3u8DownloadManager sharedManager] allDownloadersAscending:NO];
NSArray<BszM3u8DownloadTaskInfo *> *downloading = [[BszM3u8DownloadManager sharedManager] currentDownloadingTasksAscending:NO];
NSArray<BszM3u8DownloadTaskInfo *> *completed = [[BszM3u8DownloadManager sharedManager] completedTasksAscending:NO];
BszM3u8DownloadTaskInfo会尽量返回“最新状态”:如果该 task 当前内存里存在 downloader,则状态/进度更实时。
核心字段:
taskId:任务 id(用于管理/查询)urlString:原始 m3u8 URLoutputDir:本地输出目录(绝对路径)ext:业务扩展字段(只支持字符串键值)progress:0.0 ~ 1.0speedBytesPerSecond:瞬时速度(运行期有效,不落盘)status:任务状态(见下方状态表)createdAt:创建时间戳(秒,内部用于排序)
- 稳定且唯一:建议业务自行生成(如内容 id / 视频 id / 业务唯一 key)
- 可读性:推荐
movie_001/course_123_4这种可读格式 - 不想自己管:传
nil会退化为使用urlString作为 taskId
注意:默认下载目录名是由 task key(taskId 或 url)生成的稳定子目录(会包含短前缀 + hash),以避免过长路径、减少冲突。
| 状态 | 含义 | 典型 UI 展示 |
|---|---|---|
NotReady |
未准备(未创建/未进入队列) | 不展示或灰态 |
Ready |
已进入等待队列(排队中) | “等待中” |
Starting |
启动中 | “启动中” |
Downloading |
下载中 | 进度条 + 速度 |
Paused |
已暂停 | “已暂停” |
Stopped |
已停止(错误/中止/失败) | “失败/已停止”,可提供重试 |
Completed |
已完成(index.m3u8 已生成或本地缓存判定完成) | “已完成”,可播放 |
补充:如果并发达到上限,新任务会被标记为 Ready 并进入等待队列;等有空位会自动开始。
下载完成后,你通常会拿到一个本地输出目录 outputDir,其中包含 index.m3u8、若干 ts(以及可能的 key 文件)。
playURLForTaskId:error: 内部会自动:
- 查询任务对应
outputDir - 启动/复用本地 HTTP Server
- 返回可直接播放的
index.m3u8URL(形如http://127.0.0.1:xxxx/index.m3u8)
NSError *error = nil;
NSString *playURL = [[BszM3u8LocalServer sharedServer] playURLForTaskId:@"movie_001" error:&error];
if (!playURL) {
NSLog(@"play url error: %@", error.localizedDescription);
return;
}
// AVPlayer
AVPlayer *player = [AVPlayer playerWithURL:[NSURL URLWithString:playURL]];如果你已经拿到了本地目录(例如来自 taskInfo.outputDir):
NSError *error = nil;
NSString *playURL = [[BszM3u8LocalServer sharedServer] playURLForRootDirectory:taskInfo.outputDir error:&error];通常把 playURL 交给 <video> 或三方播放器即可。若遇到播放失败,可优先检查:
index.m3u8是否已生成(未完成时会返回index.m3u8 not found yet)- 播放器是否依赖 Range 请求(本地 server 已针对 Range 做了兼容增强)
- H5 环境是否需要 CORS/OPTIONS(本地 server 已做常见处理)
如果你的 m3u8 包含 #EXT-X-KEY(例如 AES-128),离线播放还需要 key 文件也能成功落盘;若 key 获取失败,任务会被标记为 Stopped,避免出现“文件不全但误判 Completed”。
- 默认 store 根目录:
Documents/BszM3u8Downloader/ - 会对 store 根目录与
downloads/目录设置NSURLIsExcludedFromBackupKey=YES(不进入 iCloud 备份) tasks.plist保存任务的最小信息(url、outputDir、ext、progress/status、createdAt 等),用于 App 重启后恢复列表
iOS 沙盒根路径会随安装/重签名等发生变化。如果持久化保存绝对路径,重启后旧路径可能失效。
因此:tasks.plist 内的 outputDir 会优先以“相对 store 根目录”的方式持久化,运行时再拼接当前沙盒路径,从而避免“重启后 Completed 变失败/Stopped”的问题。
你可以把下载落盘到自己的目录(传 nil/空字符串 会恢复默认):
[[BszM3u8DownloadManager sharedManager] setDownloadRootDirectory:@"/your/custom/dir"];注意:若你使用自定义根目录,持久化可能会保留绝对路径(用于保持你的目录语义);建议你确保该目录长期可用。
如果你需要给 JS/Flutter/React Native 等层使用,可以用字典接口(所有 value 都是字符串,便于序列化):
NSArray<NSDictionary<NSString *, NSString *> *> *items = [[BszM3u8DownloadManager sharedManager] allTaskDicsAscending:NO];createdAt在字典中会以“毫秒字符串”输出,避免同秒并发创建导致排序不稳定。
大多数场景只用 manager 就够了;如果你确实需要调节更底层参数(例如单任务分片并发数、前后台策略等),可以拿到已存在的 downloader:
BszM3u8Downloader *d = [[BszM3u8DownloadManager sharedManager] existingDownloaderForTaskId:@"movie_001"];
d.maxConcurrentOperationCount = 3;
d.autoPauseWhenAppDidEnterBackground = YES;说明任务尚未完成或落盘目录异常。
- 确认
taskInfo.status == BszM3u8DownloadStatusCompleted - 或检查
taskInfo.outputDir下是否存在index.m3u8
默认实现已通过“相对路径持久化”解决沙盒路径变化问题。
如果你自定义了下载根目录,请确保该目录在重启后仍然存在且可访问。
本地 server 已对常见的 Range/MIME/CORS/OPTIONS 等做了补强;如果仍然 404:
- 确认传入的 rootDirectory 是“任务输出目录”(包含 index.m3u8)
- 确认播放器请求的路径没有越界(server 会拒绝路径穿越)
该库会尝试下载 key 并把 m3u8 中的 key URI 改写成本地文件名,但前提是:
- key 的 URI 可被正常访问(不依赖临时 cookie/一次性签名/设备绑定等)
- 播放器在离线播放时会按 m3u8 引用读取本地 key(本地 server 会按文件方式返回)
若你的加密方案属于 DRM/强鉴权,通常无法通过纯离线落盘实现播放。
- 自用项目, 但是如果能帮到更多人就更好了。
- 参考了:AriaM3U8Downloader
- 特别感谢 @moxcomic 的开源贡献。