0.6.5 - Local streaming http server
This commit is contained in:
parent
28c2de55fb
commit
21e7f55017
31 changed files with 1744 additions and 460 deletions
|
@ -32,7 +32,7 @@ apply plugin: 'com.android.application'
|
|||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
android {
|
||||
compileSdkVersion 28
|
||||
compileSdkVersion 29
|
||||
|
||||
lintOptions {
|
||||
disable 'InvalidPackage'
|
||||
|
@ -42,7 +42,7 @@ android {
|
|||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "f.f.freezer"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 28
|
||||
targetSdkVersion 29
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
@ -73,7 +73,9 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation group: 'org', name: 'jaudiotagger', version: '2.0.3'
|
||||
//implementation group: 'org', name: 'jaudiotagger', version: '2.0.3'
|
||||
implementation files('libs/jaudiotagger-2.2.3.jar')
|
||||
implementation group: 'org.nanohttpd', name: 'nanohttpd', version: '2.3.1'
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
|
BIN
android/app/libs/jaudiotagger-2.2.3.jar
Normal file
BIN
android/app/libs/jaudiotagger-2.2.3.jar
Normal file
Binary file not shown.
|
@ -26,7 +26,8 @@
|
|||
android:name=".DownloadService"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:process="f.f.freezer.DownloadService" ></service>
|
||||
android:stopWithTask="false"
|
||||
android:process=":FreezerDownloadService" ></service>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package f.f.freezer;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import org.jaudiotagger.audio.AudioFile;
|
||||
|
@ -14,24 +13,17 @@ import org.jaudiotagger.tag.id3.valuepair.ImageFormats;
|
|||
import org.jaudiotagger.tag.images.Artwork;
|
||||
import org.jaudiotagger.tag.images.ArtworkFactory;
|
||||
import org.jaudiotagger.tag.reference.PictureTypes;
|
||||
import org.jaudiotagger.tag.vorbiscomment.VorbisCommentFieldKey;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Scanner;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
|
@ -74,21 +66,6 @@ public class Deezer {
|
|||
|
||||
public native void decryptFile(String trackId, String inputFilename, String outputFilename);
|
||||
|
||||
//Get guest SID cookie from deezer.com
|
||||
public static String getSidCookie() throws Exception {
|
||||
URL url = new URL("https://deezer.com/");
|
||||
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
|
||||
connection.setConnectTimeout(20000);
|
||||
connection.setRequestMethod("HEAD");
|
||||
String sid = "";
|
||||
for (String cookie : connection.getHeaderFields().get("Set-Cookie")) {
|
||||
if (cookie.startsWith("sid=")) {
|
||||
sid = cookie.split(";")[0].split("=")[1];
|
||||
}
|
||||
}
|
||||
return sid;
|
||||
}
|
||||
|
||||
public JSONObject callGWAPI(String method, String params) throws Exception {
|
||||
//Get token
|
||||
if (token == null) {
|
||||
|
@ -175,26 +152,6 @@ public class Deezer {
|
|||
return out;
|
||||
}
|
||||
|
||||
public int qualityFallback(String trackId, String md5origin, String mediaVersion, int originalQuality) throws Exception {
|
||||
//Create HEAD requests to check if exists
|
||||
URL url = new URL(getTrackUrl(trackId, md5origin, mediaVersion, originalQuality));
|
||||
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("HEAD");
|
||||
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36");
|
||||
connection.setRequestProperty("Accept-Language", "*");
|
||||
connection.setRequestProperty("Accept", "*/*");
|
||||
int rc = connection.getResponseCode();
|
||||
//Track not available
|
||||
if (rc > 400) {
|
||||
logger.warn("Quality fallback, response code: " + Integer.toString(rc) + ", current: " + Integer.toString(originalQuality));
|
||||
//Returns -1 if no quality available
|
||||
if (originalQuality == 1) return -1;
|
||||
if (originalQuality == 3) return qualityFallback(trackId, md5origin, mediaVersion, 1);
|
||||
if (originalQuality == 9) return qualityFallback(trackId, md5origin, mediaVersion, 3);
|
||||
}
|
||||
return originalQuality;
|
||||
}
|
||||
|
||||
//Generate track download URL
|
||||
public static String getTrackUrl(String trackId, String md5origin, String mediaVersion, int quality) {
|
||||
try {
|
||||
|
@ -490,4 +447,138 @@ public class Deezer {
|
|||
return output;
|
||||
}
|
||||
|
||||
//Track decryption key
|
||||
static byte[] getKey(String id) {
|
||||
String secret = "g4el58wc0zvf9na1";
|
||||
try {
|
||||
MessageDigest md5 = MessageDigest.getInstance("MD5");
|
||||
md5.update(id.getBytes());
|
||||
byte[] md5id = md5.digest();
|
||||
String idmd5 = bytesToHex(md5id).toLowerCase();
|
||||
String key = "";
|
||||
for(int i=0; i<16; i++) {
|
||||
int s0 = idmd5.charAt(i);
|
||||
int s1 = idmd5.charAt(i+16);
|
||||
int s2 = secret.charAt(i);
|
||||
key += (char)(s0^s1^s2);
|
||||
}
|
||||
return key.getBytes();
|
||||
} catch (Exception e) {
|
||||
Log.e("E", e.toString());
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
|
||||
//Decrypt 2048b of data
|
||||
static byte[] decryptChunk(byte[] key, byte[] data) {
|
||||
try {
|
||||
byte[] IV = {00, 01, 02, 03, 04, 05, 06, 07};
|
||||
SecretKeySpec Skey = new SecretKeySpec(key, "Blowfish");
|
||||
Cipher cipher = Cipher.getInstance("Blowfish/CBC/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, Skey, new javax.crypto.spec.IvParameterSpec(IV));
|
||||
return cipher.doFinal(data);
|
||||
}catch (Exception e) {
|
||||
Log.e("D", e.toString());
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
|
||||
static class QualityInfo {
|
||||
int quality;
|
||||
String md5origin;
|
||||
String mediaVersion;
|
||||
String trackId;
|
||||
int initialQuality;
|
||||
DownloadLog logger;
|
||||
|
||||
QualityInfo(int quality, String trackId, String md5origin, String mediaVersion, DownloadLog logger) {
|
||||
this.quality = quality;
|
||||
this.initialQuality = quality;
|
||||
this.trackId = trackId;
|
||||
this.mediaVersion = mediaVersion;
|
||||
this.md5origin = md5origin;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
boolean fallback(Deezer deezer) {
|
||||
//Quality fallback
|
||||
try {
|
||||
qualityFallback();
|
||||
//No quality
|
||||
if (quality == -1)
|
||||
throw new Exception("No quality to fallback to!");
|
||||
|
||||
//Success
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
logger.warn("Quality fallback failed! ID: " + trackId + " " + e.toString());
|
||||
quality = initialQuality;
|
||||
}
|
||||
|
||||
//Track ID Fallback
|
||||
JSONObject privateJson = null;
|
||||
try {
|
||||
//Fetch meta
|
||||
JSONObject privateRaw = deezer.callGWAPI("deezer.pageTrack", "{\"sng_id\": \"" + trackId + "\"}");
|
||||
privateJson = privateRaw.getJSONObject("results").getJSONObject("DATA");
|
||||
if (privateJson.has("FALLBACK")) {
|
||||
//Fetch new track
|
||||
String fallbackId = privateJson.getJSONObject("FALLBACK").getString("SNG_ID");
|
||||
if (!fallbackId.equals(trackId)) {
|
||||
JSONObject newPrivate = deezer.callGWAPI("song.getListData", "{\"sng_ids\": [" + fallbackId + "]}");
|
||||
JSONObject trackData = newPrivate.getJSONObject("results").getJSONArray("data").getJSONObject(0);
|
||||
trackId = trackData.getString("SNG_ID");
|
||||
md5origin = trackData.getString("MD5_ORIGIN");
|
||||
mediaVersion = trackData.getString("MEDIA_VERSION");
|
||||
return fallback(deezer);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("ID fallback failed! ID: " + trackId + " " + e.toString());
|
||||
}
|
||||
|
||||
//ISRC Fallback
|
||||
try {
|
||||
JSONObject newTrackJson = Deezer.callPublicAPI("track", "isrc:" + privateJson.getString("ISRC"));
|
||||
//Same track check
|
||||
if (newTrackJson.getInt("id") == Integer.parseInt(trackId)) throw new Exception("No more to ISRC fallback!");
|
||||
//Get private data
|
||||
privateJson = deezer.callGWAPI("song.getListData", "{\"sng_ids\": [" + newTrackJson.getInt("id") + "]}");
|
||||
JSONObject trackData = privateJson.getJSONObject("results").getJSONArray("data").getJSONObject(0);
|
||||
trackId = trackData.getString("SNG_ID");
|
||||
md5origin = trackData.getString("MD5_ORIGIN");
|
||||
mediaVersion = trackData.getString("MEDIA_VERSION");
|
||||
return fallback(deezer);
|
||||
} catch (Exception e) {
|
||||
logger.error("ISRC Fallback failed, track unavailable! ID: " + trackId + " " + e.toString());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void qualityFallback() throws Exception {
|
||||
//Create HEAD requests to check if exists
|
||||
URL url = new URL(getTrackUrl(trackId, md5origin, mediaVersion, quality));
|
||||
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("HEAD");
|
||||
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36");
|
||||
connection.setRequestProperty("Accept-Language", "*");
|
||||
connection.setRequestProperty("Accept", "*/*");
|
||||
int rc = connection.getResponseCode();
|
||||
//Track not available
|
||||
if (rc > 400) {
|
||||
logger.warn("Quality fallback, response code: " + Integer.toString(rc) + ", current: " + Integer.toString(quality));
|
||||
//-1 if no quality available
|
||||
if (quality == 1) {
|
||||
quality = -1;
|
||||
return;
|
||||
}
|
||||
if (quality == 3) quality = 1;
|
||||
if (quality == 9) quality = 3;
|
||||
qualityFallback();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ public class DownloadLog {
|
|||
BufferedWriter writer;
|
||||
|
||||
//Open/Create file
|
||||
public void open(Context context) {
|
||||
void open(Context context) {
|
||||
File file = new File(context.getExternalFilesDir(""), "download.log");
|
||||
try {
|
||||
if (!file.exists()) {
|
||||
|
@ -27,7 +27,7 @@ public class DownloadLog {
|
|||
}
|
||||
|
||||
//Close log
|
||||
public void close() {
|
||||
void close() {
|
||||
try {
|
||||
writer.close();
|
||||
} catch (Exception ignored) {
|
||||
|
@ -35,13 +35,13 @@ public class DownloadLog {
|
|||
}
|
||||
}
|
||||
|
||||
public String time() {
|
||||
String time() {
|
||||
SimpleDateFormat format = new SimpleDateFormat("yyyy.MM.dd HH:mm:ss");
|
||||
return format.format(Calendar.getInstance().getTime());
|
||||
}
|
||||
|
||||
//Write error to log
|
||||
public void error(String info) {
|
||||
void error(String info) {
|
||||
if (writer == null) return;
|
||||
String data = "E:" + time() + ": " + info;
|
||||
try {
|
||||
|
@ -55,7 +55,7 @@ public class DownloadLog {
|
|||
}
|
||||
|
||||
//Write error to log with download info
|
||||
public void error(String info, Download download) {
|
||||
void error(String info, Download download) {
|
||||
if (writer == null) return;
|
||||
String data = "E:" + time() + " (TrackID: " + download.trackId + ", ID: " + Integer.toString(download.id) + "): " +info;
|
||||
try {
|
||||
|
@ -69,7 +69,7 @@ public class DownloadLog {
|
|||
}
|
||||
|
||||
//Write warning to log
|
||||
public void warn(String info) {
|
||||
void warn(String info) {
|
||||
if (writer == null) return;
|
||||
String data = "W:" + time() + ": " + info;
|
||||
try {
|
||||
|
@ -83,7 +83,7 @@ public class DownloadLog {
|
|||
}
|
||||
|
||||
//Write warning to log with download info
|
||||
public void warn(String info, Download download) {
|
||||
void warn(String info, Download download) {
|
||||
if (writer == null) return;
|
||||
String data = "W:" + time() + " (TrackID: " + download.trackId + ", ID: " + Integer.toString(download.id) + "): " +info;
|
||||
try {
|
||||
|
|
|
@ -119,7 +119,9 @@ public class DownloadService extends Service {
|
|||
if (intent != null)
|
||||
activityMessenger = intent.getParcelableExtra("activityMessenger");
|
||||
|
||||
return super.onStartCommand(intent, flags, startId);
|
||||
//return super.onStartCommand(intent, flags, startId);
|
||||
//Prevent battery savers I guess
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
//Android O+ Notifications
|
||||
|
@ -313,73 +315,33 @@ public class DownloadService extends Service {
|
|||
return;
|
||||
}
|
||||
|
||||
//Quality fallback
|
||||
int newQuality;
|
||||
try {
|
||||
newQuality = deezer.qualityFallback(download.trackId, download.md5origin, download.mediaVersion, download.quality);
|
||||
} catch (Exception e) {
|
||||
logger.error("Quality fallback failed: " + e.toString(), download);
|
||||
download.state = Download.DownloadState.ERROR;
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
//Fallback
|
||||
Deezer.QualityInfo qualityInfo = new Deezer.QualityInfo(this.download.quality, this.download.trackId, this.download.md5origin, this.download.mediaVersion, logger);
|
||||
if (!download.isUserUploaded()) {
|
||||
try {
|
||||
boolean res = qualityInfo.fallback(deezer);
|
||||
if (!res)
|
||||
throw new Exception("No more to fallback!");
|
||||
|
||||
//TrackID Fallback
|
||||
try {
|
||||
if (newQuality == -1 && !download.isUserUploaded() && privateJson.has("FALLBACK")) {
|
||||
logger.warn("TrackID Fallback!", download);
|
||||
String fallbackId = privateJson.getJSONObject("FALLBACK").getString("SNG_ID");
|
||||
JSONObject newPrivate = deezer.callGWAPI("song.getListData", "{\"sng_ids\": [" + fallbackId + "]}");
|
||||
JSONObject trackData = newPrivate.getJSONObject("results").getJSONArray("data").getJSONObject(0);
|
||||
download.trackId = trackData.getString("SNG_ID");
|
||||
download.md5origin = trackData.getString("MD5_ORIGIN");
|
||||
download.mediaVersion = trackData.getString("MEDIA_VERSION");
|
||||
run();
|
||||
download.quality = qualityInfo.quality;
|
||||
} catch (Exception e) {
|
||||
logger.error("Fallback failed " + e.toString());
|
||||
download.state = Download.DownloadState.DEEZER_ERROR;
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("ID fallback failed: " + e.toString(), download);
|
||||
} else {
|
||||
//User uploaded MP3
|
||||
qualityInfo.quality = 3;
|
||||
}
|
||||
|
||||
//ISRC Fallback
|
||||
try {
|
||||
if (newQuality == -1 && !download.isUserUploaded()) {
|
||||
logger.warn("ISRC Fallback!", download);
|
||||
JSONObject newTrackJson = Deezer.callPublicAPI("track", "isrc:" + trackJson.getString("isrc"));
|
||||
//Same track check
|
||||
if (newTrackJson.getInt("id") == trackJson.getInt("id")) throw new Exception("No more to fallback!");
|
||||
//Get private data
|
||||
JSONObject privateJson = deezer.callGWAPI("song.getListData", "{\"sng_ids\": [" + newTrackJson.getInt("id") + "]}");
|
||||
JSONObject trackData = privateJson.getJSONObject("results").getJSONArray("data").getJSONObject(0);
|
||||
download.trackId = trackData.getString("SNG_ID");
|
||||
download.md5origin = trackData.getString("MD5_ORIGIN");
|
||||
download.mediaVersion = trackData.getString("MEDIA_VERSION");
|
||||
run();
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("ISRC Fallback failed, track unavailable! " + e.toString(), download);
|
||||
download.state = Download.DownloadState.DEEZER_ERROR;
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
//No quality available
|
||||
if (newQuality == -1) {
|
||||
logger.error("No available quality!", download);
|
||||
download.state = Download.DownloadState.DEEZER_ERROR;
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
download.quality = newQuality;
|
||||
|
||||
if (!download.priv) {
|
||||
//Check file
|
||||
try {
|
||||
if (download.isUserUploaded()) {
|
||||
outFile = new File(Deezer.generateUserUploadedMP3Filename(download.path, privateJson));
|
||||
} else {
|
||||
outFile = new File(Deezer.generateFilename(download.path, trackJson, albumJson, newQuality));
|
||||
outFile = new File(Deezer.generateFilename(download.path, trackJson, albumJson, qualityInfo.quality));
|
||||
}
|
||||
parentDir = new File(outFile.getParent());
|
||||
} catch (Exception e) {
|
||||
|
@ -415,7 +377,7 @@ public class DownloadService extends Service {
|
|||
}
|
||||
|
||||
//Download
|
||||
String sURL = Deezer.getTrackUrl(download.trackId, download.md5origin, download.mediaVersion, newQuality);
|
||||
String sURL = Deezer.getTrackUrl(qualityInfo.trackId, qualityInfo.md5origin, qualityInfo.mediaVersion, qualityInfo.quality);
|
||||
try {
|
||||
URL url = new URL(sURL);
|
||||
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
|
||||
|
@ -858,7 +820,23 @@ public class DownloadService extends Service {
|
|||
|
||||
//Parse settings from bundle sent from UI
|
||||
static DownloadSettings fromBundle(Bundle b) {
|
||||
return new DownloadSettings(b.getInt("downloadThreads"), b.getBoolean("overwriteDownload"), b.getBoolean("downloadLyrics"), b.getBoolean("trackCover"), b.getString("arl"), b.getBoolean("albumCover"), b.getBoolean("nomediaFiles"));
|
||||
JSONObject json;
|
||||
try {
|
||||
json = new JSONObject(b.getString("json"));
|
||||
return new DownloadSettings(
|
||||
json.getInt("downloadThreads"),
|
||||
json.getBoolean("overwriteDownload"),
|
||||
json.getBoolean("downloadLyrics"),
|
||||
json.getBoolean("trackCover"),
|
||||
json.getString("arl"),
|
||||
json.getBoolean("albumCover"),
|
||||
json.getBoolean("nomediaFiles")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
//Shouldn't happen
|
||||
Log.e("ERR", "Error loading settings!");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -23,13 +23,20 @@ import java.io.ByteArrayOutputStream;
|
|||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity;
|
||||
import io.flutter.embedding.engine.FlutterEngine;
|
||||
|
@ -48,6 +55,7 @@ public class MainActivity extends FlutterActivity {
|
|||
Messenger serviceMessenger;
|
||||
Messenger activityMessenger;
|
||||
SQLiteDatabase db;
|
||||
StreamServer streamServer;
|
||||
|
||||
//Data if started from intent
|
||||
String intentPreload;
|
||||
|
@ -122,13 +130,7 @@ public class MainActivity extends FlutterActivity {
|
|||
//Update settings from UI
|
||||
if (call.method.equals("updateSettings")) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putInt("downloadThreads", (int)call.argument("downloadThreads"));
|
||||
bundle.putBoolean("overwriteDownload", (boolean)call.argument("overwriteDownload"));
|
||||
bundle.putBoolean("downloadLyrics", (boolean)call.argument("downloadLyrics"));
|
||||
bundle.putBoolean("trackCover", (boolean)call.argument("trackCover"));
|
||||
bundle.putString("arl", (String)call.argument("arl"));
|
||||
bundle.putBoolean("albumCover", (boolean)call.argument("albumCover"));
|
||||
bundle.putBoolean("nomediaFiles", (boolean)call.argument("nomediaFiles"));
|
||||
bundle.putString("json", call.argument("json").toString());
|
||||
sendMessage(DownloadService.SERVICE_SETTINGS_UPDATE, bundle);
|
||||
|
||||
result.success(null);
|
||||
|
@ -185,6 +187,31 @@ public class MainActivity extends FlutterActivity {
|
|||
result.success(System.getProperty("os.arch"));
|
||||
return;
|
||||
}
|
||||
//Start streaming server
|
||||
if (call.method.equals("startServer")) {
|
||||
if (streamServer == null) {
|
||||
//Get offline path
|
||||
String offlinePath = getExternalFilesDir("offline").getAbsolutePath();
|
||||
//Start server
|
||||
streamServer = new StreamServer(call.argument("arl"), offlinePath);
|
||||
streamServer.start();
|
||||
}
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
//Get quality info from stream
|
||||
if (call.method.equals("getStreamInfo")) {
|
||||
if (streamServer == null) {
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
StreamServer.StreamInfo info = streamServer.streams.get(call.argument("id").toString());
|
||||
if (info != null)
|
||||
result.success(info.toJSON());
|
||||
else
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
|
||||
result.error("0", "Not implemented!", "Not implemented!");
|
||||
})));
|
||||
|
@ -208,14 +235,38 @@ public class MainActivity extends FlutterActivity {
|
|||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
|
||||
//Bind downloader service
|
||||
activityMessenger = new Messenger(new IncomingHandler(this));
|
||||
Intent intent = new Intent(this, DownloadService.class);
|
||||
intent.putExtra("activityMessenger", activityMessenger);
|
||||
bindService(intent, connection, Context.BIND_AUTO_CREATE);
|
||||
startService(intent);
|
||||
bindService(intent, connection, 0);
|
||||
//Get DB
|
||||
DownloadsDatabase dbHelper = new DownloadsDatabase(getApplicationContext());
|
||||
db = dbHelper.getWritableDatabase();
|
||||
|
||||
//Trust all SSL Certs - Credits to Kilowatt36
|
||||
TrustManager[] trustAllCerts = new TrustManager[]{
|
||||
new X509TrustManager() {
|
||||
public java.security.cert.X509Certificate[] getAcceptedIssuers() {
|
||||
return null;
|
||||
}
|
||||
public void checkClientTrusted(X509Certificate[] certs, String authType) {
|
||||
}
|
||||
public void checkServerTrusted(X509Certificate[] certs, String authType) {
|
||||
}
|
||||
}
|
||||
};
|
||||
SSLContext sc;
|
||||
try {
|
||||
sc = SSLContext.getInstance("SSL");
|
||||
sc.init(null, trustAllCerts, new java.security.SecureRandom());
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
|
||||
} catch (NoSuchAlgorithmException | KeyManagementException e) {
|
||||
Log.e(this.getLocalClassName(), e.getMessage());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -229,6 +280,14 @@ public class MainActivity extends FlutterActivity {
|
|||
db.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
//Stop server
|
||||
if (streamServer != null)
|
||||
streamServer.stop();
|
||||
}
|
||||
|
||||
//Connection to download service
|
||||
private ServiceConnection connection = new ServiceConnection() {
|
||||
@Override
|
||||
|
|
285
android/app/src/main/java/f/f/freezer/StreamServer.java
Normal file
285
android/app/src/main/java/f/f/freezer/StreamServer.java
Normal file
|
@ -0,0 +1,285 @@
|
|||
package f.f.freezer;
|
||||
|
||||
import android.content.pm.PackageManager;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.net.URL;
|
||||
import java.util.HashMap;
|
||||
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import fi.iki.elonen.NanoHTTPD;
|
||||
|
||||
public class StreamServer {
|
||||
|
||||
public HashMap<String, StreamInfo> streams = new HashMap<>();
|
||||
|
||||
private WebServer server;
|
||||
private String host = "127.0.0.1";
|
||||
private int port = 36958;
|
||||
private String offlinePath;
|
||||
|
||||
//Shared log & API
|
||||
private DownloadLog logger;
|
||||
private Deezer deezer;
|
||||
|
||||
StreamServer(String arl, String offlinePath) {
|
||||
//Initialize shared variables
|
||||
logger = new DownloadLog();
|
||||
deezer = new Deezer();
|
||||
deezer.init(logger, arl);
|
||||
this.offlinePath = offlinePath;
|
||||
}
|
||||
|
||||
//Create server
|
||||
void start() {
|
||||
try {
|
||||
server = new WebServer(host, port);
|
||||
server.start();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
if (server != null)
|
||||
server.stop();
|
||||
}
|
||||
|
||||
//Information about streamed audio - for showing in UI
|
||||
public class StreamInfo {
|
||||
String format;
|
||||
long size;
|
||||
//"Stream" or "Offline"
|
||||
String source;
|
||||
|
||||
StreamInfo(String format, long size, String source) {
|
||||
this.format = format;
|
||||
this.size = size;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
//For passing into UI
|
||||
public HashMap<String, Object> toJSON() {
|
||||
HashMap<String, Object> out = new HashMap<>();
|
||||
out.put("format", format);
|
||||
out.put("size", size);
|
||||
out.put("source", source);
|
||||
return out;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private class WebServer extends NanoHTTPD {
|
||||
public WebServer(String hostname, int port) {
|
||||
super(hostname, port);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response serve(IHTTPSession session) {
|
||||
//Must be only GET
|
||||
if (session.getMethod() != Method.GET)
|
||||
return newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, MIME_PLAINTEXT, "Only GET request supported!");
|
||||
|
||||
//Parse range header
|
||||
String rangeHeader = session.getHeaders().get("range");
|
||||
int startBytes = 0;
|
||||
boolean isRanged = false;
|
||||
int end = -1;
|
||||
if (rangeHeader != null && rangeHeader.startsWith("bytes")) {
|
||||
isRanged = true;
|
||||
String[] ranges = rangeHeader.split("=")[1].split("-");
|
||||
startBytes = Integer.parseInt(ranges[0]);
|
||||
if (ranges.length > 1 && !ranges[1].equals(" ")) {
|
||||
end = Integer.parseInt(ranges[1]);
|
||||
}
|
||||
}
|
||||
|
||||
//Check query parameters
|
||||
if (session.getParameters().keySet().size() < 4) {
|
||||
//Play offline
|
||||
if (session.getParameters().get("id") != null) {
|
||||
return offlineStream(session, startBytes, end, isRanged);
|
||||
}
|
||||
//Missing QP
|
||||
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Invalid / Missing QP");
|
||||
}
|
||||
|
||||
//Stream
|
||||
return deezerStream(session, startBytes, end, isRanged);
|
||||
|
||||
}
|
||||
|
||||
private Response offlineStream(IHTTPSession session, int startBytes, int end, boolean isRanged) {
|
||||
//Get path
|
||||
String trackId = session.getParameters().get("id").get(0);
|
||||
File file = new File(offlinePath, trackId);
|
||||
long size = file.length();
|
||||
//Read header
|
||||
boolean isFlac = false;
|
||||
try {
|
||||
InputStream inputStream = new FileInputStream(file);
|
||||
byte[] buffer = new byte[4];
|
||||
inputStream.read(buffer, 0, 4);
|
||||
inputStream.close();
|
||||
if (new String(buffer).equals("fLaC"))
|
||||
isFlac = true;
|
||||
} catch (Exception e) {
|
||||
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Invalid file!");
|
||||
}
|
||||
//Open file
|
||||
RandomAccessFile randomAccessFile;
|
||||
try {
|
||||
randomAccessFile = new RandomAccessFile(file, "r");
|
||||
randomAccessFile.seek(startBytes);
|
||||
} catch (Exception e) {
|
||||
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Failed getting data!");
|
||||
}
|
||||
|
||||
//Generate response
|
||||
Response response = newFixedLengthResponse(
|
||||
isRanged ? Response.Status.PARTIAL_CONTENT : Response.Status.OK,
|
||||
isFlac ? "audio/flac" : "audio/mpeg",
|
||||
new InputStream() {
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
return 0;
|
||||
}
|
||||
//Pass thru
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) throws IOException {
|
||||
return randomAccessFile.read(b, off, len);
|
||||
}
|
||||
},
|
||||
((end == -1) ? size : end) - startBytes
|
||||
);
|
||||
//Ranged header
|
||||
if (isRanged) {
|
||||
String range = "bytes " + Integer.toString(startBytes) + "-" + Long.toString((end == -1) ? size - 1 : end);
|
||||
range += "/" + Long.toString(size);
|
||||
response.addHeader("Content-Range", range);
|
||||
}
|
||||
response.addHeader("Accept-Ranges", "bytes");
|
||||
|
||||
//Save stream info
|
||||
streams.put(trackId, new StreamInfo((isFlac ? "FLAC" : "MP3"), size, "Offline"));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private Response deezerStream(IHTTPSession session, int startBytes, int end, boolean isRanged) {
|
||||
//Get QP into Quality Info
|
||||
Deezer.QualityInfo qualityInfo = new Deezer.QualityInfo(
|
||||
Integer.parseInt(session.getParameters().get("q").get(0)),
|
||||
session.getParameters().get("id").get(0),
|
||||
session.getParameters().get("md5origin").get(0),
|
||||
session.getParameters().get("mv").get(0),
|
||||
logger
|
||||
);
|
||||
//Fallback
|
||||
try {
|
||||
boolean res = qualityInfo.fallback(deezer);
|
||||
if (!res)
|
||||
throw new Exception("No more to fallback!");
|
||||
} catch (Exception e) {
|
||||
return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Fallback failed!");
|
||||
}
|
||||
|
||||
//Calculate Deezer offsets
|
||||
int deezerStart = startBytes - (startBytes % 2048);
|
||||
int dropBytes = startBytes % 2048;
|
||||
//Start download
|
||||
String sURL = Deezer.getTrackUrl(qualityInfo.trackId, qualityInfo.md5origin, qualityInfo.mediaVersion, qualityInfo.quality);
|
||||
try {
|
||||
URL url = new URL(sURL);
|
||||
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
|
||||
//Set headers
|
||||
connection.setConnectTimeout(30000);
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36");
|
||||
connection.setRequestProperty("Accept-Language", "*");
|
||||
connection.setRequestProperty("Accept", "*/*");
|
||||
connection.setRequestProperty("Range", "bytes=" + Integer.toString(deezerStart) + "-" + ((end == -1) ? "" : Integer.toString(end)));
|
||||
connection.connect();
|
||||
|
||||
//Get decryption key
|
||||
final byte[] key = Deezer.getKey(qualityInfo.trackId);
|
||||
|
||||
//Write response headers
|
||||
Response outResponse = newFixedLengthResponse(
|
||||
isRanged ? Response.Status.PARTIAL_CONTENT : Response.Status.OK,
|
||||
(qualityInfo.quality == 9) ? "audio/flac" : "audio/mpeg",
|
||||
new BufferedInputStream(new FilterInputStream(connection.getInputStream()) {
|
||||
|
||||
int counter = deezerStart / 2048;
|
||||
int drop = dropBytes;
|
||||
|
||||
//Decryption stream
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) throws IOException {
|
||||
//Read 2048b or EOF
|
||||
byte[] buffer = new byte[2048];
|
||||
int read = 0;
|
||||
int totalRead = 0;
|
||||
while (read != -1 && totalRead != 2048) {
|
||||
read = in.read(buffer, totalRead, 2048 - totalRead);
|
||||
if (read != -1)
|
||||
totalRead += read;
|
||||
}
|
||||
if (totalRead == 0)
|
||||
return -1;
|
||||
|
||||
//Not full chunk return unencrypted
|
||||
if (totalRead != 2048) {
|
||||
System.arraycopy(buffer, 0, b, off, totalRead);
|
||||
return totalRead;
|
||||
}
|
||||
//Decrypt
|
||||
if ((counter % 3) == 0) {
|
||||
buffer = Deezer.decryptChunk(key, buffer);
|
||||
}
|
||||
//Drop bytes from rounding to 2048
|
||||
if (drop > 0) {
|
||||
int output = 2048 - drop;
|
||||
System.arraycopy(buffer, drop, b, off, output);
|
||||
drop = 0;
|
||||
counter++;
|
||||
return output;
|
||||
}
|
||||
//Copy
|
||||
System.arraycopy(buffer, 0, b, off, 2048);
|
||||
counter++;
|
||||
return 2048;
|
||||
}
|
||||
}, 2048),
|
||||
connection.getContentLength() - dropBytes
|
||||
);
|
||||
//Ranged header
|
||||
if (isRanged) {
|
||||
String range = "bytes " + Integer.toString(startBytes) + "-" + Integer.toString((end == -1) ? (connection.getContentLength() + deezerStart) - 1 : end);
|
||||
range += "/" + Integer.toString(connection.getContentLength() + deezerStart);
|
||||
outResponse.addHeader("Content-Range", range);
|
||||
}
|
||||
outResponse.addHeader("Accept-Ranges", "bytes");
|
||||
|
||||
//Save stream info, use original track id
|
||||
streams.put(session.getParameters().get("id").get(0), new StreamInfo(
|
||||
((qualityInfo.quality == 9) ? "FLAC" : "MP3"),
|
||||
deezerStart + connection.getContentLength(),
|
||||
"Stream"
|
||||
));
|
||||
|
||||
return outResponse;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Failed getting data!");
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue