diff --git a/CHANGELOG.md b/CHANGELOG.md index 857a6a63..e3b95ef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +1.6.1 +======================== +* Fixed UI freeze, which occured for a high number of recorded models +* Added Cam4 +* Updated the embedded JRE for the Windows bundles to 8u192 + 1.6.0 ======================== * Added support for multiple cam sites diff --git a/ctbrec-macos.sh b/ctbrec-macos.sh new file mode 100755 index 00000000..f28903cc --- /dev/null +++ b/ctbrec-macos.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +DIR=$(dirname $0) +pushd $DIR +JAVA_HOME="$DIR/jre/Contents/Home" +JAVA="$JAVA_HOME/bin/java" +$JAVA -version +$JAVA -cp ${name.final}.jar ctbrec.ui.Launcher +popd \ No newline at end of file diff --git a/pom.xml b/pom.xml index 9a768d26..77b1df4a 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 ctbrec ctbrec - 1.6.0 + 1.6.1 UTF-8 @@ -66,6 +66,7 @@ src/assembly/win64-jre.xml src/assembly/win32-jre.xml src/assembly/linux.xml + src/assembly/macos-jre.xml @@ -77,7 +78,7 @@ 1.7.22 - l4j-clui + l4j-win package launch4j diff --git a/server-macos.sh b/server-macos.sh new file mode 100755 index 00000000..7ab059e4 --- /dev/null +++ b/server-macos.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +DIR=$(dirname $0) +pushd $DIR +JAVA_HOME="$DIR/jre/Contents/Home" +JAVA="$JAVA_HOME/bin/java" +$JAVA -version +$JAVA -cp ${name.final}.jar -Dctbrec.config=server.json ctbrec.recorder.server.HttpServer +popd diff --git a/src/assembly/macos-jre.xml b/src/assembly/macos-jre.xml new file mode 100644 index 00000000..c8a1c261 --- /dev/null +++ b/src/assembly/macos-jre.xml @@ -0,0 +1,34 @@ + + + macos-jre + + zip + + false + + + ${project.basedir}/ctbrec-macos.sh + ctbrec + true + + + ${project.basedir}/server-macos.sh + ctbrec + true + + + ${project.build.directory}/${name.final}.jar + ctbrec + + + + + jre/jre1.8.0_192_macos + + **/* + + ctbrec/jre + false + + + diff --git a/src/assembly/win32-jre.xml b/src/assembly/win32-jre.xml index b9fba758..7efe1c9d 100644 --- a/src/assembly/win32-jre.xml +++ b/src/assembly/win32-jre.xml @@ -22,7 +22,7 @@ - jre/jre1.8.0_181_win32 + jre/jre1.8.0_192_win32 **/* diff --git a/src/assembly/win64-jre.xml b/src/assembly/win64-jre.xml index d81bd0ce..65b22409 100644 --- a/src/assembly/win64-jre.xml +++ b/src/assembly/win64-jre.xml @@ -22,7 +22,7 @@ - jre/jre1.8.0_181_win64 + jre/jre1.8.0_192_win64 **/* diff --git a/src/main/java/ctbrec/Settings.java b/src/main/java/ctbrec/Settings.java index d1f41691..db2cbf0c 100644 --- a/src/main/java/ctbrec/Settings.java +++ b/src/main/java/ctbrec/Settings.java @@ -26,6 +26,8 @@ public class Settings { public String mfcPassword = ""; public String camsodaUsername = ""; public String camsodaPassword = ""; + public String cam4Username; + public String cam4Password; public String lastDownloadDir = ""; public List models = new ArrayList(); diff --git a/src/main/java/ctbrec/io/ModelJsonAdapter.java b/src/main/java/ctbrec/io/ModelJsonAdapter.java index 0c6d8129..fdaefc39 100644 --- a/src/main/java/ctbrec/io/ModelJsonAdapter.java +++ b/src/main/java/ctbrec/io/ModelJsonAdapter.java @@ -69,7 +69,7 @@ public class ModelJsonAdapter extends JsonAdapter { } return model; } catch (ClassNotFoundException | InstantiationException | IllegalAccessException e) { - throw new IOException("Couldn't instantiate mode class [" + type + "]", e); + throw new IOException("Couldn't instantiate model class [" + type + "]", e); } } diff --git a/src/main/java/ctbrec/recorder/LocalRecorder.java b/src/main/java/ctbrec/recorder/LocalRecorder.java index 4f3f983b..e1916d93 100644 --- a/src/main/java/ctbrec/recorder/LocalRecorder.java +++ b/src/main/java/ctbrec/recorder/LocalRecorder.java @@ -166,7 +166,12 @@ public class LocalRecorder implements Recorder { @Override public List getModelsRecording() { - return Collections.unmodifiableList(new ArrayList<>(models)); + lock.lock(); + try { + return Collections.unmodifiableList(new ArrayList<>(models)); + } finally { + lock.unlock(); + } } @Override @@ -308,24 +313,19 @@ public class LocalRecorder implements Recorder { public void run() { running = true; while (running) { - lock.lock(); - try { - for (Model model : getModelsRecording()) { - try { - if (!recordingProcesses.containsKey(model)) { - boolean isOnline = model.isOnline(IGNORE_CACHE); - LOG.trace("Checking online state for {}: {}", model, (isOnline ? "online" : "offline")); - if (isOnline) { - LOG.info("Model {}'s room back to public. Starting recording", model); - startRecordingProcess(model); - } + for (Model model : getModelsRecording()) { + try { + if (!recordingProcesses.containsKey(model)) { + boolean isOnline = model.isOnline(IGNORE_CACHE); + LOG.trace("Checking online state for {}: {}", model, (isOnline ? "online" : "offline")); + if (isOnline) { + LOG.info("Model {}'s room back to public. Starting recording", model); + startRecordingProcess(model); } - } catch (Exception e) { - LOG.error("Couldn't check if model {} is online", model.getName(), e); } + } catch (Exception e) { + LOG.error("Couldn't check if model {} is online", model.getName(), e); } - } finally { - lock.unlock(); } try { diff --git a/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java b/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java index 89a1600b..787ca938 100644 --- a/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java +++ b/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java @@ -13,6 +13,7 @@ import java.util.concurrent.Executors; import com.iheartradio.m3u8.Encoding; import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; import com.iheartradio.m3u8.PlaylistParser; import com.iheartradio.m3u8.data.MediaPlaylist; @@ -41,7 +42,7 @@ public abstract class AbstractHlsDownload implements Download { Response response = client.execute(request); try { InputStream inputStream = response.body().byteStream(); - PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); if(playlist.hasMediaPlaylist()) { MediaPlaylist mediaPlaylist = playlist.getMediaPlaylist(); diff --git a/src/main/java/ctbrec/recorder/server/HttpServer.java b/src/main/java/ctbrec/recorder/server/HttpServer.java index a83c0025..00537280 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.camsoda.Camsoda; import ctbrec.sites.chaturbate.Chaturbate; import ctbrec.sites.mfc.MyFreeCams; @@ -64,6 +65,7 @@ public class HttpServer { sites.add(new Chaturbate()); sites.add(new MyFreeCams()); sites.add(new Camsoda()); + 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..5d791883 --- /dev/null +++ b/src/main/java/ctbrec/sites/cam4/Cam4.java @@ -0,0 +1,155 @@ +package ctbrec.sites.cam4; + +import java.io.IOException; + +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpClient; +import ctbrec.recorder.Recorder; +import ctbrec.sites.AbstractSite; +import ctbrec.ui.DesktopIntergation; +import ctbrec.ui.SettingsTab; +import ctbrec.ui.TabProvider; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +public class Cam4 extends AbstractSite { + + public static final String BASE_URI = "https://www.cam4.com"; + + public static final String AFFILIATE_LINK = BASE_URI + "/?referrerId=1514a80d87b5effb456cca02f6743aa1"; + + private HttpClient httpClient; + private Recorder recorder; + + @Override + public String getName() { + return "Cam4"; + } + + @Override + public String getBaseUrl() { + return BASE_URI; + } + + @Override + public String getAffiliateLink() { + return AFFILIATE_LINK; + } + + @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 { + if (!credentialsAvailable()) { + throw new IOException("Not logged in"); + } + return ((Cam4HttpClient)getHttpClient()).getTokenBalance(); + } + + @Override + public String getBuyTokensLink() { + return getAffiliateLink(); + } + + @Override + public void login() throws IOException { + if (credentialsAvailable()) { + boolean success = getHttpClient().login(); + LoggerFactory.getLogger(getClass()).debug("Login success: {}", success); + } + } + + @Override + public HttpClient getHttpClient() { + if(httpClient == null) { + httpClient = new Cam4HttpClient(); + } + 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 true; + } + + @Override + public boolean isSiteForModel(Model m) { + return m instanceof Cam4Model; + } + + @Override + public boolean credentialsAvailable() { + String username = Config.getInstance().getSettings().cam4Username; + return username != null && !username.trim().isEmpty(); + } + + @Override + public Node getConfigurationGui() { + GridPane layout = SettingsTab.createGridLayout(); + layout.add(new Label("Cam4 User"), 0, 0); + TextField username = new TextField(Config.getInstance().getSettings().cam4Username); + username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().cam4Username = username.getText()); + GridPane.setFillWidth(username, true); + GridPane.setHgrow(username, Priority.ALWAYS); + GridPane.setColumnSpan(username, 2); + layout.add(username, 1, 0); + + layout.add(new Label("Cam4 Password"), 0, 1); + PasswordField password = new PasswordField(); + password.setText(Config.getInstance().getSettings().cam4Password); + password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().cam4Password = password.getText()); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, 1); + + Button createAccount = new Button("Create new Account"); + createAccount.setOnAction((e) -> DesktopIntergation.open(Cam4.AFFILIATE_LINK)); + layout.add(createAccount, 1, 2); + GridPane.setColumnSpan(createAccount, 2); + GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } +} diff --git a/src/main/java/ctbrec/sites/cam4/Cam4FollowedTab.java b/src/main/java/ctbrec/sites/cam4/Cam4FollowedTab.java new file mode 100644 index 00000000..bd25fa90 --- /dev/null +++ b/src/main/java/ctbrec/sites/cam4/Cam4FollowedTab.java @@ -0,0 +1,75 @@ +package ctbrec.sites.cam4; + +import ctbrec.ui.FollowedTab; +import ctbrec.ui.ThumbOverviewTab; +import javafx.concurrent.WorkerStateEvent; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.HBox; + +public class Cam4FollowedTab extends ThumbOverviewTab implements FollowedTab { + private Label status; + + public Cam4FollowedTab(Cam4 cam4) { + super("Followed", new Cam4FollowedUpdateService(cam4), cam4); + status = new Label("Logging in..."); + grid.getChildren().add(status); + } + + @Override + protected void createGui() { + super.createGui(); + addOnlineOfflineSelector(); + } + + private void addOnlineOfflineSelector() { + ToggleGroup group = new ToggleGroup(); + RadioButton online = new RadioButton("online"); + online.setToggleGroup(group); + RadioButton offline = new RadioButton("offline"); + offline.setToggleGroup(group); + pagination.getChildren().add(online); + pagination.getChildren().add(offline); + HBox.setMargin(online, new Insets(5,5,5,40)); + HBox.setMargin(offline, new Insets(5,5,5,5)); + online.setSelected(true); + group.selectedToggleProperty().addListener((e) -> { + ((Cam4FollowedUpdateService)updateService).setShowOnline(online.isSelected()); + queue.clear(); + updateService.restart(); + }); + } + + @Override + protected void onSuccess() { + grid.getChildren().remove(status); + super.onSuccess(); + } + + @Override + protected void onFail(WorkerStateEvent event) { + status.setText("Login failed"); + super.onFail(event); + } + + @Override + public void selected() { + status.setText("Logging in..."); + super.selected(); + } + + public void setScene(Scene scene) { + scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + if(this.isSelected()) { + if(event.getCode() == KeyCode.DELETE) { + follow(selectedThumbCells, false); + } + } + }); + } +} diff --git a/src/main/java/ctbrec/sites/cam4/Cam4FollowedUpdateService.java b/src/main/java/ctbrec/sites/cam4/Cam4FollowedUpdateService.java new file mode 100644 index 00000000..c752fb4a --- /dev/null +++ b/src/main/java/ctbrec/sites/cam4/Cam4FollowedUpdateService.java @@ -0,0 +1,94 @@ +package ctbrec.sites.cam4; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.stream.Collectors; + +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 Cam4FollowedUpdateService extends PaginatedScheduledService { + + private static final transient Logger LOG = LoggerFactory.getLogger(Cam4FollowedUpdateService.class); + private Cam4 site; + private boolean showOnline = true; + + public Cam4FollowedUpdateService(Cam4 site) { + this.site = site; + 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> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + List models = new ArrayList<>(); + String username = Config.getInstance().getSettings().cam4Username; + String url = site.getBaseUrl() + '/' + username + "/edit/friends_favorites"; + Request req = new Request.Builder().url(url).build(); + Response response = site.getHttpClient().execute(req, true); + if(response.isSuccessful()) { + String content = response.body().string(); + Elements cells = HtmlParser.getTags(content, "div#favorites div.ff_thumb"); + for (Element cell : cells) { + String cellHtml = cell.html(); + Element link = HtmlParser.getTag(cellHtml, "div.ff_img a"); + String path = link.attr("href"); + String modelName = path.substring(1); + Cam4Model model = (Cam4Model) site.createModel(modelName); + model.setPreview("https://snapshots.xcdnpro.com/thumbnails/"+model.getName()+"?s=" + System.currentTimeMillis()); + model.setOnlineState(parseOnlineState(cellHtml)); + models.add(model); + } + return models.stream() + .filter(m -> { + try { + return m.isOnline() == showOnline; + } catch (IOException | ExecutionException | InterruptedException e) { + LOG.error("Couldn't determine online state", e); + return false; + } + }).collect(Collectors.toList()); + } else { + IOException e = new IOException(response.code() + " " + response.message()); + response.close(); + throw e; + } + } + + private String parseOnlineState(String cellHtml) { + Element state = HtmlParser.getTag(cellHtml, "div.ff_name div"); + return state.attr("class").equals("online") ? "NORMAL" : "OFFLINE"; + } + }; + } + + public void setShowOnline(boolean online) { + this.showOnline = online; + } +} diff --git a/src/main/java/ctbrec/sites/cam4/Cam4HttpClient.java b/src/main/java/ctbrec/sites/cam4/Cam4HttpClient.java new file mode 100644 index 00000000..3a40c555 --- /dev/null +++ b/src/main/java/ctbrec/sites/cam4/Cam4HttpClient.java @@ -0,0 +1,109 @@ +package ctbrec.sites.cam4; + +import java.io.IOException; +import java.net.HttpCookie; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.io.HttpClient; +import javafx.application.Platform; +import okhttp3.Cookie; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.Response; + +public class Cam4HttpClient extends HttpClient { + + private static final transient Logger LOG = LoggerFactory.getLogger(Cam4HttpClient.class); + + @Override + public synchronized boolean login() throws IOException { + if(loggedIn) { + return true; + } + + BlockingQueue queue = new LinkedBlockingQueue<>(); + + Runnable showDialog = () -> { + // login with javafx WebView + Cam4LoginDialog loginDialog = new Cam4LoginDialog(); + + // transfer cookies from WebView to OkHttp cookie jar + transferCookies(loginDialog); + + try { + queue.put(true); + } catch (InterruptedException e) { + LOG.error("Error while signaling termination", e); + } + }; + + if(Platform.isFxApplicationThread()) { + showDialog.run(); + } else { + Platform.runLater(showDialog); + try { + queue.take(); + } catch (InterruptedException e) { + LOG.error("Error while waiting for login dialog to close", e); + throw new IOException(e); + } + } + + loggedIn = checkLoginSuccess(); + return loggedIn; + } + + /** + * check, if the login worked by requesting unchecked mail + * @throws IOException + */ + private boolean checkLoginSuccess() throws IOException { + String mailUrl = Cam4.BASE_URI + "/mail/unreadThreads"; + Request req = new Request.Builder() + .url(mailUrl) + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + Response response = execute(req); + if(response.isSuccessful() && response.body().contentLength() > 0) { + JSONObject json = new JSONObject(response.body().string()); + return json.has("status") && Objects.equals("success", json.getString("status")); + } else { + response.close(); + return false; + } + } + + private void transferCookies(Cam4LoginDialog loginDialog) { + HttpUrl redirectedUrl = HttpUrl.parse(loginDialog.getUrl()); + List cookies = new ArrayList<>(); + for (HttpCookie webViewCookie : loginDialog.getCookies()) { + Cookie cookie = Cookie.parse(redirectedUrl, webViewCookie.toString()); + cookies.add(cookie); + } + cookieJar.saveFromResponse(redirectedUrl, cookies); + + HttpUrl origUrl = HttpUrl.parse(Cam4LoginDialog.URL); + cookies = new ArrayList<>(); + for (HttpCookie webViewCookie : loginDialog.getCookies()) { + Cookie cookie = Cookie.parse(origUrl, webViewCookie.toString()); + cookies.add(cookie); + } + cookieJar.saveFromResponse(origUrl, cookies); + } + + protected int getTokenBalance() throws IOException { + if(!loggedIn) { + login(); + } + + throw new RuntimeException("Not implemented, yet"); + } +} diff --git a/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java b/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java new file mode 100644 index 00000000..9aae0544 --- /dev/null +++ b/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java @@ -0,0 +1,103 @@ +package ctbrec.sites.cam4; + +import java.io.InputStream; +import java.net.CookieHandler; +import java.net.CookieManager; +import java.net.HttpCookie; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.List; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import javafx.concurrent.Worker.State; +import javafx.scene.Scene; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.image.Image; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.web.WebEngine; +import javafx.scene.web.WebView; +import javafx.stage.Stage; + +public class Cam4LoginDialog { + + private static final transient Logger LOG = LoggerFactory.getLogger(Cam4LoginDialog.class); + public static final String URL = Cam4.BASE_URI + "/login"; + private List cookies = null; + private String url; + private Region veil; + private ProgressIndicator p; + + public Cam4LoginDialog() { + Stage stage = new Stage(); + stage.setTitle("Cam4 Login"); + InputStream icon = getClass().getResourceAsStream("/icon.png"); + stage.getIcons().add(new Image(icon)); + CookieManager cookieManager = new CookieManager(); + CookieHandler.setDefault(cookieManager); + WebView webView = createWebView(stage); + + veil = new Region(); + veil.setStyle("-fx-background-color: rgba(0, 0, 0, 0.4)"); + p = new ProgressIndicator(); + p.setMaxSize(140, 140); + + StackPane stackPane = new StackPane(); + stackPane.getChildren().addAll(webView, veil, p); + + stage.setScene(new Scene(stackPane, 480, 854)); + stage.showAndWait(); + cookies = cookieManager.getCookieStore().getCookies(); + } + + private WebView createWebView(Stage stage) { + WebView browser = new WebView(); + WebEngine webEngine = browser.getEngine(); + webEngine.setJavaScriptEnabled(true); + webEngine.locationProperty().addListener((obs, oldV, newV) -> { + try { + URL _url = new URL(newV); + if (Objects.equals(_url.getPath(), "/")) { + stage.close(); + } + } catch (MalformedURLException e) { + LOG.error("Couldn't parse new url {}", newV, e); + } + url = newV.toString(); + }); + webEngine.getLoadWorker().stateProperty().addListener((observable, oldState, newState) -> { + if (newState == State.SUCCEEDED) { + String username = Config.getInstance().getSettings().cam4Username; + if (username != null && !username.trim().isEmpty()) { + webEngine.executeScript("$('input[name=username]').attr('value','" + username + "')"); + } + String password = Config.getInstance().getSettings().cam4Password; + if (password != null && !password.trim().isEmpty()) { + webEngine.executeScript("$('input[name=password]').attr('value','" + password + "')"); + } + webEngine.executeScript("$('div[class~=navbar]').css('display','none')"); + webEngine.executeScript("$('div#footer').css('display','none')"); + webEngine.executeScript("$('div#content').css('padding','0')"); + veil.setVisible(false); + p.setVisible(false); + } else if (newState == State.CANCELLED || newState == State.FAILED) { + veil.setVisible(false); + p.setVisible(false); + } + }); + webEngine.load(URL); + return browser; + } + + public List getCookies() { + return cookies; + } + + public String getUrl() { + return url; + } +} 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..c8972af8 --- /dev/null +++ b/src/main/java/ctbrec/sites/cam4/Cam4Model.java @@ -0,0 +1,231 @@ +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.jsoup.nodes.Element; +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.Config; +import ctbrec.recorder.download.StreamSource; +import ctbrec.sites.Site; +import ctbrec.ui.HtmlParser; +import okhttp3.FormBody; +import okhttp3.Request; +import okhttp3.RequestBody; +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()); + if(json.length() == 0) { + throw new IOException("Couldn't fetch model details"); + } + 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 getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { + MasterPlaylist masterPlaylist = getMasterPlaylist(); + List 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() { + resolution = null; + playlistUrl = null; + } + + @Override + public void receiveTip(int tokens) throws IOException { + throw new RuntimeException("Not implemented for Cam4, yet"); + } + + @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 { + String url = site.getBaseUrl() + "/profiles/addFriendFavorite?action=addFavorite&object=" + getName() + "&_=" + System.currentTimeMillis(); + Request req = new Request.Builder() + .url(url) + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + Response response = site.getHttpClient().execute(req, true); + boolean success = response.isSuccessful(); + response.close(); + return success; + } + + @Override + public boolean unfollow() throws IOException { + // get model user id + String url = site.getBaseUrl() + '/' + getName(); + Request req = new Request.Builder().url(url).build(); + Response response = site.getHttpClient().execute(req, true); + String broadCasterId = null; + if(response.isSuccessful()) { + String content = response.body().string(); + try { + Element tag = HtmlParser.getTag(content, "input[name=\"broadcasterId\"]"); + broadCasterId = tag.attr("value"); + } catch(Exception e) { + LOG.debug(content); + throw new IOException(e); + } + + // send unfollow request + String username = Config.getInstance().getSettings().cam4Username; + url = site.getBaseUrl() + '/' + username + "/edit/friends_favorites"; + RequestBody body = new FormBody.Builder() + .add("deleteFavorites", broadCasterId) + .add("simpleresult", "true") + .build(); + req = new Request.Builder() + .url(url) + .post(body) + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + response = site.getHttpClient().execute(req, true); + if(response.isSuccessful()) { + return Objects.equals(response.body().string(), "Ok"); + } else { + response.close(); + return false; + } + } else { + response.close(); + 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; + } + + public void setOnlineState(String onlineState) { + this.onlineState = onlineState; + } +} 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..62136129 --- /dev/null +++ b/src/main/java/ctbrec/sites/cam4/Cam4TabProvider.java @@ -0,0 +1,45 @@ +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 getTabs(Scene scene) { + List tabs = new ArrayList<>(); + + tabs.add(createTab("Female", cam4.getBaseUrl() + "/directoryResults?online=true&gender=female&orderBy=MOST_VIEWERS")); + tabs.add(createTab("Male", cam4.getBaseUrl() + "/directoryResults?online=true&gender=male&orderBy=MOST_VIEWERS")); + tabs.add(createTab("Couples", cam4.getBaseUrl() + "/directoryResults?online=true&broadcastType=male_group&broadcastType=female_group&broadcastType=male_female_group&orderBy=MOST_VIEWERS")); + tabs.add(createTab("HD", cam4.getBaseUrl() + "/directoryResults?online=true&hd=true&orderBy=MOST_VIEWERS")); + + Cam4FollowedTab followed = new Cam4FollowedTab(cam4); + followed.setRecorder(recorder); + tabs.add(followed); + + return tabs; + } + + private Tab createTab(String name, String url) { + Cam4UpdateService updateService = new Cam4UpdateService(url, false, cam4); + ThumbOverviewTab tab = new ThumbOverviewTab(name, updateService, cam4); + tab.setRecorder(recorder); + return tab; + } + +} 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..bcd89205 --- /dev/null +++ b/src/main/java/ctbrec/sites/cam4/Cam4UpdateService.java @@ -0,0 +1,104 @@ +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> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + if(loginRequired && StringUtil.isBlank(Config.getInstance().getSettings().cam4Username)) { + 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 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("https://snapshots.xcdnpro.com/thumbnails/"+model.getName()+"?s=" + System.currentTimeMillis()); + model.setDescription(parseDesription(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/sites/chaturbate/Chaturbate.java b/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java index 2aa1fb7c..984e8c69 100644 --- a/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java +++ b/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java @@ -26,7 +26,6 @@ import com.squareup.moshi.Moshi; import ctbrec.Config; import ctbrec.Model; -import ctbrec.Settings; import ctbrec.io.HttpClient; import ctbrec.recorder.Recorder; import ctbrec.sites.AbstractSite; @@ -121,8 +120,7 @@ public class Chaturbate extends AbstractSite { @Override public void login() { - Settings settings = Config.getInstance().getSettings(); - if (settings.username != null && !settings.username.isEmpty()) { + if (credentialsAvailable()) { new Thread() { @Override public void run() { diff --git a/src/main/java/ctbrec/ui/CamrecApplication.java b/src/main/java/ctbrec/ui/CamrecApplication.java index eda28a84..352dadb9 100644 --- a/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/src/main/java/ctbrec/ui/CamrecApplication.java @@ -28,6 +28,7 @@ import ctbrec.recorder.Recorder; import ctbrec.recorder.RemoteRecorder; import ctbrec.sites.Site; import ctbrec.sites.camsoda.Camsoda; +import ctbrec.sites.cam4.Cam4; import ctbrec.sites.chaturbate.Chaturbate; import ctbrec.sites.mfc.MyFreeCams; import javafx.application.Application; @@ -62,6 +63,7 @@ public class CamrecApplication extends Application { sites.add(new Chaturbate()); sites.add(new MyFreeCams()); sites.add(new Camsoda()); + sites.add(new Cam4()); loadConfig(); createHttpClient(); bus = new AsyncEventBus(Executors.newSingleThreadExecutor()); diff --git a/src/main/java/ctbrec/ui/CookieJarImpl.java b/src/main/java/ctbrec/ui/CookieJarImpl.java index e0bee9ee..ce18ea4d 100644 --- a/src/main/java/ctbrec/ui/CookieJarImpl.java +++ b/src/main/java/ctbrec/ui/CookieJarImpl.java @@ -6,6 +6,7 @@ import java.util.Iterator; import java.util.List; import java.util.NoSuchElementException; import java.util.Objects; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,7 +51,11 @@ public class CookieJarImpl implements CookieJar { public List loadForRequest(HttpUrl url) { String host = getHost(url); List cookies = cookieStore.get(host); - LOG.debug("Cookies for {}: {}", url.host(), cookies); + LOG.debug("Cookies for {}", url); + Optional.ofNullable(cookies).ifPresent(cookiez -> cookiez.forEach(c -> { + LOG.debug(" {} expires on:{}", c, c.expiresAt()); + })); + //LOG.debug("Cookies for {}: {}", url.host(), cookies); return cookies != null ? cookies : new ArrayList(); } diff --git a/src/main/java/ctbrec/ui/ThumbCell.java b/src/main/java/ctbrec/ui/ThumbCell.java index c4f35030..f579a948 100644 --- a/src/main/java/ctbrec/ui/ThumbCell.java +++ b/src/main/java/ctbrec/ui/ThumbCell.java @@ -208,6 +208,8 @@ public class ThumbCell extends StackPane { LOG.trace("Removing invalid resolution value for {}", model.getName()); model.invalidateCacheEntries(); } + + Thread.sleep(500); } catch (IOException | InterruptedException e1) { LOG.warn("Couldn't update resolution tag for model {}", model.getName(), e1); } catch(ExecutionException e) { @@ -419,13 +421,11 @@ public class ThumbCell extends StackPane { } public void setModel(Model model) { - //this.model = model; this.model.setName(model.getName()); this.model.setDescription(model.getDescription()); this.model.setPreview(model.getPreview()); this.model.setTags(model.getTags()); this.model.setUrl(model.getUrl()); - update(); }