基于Flutter的m3u8下载器

基于Flutter的m3u8下载器

Flutter m3u8下载器。后台任务下载,支持加密下载。
只实现了Android端,并且只支持单m3u8视频下载(m3u8文件包含了多个ts文件,本质是多个ts同时下载)。

项目地址:m3u8_downloader

一、m3u8文件内容分析

用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
......

二、实现下载的思路

  1. 解析m3u8文件。获取到.ts文件、.key文件的下载地址。
    /**
     * 将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;
    }
  1. 下载.ts .key文件。使用Executors、ExecutorService并发下载。
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);
           }
       }
   });
}
  1. 通过md5加密.m3u8文件 .ts文件、.key文件的下载地址。这样有两个好处,一是可以避免文件重复下载,二是有利于文件重组。
/**
 * 加密TS的URL地址
 */
public String obtainEncodeTsFileName(){
    if (url == null)return "error.ts";
    return MD5Utils.encode(url).concat(".ts");
}
  1. 文件重组,重新定义根m3u8。创建根文件夹,把所有的.ts文件 .key文件集中起来。
	/**
     * 生成本地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和原生相互通信

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;
  }
}

四、下载的回调信息怎么发送到Flutter?

直接通过MethodChannel把回调消息发送到Flutter,这是不可取的。因为回调信息是异步的。

思路:再添加一条MethodChannel通道作为回调消息的通知。在Flutter中,定义下载的回调函数,然后把这个函数传给原生端,当原生端的下载的信息发生变化时,通过调用该回调函数,把消息通知出来。

原生 添加MethodChannel

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});
    }
}

Flutter添加MethodChannel

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');
}

需要注意的地方

  1. 原生端初始化时,初始化FlutterM3U8BackgroundExecutor
if (call.method.equals("initialize")) {
  long callbackHandle = ((JSONArray) call.arguments).getLong(0);

  flutterM3U8BackgroundExecutor.setCallbackDispatcher(context, callbackHandle);
  flutterM3U8BackgroundExecutor.startBackgroundIsolate(context);
  result.success(true);
}
  1. 下载函数中去回调方法
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);
          }
        });
      }
    }

你可能感兴趣的:(Flutter,flutter,android,java)