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 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 toJSON() { HashMap 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!"); } } }