M3U8 Dumper

This commit is contained in:
Vologhat
2022-07-10 14:11:27 +03:00
parent 1c20fd4c19
commit 1f859997f4
9 changed files with 221 additions and 455 deletions
@@ -1,6 +0,0 @@
package ru.vtosters.lite.downloaders.music;
public enum KeyType {
NONE,
AES128
}
@@ -1,179 +0,0 @@
package ru.vtosters.lite.downloaders.music;
import android.util.Log;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Map;
import ru.vtosters.lite.net.NetClient;
import ru.vtosters.lite.net.NetRequest;
public class M3U8 {
private final String url;
private final OutputStream output;
private final Callback callback;
private final int returnChunk;
public M3U8(String url, String savedFileName, OutputStream output, Callback callback, int chunksNum) {
try {
this.url = url;
var file = new File(savedFileName).getAbsoluteFile();
file.getParentFile().mkdirs();
this.output = new FileOutputStream(file);
this.callback = callback;
this.returnChunk = chunksNum - 1;
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
}
}
public static InputStream getStream(NetClient client, URL url) {
var request = new NetRequest.Builder()
.url(url.toString())
.build();
try {
var response = client.newCall(request).execute();
if (!response.isSuccessful()) {
return null;
}
return response.getDataStream();
} catch (IOException e) {
return null;
}
}
// пожалуйста не смотрите сюда
public boolean run(NetClient client) {
try {
URL mediaURL;
URL m3u8Url = new URL(url);
int headerSize = 0;
try (InputStream is = getStream(client, m3u8Url)) {
if (is == null) {
return false;
}
try (BufferedReader br = new BufferedReader(new InputStreamReader(is))) {
String line;
ArrayList<Map.Entry<Long, URL>> urls = new ArrayList<>();
long newUrl = 0;
while ((line = br.readLine()) != null) {
headerSize += line.getBytes().length;
line = line.trim();
Property property = Property.checkProperty(line);
if (property == null) {
newUrl = 0;
} else if (property.type.equals("EXT-X-STREAM-INF")) {
if (property.properties.get("BANDWIDTH") != null)
newUrl = Long.parseLong(property.properties.get("BANDWIDTH"));
else newUrl = 1;
} else if (property.type.equals("FILE") && newUrl > 0) {
urls.add(new java.util.AbstractMap.SimpleEntry<>(newUrl, new URL(m3u8Url, line)));
newUrl = 0;
} else {
newUrl = 0;
}
}
Collections.sort(urls, new Comparator<>() {
@Override
public int compare(Map.Entry<Long, URL> o1, Map.Entry<Long, URL> o2) {
return -Long.compare(o1.getKey(), o2.getKey());
}
});
if (urls.size() > 0) {
m3u8Url = urls.get(0).getValue();
}
}
this.callback.onProgress(5);
mediaURL = m3u8Url;
ArrayList<TSDownload> tsDownloads = new ArrayList<>();
var iis = getStream(client, mediaURL);
if (iis == null) {
return false;
}
try (BufferedReader br = new BufferedReader(new InputStreamReader(iis))) {
var type = KeyType.NONE;
var key = new byte[16];
var iv = new byte[16];
String line;
while ((line = br.readLine()) != null) {
line = line.trim();
var property = Property.checkProperty(line);
if (property == null)
continue;
else if (property.type.equals("EXT-X-KEY")) {
if ("NONE".equals(property.properties.get("METHOD"))) {
type = KeyType.NONE;
continue;
}
type = KeyType.AES128;
var keyUrl = new URL(mediaURL, property.properties.get("URI"));
var keyStream = getStream(client, keyUrl);
if (keyStream == null) {
return false;
}
try (InputStream i = keyStream) {
var keyLength = is.read(key);
if (keyLength != key.length) {
throw new RuntimeException("wtf");
}
}
if (property.properties.get("IV") != null) {
String ivstr = property.properties.get("IV");
ivstr = ivstr.substring(2);
for (int i = 0; i < iv.length; i++) {
iv[i] = (byte) Integer.parseInt(ivstr.substring(i * 2, (i + 1) * 2), 16);
}
} else {
Arrays.fill(iv, (byte) 0);
}
} else if (property.type.equals("FILE")) {
var url = new URL(mediaURL, line);
tsDownloads.add(new TSDownload(url, type, key, iv));
for (var i = iv.length; i > 0; i--) {
iv[i - 1] = (byte) (iv[i - 1] + 1);
if (iv[i - 1] != 0) {
break;
}
}
}
}
}
this.callback.onProgress(10);
try (var fileStream = output) {
for (var download : tsDownloads) {
if (download.start(client)) {
fileStream.write(download.getData());
this.callback.onSizeReceived((long) download.getData().length * tsDownloads.size(), headerSize);
this.callback.onProgress(10 + Math.round(80.0f * tsDownloads.indexOf(download) / tsDownloads.size()));
if (this.returnChunk == tsDownloads.indexOf(download)) {
return true;
} else {
return false;
}
}
}
}
}
} catch (Exception e) {
Log.e("VTosters Lite", e.toString());
return false;
}
return true;
}
}
@@ -0,0 +1,55 @@
package ru.vtosters.lite.downloaders.music;
import android.util.Log;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import javax.crypto.NoSuchPaddingException;
import ru.vtosters.lite.utils.StreamUtils;
import ru.vtosters.lite.music.TransportStream;
import ru.vtosters.lite.music.VKM3UParser;
public class M3UDownloader {
private String mURL;
private File mOutDir;
private Callback mCallback;
public M3UDownloader(String url, File outDir, Callback callback) {
mURL = url;
mOutDir = outDir;
mCallback = callback;
}
public void execute() {
VKM3UParser parser = new VKM3UParser(mURL);
List<TransportStream> tses = parser.getTransportStreams();
mCallback.onProgress(5);
for (TransportStream ts : tses) {
String tsURL = parser.getBaseUrl() + ts.getName();
Log.d("M3UDownloader", "Downloading " + tsURL);
try {
InputStream is = StreamUtils.openStream(tsURL);
byte[] content = TransportStream.METHOD_AES128.equals(ts.getMethod())
? StreamUtils.decodeStream(is, ts.getKeyURL())
: StreamUtils.readAllBytes(is);
File tsDump = new File(mOutDir, ts.getName());
StreamUtils.writeToFile(tsDump, content);
mCallback.onSizeReceived((long) content.length * tses.size(), parser.getHeapSize());
mCallback.onProgress(10 + Math.round(80.0f * tses.indexOf(ts) / tses.size()));
} catch (IOException | NoSuchPaddingException | NoSuchAlgorithmException | InvalidAlgorithmParameterException | InvalidKeyException e) {
e.printStackTrace();
mCallback.onFailure();
return;
}
}
mCallback.onSuccess();
}
}
@@ -1,140 +0,0 @@
package ru.vtosters.lite.downloaders.music;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
public class Property {
public String type;
public Map<String, String> properties;
public String[] values;
public String value;
@NonNull
public String toString() {
StringBuilder sb = new StringBuilder("Property type = ");
sb.append(type);
if (properties != null) {
sb.append(", properties = { ");
for (Map.Entry<String, String> i : properties.entrySet()) {
sb.append(i.getKey());
sb.append(" = ");
sb.append(i.getValue());
sb.append(", ");
}
sb.append("}");
}
if (values != null) {
sb.append(", values = [ ");
for (String i : values) {
sb.append(i);
sb.append(", ");
}
sb.append("]");
}
return sb.toString();
}
public static Property checkProperty(String line) {
Property property = parseLine(line);
if (property.type.equals("FILE")) return property;
if (property.type.equals("EXT-X-STREAM-INF") && property.properties != null) {
if (property.properties.get("BANDWIDTH") != null) {
try {
Long.parseLong(property.properties.get("BANDWIDTH"));
} catch (NumberFormatException e) {
return null;
}
}
return property;
}
if (property.type.equals("EXTINF") && property.values != null) {
// #EXTINF:10.23,
try {
Double.parseDouble(property.values[0]);
} catch (NumberFormatException e) {
return null;
}
return property;
}
if (property.type.equals("EXT-X-KEY") && property.properties != null) {
// #EXT-X-KEY:METHOD=AES-128,URI=\"(.*)\",IV=0[xX](.{32})
boolean aes = "AES-128".equals(property.properties.get("METHOD"));
if (!aes && !"NONE".equals(property.properties.get("METHOD"))) {
return null;
}
if (!property.properties.containsKey("URI") && aes) {
return null;
}
if (property.properties.containsKey("IV") && !property.properties.get("IV").matches("0[xX](.{32})")) {
return null;
}
return property;
}
return null;
}
public static Property parseLine(String line) {
Property ret = new Property();
if (!line.startsWith("#")) {
ret.type = "FILE";
ret.value = line;
return ret;
}
String[] types = line.split(":", 2);
ret.type = types[0].substring(1);
if (types.length == 1) {
return ret;
}
ret.value = types[1];
ret.values = splitProperty(types[1]);
ret.properties = new HashMap<>();
for (String p : ret.values) {
if (p.length() > 0) {
String[] ps = p.split("=", 2);
if (ps.length == 1) {
ret.properties.put(p, null);
} else {
String k = ps[0];
String v = ps[1];
if (v.charAt(0) == '"' && v.charAt(v.length() - 1) == '"') {
v = v.substring(1, v.length() - 1);
}
ret.properties.put(k, v);
}
}
}
return ret;
}
public static String[] splitProperty(String line) {
ArrayList<String> list = new ArrayList<>();
boolean escape = false;
boolean quote = false;
StringBuilder sb = new StringBuilder();
for (char c : line.toCharArray()) {
if (escape) {
escape = false;
} else if (quote) {
if (c == '"') quote = false;
} else {
if (c == ',') {
list.add(sb.toString());
sb.delete(0, sb.length());
continue;
}
if (c == '\\') escape = true;
if (c == '"') quote = true;
}
sb.append(c);
}
list.add(sb.toString());
return list.toArray(new String[]{});
}
}
@@ -1,112 +0,0 @@
package ru.vtosters.lite.downloaders.music;
import android.util.Log;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import ru.vtosters.lite.downloaders.music.utils.ByteUtils;
import ru.vtosters.lite.net.NetClient;
public class TSDownload {
private static final int TS_PACKET_SIZE = 188;
private final URL url;
private final KeyType type;
private final byte[] key;
private final byte[] iv;
private byte[] bytes;
public TSDownload(URL url, KeyType type, byte[] key, byte[] iv) {
this.url = url;
this.type = type;
this.key = key.clone();
this.iv = iv.clone();
}
private static void closeQuietly(Closeable closeable) {
try {
if (closeable != null) {
closeable.close();
}
} catch (IOException ignored) {
}
}
private static Cipher getAesCp(byte[] key, byte[] iv) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException {
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
Key skey = new SecretKeySpec(key, "AES");
IvParameterSpec param = new IvParameterSpec(iv);
cipher.init(Cipher.DECRYPT_MODE, skey, param);
return cipher;
}
public URL getUrl() {
return url;
}
public KeyType getType() {
return type;
}
public byte[] getKey() {
return key;
}
public byte[] getIv() {
return iv;
}
public boolean start(NetClient client) {
try {
InputStream iis = M3U8.getStream(client, getUrl());
if (iis == null) {
return false;
}
KeyType type = getType();
byte[] bytes;
byte[] buffer = new byte[4096];
if (type == KeyType.AES128) {
CipherInputStream stream = new CipherInputStream(iis, getAesCp(key, iv));
bytes = ByteUtils.toByteArray(stream);
closeQuietly(stream);
} else if (type == KeyType.NONE) {
bytes = ByteUtils.toByteArray(iis);
} else {
throw new IllegalStateException();
}
if (bytes.length % TS_PACKET_SIZE != 0) {
throw new IllegalStateException();
}
for (int i = 0; i < bytes.length; i = i + TS_PACKET_SIZE) {
if (bytes[i] != 0x47) {
throw new IllegalStateException();
}
}
this.bytes = bytes;
} catch (Exception e) {
Log.w("VTosters Lite", e);
return false;
}
return true;
}
public byte[] getData() {
return bytes;
}
}
@@ -1,18 +0,0 @@
package ru.vtosters.lite.downloaders.music.utils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
public class ByteUtils {
// copilot did something useful wow wow wow
public static byte[] toByteArray(InputStream is) throws IOException {
ByteArrayOutputStream os = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
int len;
while ((len = is.read(buffer)) != -1) {
os.write(buffer, 0, len);
}
return os.toByteArray();
}
}
@@ -0,0 +1,36 @@
package ru.vtosters.lite.music;
public class TransportStream {
public static final String METHOD_NONE = "NONE";
public static final String METHOD_AES128 = "AES-128";
private String mMethod;
private String mName;
private String mKeyURL;
public TransportStream() {
this.mMethod = METHOD_NONE;
}
public TransportStream(String method, String keyURL) {
this.mMethod = method;
this.mKeyURL = keyURL;
}
public void setName(String name) {
mName = name;
}
public String getMethod() {
return mMethod;
}
public String getName() {
return mName;
}
public String getKeyURL() {
return mKeyURL;
}
}
@@ -0,0 +1,65 @@
package ru.vtosters.lite.music;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
public class VKM3UParser {
private final String mData;
private final List<TransportStream> mTransportStreams = new ArrayList<>();
// maybe will be used
private int mDuration;
private int mHeapSize = 0;
private String mBaseURL;
public VKM3UParser(String data) {
mData = data;
init();
}
private void init() {
int i = 0;
Scanner scanner = new Scanner(mData);
while (scanner.hasNext()) {
String line = scanner.nextLine();
mHeapSize += line.getBytes().length;
if (line.startsWith("#EXT-X-TARGETDURATION")) {
mDuration = Integer.parseInt(getDirectiveValue(line));
} else if (line.startsWith("#EXT-X-KEY")) {
String base = getDirectiveValue(line);
final TransportStream ts;
if (!base.startsWith("METHOD=NONE")) {
String keyURL = base.substring(base.indexOf("\"") + 1, base.lastIndexOf("\""));
mBaseURL = keyURL.substring(0, keyURL.lastIndexOf("/") + 1);
ts = new TransportStream(TransportStream.METHOD_AES128, keyURL);
} else {
ts = new TransportStream();
}
mTransportStreams.add(ts);
} else if (line.startsWith("#EXTINF")) {
if (mTransportStreams.size() == i)
mTransportStreams.add(new TransportStream());
} else if (line.endsWith(".ts") || line.endsWith(".tp") || line.endsWith(".mpeg-ts") || line.endsWith(".m2ts")) {
mTransportStreams.get(i).setName(line);
++i;
}
}
}
private String getDirectiveValue(String directive) {
return directive.substring(directive.indexOf(":") + 1);
}
public int getHeapSize() {
return mHeapSize;
}
public String getBaseUrl() {
return mBaseURL;
}
public List<TransportStream> getTransportStreams() {
return mTransportStreams;
}
}
@@ -0,0 +1,65 @@
package ru.vtosters.lite.utils;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class StreamUtils {
private static final int BUFFER_SIZE = 2048;
public static byte[] decodeStream(InputStream encIs, String keyURL) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, IOException {
CipherInputStream cip = new CipherInputStream(encIs, getCipher(keyURL));
return readAllBytes(cip);
}
public static Cipher getCipher(String keyURL) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, IOException {
String key = new String(readAllBytes(openStream(keyURL)));
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
Key keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES");
// bypassing ecb
byte[] iv = new byte[16];
Arrays.fill(iv, (byte) 0x0);
IvParameterSpec ivps = new IvParameterSpec(iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivps);
return cipher;
}
public static InputStream openStream(String url) throws IOException {
URLConnection connection = new URL(url).openConnection();
return connection.getInputStream();
}
public static String readAllLines(InputStream is) throws IOException {
return new String(readAllBytes(is), "utf-8");
}
public static byte[] readAllBytes(InputStream is) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buffer = new byte[BUFFER_SIZE];
int len = 0;
while ((len = is.read(buffer)) > 0)
bos.write(buffer, 0, len);
return bos.toByteArray();
}
public static void writeToFile(File file, byte[] content) throws IOException {
FileOutputStream fos = new FileOutputStream(file);
fos.write(content);
}
}