Flutter m3u8下载器。后台任务下载,支持加密下载。
只实现了Android端,并且只支持单m3u8视频下载(m3u8文件包含了多个ts文件,本质是多个ts同时下载)。
项目地址:m3u8_downloader
用http://youku.cdn-iqiyi.com/20180523/11112_b1fb9d8b/index.m3u8地址举例。提取内容:
#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=800000,RESOLUTION=1080x608
1000k/hls/index.m3u8
可以看出该链接重定向到1000k/hls/index.m3u8
,拼接链接得到http://youku.cdn-iqiyi.com/20180523/11112_b1fb9d8b/1000k/hls/index.m3u8
。继续提取:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:9
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:4.276000,
65f7a658c87000.ts
#EXTINF:4.170000,
65f7a658c87001.ts
#EXTINF:5.754600,
65f7a658c87002.ts
#EXTINF:4.170000,
65f7a658c87003.ts
.....
该文件中每一行#EXTINF
下面都是一些.ts的文件,其实这些就是视频片段。同样的,通过URL拼接,可以下载ts文件。
需要注意的是,有的m3u8文件中包含加密的key,也声明了加密的方式#EXT-X-KEY:METHOD=AES-128,URI="key.key"
。比如:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-TARGETDURATION:13
#EXT-X-KEY:METHOD=AES-128,URI="key.key"
#EXTINF:3.92,
223f43bef60c77c7bd5aa8008e4d8604.ts
#EXTINF:3.0,
736898a19f24587ee61579cc74ac2e98.ts
#EXTINF:3.0,
b9e5e152c12629813502b61218e3aa8a.ts
#EXTINF:3.0,
40d1fe66edb627bf2e76dce9afe588be.ts
......
/**
* 将Url转换为M3U8对象
*
* @param url
* @return
* @throws IOException
*/
public static M3U8 parseIndex(String url) throws IOException {
// 获取m3u8的数据流
BufferedReader reader = new BufferedReader(new InputStreamReader(new URL(url).openStream()));
String basepath = url.substring(0, url.lastIndexOf("/") + 1);
M3U8 ret = new M3U8();
ret.setBasePath(basepath);
String line;
float seconds = 0;
while ((line = reader.readLine()) != null) {
if (line.startsWith("#")) {
// TS 文件
if (line.startsWith("#EXTINF:")) {
line = line.substring(8);
if (line.endsWith(",")) {
line = line.substring(0, line.length() - 1);
}
seconds = Float.parseFloat(line);
}
continue;
}
// 包含了.m3u8文件
if (line.endsWith("m3u8")) {
return parseIndex(basepath + line);
}
ret.addTs(new M3U8Ts(line, seconds));
seconds = 0;
}
reader.close();
return ret;
}
for (final M3U8Ts m3U8Ts : m3U8.getTsList()) {//循环下载TS切片文件
executor.execute(new Runnable() {
@Override
public void run() {
File file;
try {
String fileName = M3U8EncryptHelper.encryptFileName(encryptKey, m3U8Ts.obtainEncodeTsFileName());
file = new File(dir + File.separator + fileName);
} catch (Exception e) {
file = new File(dir + File.separator + m3U8Ts.getUrl());
}
if (!file.exists()) {//下载过的就不管了
FileOutputStream fos = null;
InputStream inputStream = null;
try {
URL url = new URL(m3U8Ts.obtainFullUrl(basePath));
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(connTimeout);
conn.setReadTimeout(readTimeout);
if (conn.getResponseCode() == 200) {
if (isStartDownload){
isStartDownload = false;
mHandler.sendEmptyMessage(WHAT_ON_START_DOWNLOAD);
}
inputStream = conn.getInputStream();
fos = new FileOutputStream(file);//会自动创建文件
int len = 0;
byte[] buf = new byte[8 * 1024 * 1024];
while ((len = inputStream.read(buf)) != -1) {
curLength += len;
fos.write(buf, 0, len);//写入流中
}
} else {
handlerError(new Throwable(String.valueOf(conn.getResponseCode())));
}
} catch (MalformedURLException e) {
handlerError(e);
} catch (IOException e) {
handlerError(e);
} catch (Exception e) {
handlerError(e);
}
finally
{//关流
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
}
}
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
}
}
}
itemFileSize = file.length();
m3U8Ts.setFileSize(itemFileSize);
mHandler.sendEmptyMessage(WHAT_ON_PROGRESS);
curTs++;
}else {
curTs ++;
itemFileSize = file.length();
m3U8Ts.setFileSize(itemFileSize);
}
}
});
}
/**
* 加密TS的URL地址
*/
public String obtainEncodeTsFileName(){
if (url == null)return "error.ts";
return MD5Utils.encode(url).concat(".ts");
}
/**
* 生成本地m3u8索引文件,ts切片和m3u8文件放在相同目录下即可
* @param m3u8Dir
* @param m3U8
*/
public static File createLocalM3U8(File m3u8Dir, String fileName, M3U8 m3U8, String keyPath) throws IOException{
File m3u8File = new File(m3u8Dir, fileName);
BufferedWriter bfw = new BufferedWriter(new FileWriter(m3u8File, false));
bfw.write("#EXTM3U\n");
bfw.write("#EXT-X-VERSION:3\n");
bfw.write("#EXT-X-MEDIA-SEQUENCE:0\n");
bfw.write("#EXT-X-TARGETDURATION:13\n");
for (M3U8Ts m3U8Ts : m3U8.getTsList()) {
// 如果m3u8加密,定义key文件
if (keyPath != null) bfw.write("#EXT-X-KEY:METHOD=AES-128,URI=\""+keyPath+"\"\n");
bfw.write("#EXTINF:" + m3U8Ts.getSeconds()+",\n");
bfw.write(m3U8Ts.obtainEncodeTsFileName());
bfw.newLine();
}
bfw.write("#EXT-X-ENDLIST");
bfw.flush();
bfw.close();
return m3u8File;
}
Flutter 提供 MethodChannel、EventChannel、BasicMessageChannel 三种方式。官方platform-channels说明
Android端的方法处理:
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
try {
if (call.method.equals("initialize")) {
// 初始化
long callbackHandle = ((JSONArray) call.arguments).getLong(0);
result.success(true);
} else if (call.method.equals("config")) {
// 配置下载器
if (!call.hasArgument("saveDir")) {
result.error("1", "saveDir必传", "");
return;
}
M3U8DownloaderConfig config = M3U8DownloaderConfig.build(context);
String saveDir = call.argument("saveDir");
config.setSaveDir(saveDir);
if (call.hasArgument("connTimeout") && call.argument("connTimeout") != JSONObject.NULL) {
int connTimeout = call.argument("connTimeout");
config.setConnTimeout(connTimeout);
}
if (call.hasArgument("readTimeout") && call.argument("readTimeout") != JSONObject.NULL) {
int readTimeout = call.argument("readTimeout");
config.setReadTimeout(readTimeout);
}
if (call.hasArgument("debugMode") && call.argument("debugMode") != JSONObject.NULL) {
boolean debugMode = call.argument("debugMode");
config.setDebugMode(debugMode);
}
result.success(true);
} else if (call.method.equals("download")) {
// 下载方法
if (!call.hasArgument("url")) {
result.error("1", "url必传", "");
return;
}
String url = call.argument("url");
M3U8Downloader.getInstance().download(url);
result.success(null);
} else if (call.method.equals("pause")) {
// 暂停下载
if (!call.hasArgument("url")) {
result.error("1", "url必传", "");
return;
}
String url = call.argument("url");
M3U8Downloader.getInstance().pause(url);
result.success(null);
} else if (call.method.equals("cancel")) {
// 取消下载
if (!call.hasArgument("url")) {
result.error("1", "url必传", "");
return;
}
String url = call.argument("url");
boolean isDelete = false;
if (call.hasArgument("isDelete")) {
isDelete = call.argument("isDelete");
}
if (isDelete) {
M3U8Downloader.getInstance().cancelAndDelete(url, null);
} else {
M3U8Downloader.getInstance().pause(url);
}
result.success(null);
} else if (call.method.equals("isRunning")) {
// 获取运行状态
result.success(M3U8Downloader.getInstance().isRunning());
} else if (call.method.equals("getM3U8Path")) {
// 获取M3U8存储路径
if (!call.hasArgument("url")) {
result.error("1", "url必传", "");
return;
}
String url = call.argument("url");
result.success(M3U8Downloader.getInstance().getM3U8Path(url));
} else {
result.notImplemented();
}
} catch (JSONException e) {
result.error("error", "JSON error: " + e.getMessage(), null);
} catch (Exception e) {
result.error("error", "M3u8Downloader error: " + e.getMessage(), null);
}
}
Flutter端消息处理:
class M3u8Downloader {
static const MethodChannel _channel = const MethodChannel('vincent/m3u8_downloader', JSONMethodCodec());
static _GetCallbackHandle _getCallbackHandle = (Function callback) => PluginUtilities.getCallbackHandle(callback);
/// 初始化下载器
///
/// 在使用之前必须调用
static Future initialize() async {
final CallbackHandle handle = _getCallbackHandle(callbackDispatcher);
if (handle == null) {
return false;
}
final bool r = await _channel.invokeMethod('initialize', [handle.toRawHandle()]);
return r ?? false;
}
/// 下载文件
///
/// - [url] 下载链接地址
/// - [callback] 回调函数
static void download({String url, Function progressCallback, Function successCallback, Function errorCallback}) async {
assert(url != null && url != "");
Map params = {
"url": url
};
await _channel.invokeMethod("download", params);
}
/// 配置方法
///
/// - [saveDir] 文件保存位置
/// - [connTimeout] 网络连接超时时间
/// - [readTimeout] 文件读取超时时间
/// - [debugMode] 调试模式
static void config({ String saveDir, int connTimeout, int readTimeout, bool debugMode}) async {
assert(Directory(saveDir).existsSync());
await _channel.invokeMethod("config", {
"saveDir": saveDir,
"connTimeout": connTimeout,
"readTimeout": readTimeout,
"debugMode": debugMode
});
}
/// 暂停下载
///
/// - [url] 暂停指定的链接地址
static void pause(String url) async {
assert(url != null && url != "");
await _channel.invokeMethod("pause", { "url": url });
}
/// 取消下载
///
/// - [url] 下载链接地址
/// - [isDelete] 取消时是否删除文件
static void cancel(String url, { bool isDelete }) async {
assert(url != null && url != "");
await _channel.invokeMethod("cancel", { "url": url, "isDelete": isDelete });
}
/// 下载状态
static Future isRunning() async {
bool isRunning = await _channel.invokeMethod("isRunning");
return isRunning;
}
/// 通过url获取保存的路径
static Future getM3U8Path(String url) async {
String path = await _channel.invokeMethod("getM3U8Path", { "url": url });
return path;
}
}
直接通过MethodChannel把回调消息发送到Flutter,这是不可取的。因为回调信息是异步的。
思路:再添加一条MethodChannel通道作为回调消息的通知。在Flutter中,定义下载的回调函数,然后把这个函数传给原生端,当原生端的下载的信息发生变化时,通过调用该回调函数,把消息通知出来。
public class FlutterM3U8BackgroundExecutor implements MethodChannel.MethodCallHandler {
private static final String TAG = "M3u8Downloader background";
private static PluginRegistry.PluginRegistrantCallback pluginRegistrantCallback;
private MethodChannel backgroundChannel;
private FlutterEngine backgroundFlutterEngine;
private AtomicBoolean isCallbackDispatcherReady = new AtomicBoolean(false);
public static void setPluginRegistrant(PluginRegistry.PluginRegistrantCallback callback) {
pluginRegistrantCallback = callback;
}
public static void setCallbackDispatcher(Context context, long callbackHandle) {
SharedPreferences prefs = context.getSharedPreferences(M3u8DownloaderPlugin.SHARED_PREFERENCES_KEY, 0);
prefs.edit().putLong(M3u8DownloaderPlugin.CALLBACK_DISPATCHER_HANDLE_KEY, callbackHandle).apply();
}
public boolean isRunning() {
return isCallbackDispatcherReady.get();
}
private void onInitialized() {
// TODO 初始化完成后
}
@Override
public void onMethodCall(MethodCall call, MethodChannel.Result result) {
String method = call.method;
Object arguments = call.arguments;
try {
// 初始化
if (method.equals("didInitializeDispatcher")) {
onInitialized();
isCallbackDispatcherReady.set(true);
}
} catch(Exception e) {
result.error("error", "M3u8Download error: " + e.getMessage(), null);
}
}
// 启用后台Isolate
void startBackgroundIsolate(Context context) {
// 没有运行才启用
if (!isRunning()) {
SharedPreferences p = context.getSharedPreferences(M3u8DownloaderPlugin.SHARED_PREFERENCES_KEY, 0);
long callbackHandle = p.getLong(M3u8DownloaderPlugin.CALLBACK_DISPATCHER_HANDLE_KEY, 0);
startBackgroundIsolate(context, callbackHandle);
}
}
public void startBackgroundIsolate(Context context, long callbackHandle) {
if (backgroundFlutterEngine != null) {
Log.e(TAG, "Background isolate already started");
return;
}
Log.i(TAG, "Starting Background isolate...");
String appBundlePath = FlutterMain.findAppBundlePath(context);
AssetManager assets = context.getAssets();
if (appBundlePath != null && !isRunning()) {
backgroundFlutterEngine = new FlutterEngine(context);
FlutterCallbackInformation flutterCallback = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle);
if (flutterCallback == null) {
Log.e(TAG, "Fatal: failed to find callback");
return;
}
DartExecutor executor = backgroundFlutterEngine.getDartExecutor();
// 初始化通道
initializeMethodChannel(executor);
DartExecutor.DartCallback dartCallback = new DartExecutor.DartCallback(assets, appBundlePath, flutterCallback);
executor.executeDartCallback(dartCallback);
if (pluginRegistrantCallback != null) {
pluginRegistrantCallback.registerWith(new ShimPluginRegistry(backgroundFlutterEngine));
}
}
}
private void initializeMethodChannel(BinaryMessenger isolate) {
backgroundChannel = new MethodChannel(isolate, "vincent/m3u8_downloader_background", JSONMethodCodec.INSTANCE);
backgroundChannel.setMethodCallHandler(this);
}
// 执行dart回调函数
public void executeDartCallbackInBackgroundIsolate(long callbackHandle, Object args) {
backgroundChannel.invokeMethod("", new Object[] {callbackHandle, args});
}
}
void callbackDispatcher() {
// Initialize state necessary for MethodChannels.
WidgetsFlutterBinding.ensureInitialized();
const MethodChannel backgroundChannel = MethodChannel('vincent/m3u8_downloader_background', JSONMethodCodec());
backgroundChannel.setMethodCallHandler((MethodCall call) async {
final dynamic args = call.arguments;
final CallbackHandle handle = CallbackHandle.fromRawHandle(args[0]);
final Function closure = PluginUtilities.getCallbackFromHandle(handle);
if (closure == null) {
print('Fatal: could not find callback');
exit(-1);
}
closure(args[1]);
});
backgroundChannel.invokeMethod('didInitializeDispatcher');
}
if (call.method.equals("initialize")) {
long callbackHandle = ((JSONArray) call.arguments).getLong(0);
flutterM3U8BackgroundExecutor.setCallbackDispatcher(context, callbackHandle);
flutterM3U8BackgroundExecutor.startBackgroundIsolate(context);
result.success(true);
}
if (call.method.equals("download")) {
if (!call.hasArgument("url")) {
result.error("1", "url必传", "");
return;
}
String url = call.argument("url");
final long progressCallbackHandle = call.hasArgument("progressCallback") && call.argument("progressCallback") != JSONObject.NULL ? (long)call.argument("progressCallback") : -1;
final long successCallbackHandle = call.hasArgument("successCallback") && call.argument("successCallback") != JSONObject.NULL ? (long)call.argument("successCallback") : -1;
final long errorCallbackHandle = call.hasArgument("errorCallback") && call.argument("errorCallback") != JSONObject.NULL ? (long)call.argument("errorCallback") : -1;
M3U8Downloader.getInstance().download(url);
M3U8Downloader.getInstance().setOnM3U8DownloadListener(new OnM3U8DownloadListener() {
@Override
public void onDownloadProgress(final M3U8Task task) {
super.onDownloadProgress(task);
if (progressCallbackHandle != -1) {
//下载进度,非UI线程
final Map<String, Object> args = new HashMap<>();
args.put("url", task.getUrl());
args.put("state", task.getState());
args.put("progress", task.getProgress());
args.put("speed", task.getSpeed());
args.put("formatSpeed", task.getFormatSpeed());
args.put("totalSize", task.getFormatTotalSize());
handler.post(new Runnable() {
@Override
public void run() {
flutterM3U8BackgroundExecutor.executeDartCallbackInBackgroundIsolate(progressCallbackHandle, args);
}
});
}
}
@Override
public void onDownloadItem(M3U8Task task, long itemFileSize, int totalTs, int curTs) {
super.onDownloadItem(task, itemFileSize, totalTs, curTs);
//下载切片监听,非UI线程
// channel.invokeMethod();
}
@Override
public void onDownloadSuccess(M3U8Task task) {
super.onDownloadSuccess(task);
String saveDir = MUtils.getSaveFileDir(task.getUrl());
final Map<String, Object> args = new HashMap<>();
args.put("dir", saveDir);
args.put("fileName", saveDir + File.separator + "local.m3u8");
//下载成功
if (successCallbackHandle != -1) {
handler.post(new Runnable() {
@Override
public void run() {
flutterM3U8BackgroundExecutor.executeDartCallbackInBackgroundIsolate(successCallbackHandle, args);
}
});
}
}