總覽
本文簡要介紹了 M3U8 視訊檔案格式,并且用代碼實作下載下傳一個 M3U8 檔案的視訊資源。
背景
前段時間在做視訊真實位址解析下載下傳時候發現很多視訊網站用了 CKplayer,播放的時候傳過來的參數是一個 M3U8 檔案的連結,和普通的視訊檔案不一樣,M3U8 檔案并不是真正的視訊,它一般隻有幾 kb 左右,當時沒想太多,遇到 M3U8 的格式就都沒搞了,最近突發奇想研究了下 M3U8,發現其實下載下傳 M3U8 的資源也挺簡單的。
M3U8介紹
首先我們找到一個測試位址
http://playertest.longtailvideo.com/adaptive/bipbop/gear4/prog_index.m3u8
浏覽器打開下載下傳可以得到一個 prog_index.m3u8 檔案,打開内容如下:
#EXTM3U
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10, no desc
fileSequence0.ts
#EXTINF:10, no desc
fileSequence1.ts
#EXTINF:10, no desc
fileSequence2.ts
#EXTINF:10, no desc
fileSequence3.ts
#EXTINF:10, no desc
fileSequence4.ts
#EXTINF:10, no desc
fileSequence5.ts
#EXTINF:10, no desc
fileSequence6.ts
#EXTINF:10, no desc
fileSequence7.ts
#EXTINF:10, no desc
fileSequence8.ts
#EXTINF:10, no desc
fileSequence9.ts
#EXT-X-ENDLIST
可以看到 M3U8 檔案一般以 #EXTM3U 開頭,接着包含幾行類似 #EXT-X-TARGETDURATION:10 這樣的資訊行,M3U8 檔案具體格式稍微有點多,不在本篇介紹範圍内,感興趣的讀者可以看 m3u8檔案資訊總結 這篇介紹,我們看有關下載下傳的重點,M3U8 檔案包含着許多視訊切片的位址,這些切片資源組合起來實際就是真實的視訊了,我們看到接下來有 #EXTINF:10, no desc 和 fileSequence0.ts 這兩行資訊,前一行包含了時間,後一行包含了此段切片視訊真實位址,不過此位址是相對的,是相對 M3U8檔案的路徑,有的 M3U8 檔案裡面的切片是完整的路徑,而我們隻要解析 M3U8 檔案擷取每段切片位址,下載下傳到本地,然後按順序拼接成一個完整的 ts 檔案即可。
代碼
首先我們編寫一個 M3U8 的實體類
M3U8.java
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class M3U8 {
private String basepath;
private List<Ts> tsList = new ArrayList<>();
private long startTime;// 開始時間
private long endTime;// 結束時間
private long startDownloadTime;// 開始下載下傳時間
private long endDownloadTime;// 結束下載下傳時間
public String getBasepath() {
return basepath;
}
public void setBasepath(String basepath) {
this.basepath = basepath;
}
public List<Ts> getTsList() {
return tsList;
}
public void setTsList(List<Ts> tsList) {
this.tsList = tsList;
}
public void addTs(Ts ts) {
this.tsList.add(ts);
}
public long getStartDownloadTime() {
return startDownloadTime;
}
public void setStartDownloadTime(long startDownloadTime) {
this.startDownloadTime = startDownloadTime;
}
public long getEndDownloadTime() {
return endDownloadTime;
}
public void setEndDownloadTime(long endDownloadTime) {
this.endDownloadTime = endDownloadTime;
}
/**
* 擷取開始時間
*
* @return
*/
public long getStartTime() {
if (tsList.size() > 0) {
Collections.sort(tsList);
startTime = tsList.get(0).getLongDate();
return startTime;
}
return 0;
}
/**
* 擷取結束時間(加上了最後一段時間的持續時間)
*
* @return
*/
public long getEndTime() {
if (tsList.size() > 0) {
Ts m3U8Ts = tsList.get(tsList.size() - 1);
endTime = m3U8Ts.getLongDate() + (long) (m3U8Ts.getSeconds() * 1000);
return endTime;
}
return 0;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("basepath: " + basepath);
for (Ts ts : tsList) {
sb.append("\nts_file_name = " + ts);
}
sb.append("\n\nstartTime = " + startTime);
sb.append("\n\nendTime = " + endTime);
sb.append("\n\nstartDownloadTime = " + startDownloadTime);
sb.append("\n\nendDownloadTime = " + endDownloadTime);
return sb.toString();
}
public static class Ts implements Comparable<Ts> {
private String file;
private float seconds;
public Ts(String file, float seconds) {
this.file = file;
this.seconds = seconds;
}
public String getFile() {
return file;
}
public void setFile(String file) {
this.file = file;
}
public float getSeconds() {
return seconds;
}
public void setSeconds(float seconds) {
this.seconds = seconds;
}
@Override
public String toString() {
return file + " (" + seconds + "sec)";
}
/**
* 擷取時間
*/
public long getLongDate() {
try {
return Long.parseLong(file.substring(0, file.lastIndexOf(".")));
} catch (Exception e) {
return 0;
}
}
@Override
public int compareTo(Ts o) {
return file.compareTo(o.file);
}
}
}
我們就利用 http://playertest.longtailvideo.com/adaptive/bipbop/gear4/prog_index.m3u8 位址做測試,随便建立一個測試類,将下面代碼寫進去,導好該導的包,即可測試了。
public static String TEMP_DIR = "temp";
public static int connTimeout = 30 * 60 * 1000;
public static int readTimeout = 30 * 60 * 1000;
public static String s1 = "http://playertest.longtailvideo.com/adaptive/bipbop/gear4/prog_index.m3u8";
public static void main(String[] args) {
File tfile = new File(TEMP_DIR);
if (!tfile.exists()) {
tfile.mkdirs();
}
M3U8 m3u8ByURL = getM3U8ByURL(s1);
String basePath = m3u8ByURL.getBasepath();
m3u8ByURL.getTsList().stream().parallel().forEach(m3U8Ts -> {
File file = new File(TEMP_DIR + File.separator + m3U8Ts.getFile());
if (!file.exists()) {// 下載下傳過的就不管了
FileOutputStream fos = null;
InputStream inputStream = null;
try {
URL url = new URL(basePath + m3U8Ts.getFile());
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(connTimeout);
conn.setReadTimeout(readTimeout);
if (conn.getResponseCode() == 200) {
inputStream = conn.getInputStream();
fos = new FileOutputStream(file);// 會自動建立檔案
int len = 0;
byte[] buf = new byte[1024];
while ((len = inputStream.read(buf)) != -1) {
fos.write(buf, 0, len);// 寫入流中
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {// 關流
try {
if (inputStream != null) {
inputStream.close();
}
if (fos != null) {
fos.close();
}
} catch (IOException e) {e.printStackTrace();}
}
}
});
System.out.println("檔案下載下傳完畢!");
mergeFiles(tfile.listFiles(), "test.ts");
}
public static M3U8 getM3U8ByURL(String m3u8URL) {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(m3u8URL).openConnection();
if (conn.getResponseCode() == 200) {
String realUrl = conn.getURL().toString();
BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream()));
String basepath = realUrl.substring(0, realUrl.lastIndexOf("/") + 1);
M3U8 ret = new M3U8();
ret.setBasepath(basepath);
String line;
float seconds = 0;
int mIndex;
while ((line = reader.readLine()) != null) {
if (line.startsWith("#")) {
if (line.startsWith("#EXTINF:")) {
line = line.substring(8);
if ((mIndex = line.indexOf(",")) != -1) {
line = line.substring(0, mIndex + 1);
}
try {
seconds = Float.parseFloat(line);
} catch (Exception e) {
seconds = 0;
}
}
continue;
}
if (line.endsWith("m3u8")) {
return getM3U8ByURL(basepath + line);
}
ret.addTs(new M3U8.Ts(line, seconds));
seconds = 0;
}
reader.close();
return ret;
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return null;
}
public static boolean mergeFiles(File[] fpaths, String resultPath) {
if (fpaths == null || fpaths.length < 1) {
return false;
}
if (fpaths.length == 1) {
return fpaths[0].renameTo(new File(resultPath));
}
for (int i = 0; i < fpaths.length; i++) {
if (!fpaths[i].exists() || !fpaths[i].isFile()) {
return false;
}
}
File resultFile = new File(resultPath);
try {
FileOutputStream fs = new FileOutputStream(resultFile, true);
FileChannel resultFileChannel = fs.getChannel();
FileInputStream tfs;
for (int i = 0; i < fpaths.length; i++) {
tfs = new FileInputStream(fpaths[i]);
FileChannel blk = tfs.getChannel();
resultFileChannel.transferFrom(blk, resultFileChannel.size(), blk.size());
tfs.close();
blk.close();
}
fs.close();
resultFileChannel.close();
} catch (Exception e) {
e.printStackTrace();
return false;
}
// for (int i = 0; i < fpaths.length; i ++) {
// fpaths[i].delete();
// }
return true;
}
結果
我們可以看到,在我的 temp 目錄下一句下載下傳好了 10 個切片檔案,并且在項目根目錄下也已經合并成一個整體的檔案了。(這裡要注意的是有些系統 File.listfiles 方法得到的檔案順序并不是按照檔案建立時間排序的,是以這裡可能需要自行排序)。

總結
有些M3U8格式可能不完全和本次測試的格式一樣,比如有的切片是完整路徑,有的裡面還嵌套了一層 M3U8,有的 ts 切片甚至還加密了,但萬變不離其宗,也就多幾步操作而已,好了,祝大家都能下載下傳自己想要的片吧。