diff --git a/android/app/src/main/java/f/f/freezer/Deezer.java b/android/app/src/main/java/f/f/freezer/Deezer.java index 465949f..013351e 100644 --- a/android/app/src/main/java/f/f/freezer/Deezer.java +++ b/android/app/src/main/java/f/f/freezer/Deezer.java @@ -1,6 +1,7 @@ package f.f.freezer; import android.util.Log; +import android.util.Pair; import org.jaudiotagger.audio.AudioFile; import org.jaudiotagger.audio.AudioFileIO; @@ -36,6 +37,7 @@ public class Deezer { String token; String arl; String sid; + String accessToken; boolean authorized = false; boolean authorizing = false; @@ -63,9 +65,98 @@ public class Deezer { logger.warn("Error authorizing to Deezer API! " + e.toString()); } } + //TV API + if (accessToken == null) { + try { + authorizeTVAPI(); + } catch (Exception e) { + this.accessToken = null; + e.printStackTrace(); + logger.warn("Error authorizing TV API - FLAC might not be available! " + e.toString()); + } + } + authorizing = false; } + //Make POST request + private String POST(String _url, String data, String cookie) throws Exception { + URL url = new URL(_url); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setConnectTimeout(20000); + connection.setDoOutput(true); + connection.setRequestMethod("POST"); + 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("Content-Type", "application/json"); + connection.setRequestProperty("Accept", "*/*"); + connection.setRequestProperty("Content-Length", Integer.toString(data.getBytes(StandardCharsets.UTF_8).length)); + if (cookie != null) { + connection.setRequestProperty("Cookie", cookie); + } + + //Write body + DataOutputStream wr = new DataOutputStream(connection.getOutputStream()); + wr.writeBytes(data); + wr.close(); + //Get response + String output = ""; + Scanner scanner = new Scanner(connection.getInputStream()); + while (scanner.hasNext()) { + output += scanner.nextLine(); + } + scanner.close(); + connection.disconnect(); + + return output; + } + + private String GET(String _url) throws Exception { + URL url = new URL(_url); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setConnectTimeout(20000); + 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", "*/*"); + //Get response + String output = ""; + Scanner scanner = new Scanner(connection.getInputStream()); + while (scanner.hasNext()) { + output += scanner.nextLine(); + } + scanner.close(); + connection.disconnect(); + + return output; + } + + //Authorize TV API for generating URLs + public void authorizeTVAPI() throws Exception { + JSONObject json = new JSONObject(POST( + "https://distribution-api.deezer.com/device/token", + "{\"brand_name\":\"Hisense\",\"device_id\":\"7239e4071d8992c955ad\",\"model_name\":\"HE50A6109FUWTS\",\"country_code\":\"FRA\"}", + null + )); + String deviceToken = json.getString("device_token"); + // Get unauthorized token + json = new JSONObject(GET("https://connect.deezer.com/oauth/access_token.php?grant_type=client_credentials&client_id=447462&client_secret=a83bf7f38ad2f137e444727cfc3775cf&output=json")); + String accessToken = json.getString("access_token"); + // Get smart login code + json = new JSONObject(POST( + "https://connect.deezer.com/2.0/smartlogin/device?access_token=" + accessToken + "&device_token=" + deviceToken, + "", null + )); + String smartLoginCode = json.getJSONObject("data").getString("smartLoginCode"); + // Get the fuck that is + callGWAPI("deezer.associateSmartLoginCodeWithUser", "{\"SMARTLOGIN_CODE\": \"" + smartLoginCode + "\"}"); + // Get authorized access tonk + json = new JSONObject(GET("https://connect.deezer.com/2.0/smartlogin/" + smartLoginCode + "?access_token=" + accessToken)); + accessToken = json.getJSONObject("data").getString("accessToken"); + this.accessToken = accessToken; + Log.d("DDDD", "Authorized TV: " + this.accessToken); + } + public JSONObject callGWAPI(String method, String params) throws Exception { //Get token if (token == null) { @@ -73,36 +164,11 @@ public class Deezer { callGWAPI("deezer.getUserData", "{}"); } - //Call - URL url = new URL("https://www.deezer.com/ajax/gw-light.php?method=" + method + "&input=3&api_version=1.0&api_token=" + token); - HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); - connection.setConnectTimeout(20000); - connection.setDoOutput(true); - connection.setRequestMethod("POST"); - 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("Content-Type", "application/json"); - connection.setRequestProperty("Accept", "*/*"); - connection.setRequestProperty("Content-Length", Integer.toString(params.getBytes(StandardCharsets.UTF_8).length)); - String cookies = "arl=" + arl + "; sid=" + sid; - connection.setRequestProperty("Cookie", cookies); - - //Write body - DataOutputStream wr = new DataOutputStream(connection.getOutputStream()); - wr.writeBytes(params); - wr.close(); - //Get response - String data = ""; - Scanner scanner = new Scanner(connection.getInputStream()); - while (scanner.hasNext()) { - data += scanner.nextLine(); - } - - //End - try { - connection.disconnect(); - scanner.close(); - } catch (Exception e) {} + String data = POST( + "https://www.deezer.com/ajax/gw-light.php?method=" + method + "&input=3&api_version=1.0&api_token=" + token, + params, + "arl=" + arl + "; sid=" + sid + ); //Parse JSON JSONObject out = new JSONObject(data); @@ -110,16 +176,7 @@ public class Deezer { //Save token if ((token == null || token.equals("null")) && method.equals("deezer.getUserData")) { token = out.getJSONObject("results").getString("checkForm"); - //SID - try { - String newSid = null; - for (String cookie : connection.getHeaderFields().get("Set-Cookie")) { - if (cookie.startsWith("sid=")) { - newSid = cookie.split(";")[0].split("=")[1]; - } - } - this.sid = newSid; - } catch (Exception ignored) {} + sid = out.getJSONObject("results").getString("SESSION_ID"); } return out; @@ -153,7 +210,7 @@ public class Deezer { } //Generate track download URL - public static String getTrackUrl(String trackId, String md5origin, String mediaVersion, int quality) { + public static String generateTrackUrl(String trackId, String md5origin, String mediaVersion, int quality) { try { int magic = 164; @@ -201,6 +258,39 @@ public class Deezer { return null; } + // Returns URL and wether encrypted + public Pair getTrackUrl(String trackId, String md5origin, String mediaVersion, int quality) { + // TV API URL Gen + if (this.accessToken != null && quality == 9) { + try { + URL url = new URL("https://api.deezer.com/platform/gcast/track/" + trackId + "/streamUrls"); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setConnectTimeout(20000); + 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("Authorization", "Bearer " + this.accessToken); + //Get response + String output = ""; + Scanner scanner = new Scanner(connection.getInputStream()); + while (scanner.hasNext()) { + output += scanner.nextLine(); + } + scanner.close(); + connection.disconnect(); + + JSONObject json = new JSONObject(output).getJSONObject("data").getJSONObject("attributes"); + return new Pair(json.getString("url_flac"), false); + } catch (Exception e) { + e.printStackTrace(); + logger.warn("Failed generating ATV URL!"); + } + } + // Normal url gen + return new Pair(Deezer.generateTrackUrl(trackId, md5origin, mediaVersion, quality), true); + } + public static String bytesToHex(byte[] bytes) { final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); char[] hexChars = new char[bytes.length * 2]; @@ -499,6 +589,7 @@ public class Deezer { String trackId; int initialQuality; DownloadLog logger; + boolean encrypted; QualityInfo(int quality, String trackId, String md5origin, String mediaVersion, DownloadLog logger) { this.quality = quality; @@ -509,16 +600,16 @@ public class Deezer { this.logger = logger; } - boolean fallback(Deezer deezer) { + String fallback(Deezer deezer) { //Quality fallback try { - qualityFallback(); + String url = qualityFallback(deezer); //No quality if (quality == -1) throw new Exception("No quality to fallback to!"); //Success - return true; + return url; } catch (Exception e) { logger.warn("Quality fallback failed! ID: " + trackId + " " + e.toString()); quality = initialQuality; @@ -562,12 +653,15 @@ public class Deezer { logger.error("ISRC Fallback failed, track unavailable! ID: " + trackId + " " + e.toString()); } - return false; + return null; } - private void qualityFallback() throws Exception { + private String qualityFallback(Deezer deezer) throws Exception { + Pair urlGen = deezer.getTrackUrl(trackId, md5origin, mediaVersion, quality); + this.encrypted = urlGen.second; + //Create HEAD requests to check if exists - URL url = new URL(getTrackUrl(trackId, md5origin, mediaVersion, quality)); + URL url = new URL(urlGen.first); 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"); @@ -580,12 +674,13 @@ public class Deezer { //-1 if no quality available if (quality == 1) { quality = -1; - return; + return null; } if (quality == 3) quality = 1; if (quality == 9) quality = 3; - qualityFallback(); + return qualityFallback(deezer); } + return urlGen.first; } } diff --git a/android/app/src/main/java/f/f/freezer/DownloadService.java b/android/app/src/main/java/f/f/freezer/DownloadService.java index 0c9ae88..08c505a 100644 --- a/android/app/src/main/java/f/f/freezer/DownloadService.java +++ b/android/app/src/main/java/f/f/freezer/DownloadService.java @@ -17,6 +17,7 @@ import android.os.Message; import android.os.Messenger; import android.os.RemoteException; import android.util.Log; +import android.util.Pair; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; @@ -318,10 +319,11 @@ public class DownloadService extends Service { //Fallback Deezer.QualityInfo qualityInfo = new Deezer.QualityInfo(this.download.quality, this.download.trackId, this.download.md5origin, this.download.mediaVersion, logger); + String sURL = null; if (!download.isUserUploaded()) { try { - boolean res = qualityInfo.fallback(deezer); - if (!res) + sURL = qualityInfo.fallback(deezer); + if (sURL == null) throw new Exception("No more to fallback!"); download.quality = qualityInfo.quality; @@ -378,7 +380,6 @@ public class DownloadService extends Service { } //Download - String sURL = Deezer.getTrackUrl(qualityInfo.trackId, qualityInfo.md5origin, qualityInfo.mediaVersion, qualityInfo.quality); try { URL url = new URL(sURL); HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); @@ -436,15 +437,17 @@ public class DownloadService extends Service { //Post processing //Decrypt - try { - File decFile = new File(tmpFile.getPath() + ".DEC"); - deezer.decryptFile(download.trackId, tmpFile.getPath(), decFile.getPath()); - tmpFile.delete(); - tmpFile = decFile; - } catch (Exception e) { - logger.error("Decryption error: " + e.toString(), download); - e.printStackTrace(); - //Shouldn't ever fail + if (qualityInfo.encrypted) { + try { + File decFile = new File(tmpFile.getPath() + ".DEC"); + deezer.decryptFile(download.trackId, tmpFile.getPath(), decFile.getPath()); + tmpFile.delete(); + tmpFile = decFile; + } catch (Exception e) { + logger.error("Decryption error: " + e.toString(), download); + e.printStackTrace(); + //Shouldn't ever fail + } } diff --git a/android/app/src/main/java/f/f/freezer/StreamServer.java b/android/app/src/main/java/f/f/freezer/StreamServer.java index ed7e217..c5d4655 100644 --- a/android/app/src/main/java/f/f/freezer/StreamServer.java +++ b/android/app/src/main/java/f/f/freezer/StreamServer.java @@ -28,6 +28,7 @@ public class StreamServer { //Shared log & API private DownloadLog logger; private Deezer deezer; + private boolean authorized = false; StreamServer(String arl, String offlinePath) { //Initialize shared variables @@ -174,6 +175,12 @@ public class StreamServer { } private Response deezerStream(IHTTPSession session, int startBytes, int end, boolean isRanged) { + // Authorize + if (!authorized) { + deezer.authorize(); + authorized = true; + } + //Get QP into Quality Info Deezer.QualityInfo qualityInfo = new Deezer.QualityInfo( Integer.parseInt(session.getParameters().get("q").get(0)), @@ -183,19 +190,23 @@ public class StreamServer { logger ); //Fallback + String sURL; try { - boolean res = qualityInfo.fallback(deezer); - if (!res) + sURL = qualityInfo.fallback(deezer); + if (sURL == null) 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 _deezerStart = startBytes; + if (qualityInfo.encrypted) + _deezerStart -= startBytes % 2048; + final int deezerStart = _deezerStart; 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(); @@ -208,58 +219,70 @@ public class StreamServer { connection.setRequestProperty("Range", "bytes=" + Integer.toString(deezerStart) + "-" + ((end == -1) ? "" : Integer.toString(end))); connection.connect(); - //Get decryption key - final byte[] key = Deezer.getKey(qualityInfo.trackId); + Response outResponse; + // Encrypted response + if (qualityInfo.encrypted) { + //Get decryption key + final byte[] key = Deezer.getKey(qualityInfo.trackId); - //Write response headers - Response outResponse = newFixedLengthResponse( + 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 + ); + } else { + // Decrypted + outResponse = newFixedLengthResponse( isRanged ? Response.Status.PARTIAL_CONTENT : Response.Status.OK, (qualityInfo.quality == 9) ? "audio/flac" : "audio/mpeg", - new BufferedInputStream(new FilterInputStream(connection.getInputStream()) { + connection.getInputStream(), + connection.getContentLength() + ); + } - 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); diff --git a/pubspec.yaml b/pubspec.yaml index 648b686..3fff025 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.6.13+1 +version: 0.6.14+1 environment: sdk: ">=2.8.0 <3.0.0"