From 34f443c6a995bbdbbc073ca697a95d7c38941434 Mon Sep 17 00:00:00 2001
From: 0xboobface <0xboobface@gmail.com>
Date: Sat, 27 Oct 2018 22:32:07 +0200
Subject: [PATCH] First addition for Cam4

---
 .../ctbrec/recorder/server/HttpServer.java    |   2 +
 src/main/java/ctbrec/sites/cam4/Cam4.java     | 114 +++++++++++
 .../java/ctbrec/sites/cam4/Cam4Model.java     | 177 ++++++++++++++++++
 .../ctbrec/sites/cam4/Cam4TabProvider.java    |  33 ++++
 .../ctbrec/sites/cam4/Cam4UpdateService.java  | 105 +++++++++++
 .../java/ctbrec/ui/CamrecApplication.java     |   2 +
 6 files changed, 433 insertions(+)
 create mode 100644 src/main/java/ctbrec/sites/cam4/Cam4.java
 create mode 100644 src/main/java/ctbrec/sites/cam4/Cam4Model.java
 create mode 100644 src/main/java/ctbrec/sites/cam4/Cam4TabProvider.java
 create mode 100644 src/main/java/ctbrec/sites/cam4/Cam4UpdateService.java

diff --git a/src/main/java/ctbrec/recorder/server/HttpServer.java b/src/main/java/ctbrec/recorder/server/HttpServer.java
index ccf47cde..69aceead 100644
--- a/src/main/java/ctbrec/recorder/server/HttpServer.java
+++ b/src/main/java/ctbrec/recorder/server/HttpServer.java
@@ -20,6 +20,7 @@ import ctbrec.Config;
 import ctbrec.recorder.LocalRecorder;
 import ctbrec.recorder.Recorder;
 import ctbrec.sites.Site;
+import ctbrec.sites.cam4.Cam4;
 import ctbrec.sites.chaturbate.Chaturbate;
 import ctbrec.sites.mfc.MyFreeCams;
 
