VideoSpider.java
## 使用方法
下载代码,修改C#或Java代码的这四个参数,改成对应的自己要下载的地址、key和保存路径,然后运行即可
string DRMKey = "11, 22, 33, 44, 55, 66, 77, 88, 99, 00, 111, 111, 111, 111, 111, 111"; //DRMKey
string m3u8Url = "https://XXXXXXX/123.m3u8"; //m3u8在线地址
string savePath = "D:\\VIDEO\\"; //保存的本地路径
string saveFileName = "VIDEO_FILE_NAME"; //保存的文件(夹)名称,如果为空 则使用默认m3u8文件名
C#可以创建个控制台应用,代码全部复制粘贴,修改以上参数就可以运行了。Java的也可以直接下载运行,我的JDK版本1.8
本来想做个桌面应用程序的,结果嫌麻烦,费时间就没做了。哪位看官要是有时间可以做个桌面程序方便操作,另外可以加上多线程去下载试试。
> **DRMKey参数说明**:解密视频需要key和IV, 我们可以看到 IV在m3u8文件里有,每一个.ts文件都有一个对应的IV,#EXT-X-KEY:后面的 **IV=xxxxxx** 就是我们需要用到的 IV了, 可是key却没有,需要在网页上找找了,有一点基础的基本都能找到。下面是我找到key的过程,仅供参考:
> 打开控制台后,重新加载页面,发现一个 **qiniu-web-player.js** 在控制台输出了一些配置信息和日志记录,其中的 hls.DRMKey 引起了我的注意
数组长度也是16位,刚好加解密用到的key的长度也是16位,所以这个应该就是AES加解密要用到的key了。不过需要先转换一下。。
![DRMKey](https://github.com/Myron1024/m3u8_download/blob/master/screenshot/DRMKey.png?raw=true)
> 经过一番搜索得知转换步骤为:把数组里每个元素转换成16进制字符串,然后把16进制字符串转为ASCII码,这16个ASCII字符最终拼接出来的结果就是AES的key了。
> 不过**此处DRMKey的参数值只需要配置成数组的字符串格式即可(不包括前后中括号)**
C# 代码如下:
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace VideoDownload
{
class Program
{
private static List error_arr = new List();
static void Main(string[] args)
{
string DRMKey = "50, 101, 54, 52, 54, 55, 49, 48, 100, 56, 97, 54, 56, 57, 55, 102"; //DRMKey
string m3u8Url = "https://xxxxxxxxxxx/video_m3u8_drm_1e352f596a55eaaedb2c0a0742ef83e8.m3u8?pm3u8&e=1698347593&token=MQZe3HEuDyokCVRSKOiKUv7FEHqhkZDDy2bqB9lV:EFxe81GER99XZLO2ExcxOkQKthk=&id=723"; //m3u8在线地址
string savePath = "D:\\VIDEO\\"; //保存的本地路径
string saveFileName = "VIDEO_FILE_NAME"; //保存的文件(夹)名称,如果为空 则使用默认m3u8文件名
try
{
// 创建本地保存目录
int index = m3u8Url.LastIndexOf("/");
string dirName = string.IsNullOrEmpty(saveFileName) ? m3u8Url.Substring(index + 1) : saveFileName;
string finalSavePath = savePath + dirName + "\\";
if (!Directory.Exists(finalSavePath))
{
Directory.CreateDirectory(finalSavePath);
}
// 读取m3u8文件内容
string m3u8Content = HttpGet(m3u8Url);
//string m3u8Content = File.ReadAllText("D:/test.m3u8");
string aesKey = getAESKey(DRMKey);
//Console.WriteLine("aesKey:" + aesKey);
Uri uri = new Uri(m3u8Url);
string domain = uri.Scheme + "://" + uri.Authority;
//Console.WriteLine("m3u8域名为:" + domain);
List tsList = Regex.Matches(m3u8Content, @"\n(.*?.ts)").Select(m => m.Value).ToList();
List ivList = Regex.Matches(m3u8Content, @"IV=(.*?)\n").Select(m => m.Value).ToList();
if (tsList.Count != ivList.Count || tsList.Count == 0)
{
Console.WriteLine("m3u8Content 解析失败");
}
else
{
Console.WriteLine("m3u8Content 解析完成,共有 " + ivList.Count + " 个ts文件");
for (int i = 0; i < tsList.Count; i++)
{
string ts = tsList[i].Replace("\n", "");
string iv = ivList[i].Replace("\n", "");
iv = iv.Replace("IV=0x", "");
iv = iv.Substring(0, 16); //去除前缀,取IV前16位
int idx = ts.LastIndexOf("/");
string tsFileName = ts.Substring(idx + 1);
try
{
string saveFilepath = finalSavePath + tsFileName;
if (!File.Exists(saveFilepath))
{
Console.WriteLine("开始下载ts: " + domain + ts);
byte[] encByte = HttpGetByte(domain + ts);
if (encByte != null)
{
Console.WriteLine("开始解密, IV -> " + iv);
byte[] decByte = null;
try
{
decByte = AESDecrypt(encByte, aesKey, iv);
}
catch (Exception e1)
{
error_arr.Add(tsFileName);
Console.WriteLine("解密ts文件异常。" + e1.Message);
}
if (decByte != null)
{
//保存视频文件
File.WriteAllBytes(saveFilepath, decByte);
Console.WriteLine(tsFileName + " 下载完成");
}
}
else
{
error_arr.Add(tsFileName);
Console.WriteLine("HttpGetByte 结果返回null");
}
}
else
{
Console.WriteLine($"文件 {saveFilepath} 已存在");
}
}
catch (Exception ee)
{
error_arr.Add(tsFileName);
Console.WriteLine("发生异常。" + ee);
}
}
}
}
catch (Exception ex)
{
Console.WriteLine("发生异常。" + ex);
}
Console.WriteLine("所有操作已完成. 保存目录 " + savePath);
if (error_arr.Count > 0)
{
List list = error_arr.Distinct().ToList();
Console.WriteLine($"其中 共有{error_arr.Count}个文件下载失败:");
list.ForEach(x =>
{
Console.WriteLine(x);
});
}
Console.ReadKey();
}
private static string getAESKey(string key)
{
string[] arr = key.Split(",");
string aesKey = "";
for (int i = 0; i < arr.Length; i++)
{
string tmp = int.Parse(arr[i].Trim()).ToString("X"); //10进制转16进制
tmp = HexStringToASCII(tmp);
aesKey += tmp;
}
return aesKey;
}
///
/// 十六进制字符串转换为ASCII
///
/// 一条十六进制字符串
/// 返回一条ASCII码
public static string HexStringToASCII(string hexstring)
{
byte[] bt = HexStringToBinary(hexstring);
string lin = "";
for (int i = 0; i < bt.Length; i++)
{
lin = lin + bt[i] + " ";
}
string[] ss = lin.Trim().Split(new char[] { ' ' });
char[] c = new char[ss.Length];
int a;
for (int i = 0; i < c.Length; i++)
{
a = Convert.ToInt32(ss[i]);
c[i] = Convert.ToChar(a);
}
string b = new string(c);
return b;
}
///
/// 16进制字符串转换为二进制数组
///
/// 用空格切割字符串
/// 返回一个二进制字符串
public static byte[] HexStringToBinary(string hexstring)
{
string[] tmpary = hexstring.Trim().Split(' ');
byte[] buff = new byte[tmpary.Length];
for (int i = 0; i < buff.Length; i++)
{
buff[i] = Convert.ToByte(tmpary[i], 16);
}
return buff;
}
///
/// AES解密
///
///
///
///
///
public static byte[] AESDecrypt(byte[] cipherText, string Key, string IV)
{
// Check arguments.
if (cipherText == null || cipherText.Length <= 0)
throw new ArgumentNullException("cipherText");
if (Key == null || Key.Length <= 0)
throw new ArgumentNullException("Key");
if (IV == null || IV.Length <= 0)
throw new ArgumentNullException("IV");
// Declare the string used to hold
// the decrypted text.
byte[] res = null;
// Create an AesManaged object
// with the specified key and IV.
using (AesManaged aesAlg = new AesManaged())
{
aesAlg.Key = Encoding.ASCII.GetBytes(Key);
aesAlg.IV = Encoding.ASCII.GetBytes(IV);
aesAlg.Mode = CipherMode.CBC;
aesAlg.Padding = PaddingMode.PKCS7;
// Create a decrytor to perform the stream transform.
ICryptoTransform decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV);
// Create the streams used for decryption.
using (MemoryStream msDecrypt = new MemoryStream(cipherText))
{
using (CryptoStream csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
{
byte[] tmp = new byte[cipherText.Length + 32];
int len = csDecrypt.Read(tmp, 0, cipherText.Length + 32);
byte[] ret = new byte[len];
Array.Copy(tmp, 0, ret, 0, len);
res = ret;
}
}
}
return res;
}
public static string HttpGet(string url)
{
try
{
HttpWebRequest request = (HttpWebRequest)HttpWebRequest.Create(url);
request.Timeout = 20000;
var response = (HttpWebResponse)request.GetResponse();
using (StreamReader reader = new StreamReader(response.GetResponseStream(), Encoding.UTF8))
{
return reader.ReadToEnd();
}
}
catch (Exception ex)
{
Console.Write("HttpGet 异常," + ex.Message);
Console.Write(ex);
return "";
}
}
public static byte[] HttpGetByte(string url)
{
try
{
byte[] arraryByte = null;
HttpWebRequest request = (HttpWebRequest)HttpWebRequest.Create(url);
request.Timeout = 20000;
request.Method = "GET";
using (WebResponse wr = request.GetResponse())
{
int length = (int)wr.ContentLength;
using (StreamReader reader = new StreamReader(wr.GetResponseStream(), Encoding.UTF8))
{
HttpWebResponse response = wr as HttpWebResponse;
Stream stream = response.GetResponseStream();
//读取到内存
MemoryStream stmMemory = new MemoryStream();
byte[] buffer1 = new byte[length];
int i;
//将字节逐个放入到Byte 中
while ((i = stream.Read(buffer1, 0, buffer1.Length)) > 0)
{
stmMemory.Write(buffer1, 0, i);
}
arraryByte = stmMemory.ToArray();
stmMemory.Close();
}
}
return arraryByte;
}
catch (Exception ex)
{
Console.Write("HttpGetByte 异常," + ex.Message);
Console.Write(ex);
return null;
}
}
}
}
java VideoDownload代码如下:
public class VideoDownload {
public static void main(String[] args) {
String DRMKey = "50, 101, 54, 52, 54, 55, 49, 48, 100, 56, 97, 54, 56, 57, 55, 102";
String m3u8Url = "https://xxxxxxxxxm/video_m3u8_drm_1e352f596a55eaaedb2c0a0742ef83e8.m3u8?pm3u8&e=1698347593&token=MQZe3HEuDyokCVRSKOiKUv7FEHqhkZDDy2bqB9lV:EFxe81GER99XZLO2ExcxOkQKthk=&id=723"; //m3u8在线地址
String savePath = "D:\\VIDEO\\"; //保存的本地路径
String saveFileName = "VIDEO_FILE_NAME"; //保存的文件(夹)名称,如果为空 则使用默认m3u8文件名
VideoSpider.run(DRMKey, m3u8Url, savePath, saveFileName);
}
}
java CommonUtils 工具类 代码如下:
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.Charset;
import java.security.Security;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class CommonUtils {
public static String getAESKey(String key) {
String[] arr = key.split(",");
String aesKey = "";
for (int i = 0; i < arr.length; i++) {
String tmp = intTohex(Integer.valueOf(arr[i].trim())); //10进制转16进制
tmp = convertHexToString(tmp);
aesKey += tmp;
}
return aesKey;
}
public static byte[] decryptAESByKey(byte[] source, SecretKey key, String iv) throws Exception {
Security.addProvider(new BouncyCastleProvider());
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");//"算法/模式/补码方式"
//使用CBC模式,需要一个向量iv,可增加加密算法的强度
IvParameterSpec ips = new IvParameterSpec(iv.getBytes());
cipher.init(Cipher.DECRYPT_MODE, key, ips);
return cipher.doFinal(source);
}
//将Base64编码后的AES秘钥转换成SecretKey对象
public static SecretKey loadKeyAES(String base64Key) throws Exception{
byte[] bytes = base64Key.getBytes("ASCII");
SecretKeySpec key = new SecretKeySpec(bytes, "AES");
return key;
}
/**
* int型转换成16进制
* @param n
* @return
*/
public static String intTohex(int n) {
StringBuffer s = new StringBuffer();
String a;
char[] b = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};
while (n != 0) {
s = s.append(b[n % 16]);
n = n / 16;
}
a = s.reverse().toString();
if ("".equals(a)) {
a = "00";
}
if (a.length() == 1) {
a = "0" + a;
}
return a;
}
/**
* 16进制转Ascii
* @param hex
* @return
*/
public static String convertHexToString(String hex){
StringBuilder sb = new StringBuilder();
StringBuilder temp = new StringBuilder();
//49204c6f7665204a617661 split into two characters 49, 20, 4c...
for( int i=0; i List> zip(List... lists) {
List> zipped = new ArrayList>();
for (List list : lists) {
for (int i = 0, listSize = list.size(); i < listSize; i++) {
List list2;
if (i >= zipped.size())
zipped.add(list2 = new ArrayList());
else
list2 = zipped.get(i);
list2.add(list.get(i));
}
}
return zipped;
}
public static List getMatchers(String regex, String source){
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(source);
System.out.println("matcher 地址:" +matcher);
List list = new ArrayList<>();
while (matcher.find()) {
list.add(matcher.group());
}
return list;
}
/**
* 执行cmd命令,并获取返回结果
* @param commandStr
*/
public static void exeCmd(String commandStr) {
BufferedReader br = null;
try {
//执行cmd命令
Process p = Runtime.getRuntime().exec(commandStr);
//返回值是流,以便读取。
br = new BufferedReader(new InputStreamReader(p.getInputStream(), Charset.forName("GBK")));
String line = null;
StringBuilder sb = new StringBuilder();
while ((line = br.readLine()) != null) {
sb.append(line + "\n");
}
System.out.println(sb.toString());
} catch (Exception e) {
e.printStackTrace();
} finally {
if (br != null){
try {
br.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}
VideoSpider.java 代码如下:
import javax.crypto.SecretKey;
import java.awt.*;
import java.io.File;
import java.io.FileOutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLDecoder;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
public class VideoSpider {
private static List error_arr = new ArrayList<>();
/**
* 下载视频
* @param DRMKey key
* @param m3u8Url m3u8在线地址
* @param savePath 视频保存目录
* @param saveFileName 保存的文件(夹)名
*/
public static void run (String DRMKey, String m3u8Url, String savePath, String saveFileName) {
try {
String StartDate = new SimpleDateFormat("HH:mm:ss").format(new Date());
System.out.println(StartDate + " ---- 开始下载 ----");
// 根据保存的文件名或m3u8文件名,创建本地保存目录
int index = m3u8Url.lastIndexOf("/");
String dirName = "".equals(saveFileName) ? m3u8Url.substring(index + 1) : saveFileName;
String finalSavePath = savePath;
File dir = new File(finalSavePath);
if (!dir.exists()) {
dir.mkdir();
}
// ---------------- 读取线上m3u8文件内容 ----------------
System.out.println(" ---- 地址 ----"+ m3u8Url);
String m3u8Content = CommonUtils.doGet(m3u8Url);
// ---------------- 读取本地m3u8文件测试 Start ----------------
// StringBuilder m3u8Content = new StringBuilder();
// String filePath = "D:\\test2.m3u8";
// File file = new File(filePath);
// try {
// FileReader reader = new FileReader(file);
// BufferedReader bufferedReader = new BufferedReader(reader);
// String tempString = null;
// while ((tempString = bufferedReader.readLine()) != null) {
// m3u8Content.append(tempString);
// m3u8Content.append("\r\n");
// }
// bufferedReader.close();
// } catch (FileNotFoundException e) {
// e.printStackTrace();
// } catch (IOException e) {
// e.printStackTrace();
// }
// ---------------- 读取本地m3u8文件测试 End ----------------
String aesKey = CommonUtils.getAESKey(DRMKey);
System.out.println("m3u8Content aesKey"+aesKey);
SecretKey skey = CommonUtils.loadKeyAES(aesKey);
// 根据在线m3u8地址 获取域名,如果操作本地m3u8文件,则直接手动设置 domain
URL url = new URL(m3u8Url);
String domain = url.getProtocol() + "://" + url.getAuthority();
List tsList = CommonUtils.getMatchers("(/ts.*?)\r\n#", m3u8Content.toString());
List ivList = CommonUtils.getMatchers("IV=(.*?)\r\n", m3u8Content.toString());
if (tsList.size() != ivList.size()) {
System.out.println("m3u8Content 解析失败");
} else {
System.out.println("m3u8Content 解析完成,共有 " + ivList.size() + " 个ts文件");
List> listTuple = CommonUtils.zip(tsList, ivList);
listTuple.forEach(x -> {
String ts = x.get(0).replace("\r\n#", "");
System.out.println("m3u8Content 解析完成,ts " +ts);
String iv = x.get(1).replace("\r\n", "");
iv = iv.replace("IV=0x", "");
iv = iv.substring(0, 16); //去除前缀,取IV前16位
int idx = ts.lastIndexOf("/");
String tsFileName = ts.substring(idx + 1);
try {
String saveFilepath ="D:\\VIDEO\\"+ iv+".ts";
File saveFile = new File(saveFilepath);
if (!saveFile.exists()) {
System.out.println("开始下载ts: " + domain + ts);
byte[] encByte = CommonUtils.doGetByteArr(domain + ts);
if (encByte != null) {
System.out.println("开始解密, IV -> " + iv);
byte[] decByte = null; //解密视频流
try {
decByte = CommonUtils.decryptAESByKey(encByte, skey, iv);
} catch (Exception e) {
error_arr.add(tsFileName);
System.out.println("解密ts文件["+tsFileName+"]异常。" + e.getMessage());
e.printStackTrace();
}
if (decByte != null) {
//保存视频文件
FileOutputStream fos = new FileOutputStream(saveFile);
fos.write(decByte,0,decByte.length);
fos.flush();
fos.close();
Integer ii = listTuple.indexOf(x);
System.out.println(tsFileName + " 下载完成. " + (ii + 1) + "/" + ivList.size());
}
} else {
error_arr.add(tsFileName);
System.out.println("doGetByteArr 结果返回null");
}
} else {
System.out.println("文件 " + saveFilepath + " 已存在");
}
} catch (Exception e) {
error_arr.add(tsFileName);
e.printStackTrace();
}
});
System.out.println("所有操作已完成. 保存目录 " + finalSavePath);
if (error_arr.size() > 0) {
List list = error_arr.stream().distinct().collect(Collectors.toList());
System.out.println("其中 共有" + list.size() + "个文件下载失败:");
list.forEach(x -> {
System.out.println(x);
});
} else {
// 文件全部下载成功,调用 cmd的 copy /b命令合并 eg: CommonUtils.exeCmd("cmd /c copy /b D:\\cmdtest\\*.ts D:\\cmdtest\\newfile.ts");
CommonUtils.exeCmd("cmd /c copy /b " + finalSavePath + "*.ts " + finalSavePath + dirName + ".ts");
}
String endDate = new SimpleDateFormat("HH:mm:ss").format(new Date());
System.out.println(endDate + " ---- 下载完成 ----");
}
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
}
}
原文:点击跳转