@@ -60,6 +61,7 @@ public class HttpServer {
     private void createSites() {
         sites.add(new Chaturbate());
         sites.add(new MyFreeCams());
+        sites.add(new Cam4());
     }
 
     private void addShutdownHook() {
diff --git a/src/main/java/ctbrec/sites/cam4/Cam4.java b/src/main/java/ctbrec/sites/cam4/Cam4.java
new file mode 100644
index 00000000..9198aa87
--- /dev/null
+++ b/src/main/java/ctbrec/sites/cam4/Cam4.java
@@ -0,0 +1,114 @@
+package ctbrec.sites.cam4;
+
+import java.io.IOException;
+
+import ctbrec.Model;
+import ctbrec.io.HttpClient;
+import ctbrec.recorder.Recorder;
+import ctbrec.sites.AbstractSite;
+import ctbrec.ui.TabProvider;
+import javafx.scene.Node;
+
+public class Cam4 extends AbstractSite {
+
+    public static final String BASE_URI = "https://www.cam4.com";
+
+    private HttpClient httpClient;
+    private Recorder recorder;
+
+    @Override
+    public String getName() {
+        return "Cam4";
+    }
+
+    @Override
+    public String getBaseUrl() {
+        return BASE_URI;
+    }
+
+    @Override
+    public String getAffiliateLink() {
+        return getBaseUrl() + "/?referrerId=1514a80d87b5effb456cca02f6743aa1";
+    }
+
+    @Override
+    public void setRecorder(Recorder recorder) {
+        this.recorder = recorder;
+    }
+
+    @Override
+    public TabProvider getTabProvider() {
+        return new Cam4TabProvider(this, recorder);
+    }
+
+    @Override
+    public Model createModel(String name) {
+        Cam4Model m = new Cam4Model();
+        m.setSite(this);
+        m.setName(name);
+        m.setUrl(getBaseUrl() + '/' + name + '/');
+        return m;
+    }
+
+    @Override
+    public Integer getTokenBalance() throws IOException {
+        return 0;
+    }
+
+    @Override
+    public String getBuyTokensLink() {
+        return getAffiliateLink();
+    }
+
+    @Override
+    public void login() throws IOException {
+    }
+
+    @Override
+    public HttpClient getHttpClient() {
+        if(httpClient == null) {
+            httpClient = new HttpClient() {
+                @Override
+                public boolean login() throws IOException {
+                    return false;
+                }
+            };
+        }
+        return httpClient;
+    }
+
+    @Override
+    public void shutdown() {
+        getHttpClient().shutdown();
+    }
+
+    @Override
+    public void init() throws IOException {
+    }
+
+    @Override
+    public boolean supportsTips() {
+        return false;
+    }
+
+    @Override
+    public boolean supportsFollow() {
+        return false;
+    }
+
+    @Override
+    public boolean isSiteForModel(Model m) {
+        return m instanceof Cam4Model;
+    }
+
+    @Override
+    public Node getConfigurationGui() {
+        return null;
+    }
+
+    @Override
+    public boolean credentialsAvailable() {
+        return false;
+    }
+
+}
diff --git a/src/main/java/ctbrec/sites/cam4/Cam4Model.java b/src/main/java/ctbrec/sites/cam4/Cam4Model.java
new file mode 100644
index 00000000..35b96268
--- /dev/null
+++ b/src/main/java/ctbrec/sites/cam4/Cam4Model.java
@@ -0,0 +1,177 @@
+package ctbrec.sites.cam4;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.iheartradio.m3u8.Encoding;
+import com.iheartradio.m3u8.Format;
+import com.iheartradio.m3u8.ParseException;
+import com.iheartradio.m3u8.PlaylistException;
+import com.iheartradio.m3u8.PlaylistParser;
+import com.iheartradio.m3u8.data.MasterPlaylist;
+import com.iheartradio.m3u8.data.Playlist;
+import com.iheartradio.m3u8.data.PlaylistData;
+
+import ctbrec.AbstractModel;
+import ctbrec.recorder.download.StreamSource;
+import ctbrec.sites.Site;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class Cam4Model extends AbstractModel {
+
+    private static final transient Logger LOG = LoggerFactory.getLogger(Cam4Model.class);
+    private Cam4 site;
+    private String playlistUrl;
+    private String onlineState = "offline";
+    private int[] resolution = null;
+
+    @Override
+    public boolean isOnline() throws IOException, ExecutionException, InterruptedException {
+        return isOnline(false);
+    }
+
+    @Override
+    public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException {
+        if(ignoreCache || onlineState == null) {
+            loadModelDetails();
+        }
+        return Objects.equals("NORMAL", onlineState);
+    }
+
+    private void loadModelDetails() throws IOException {
+        String url = "https://www.cam4.de.com/getBroadcasting?usernames=" + getName();
+        LOG.debug("Loading model details {}", url);
+        Request req = new Request.Builder().url(url).build();
+        Response response = site.getHttpClient().execute(req);
+        if(response.isSuccessful()) {
+            JSONArray json = new JSONArray(response.body().string());
+            JSONObject details = json.getJSONObject(0);
+            onlineState = details.getString("showType");
+            playlistUrl = details.getString("hlsPreviewUrl");
+            if(details.has("resolution")) {
+                String res = details.getString("resolution");
+                String[] tokens = res.split(":");
+                resolution = new int[] {Integer.parseInt(tokens[0]), Integer.parseInt(tokens[1])};
+            }
+        } else {
+            IOException io = new IOException(response.code() + " " + response.message());
+            response.close();
+            throw io;
+        }
+    }
+
+    @Override
+    public String getOnlineState(boolean failFast) throws IOException, ExecutionException {
+        return onlineState;
+    }
+
+    private String getPlaylistUrl() throws IOException {
+        if(playlistUrl == null) {
+            loadModelDetails();
+        }
+        return playlistUrl;
+    }
+
+    @Override
+    public List<StreamSource> getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException {
+        MasterPlaylist masterPlaylist = getMasterPlaylist();
+        List<StreamSource> sources = new ArrayList<>();
+        for (PlaylistData playlist : masterPlaylist.getPlaylists()) {
+            if (playlist.hasStreamInfo()) {
+                StreamSource src = new StreamSource();
+                src.bandwidth = playlist.getStreamInfo().getBandwidth();
+                src.height = playlist.getStreamInfo().getResolution().height;
+                String masterUrl = getPlaylistUrl();
+                String baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf('/') + 1);
+                String segmentUri = baseUrl + playlist.getUri();
+                src.mediaPlaylistUrl = segmentUri;
+                LOG.trace("Media playlist {}", src.mediaPlaylistUrl);
+                sources.add(src);
+            }
+        }
+        return sources;
+    }
+
+    private MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException {
+        LOG.trace("Loading master playlist {}", getPlaylistUrl());
+        Request req = new Request.Builder().url(getPlaylistUrl()).build();
+        Response response = site.getHttpClient().execute(req);
+        try {
+            InputStream inputStream = response.body().byteStream();
+            PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8);
+            Playlist playlist = parser.parse();
+            MasterPlaylist master = playlist.getMasterPlaylist();
+            return master;
+        } finally {
+            response.close();
+        }
+    }
+
+    @Override
+    public void invalidateCacheEntries() {
+        // TODO Auto-generated method stub
+
+    }
+
+    @Override
+    public void receiveTip(int tokens) throws IOException {
+        // TODO Auto-generated method stub
+
+    }
+
+    @Override
+    public int[] getStreamResolution(boolean failFast) throws ExecutionException {
+        if(resolution == null) {
+            if(failFast) {
+                return new int[2];
+            } else {
+                try {
+                    loadModelDetails();
+                } catch (IOException e) {
+                    throw new ExecutionException(e);
+                }
+            }
+        }
+        return resolution;
+    }
+
+    @Override
+    public boolean follow() throws IOException {
+        // TODO Auto-generated method stub
+        return false;
+    }
+
+    @Override
+    public boolean unfollow() throws IOException {
+        // TODO Auto-generated method stub
+        return false;
+    }
+
+    @Override
+    public void setSite(Site site) {
+        if(site instanceof Cam4) {
+            this.site = (Cam4) site;
+        } else {
+            throw new IllegalArgumentException("Site has to be an instance of Cam4");
+        }
+    }
+
+    @Override
+    public Site getSite() {
+        return site;
+    }
+
+    public void setPlaylistUrl(String playlistUrl) {
+        this.playlistUrl = playlistUrl;
+    }
+}
diff --git a/src/main/java/ctbrec/sites/cam4/Cam4TabProvider.java b/src/main/java/ctbrec/sites/cam4/Cam4TabProvider.java
new file mode 100644
index 00000000..a07f97e7
--- /dev/null
+++ b/src/main/java/ctbrec/sites/cam4/Cam4TabProvider.java
@@ -0,0 +1,33 @@
+package ctbrec.sites.cam4;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import ctbrec.recorder.Recorder;
+import ctbrec.ui.TabProvider;
+import ctbrec.ui.ThumbOverviewTab;
+import javafx.scene.Scene;
+import javafx.scene.control.Tab;
+
+public class Cam4TabProvider extends TabProvider {
+
+    private Cam4 cam4;
+    private Recorder recorder;
+
+    public Cam4TabProvider(Cam4 cam4, Recorder recorder) {
+        this.cam4 = cam4;
+        this.recorder = recorder;
+    }
+
+    @Override
+    public List<Tab> getTabs(Scene scene) {
+        List<Tab> tabs = new ArrayList<>();
+        String url = cam4.getBaseUrl() + "/directoryResults?online=true&gender=female&orderBy=MOST_VIEWERS";
+        Cam4UpdateService female = new Cam4UpdateService(url, false, cam4);
+        ThumbOverviewTab tab = new ThumbOverviewTab("Female", female, cam4);
+        tab.setRecorder(recorder);
+        tabs.add(tab);
+        return tabs;
+    }
+
+}
diff --git a/src/main/java/ctbrec/sites/cam4/Cam4UpdateService.java b/src/main/java/ctbrec/sites/cam4/Cam4UpdateService.java
new file mode 100644
index 00000000..26dd5787
--- /dev/null
+++ b/src/main/java/ctbrec/sites/cam4/Cam4UpdateService.java
@@ -0,0 +1,105 @@
+package ctbrec.sites.cam4;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+
+import org.eclipse.jetty.util.StringUtil;
+import org.json.JSONObject;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Config;
+import ctbrec.Model;
+import ctbrec.ui.HtmlParser;
+import ctbrec.ui.PaginatedScheduledService;
+import javafx.concurrent.Task;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class Cam4UpdateService extends PaginatedScheduledService {
+
+    private static final transient Logger LOG = LoggerFactory.getLogger(Cam4UpdateService.class);
+    private String url;
+    private Cam4 site;
+    private boolean loginRequired;
+
+    public Cam4UpdateService(String url, boolean loginRequired, Cam4 site) {
+        this.site = site;
+        this.url = url;
+        this.loginRequired = loginRequired;
+
+        ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactory() {
+            @Override
+            public Thread newThread(Runnable r) {
+                Thread t = new Thread(r);
+                t.setDaemon(true);
+                t.setName("ThumbOverviewTab UpdateService");
+                return t;
+            }
+        });
+        setExecutor(executor);
+    }
+
+    @Override
+    protected Task<List<Model>> createTask() {
+        return new Task<List<Model>>() {
+            @Override
+            public List<Model> call() throws IOException {
+                if(loginRequired && StringUtil.isBlank(Config.getInstance().getSettings().username)) { // FIXME change to cam4 username
+                    return Collections.emptyList();
+                } else {
+                    String url = Cam4UpdateService.this.url + "&page=" + page;
+                    LOG.debug("Fetching page {}", url);
+                    Request request = new Request.Builder().url(url).build();
+                    Response response = site.getHttpClient().execute(request, loginRequired);
+                    if (response.isSuccessful()) {
+                        JSONObject json = new JSONObject(response.body().string());
+                        String html = json.getString("html");
+                        Elements profilesBoxes = HtmlParser.getTags(html, "div[class~=profileDataBox]");
+                        List<Model> models = new ArrayList<>(profilesBoxes.size());
+                        for (Element profileBox : profilesBoxes) {
+                            String boxHtml = profileBox.html();
+                            Element profileLink = HtmlParser.getTag(boxHtml, "a.profile-preview");
+                            String path = profileLink.attr("href");
+                            String slug = path.substring(1);
+                            Cam4Model model = (Cam4Model) site.createModel(slug);
+                            String playlistUrl = profileLink.attr("data-hls-preview-url");
+                            model.setPlaylistUrl(playlistUrl);
+                            model.setPreview(HtmlParser.getTag(boxHtml, "a img").attr("data-src"));
+                            model.setDescription(parseDesription(boxHtml));
+                            //model.setOnlineState(parseOnlineState(boxHtml));
+                            models.add(model);
+                        }
+                        response.close();
+                        return models;
+                    } else {
+                        int code = response.code();
+                        response.close();
+                        throw new IOException("HTTP status " + code);
+                    }
+                }
+            }
+
+            private String parseDesription(String boxHtml) {
+                try {
+                    return HtmlParser.getText(boxHtml, "div[class~=statusMsg2]");
+                } catch(Exception e) {
+                    LOG.trace("Couldn't parse description for room");
+                }
+                return "";
+            }
+        };
+    }
+
+    public void setUrl(String url) {
+        this.url = url;
+    }
+
+}
diff --git a/src/main/java/ctbrec/ui/CamrecApplication.java b/src/main/java/ctbrec/ui/CamrecApplication.java
index 3e1f258f..acb92508 100644
--- a/src/main/java/ctbrec/ui/CamrecApplication.java
+++ b/src/main/java/ctbrec/ui/CamrecApplication.java
@@ -27,6 +27,7 @@ import ctbrec.recorder.LocalRecorder;
 import ctbrec.recorder.Recorder;
 import ctbrec.recorder.RemoteRecorder;
 import ctbrec.sites.Site;
+import ctbrec.sites.cam4.Cam4;
 import ctbrec.sites.chaturbate.Chaturbate;
 import ctbrec.sites.mfc.MyFreeCams;
 import javafx.application.Application;
@@ -60,6 +61,7 @@ public class CamrecApplication extends Application {
     public void start(Stage primaryStage) throws Exception {
         sites.add(new Chaturbate());
         sites.add(new MyFreeCams());
+        sites.add(new Cam4());
         loadConfig();
         createHttpClient();
         bus = new AsyncEventBus(Executors.newSingleThreadExecutor());