diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b95ef4..5a025d00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +1.7.0 +======================== +* Added CamSoda +* Added detection of model name changes for MyFreeCams +* Added setting to define a maximum resolution +* Fixed sorting by date in recordings table + 1.6.1 ======================== * Fixed UI freeze, which occured for a high number of recorded models 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 f38886a0..5994a6db 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 ctbrec ctbrec - 1.6.1 + 1.7.0 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/main/java/ctbrec/AbstractModel.java b/src/main/java/ctbrec/AbstractModel.java index 267f9661..bf0395d0 100644 --- a/src/main/java/ctbrec/AbstractModel.java +++ b/src/main/java/ctbrec/AbstractModel.java @@ -2,14 +2,11 @@ package ctbrec; import java.io.IOException; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutionException; -import com.iheartradio.m3u8.ParseException; -import com.iheartradio.m3u8.PlaylistException; - -import ctbrec.recorder.download.StreamSource; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; public abstract class AbstractModel implements Model { @@ -20,6 +17,11 @@ public abstract class AbstractModel implements Model { private List tags = new ArrayList<>(); private int streamUrlIndex = -1; + @Override + public boolean isOnline() throws IOException, ExecutionException, InterruptedException { + return isOnline(false); + } + @Override public String getUrl() { return url; @@ -81,16 +83,13 @@ public abstract class AbstractModel implements Model { } @Override - public String getSegmentPlaylistUrl() throws IOException, ExecutionException, ParseException, PlaylistException { - List streamSources = getStreamSources(); - String url = null; - if(getStreamUrlIndex() >= 0 && getStreamUrlIndex() < streamSources.size()) { - url = streamSources.get(getStreamUrlIndex()).getMediaPlaylistUrl(); - } else { - Collections.sort(streamSources); - url = streamSources.get(streamSources.size()-1).getMediaPlaylistUrl(); - } - return url; + public void readSiteSpecificData(JsonReader reader) throws IOException { + // noop default implementation, can be overriden by concrete models + } + + @Override + public void writeSiteSpecificData(JsonWriter writer) throws IOException { + // noop default implementation, can be overriden by concrete models } @Override diff --git a/src/main/java/ctbrec/Model.java b/src/main/java/ctbrec/Model.java index 68d9646b..351dda3e 100644 --- a/src/main/java/ctbrec/Model.java +++ b/src/main/java/ctbrec/Model.java @@ -6,6 +6,8 @@ import java.util.concurrent.ExecutionException; import com.iheartradio.m3u8.ParseException; import com.iheartradio.m3u8.PlaylistException; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; import ctbrec.recorder.download.StreamSource; import ctbrec.sites.Site; @@ -27,7 +29,6 @@ public interface Model { public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException; public String getOnlineState(boolean failFast) throws IOException, ExecutionException; public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException; - public String getSegmentPlaylistUrl() throws IOException, ExecutionException, ParseException, PlaylistException; public void invalidateCacheEntries(); public void receiveTip(int tokens) throws IOException; public int[] getStreamResolution(boolean failFast) throws ExecutionException; @@ -35,4 +36,6 @@ public interface Model { public boolean unfollow() throws IOException; public void setSite(Site site); public Site getSite(); + public void writeSiteSpecificData(JsonWriter writer) throws IOException; + public void readSiteSpecificData(JsonReader reader) throws IOException; } \ No newline at end of file diff --git a/src/main/java/ctbrec/Settings.java b/src/main/java/ctbrec/Settings.java index 1da9aaf6..a8809ddf 100644 --- a/src/main/java/ctbrec/Settings.java +++ b/src/main/java/ctbrec/Settings.java @@ -24,6 +24,8 @@ public class Settings { public String password = ""; // chaturbate password TODO maybe rename this onetime public String mfcUsername = ""; public String mfcPassword = ""; + public String camsodaUsername = ""; + public String camsodaPassword = ""; public String cam4Username; public String cam4Password; public String lastDownloadDir = ""; @@ -32,6 +34,7 @@ public class Settings { public boolean determineResolution = false; public boolean requireAuthentication = false; public boolean chooseStreamQuality = false; + public int maximumResolution = 0; public byte[] key = null; public ProxyType proxyType = ProxyType.DIRECT; public String proxyHost; diff --git a/src/main/java/ctbrec/ui/CookieJarImpl.java b/src/main/java/ctbrec/io/CookieJarImpl.java similarity index 98% rename from src/main/java/ctbrec/ui/CookieJarImpl.java rename to src/main/java/ctbrec/io/CookieJarImpl.java index ce18ea4d..712ff30c 100644 --- a/src/main/java/ctbrec/ui/CookieJarImpl.java +++ b/src/main/java/ctbrec/io/CookieJarImpl.java @@ -1,4 +1,4 @@ -package ctbrec.ui; +package ctbrec.io; import java.util.ArrayList; import java.util.HashMap; @@ -34,6 +34,7 @@ public class CookieJarImpl implements CookieJar { if(newCookie.name().equalsIgnoreCase(name)) { LOG.debug("Replacing cookie {} {} -> {} [{}]", oldCookie.name(), oldCookie.value(), newCookie.value(), oldCookie.domain()); iterator.remove(); + break; } } } diff --git a/src/main/java/ctbrec/io/HttpClient.java b/src/main/java/ctbrec/io/HttpClient.java index 3b8e18e8..02c8f818 100644 --- a/src/main/java/ctbrec/io/HttpClient.java +++ b/src/main/java/ctbrec/io/HttpClient.java @@ -7,7 +7,6 @@ import java.util.concurrent.TimeUnit; import ctbrec.Config; import ctbrec.Settings.ProxyType; -import ctbrec.ui.CookieJarImpl; import okhttp3.ConnectionPool; import okhttp3.Credentials; import okhttp3.OkHttpClient; diff --git a/src/main/java/ctbrec/io/ModelJsonAdapter.java b/src/main/java/ctbrec/io/ModelJsonAdapter.java index fdaefc39..804c77fa 100644 --- a/src/main/java/ctbrec/io/ModelJsonAdapter.java +++ b/src/main/java/ctbrec/io/ModelJsonAdapter.java @@ -32,45 +32,51 @@ public class ModelJsonAdapter extends JsonAdapter { String url = null; String type = null; int streamUrlIndex = -1; + + Model model = null; while(reader.hasNext()) { - Token token = reader.peek(); - if(token == Token.NAME) { - String key = reader.nextName(); - if(key.equals("name")) { - name = reader.nextString(); - } else if(key.equals("description")) { - description = reader.nextString(); - } else if(key.equals("url")) { - url = reader.nextString(); - } else if(key.equals("type")) { - type = reader.nextString(); - } else if(key.equals("streamUrlIndex")) { - streamUrlIndex = reader.nextInt(); + try { + Token token = reader.peek(); + if(token == Token.NAME) { + String key = reader.nextName(); + if(key.equals("name")) { + name = reader.nextString(); + model.setName(name); + } else if(key.equals("description")) { + description = reader.nextString(); + model.setDescription(description); + } else if(key.equals("url")) { + url = reader.nextString(); + model.setUrl(url); + } else if(key.equals("type")) { + type = reader.nextString(); + Class modelClass = Class.forName(Optional.ofNullable(type).orElse(ChaturbateModel.class.getName())); + model = (Model) modelClass.newInstance(); + } else if(key.equals("streamUrlIndex")) { + streamUrlIndex = reader.nextInt(); + model.setStreamUrlIndex(streamUrlIndex); + } else if(key.equals("siteSpecific")) { + reader.beginObject(); + model.readSiteSpecificData(reader); + reader.endObject(); + } + } else { + reader.skipValue(); } - } else { - reader.skipValue(); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException e) { + throw new IOException("Couldn't instantiate model class [" + type + "]", e); } } reader.endObject(); - try { - Class modelClass = Class.forName(Optional.ofNullable(type).orElse(ChaturbateModel.class.getName())); - Model model = (Model) modelClass.newInstance(); - model.setName(name); - model.setDescription(description); - model.setUrl(url); - model.setStreamUrlIndex(streamUrlIndex); - if(sites != null) { - for (Site site : sites) { - if(site.isSiteForModel(model)) { - model.setSite(site); - } + if(sites != null) { + for (Site site : sites) { + if(site.isSiteForModel(model)) { + model.setSite(site); } } - return model; - } catch (ClassNotFoundException | InstantiationException | IllegalAccessException e) { - throw new IOException("Couldn't instantiate model class [" + type + "]", e); } + return model; } @Override @@ -81,6 +87,10 @@ public class ModelJsonAdapter extends JsonAdapter { writeValueIfSet(writer, "description", model.getDescription()); writeValueIfSet(writer, "url", model.getUrl()); writer.name("streamUrlIndex").value(model.getStreamUrlIndex()); + writer.name("siteSpecific"); + writer.beginObject(); + model.writeSiteSpecificData(writer); + writer.endObject(); writer.endObject(); } diff --git a/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java b/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java index 787ca938..84a3744d 100644 --- a/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java +++ b/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java @@ -6,10 +6,16 @@ import java.io.InputStream; import java.net.URL; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.iheartradio.m3u8.Encoding; import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; @@ -20,12 +26,16 @@ import com.iheartradio.m3u8.data.MediaPlaylist; import com.iheartradio.m3u8.data.Playlist; import com.iheartradio.m3u8.data.TrackData; +import ctbrec.Config; +import ctbrec.Model; import ctbrec.io.HttpClient; import okhttp3.Request; import okhttp3.Response; public abstract class AbstractHlsDownload implements Download { + private static final transient Logger LOG = LoggerFactory.getLogger(AbstractHlsDownload.class); + ExecutorService downloadThreadPool = Executors.newFixedThreadPool(5); HttpClient client; volatile boolean running = false; @@ -69,6 +79,34 @@ public abstract class AbstractHlsDownload implements Download { } } + + String getSegmentPlaylistUrl(Model model) throws IOException, ExecutionException, ParseException, PlaylistException { + List streamSources = model.getStreamSources(); + String url = null; + if(model.getStreamUrlIndex() >= 0 && model.getStreamUrlIndex() < streamSources.size()) { + url = streamSources.get(model.getStreamUrlIndex()).getMediaPlaylistUrl(); + } else { + Collections.sort(streamSources); + // filter out stream resolutions, which are too high + int maxRes = Config.getInstance().getSettings().maximumResolution; + if(maxRes > 0) { + for (Iterator iterator = streamSources.iterator(); iterator.hasNext();) { + StreamSource streamSource = iterator.next(); + if(streamSource.height > 0 && maxRes < streamSource.height) { + LOG.trace("Res too high {} > {}", streamSource.height, maxRes); + iterator.remove(); + } + } + } + if(streamSources.isEmpty()) { + throw new ExecutionException(new RuntimeException("No stream left in playlist")); + } else { + url = streamSources.get(streamSources.size()-1).getMediaPlaylistUrl(); + } + } + return url; + } + @Override public boolean isAlive() { return alive; diff --git a/src/main/java/ctbrec/recorder/download/HlsDownload.java b/src/main/java/ctbrec/recorder/download/HlsDownload.java index 313b0c0e..6974056a 100644 --- a/src/main/java/ctbrec/recorder/download/HlsDownload.java +++ b/src/main/java/ctbrec/recorder/download/HlsDownload.java @@ -48,7 +48,7 @@ public class HlsDownload extends AbstractHlsDownload { throw new IOException(model.getName() +"'s room is not public"); } - String segments = model.getSegmentPlaylistUrl(); + String segments = getSegmentPlaylistUrl(model); if(segments != null) { if (!Files.exists(downloadDir, LinkOption.NOFOLLOW_LINKS)) { Files.createDirectories(downloadDir); diff --git a/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java b/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java index f122b895..51e7b422 100644 --- a/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java +++ b/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java @@ -101,7 +101,7 @@ public class MergedHlsDownload extends AbstractHlsDownload { target = new File(targetFile.getAbsolutePath().replaceAll("\\.ts", "-00000.ts")); } - String segments = model.getSegmentPlaylistUrl(); + String segments = getSegmentPlaylistUrl(model); mergeThread = createMergeThread(target, null, true); mergeThread.start(); if(segments != null) { diff --git a/src/main/java/ctbrec/recorder/server/HttpServer.java b/src/main/java/ctbrec/recorder/server/HttpServer.java index 69aceead..00537280 100644 --- a/src/main/java/ctbrec/recorder/server/HttpServer.java +++ b/src/main/java/ctbrec/recorder/server/HttpServer.java @@ -21,6 +21,7 @@ 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; @@ -53,7 +54,9 @@ public class HttpServer { } recorder = new LocalRecorder(config); for (Site site : sites) { - site.init(); + if(site.isEnabled()) { + site.init(); + } } startHttpServer(); } @@ -61,6 +64,7 @@ public class HttpServer { private void createSites() { sites.add(new Chaturbate()); sites.add(new MyFreeCams()); + sites.add(new Camsoda()); sites.add(new Cam4()); } diff --git a/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java b/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java index 9aae0544..c170af83 100644 --- a/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java +++ b/src/main/java/ctbrec/sites/cam4/Cam4LoginDialog.java @@ -1,5 +1,6 @@ package ctbrec.sites.cam4; +import java.io.File; import java.io.InputStream; import java.net.CookieHandler; import java.net.CookieManager; @@ -13,6 +14,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ctbrec.Config; +import ctbrec.OS; import javafx.concurrent.Worker.State; import javafx.scene.Scene; import javafx.scene.control.ProgressIndicator; @@ -71,24 +73,29 @@ public class Cam4LoginDialog { }); 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); + try { + 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')"); + } catch(Exception e) { + LOG.warn("Couldn't auto fill username and password for Cam4", e); + } } else if (newState == State.CANCELLED || newState == State.FAILED) { veil.setVisible(false); p.setVisible(false); } }); + webEngine.setUserDataDirectory(new File(OS.getConfigDir(), "webengine")); webEngine.load(URL); return browser; } diff --git a/src/main/java/ctbrec/sites/camsoda/Camsoda.java b/src/main/java/ctbrec/sites/camsoda/Camsoda.java new file mode 100644 index 00000000..3cf7f937 --- /dev/null +++ b/src/main/java/ctbrec/sites/camsoda/Camsoda.java @@ -0,0 +1,171 @@ +package ctbrec.sites.camsoda; + +import java.io.IOException; + +import org.json.JSONObject; + +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; +import okhttp3.Request; +import okhttp3.Response; + +public class Camsoda extends AbstractSite { + + public static final String BASE_URI = "https://www.camsoda.com"; + private Recorder recorder; + private HttpClient httpClient; + + @Override + public String getName() { + return "CamSoda"; + } + + @Override + public String getBaseUrl() { + return BASE_URI; + } + + @Override + public String getAffiliateLink() { + return BASE_URI; + } + + @Override + public void setRecorder(Recorder recorder) { + this.recorder = recorder; + } + + @Override + public TabProvider getTabProvider() { + return new CamsodaTabProvider(this, recorder); + } + + @Override + public Model createModel(String name) { + CamsodaModel model = new CamsodaModel(); + model.setName(name); + model.setUrl(getBaseUrl() + "/" + name); + model.setSite(this); + return model; + } + + @Override + public Integer getTokenBalance() throws IOException { + if (!credentialsAvailable()) { + throw new IOException("Account settings not available"); + } + + String username = Config.getInstance().getSettings().camsodaUsername; + String url = BASE_URI + "/api/v1/user/" + username; + Request request = new Request.Builder().url(url).build(); + Response response = getHttpClient().execute(request, true); + if(response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + if(json.has("user")) { + JSONObject user = json.getJSONObject("user"); + if(user.has("tokens")) { + return user.getInt("tokens"); + } + } + } else { + throw new IOException(response.code() + " " + response.message()); + } + throw new RuntimeException("Tokens not found in response"); + } + + @Override + public String getBuyTokensLink() { + return getBaseUrl(); + } + + @Override + public void login() throws IOException { + if(credentialsAvailable()) { + getHttpClient().login(); + } + } + + @Override + public HttpClient getHttpClient() { + if(httpClient == null) { + httpClient = new CamsodaHttpClient(); + } + return httpClient; + } + + @Override + public void init() throws IOException { + } + + @Override + public void shutdown() { + if(httpClient != null) { + httpClient.shutdown(); + } + } + + @Override + public boolean supportsTips() { + return true; + } + + @Override + public boolean supportsFollow() { + return true; + } + + @Override + public boolean isSiteForModel(Model m) { + return m instanceof CamsodaModel; + } + + @Override + public boolean credentialsAvailable() { + String username = Config.getInstance().getSettings().camsodaUsername; + return username != null && !username.trim().isEmpty(); + } + + @Override + public Node getConfigurationGui() { + GridPane layout = SettingsTab.createGridLayout(); + layout.add(new Label("CamSoda User"), 0, 0); + TextField username = new TextField(Config.getInstance().getSettings().camsodaUsername); + username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().camsodaUsername = username.getText()); + GridPane.setFillWidth(username, true); + GridPane.setHgrow(username, Priority.ALWAYS); + GridPane.setColumnSpan(username, 2); + layout.add(username, 1, 0); + + layout.add(new Label("CamSoda Password"), 0, 1); + PasswordField password = new PasswordField(); + password.setText(Config.getInstance().getSettings().camsodaPassword); + password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().camsodaPassword = 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(getAffiliateLink())); + 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/camsoda/CamsodaFollowedTab.java b/src/main/java/ctbrec/sites/camsoda/CamsodaFollowedTab.java new file mode 100644 index 00000000..bc95672b --- /dev/null +++ b/src/main/java/ctbrec/sites/camsoda/CamsodaFollowedTab.java @@ -0,0 +1,80 @@ +package ctbrec.sites.camsoda; + +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 CamsodaFollowedTab extends ThumbOverviewTab implements FollowedTab { + private Label status; + boolean showOnline = true; + + public CamsodaFollowedTab(String title, Camsoda camsoda) { + super(title, new CamsodaFollowedUpdateService(camsoda), camsoda); + 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) -> { + queue.clear(); + ((CamsodaFollowedUpdateService)updateService).showOnline(online.isSelected()); + updateService.restart(); + }); + } + + @Override + protected void onSuccess() { + grid.getChildren().remove(status); + super.onSuccess(); + } + + @Override + protected void onFail(WorkerStateEvent event) { + String msg = ""; + if (event.getSource().getException() != null) { + msg = ": " + event.getSource().getException().getMessage(); + } + status.setText("Login failed" + msg); + 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/camsoda/CamsodaFollowedUpdateService.java b/src/main/java/ctbrec/sites/camsoda/CamsodaFollowedUpdateService.java new file mode 100644 index 00000000..20c4bbe0 --- /dev/null +++ b/src/main/java/ctbrec/sites/camsoda/CamsodaFollowedUpdateService.java @@ -0,0 +1,73 @@ +package ctbrec.sites.camsoda; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +import org.json.JSONArray; +import org.json.JSONObject; + +import ctbrec.Model; +import ctbrec.ui.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import okhttp3.Response; + +public class CamsodaFollowedUpdateService extends PaginatedScheduledService { + private Camsoda camsoda; + private boolean showOnline = true; + + public CamsodaFollowedUpdateService(Camsoda camsoda) { + this.camsoda = camsoda; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + List models = new ArrayList<>(); + String url = camsoda.getBaseUrl() + "/api/v1/user/current"; + Request request = new Request.Builder().url(url).build(); + Response response = camsoda.getHttpClient().execute(request, true); + if (response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + if(json.has("status") && json.getBoolean("status")) { + JSONObject user = json.getJSONObject("user"); + JSONArray following = user.getJSONArray("following"); + for (int i = 0; i < following.length(); i++) { + JSONObject m = following.getJSONObject(i); + CamsodaModel model = (CamsodaModel) camsoda.createModel(m.getString("followname")); + boolean online = m.getInt("online") == 1; + model.setOnlineState(online ? "online" : "offline"); + model.setPreview("https://md.camsoda.com/thumbs/" + model.getName() + ".jpg"); + models.add(model); + } + return models.stream() + .filter((m) -> { + try { + return m.isOnline() == showOnline; + } catch (IOException | ExecutionException | InterruptedException e) { + return false; + } + }).collect(Collectors.toList()); + } else { + response.close(); + return Collections.emptyList(); + } + } else { + int code = response.code(); + response.close(); + throw new IOException("HTTP status " + code); + } + } + }; + } + + void showOnline(boolean online) { + this.showOnline = online; + } +} diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaHttpClient.java b/src/main/java/ctbrec/sites/camsoda/CamsodaHttpClient.java new file mode 100644 index 00000000..9ab57b9e --- /dev/null +++ b/src/main/java/ctbrec/sites/camsoda/CamsodaHttpClient.java @@ -0,0 +1,151 @@ +package ctbrec.sites.camsoda; + +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.jsoup.nodes.Element; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.io.HttpClient; +import ctbrec.sites.cam4.Cam4LoginDialog; +import ctbrec.ui.HtmlParser; +import javafx.application.Platform; +import okhttp3.Cookie; +import okhttp3.FormBody; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.Response; + +public class CamsodaHttpClient extends HttpClient { + + private static final transient Logger LOG = LoggerFactory.getLogger(CamsodaHttpClient.class); + private String csrfToken = null; + + @Override + public boolean login() throws IOException { + if(loggedIn) { + return true; + } + + String url = Camsoda.BASE_URI + "/api/v1/auth/login"; + FormBody body = new FormBody.Builder() + .add("username", Config.getInstance().getSettings().camsodaUsername) + .add("password", Config.getInstance().getSettings().camsodaPassword) + .build(); + Request request = new Request.Builder() + .url(url) + .post(body) + .build(); + Response response = execute(request); + if(response.isSuccessful()) { + JSONObject resp = new JSONObject(response.body().string()); + if(resp.has("error")) { + String error = resp.getString("error"); + if (Objects.equals(error, "Please confirm that you are not a robot.")) { + //return loginWithDialog(); + throw new IOException("CamSoda requested to solve a captcha. Please try again in a while (maybe 15 min)."); + } else { + throw new IOException(resp.getString("error")); + } + } else { + return true; + } + } else { + throw new IOException(response.code() + " " + response.message()); + } + } + + @SuppressWarnings("unused") + private boolean loginWithDialog() throws IOException { + BlockingQueue queue = new LinkedBlockingQueue<>(); + + Runnable showDialog = () -> { + // login with javafx WebView + CamsodaLoginDialog loginDialog = new CamsodaLoginDialog(); + + // 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 + * @throws IOException + */ + private boolean checkLoginSuccess() throws IOException { + String url = Camsoda.BASE_URI + "/api/v1/user/current"; + Request request = new Request.Builder().url(url).build(); + try(Response response = execute(request)) { + if(response.isSuccessful()) { + JSONObject resp = new JSONObject(response.body().string()); + return resp.optBoolean("status"); + } else { + return false; + } + } + } + + private void transferCookies(CamsodaLoginDialog 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 String getCsrfToken() throws IOException { + if(csrfToken == null) { + String url = Camsoda.BASE_URI; + Request request = new Request.Builder().url(url).build(); + Response resp = execute(request, true); + if(resp.isSuccessful()) { + Element meta = HtmlParser.getTag(resp.body().string(), "meta[name=\"_token\"]"); + csrfToken = meta.attr("content"); + } else { + IOException e = new IOException(resp.code() + " " + resp.message()); + resp.close(); + throw e; + } + } + return csrfToken; + } +} diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaLoginDialog.java b/src/main/java/ctbrec/sites/camsoda/CamsodaLoginDialog.java new file mode 100644 index 00000000..6668babf --- /dev/null +++ b/src/main/java/ctbrec/sites/camsoda/CamsodaLoginDialog.java @@ -0,0 +1,109 @@ +package ctbrec.sites.camsoda; + +import java.io.File; +import java.io.InputStream; +import java.net.CookieHandler; +import java.net.CookieManager; +import java.net.HttpCookie; +import java.util.Base64; +import java.util.List; + +import ctbrec.OS; +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; + +// FIXME this dialog does not help, because google's recaptcha does not work +// with WebView even though it does work in Cam4LoginDialog +public class CamsodaLoginDialog { + + public static final String URL = Camsoda.BASE_URI; + private List cookies = null; + private String url; + private Region veil; + private ProgressIndicator p; + + public CamsodaLoginDialog() { + Stage stage = new Stage(); + stage.setTitle("CamSoda 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(1, 1, 1)"); + p = new ProgressIndicator(); + p.setMaxSize(140, 140); + + p.setVisible(true); + veil.visibleProperty().bind(p.visibleProperty()); + + StackPane stackPane = new StackPane(); + stackPane.getChildren().addAll(webView, veil, p); + + stage.setScene(new Scene(stackPane, 400, 358)); + 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(); + System.out.println(newV.toString()); + }); + webEngine.getLoadWorker().stateProperty().addListener((observable, oldState, newState) -> { + if (newState == State.SUCCEEDED) { + webEngine.executeScript("document.querySelector('a[ng-click=\"signin();\"]').click()"); + p.setVisible(false); + + // TODO make this work + // String username = Config.getInstance().getSettings().camsodaUsername; + // if (username != null && !username.trim().isEmpty()) { + // webEngine.executeScript("document.querySelector('input[name=\"loginUsername\"]').value = '" + username + "'"); + // } + // String password = Config.getInstance().getSettings().camsodaPassword; + // if (password != null && !password.trim().isEmpty()) { + // webEngine.executeScript("document.querySelector('input[name=\"loginPassword\"]').value = '" + password + "'"); + // } + } else if (newState == State.CANCELLED || newState == State.FAILED) { + p.setVisible(false); + } + }); + + webEngine.setUserStyleSheetLocation("data:text/css;base64," + Base64.getEncoder().encodeToString(CUSTOM_STYLE.getBytes())); + webEngine.setUserDataDirectory(new File(OS.getConfigDir(), "webengine")); + webEngine.load(URL); + return browser; + } + + public List getCookies() { + return cookies; + } + + public String getUrl() { + return url; + } + + private static final String CUSTOM_STYLE = "" + + ".ngdialog.ngdialog-theme-custom { padding: 0 !important }" + + ".ngdialog-overlay { background: black !important; }"; +} diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java b/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java new file mode 100644 index 00000000..1fe5beeb --- /dev/null +++ b/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java @@ -0,0 +1,270 @@ +package ctbrec.sites.camsoda; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +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 com.iheartradio.m3u8.data.StreamInfo; + +import ctbrec.AbstractModel; +import ctbrec.recorder.download.StreamSource; +import ctbrec.sites.Site; +import okhttp3.FormBody; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class CamsodaModel extends AbstractModel { + + private static final transient Logger LOG = LoggerFactory.getLogger(CamsodaModel.class); + private String streamUrl; + private Site site; + private List streamSources = null; + private String status = "n/a"; + private float sortOrder = 0; + + private static Cache streamResolutionCache = CacheBuilder.newBuilder() + .initialCapacity(10_000) + .maximumSize(10_000) + .expireAfterWrite(30, TimeUnit.MINUTES) + .build(); + + public String getStreamUrl() throws IOException { + if(streamUrl == null) { + // load model + loadModel(); + } + return streamUrl; + } + + private void loadModel() throws IOException { + String modelUrl = site.getBaseUrl() + "/api/v1/user/" + getName(); + Request req = new Request.Builder().url(modelUrl).build(); + Response response = site.getHttpClient().execute(req); + try { + JSONObject result = new JSONObject(response.body().string()); + if(result.getBoolean("status")) { + JSONObject chat = result.getJSONObject("user").getJSONObject("chat"); + status = chat.getString("status"); + if(chat.has("edge_servers")) { + String edgeServer = chat.getJSONArray("edge_servers").getString(0); + String streamName = chat.getString("stream_name"); + streamUrl = "https://" + edgeServer + "/cam/mp4:" + streamName + "_h264_aac_480p/playlist.m3u8"; + } + + } else { + throw new IOException("Result was not ok"); + } + } finally { + response.close(); + } + } + + @Override + public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { + if(ignoreCache) { + loadModel(); + } + return Objects.equals(status, "online"); + } + + @Override + public String getOnlineState(boolean failFast) throws IOException, ExecutionException { + if(failFast) { + return status; + } else { + if(status.equals("n/a")) { + loadModel(); + } + return status; + } + } + + public void setOnlineState(String state) { + this.status = state; + } + + @Override + public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { + String streamUrl = getStreamUrl(); + if(streamUrl == null) { + return Collections.emptyList(); + } + Request req = new Request.Builder().url(streamUrl).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(); + PlaylistData playlistData = master.getPlaylists().get(0); + StreamSource streamsource = new StreamSource(); + streamsource.mediaPlaylistUrl = streamUrl.replace("playlist.m3u8", playlistData.getUri()); + if(playlistData.hasStreamInfo()) { + StreamInfo info = playlistData.getStreamInfo(); + streamsource.bandwidth = info.getBandwidth(); + streamsource.width = info.hasResolution() ? info.getResolution().width : 0; + streamsource.height = info.hasResolution() ? info.getResolution().height : 0; + } else { + streamsource.bandwidth = 0; + streamsource.width = 0; + streamsource.height = 0; + } + streamSources = Collections.singletonList(streamsource); + } finally { + response.close(); + } + return streamSources; + } + + @Override + public void invalidateCacheEntries() { + streamSources = null; + streamResolutionCache.invalidate(getName()); + } + + @Override + public int[] getStreamResolution(boolean failFast) throws ExecutionException { + int[] resolution = streamResolutionCache.getIfPresent(getName()); + if(resolution != null) { + return resolution; + } else { + if(failFast) { + return new int[] {0,0}; + } else { + try { + List streamSources = getStreamSources(); + if(streamSources.isEmpty()) { + return new int[] {0,0}; + } else { + StreamSource src = streamSources.get(0); + resolution = new int[] {src.width, src.height}; + streamResolutionCache.put(getName(), resolution); + return resolution; + } + } catch (IOException | ParseException | PlaylistException e) { + throw new ExecutionException(e); + } + } + } + } + + @Override + public void receiveTip(int tokens) throws IOException { + String csrfToken = ((CamsodaHttpClient)site.getHttpClient()).getCsrfToken(); + String url = site.getBaseUrl() + "/api/v1/tip/" + getName(); + if (!Objects.equals(System.getenv("CTBREC_DEV"), "1")) { + LOG.debug("Sending tip {}", url); + RequestBody body = new FormBody.Builder() + .add("amount", Integer.toString(tokens)) + .add("comment", "") + .build(); + Request request = new Request.Builder() + .url(url) + .post(body) + .addHeader("Referer", Camsoda.BASE_URI + '/' + getName()) + .addHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:62.0) Gecko/20100101 Firefox/62.0") + .addHeader("Accept", "application/json, text/plain, */*") + .addHeader("Accept-Language", "en") + .addHeader("X-CSRF-Token", csrfToken) + .build(); + try(Response response = site.getHttpClient().execute(request, true)) { + if(!response.isSuccessful()) { + throw new IOException("HTTP status " + response.code() + " " + response.message()); + } + } + } + } + + @Override + public boolean follow() throws IOException { + String url = Camsoda.BASE_URI + "/api/v1/follow/" + getName(); + LOG.debug("Sending follow request {}", url); + String csrfToken = ((CamsodaHttpClient)site.getHttpClient()).getCsrfToken(); + Request request = new Request.Builder() + .url(url) + .post(RequestBody.create(null, "")) + .addHeader("Referer", Camsoda.BASE_URI + '/' + getName()) + .addHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:62.0) Gecko/20100101 Firefox/62.0") + .addHeader("Accept", "application/json, text/plain, */*") + .addHeader("Accept-Language", "en") + .addHeader("X-CSRF-Token", csrfToken) + .build(); + Response resp = site.getHttpClient().execute(request, true); + if (resp.isSuccessful()) { + resp.close(); + return true; + } else { + resp.close(); + throw new IOException("HTTP status " + resp.code() + " " + resp.message()); + } + } + + @Override + public boolean unfollow() throws IOException { + String url = Camsoda.BASE_URI + "/api/v1/unfollow/" + getName(); + LOG.debug("Sending follow request {}", url); + String csrfToken = ((CamsodaHttpClient)site.getHttpClient()).getCsrfToken(); + Request request = new Request.Builder() + .url(url) + .post(RequestBody.create(null, "")) + .addHeader("Referer", Camsoda.BASE_URI + '/' + getName()) + .addHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:62.0) Gecko/20100101 Firefox/62.0") + .addHeader("Accept", "application/json, text/plain, */*") + .addHeader("Accept-Language", "en") + .addHeader("X-CSRF-Token", csrfToken) + .build(); + Response resp = site.getHttpClient().execute(request, true); + if (resp.isSuccessful()) { + resp.close(); + return true; + } else { + resp.close(); + throw new IOException("HTTP status " + resp.code() + " " + resp.message()); + } + } + + @Override + public void setSite(Site site) { + if(site instanceof Camsoda) { + this.site = site; + } else { + throw new IllegalArgumentException("Site has to be an instance of Camsoda"); + } + } + + @Override + public Site getSite() { + return site; + } + + public void setStreamUrl(String streamUrl) { + this.streamUrl = streamUrl; + } + + public float getSortOrder() { + return sortOrder; + } + + public void setSortOrder(float sortOrder) { + this.sortOrder = sortOrder; + } +} diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaShowsTab.java b/src/main/java/ctbrec/sites/camsoda/CamsodaShowsTab.java new file mode 100644 index 00000000..44070fcc --- /dev/null +++ b/src/main/java/ctbrec/sites/camsoda/CamsodaShowsTab.java @@ -0,0 +1,286 @@ +package ctbrec.sites.camsoda; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.time.temporal.TemporalAccessor; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.AutosizeAlert; +import ctbrec.ui.DesktopIntergation; +import ctbrec.ui.TabSelectionListener; +import javafx.application.Platform; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.concurrent.Task; +import javafx.geometry.Insets; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Tab; +import javafx.scene.control.TitledPane; +import javafx.scene.control.Tooltip; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.GridPane; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import okhttp3.Request; +import okhttp3.Response; + +public class CamsodaShowsTab extends Tab implements TabSelectionListener { + + private static final transient Logger LOG = LoggerFactory.getLogger(CamsodaShowsTab.class); + + private Camsoda camsoda; + private Recorder recorder; + private GridPane showList; + private ProgressIndicator progressIndicator; + + public CamsodaShowsTab(Camsoda camsoda, Recorder recorder) { + this.camsoda = camsoda; + this.recorder = recorder; + createGui(); + } + + private void createGui() { + showList = new GridPane(); + showList.setPadding(new Insets(5)); + showList.setHgap(5); + showList.setVgap(5); + progressIndicator = new ProgressIndicator(); + progressIndicator.setPrefSize(100, 100); + setContent(progressIndicator); + setClosable(false); + setText("Shows"); + } + + @Override + public void selected() { + Task> task = new Task>() { + @Override + protected List call() throws Exception { + String url = camsoda.getBaseUrl() + "/api/v1/user/model_shows"; + Request req = new Request.Builder().url(url).build(); + Response response = camsoda.getHttpClient().execute(req); + if (response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + if (json.optInt("success") == 1) { + List boxes = new ArrayList<>(); + JSONArray results = json.getJSONArray("results"); + for (int i = 0; i < results.length(); i++) { + JSONObject result = results.getJSONObject(i); + String modelUrl = camsoda.getBaseUrl() + result.getString("url"); + String name = modelUrl.substring(modelUrl.lastIndexOf('/') + 1); + Model model = camsoda.createModel(name); + ZonedDateTime startTime = parseUtcTime(result.getString("start")); + ZonedDateTime endTime = parseUtcTime(result.getString("end")); + boxes.add(new ShowBox(model, startTime, endTime)); + } + return boxes; + } else { + LOG.error("Couldn't load upcoming camsoda shows. Unexpected response: {}", json.toString()); + showErrorDialog("Oh no!", "Couldn't load upcoming CamSoda shows", "Got an unexpected response from server"); + } + } else { + response.close(); + showErrorDialog("Oh no!", "Couldn't load upcoming CamSoda shows", "Got an unexpected response from server"); + LOG.error("Couldn't load upcoming camsoda shows: {} {}", response.code(), response.message()); + } + return Collections.emptyList(); + } + + private ZonedDateTime parseUtcTime(String string) { + DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + TemporalAccessor ta = formatter.parse(string.replace(" UTC", "")); + Instant instant = Instant.from(ta); + return ZonedDateTime.ofInstant(instant, ZoneId.systemDefault()); + } + + @Override + protected void done() { + super.done(); + Platform.runLater(() -> { + try { + List boxes = get(); + showList.getChildren().clear(); + int index = 0; + for (ShowBox showBox : boxes) { + showList.add(showBox, index % 2, index++ / 2); + GridPane.setMargin(showBox, new Insets(20, 20, 0, 20)); + } + } catch (Exception e) { + LOG.error("Couldn't load upcoming camsoda shows", e); + } + setContent(new ScrollPane(showList)); + }); + } + }; + new Thread(task).start(); + } + + @Override + public void deselected() { + } + + private void showErrorDialog(String title, String head, String msg) { + Platform.runLater(() -> { + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); + alert.setTitle(title); + alert.setHeaderText(head); + alert.setContentText(msg); + alert.showAndWait(); + }); + } + + private class ShowBox extends TitledPane { + + BorderPane root = new BorderPane(); + int thumbSize = 200; + + public ShowBox(Model model, ZonedDateTime startTime, ZonedDateTime endTime) { + setText(model.getName()); + setPrefHeight(268); + setContent(root); + + ImageView thumb = new ImageView(); + thumb.setPreserveRatio(true); + thumb.setFitHeight(thumbSize); + loadImage(model, thumb); + root.setLeft(new ProgressIndicator()); + BorderPane.setMargin(thumb, new Insets(10, 30, 10, 10)); + + DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.MEDIUM); + GridPane grid = new GridPane(); + + grid.add(createLabel("Start", true), 0, 0); + grid.add(createLabel(formatter.format(startTime), false), 1, 0); + grid.add(createLabel("End", true), 0, 1); + grid.add(createLabel(formatter.format(endTime), false), 1, 1); + Button record = new Button("Record Model"); + record.setTooltip(new Tooltip(record.getText())); + record.setOnAction((evt) -> record(model)); + grid.add(record, 1, 2); + GridPane.setMargin(record, new Insets(10)); + Button follow = new Button("Follow"); + follow.setTooltip(new Tooltip(follow.getText())); + follow.setOnAction((evt) -> follow(model)); + grid.add(follow, 1, 3); + GridPane.setMargin(follow, new Insets(10)); + Button openInBrowser = new Button("Open in Browser"); + openInBrowser.setTooltip(new Tooltip(openInBrowser.getText())); + openInBrowser.setOnAction((evt) -> DesktopIntergation.open(model.getUrl())); + grid.add(openInBrowser, 1, 4); + GridPane.setMargin(openInBrowser, new Insets(10)); + root.setCenter(grid); + loadImage(model, thumb); + + record.prefWidthProperty().bind(openInBrowser.widthProperty()); + follow.prefWidthProperty().bind(openInBrowser.widthProperty()); + } + + private void follow(Model model) { + setCursor(Cursor.WAIT); + new Thread(() -> { + try { + model.follow(); + } catch (Exception e) { + LOG.error("Couldn't follow model {}", model, e); + showErrorDialog("Oh no!", "Couldn't follow model", e.getMessage()); + } finally { + Platform.runLater(() -> { + setCursor(Cursor.DEFAULT); + }); + } + }).start(); + } + + private void record(Model model) { + setCursor(Cursor.WAIT); + new Thread(() -> { + try { + recorder.startRecording(model); + } catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException | IOException e) { + showErrorDialog("Oh no!", "Couldn't add model to the recorder", "Recorder error: " + e.getMessage()); + } finally { + Platform.runLater(() -> { + setCursor(Cursor.DEFAULT); + }); + } + }).start(); + } + + private void loadImage(Model model, ImageView thumb) { + new Thread(() -> { + try { + String url = camsoda.getBaseUrl() + "/api/v1/user/" + model.getName(); + Request detailRequest = new Request.Builder().url(url).build(); + Response resp = camsoda.getHttpClient().execute(detailRequest); + if (resp.isSuccessful()) { + JSONObject json = new JSONObject(resp.body().string()); + if (json.optBoolean("status") && json.has("user")) { + JSONObject user = json.getJSONObject("user"); + if (user.has("settings")) { + JSONObject settings = user.getJSONObject("settings"); + String imageUrl; + if(Objects.equals(System.getenv("CTBREC_DEV"), "1")) { + imageUrl = getClass().getResource("/image_not_found.png").toString(); + } else { + if (settings.has("offline_picture")) { + imageUrl = settings.getString("offline_picture"); + } else { + imageUrl = "https:" + user.getString("thumb"); + } + } + Platform.runLater(() -> { + Image img = new Image(imageUrl, 1000, thumbSize, true, true, true); + img.progressProperty().addListener(new ChangeListener() { + @Override + public void changed(ObservableValue observable, Number oldValue, Number newValue) { + if (newValue.doubleValue() == 1.0) { + thumb.setImage(img); + root.setLeft(thumb); + } + } + }); + + }); + } + } + } + resp.close(); + } catch (Exception e) { + LOG.error("Couldn't load model details", e); + } + }).start(); + } + + private Node createLabel(String string, boolean bold) { + Label label = new Label(string); + label.setPadding(new Insets(10)); + Font def = Font.getDefault(); + label.setFont(Font.font(def.getFamily(), bold ? FontWeight.BOLD : FontWeight.NORMAL, 16)); + return label; + } + } +} diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaTabProvider.java b/src/main/java/ctbrec/sites/camsoda/CamsodaTabProvider.java new file mode 100644 index 00000000..9aa552a7 --- /dev/null +++ b/src/main/java/ctbrec/sites/camsoda/CamsodaTabProvider.java @@ -0,0 +1,43 @@ +package ctbrec.sites.camsoda; + +import static ctbrec.sites.camsoda.Camsoda.*; + +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 CamsodaTabProvider extends TabProvider { + + private Camsoda camsoda; + private Recorder recorder; + + public CamsodaTabProvider(Camsoda camsoda, Recorder recorder) { + this.camsoda = camsoda; + this.recorder = recorder; + } + + @Override + public List getTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Online", BASE_URI + "/api/v1/browse/online")); + CamsodaFollowedTab followedTab = new CamsodaFollowedTab("Followed", camsoda); + followedTab.setRecorder(recorder); + followedTab.setScene(scene); + tabs.add(followedTab); + tabs.add(new CamsodaShowsTab(camsoda, recorder)); + return tabs; + } + + private Tab createTab(String title, String url) { + CamsodaUpdateService updateService = new CamsodaUpdateService(url, false, camsoda); + ThumbOverviewTab tab = new ThumbOverviewTab(title, updateService, camsoda); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/src/main/java/ctbrec/sites/camsoda/CamsodaUpdateService.java b/src/main/java/ctbrec/sites/camsoda/CamsodaUpdateService.java new file mode 100644 index 00000000..a43f9332 --- /dev/null +++ b/src/main/java/ctbrec/sites/camsoda/CamsodaUpdateService.java @@ -0,0 +1,124 @@ +package ctbrec.sites.camsoda; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import org.eclipse.jetty.util.StringUtil; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Model; +import ctbrec.ui.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import okhttp3.Response; + +public class CamsodaUpdateService extends PaginatedScheduledService { + + private static final transient Logger LOG = LoggerFactory.getLogger(CamsodaUpdateService.class); + + private String url; + private boolean loginRequired; + private Camsoda camsoda; + int modelsPerPage = 50; + + public CamsodaUpdateService(String url, boolean loginRequired, Camsoda camsoda) { + this.url = url; + this.loginRequired = loginRequired; + this.camsoda = camsoda; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + List models = new ArrayList<>(); + if(loginRequired && StringUtil.isBlank(ctbrec.Config.getInstance().getSettings().username)) { + return Collections.emptyList(); + } else { + String url = CamsodaUpdateService.this.url; + LOG.debug("Fetching page {}", url); + Request request = new Request.Builder().url(url).build(); + Response response = camsoda.getHttpClient().execute(request, loginRequired); + if (response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + if(json.has("status") && json.getBoolean("status")) { + JSONArray results = json.getJSONArray("results"); + for (int i = 0; i < results.length(); i++) { + JSONObject result = results.getJSONObject(i); + if(result.has("tpl")) { + JSONArray tpl = result.getJSONArray("tpl"); + String name = tpl.getString(0); + // int connections = tpl.getInt(2); + String streamName = tpl.getString(5); + String tsize = tpl.getString(6); + String serverPrefix = tpl.getString(7); + CamsodaModel model = (CamsodaModel) camsoda.createModel(name); + model.setDescription(tpl.getString(4)); + model.setSortOrder(tpl.getFloat(3)); + long unixtime = System.currentTimeMillis() / 1000; + String preview = "https://thumbs-orig.camsoda.com/thumbs/" + + streamName + '/' + serverPrefix + '/' + tsize + '/' + unixtime + '/' + name + ".jpg?cb=" + unixtime; + model.setPreview(preview); + if(result.has("edge_servers")) { + JSONArray edgeServers = result.getJSONArray("edge_servers"); + model.setStreamUrl("https://" + edgeServers.getString(0) + "/cam/mp4:" + streamName + "_h264_aac_480p/playlist.m3u8"); + } + models.add(model); + } else { + //LOG.debug("{}", result.toString(2)); + String name = result.getString("username"); + CamsodaModel model = (CamsodaModel) camsoda.createModel(name); + + + if(result.has("server_prefix")) { + String serverPrefix = result.getString("server_prefix"); + String streamName = result.getString("stream_name"); + model.setSortOrder(result.getFloat("sort_value")); + models.add(model); + if(result.has("status")) { + model.setOnlineState(result.getString("status")); + } + + if(result.has("edge_servers")) { + JSONArray edgeServers = result.getJSONArray("edge_servers"); + model.setStreamUrl("https://" + edgeServers.getString(0) + "/cam/mp4:" + streamName + "_h264_aac_480p/playlist.m3u8"); + } + + if(result.has("tsize")) { + long unixtime = System.currentTimeMillis() / 1000; + String tsize = result.getString("tsize"); + String preview = "https://thumbs-orig.camsoda.com/thumbs/" + + streamName + '/' + serverPrefix + '/' + tsize + '/' + unixtime + '/' + name + ".jpg?cb=" + unixtime; + model.setPreview(preview); + } + + } + } + } + return models.stream() + .sorted((m1,m2) -> (int)(m2.getSortOrder() - m1.getSortOrder())) + .skip( (page-1) * modelsPerPage) + .limit(modelsPerPage) + .collect(Collectors.toList()); + } else { + response.close(); + return Collections.emptyList(); + } + } else { + int code = response.code(); + response.close(); + throw new IOException("HTTP status " + code); + } + } + } + }; + } + +} diff --git a/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java b/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java index aa9bfe8c..9b573fb2 100644 --- a/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java +++ b/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java @@ -38,11 +38,6 @@ public class ChaturbateModel extends AbstractModel { this.site = site; } - @Override - public boolean isOnline() throws IOException, ExecutionException, InterruptedException { - return isOnline(false); - } - @Override public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { StreamInfo info; diff --git a/src/main/java/ctbrec/sites/mfc/MyFreeCams.java b/src/main/java/ctbrec/sites/mfc/MyFreeCams.java index c8544740..c06c1218 100644 --- a/src/main/java/ctbrec/sites/mfc/MyFreeCams.java +++ b/src/main/java/ctbrec/sites/mfc/MyFreeCams.java @@ -55,7 +55,7 @@ public class MyFreeCams extends AbstractSite { @Override public String getAffiliateLink() { - return ""; + return BASE_URI + "/?baf=8127165"; } @Override @@ -93,7 +93,7 @@ public class MyFreeCams extends AbstractSite { @Override public String getBuyTokensLink() { - return "https://www.myfreecams.com/php/purchase.php?request=tokens"; + return BASE_URI + "/php/purchase.php?request=tokens"; } @Override @@ -149,7 +149,7 @@ public class MyFreeCams extends AbstractSite { layout.add(password, 1, 1); Button createAccount = new Button("Create new Account"); - createAccount.setOnAction((e) -> DesktopIntergation.open(BASE_URI + "/php/signup.php?request=register")); + createAccount.setOnAction((e) -> DesktopIntergation.open(getAffiliateLink())); layout.add(createAccount, 1, 2); GridPane.setColumnSpan(createAccount, 2); GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); diff --git a/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java b/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java index 188f2378..17191f65 100644 --- a/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java +++ b/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java @@ -57,6 +57,7 @@ public class MyFreeCamsClient { private String ctxenc; private String chatToken; private int sessionId; + private long heartBeat; private EvictingQueue receivedTextHistory = EvictingQueue.create(10000); @@ -135,6 +136,7 @@ public class MyFreeCamsClient { // TODO find out, what the values in the json message mean, at the moment we hust send 0s, which seems to work, too // webSocket.send("1 0 0 81 0 %7B%22err%22%3A0%2C%22start%22%3A1540159843072%2C%22stop%22%3A1540159844121%2C%22a%22%3A6392%2C%22time%22%3A1540159844%2C%22key%22%3A%228da80f985c9db390809713dac71df297%22%2C%22cid%22%3A%22c504d684%22%2C%22pid%22%3A1%2C%22site%22%3A%22www%22%7D\n"); webSocket.send("1 0 0 81 0 %7B%22err%22%3A0%2C%22start%22%3A0%2C%22stop%22%3A0%2C%22a%22%3A0%2C%22time%22%3A0%2C%22key%22%3A%22%22%2C%22cid%22%3A%22%22%2C%22pid%22%3A1%2C%22site%22%3A%22www%22%7D\n"); + heartBeat = System.currentTimeMillis(); startKeepAlive(webSocket); } catch (IOException e) { e.printStackTrace(); @@ -165,6 +167,7 @@ public class MyFreeCamsClient { @Override public void onMessage(WebSocket webSocket, String text) { super.onMessage(webSocket, text); + heartBeat = System.currentTimeMillis(); receivedTextHistory.add(text); msgBuffer.append(text); Message message; @@ -469,6 +472,14 @@ public class MyFreeCamsClient { LOG.trace("--> NULL to keep the connection alive"); try { ws.send("0 0 0 0 0 -\n"); + + long millisSinceLastMessage = System.currentTimeMillis() - heartBeat; + if(millisSinceLastMessage > TimeUnit.MINUTES.toMillis(2)) { + LOG.info("No message since 2 mins. Restarting websocket"); + ws.close(1000, ""); + MyFreeCamsClient.this.ws = null; + } + Thread.sleep(TimeUnit.SECONDS.toMillis(15)); } catch (Exception e) { e.printStackTrace(); @@ -484,7 +495,7 @@ public class MyFreeCamsClient { lock.lock(); try { for (SessionState state : sessionStates.values()) { - if(Objects.equals(state.getNm(), model.getName())) { + if(Objects.equals(state.getNm(), model.getName()) || Objects.equals(model.getUid(), state.getUid())) { model.update(state, getStreamUrl(state)); return; } diff --git a/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java b/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java index 36a24da6..db794ae2 100644 --- a/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java +++ b/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java @@ -23,6 +23,8 @@ import com.iheartradio.m3u8.PlaylistParser; import com.iheartradio.m3u8.data.MasterPlaylist; import com.iheartradio.m3u8.data.Playlist; import com.iheartradio.m3u8.data.PlaylistData; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; import ctbrec.AbstractModel; import ctbrec.recorder.download.StreamSource; @@ -37,7 +39,7 @@ public class MyFreeCamsModel extends AbstractModel { private static final transient Logger LOG = LoggerFactory.getLogger(MyFreeCamsModel.class); - private int uid; + private int uid = -1; // undefined private String hlsUrl; private double camScore; private int viewerCount; @@ -207,7 +209,17 @@ public class MyFreeCamsModel extends AbstractModel { this.state = state; } + @Override + public void setName(String name) { + if(getName() != null && name != null && !getName().equals(name)) { + LOG.debug("Model name changed {} -> {}", getName(), name); + } + super.setName(name); + } + public void update(SessionState state, String streamUrl) { + uid = Integer.parseInt(state.getUid().toString()); + setName(state.getNm()); setCamScore(state.getM().getCamscore()); setState(State.of(state.getVs())); setStreamUrl(streamUrl); @@ -308,4 +320,15 @@ public class MyFreeCamsModel extends AbstractModel { public Site getSite() { return site; } + + @Override + public void readSiteSpecificData(JsonReader reader) throws IOException { + reader.nextName(); + uid = reader.nextInt(); + } + + @Override + public void writeSiteSpecificData(JsonWriter writer) throws IOException { + writer.name("uid").value(uid); + } } diff --git a/src/main/java/ctbrec/ui/CamrecApplication.java b/src/main/java/ctbrec/ui/CamrecApplication.java index acb92508..90be7e79 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.cam4.Cam4; +import ctbrec.sites.camsoda.Camsoda; import ctbrec.sites.chaturbate.Chaturbate; import ctbrec.sites.mfc.MyFreeCams; import javafx.application.Application; @@ -61,6 +62,7 @@ public class CamrecApplication extends Application { public void start(Stage primaryStage) throws Exception { sites.add(new Chaturbate()); sites.add(new MyFreeCams()); + sites.add(new Camsoda()); sites.add(new Cam4()); loadConfig(); createHttpClient(); @@ -72,9 +74,6 @@ public class CamrecApplication extends Application { try { site.setRecorder(recorder); site.init(); - if (!Objects.equals(System.getenv("CTBREC_DEV"), "1")) { - site.login(); - } } catch(Exception e) { LOG.error("Error while initializing site {}", site.getName(), e); } diff --git a/src/main/java/ctbrec/ui/JavaFxModel.java b/src/main/java/ctbrec/ui/JavaFxModel.java index 1ede0c64..a1c9d399 100644 --- a/src/main/java/ctbrec/ui/JavaFxModel.java +++ b/src/main/java/ctbrec/ui/JavaFxModel.java @@ -2,11 +2,12 @@ package ctbrec.ui; import java.io.IOException; import java.util.List; -import java.util.Objects; import java.util.concurrent.ExecutionException; import com.iheartradio.m3u8.ParseException; import com.iheartradio.m3u8.PlaylistException; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; import ctbrec.AbstractModel; import ctbrec.Model; @@ -20,14 +21,13 @@ import javafx.beans.property.SimpleBooleanProperty; */ public class JavaFxModel extends AbstractModel { private transient BooleanProperty onlineProperty = new SimpleBooleanProperty(); - private Model delegate; public JavaFxModel(Model delegate) { this.delegate = delegate; try { - onlineProperty.set(Objects.equals("public", delegate.getOnlineState(true))); - } catch (IOException | ExecutionException e) {} + onlineProperty.set(delegate.isOnline()); + } catch (IOException | ExecutionException | InterruptedException e) {} } @Override @@ -147,4 +147,14 @@ public class JavaFxModel extends AbstractModel { public Site getSite() { return delegate.getSite(); } + + @Override + public void readSiteSpecificData(JsonReader reader) throws IOException { + delegate.readSiteSpecificData(reader); + } + + @Override + public void writeSiteSpecificData(JsonWriter writer) throws IOException { + delegate.writeSiteSpecificData(writer); + } } diff --git a/src/main/java/ctbrec/ui/RecordingsTab.java b/src/main/java/ctbrec/ui/RecordingsTab.java index fc3acd64..e64184f0 100644 --- a/src/main/java/ctbrec/ui/RecordingsTab.java +++ b/src/main/java/ctbrec/ui/RecordingsTab.java @@ -34,7 +34,7 @@ import ctbrec.recorder.Recorder; import ctbrec.recorder.download.MergedHlsDownload; import ctbrec.sites.Site; import javafx.application.Platform; -import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.SimpleObjectProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.concurrent.ScheduledService; @@ -47,6 +47,7 @@ import javafx.scene.control.ContextMenu; import javafx.scene.control.MenuItem; import javafx.scene.control.ScrollPane; import javafx.scene.control.Tab; +import javafx.scene.control.TableCell; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import javafx.scene.control.cell.PropertyValueFactory; @@ -57,6 +58,7 @@ import javafx.scene.input.MouseEvent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.FlowPane; import javafx.stage.FileChooser; +import javafx.util.Callback; import javafx.util.Duration; public class RecordingsTab extends Tab implements TabSelectionListener { @@ -99,12 +101,28 @@ public class RecordingsTab extends Tab implements TabSelectionListener { TableColumn name = new TableColumn<>("Model"); name.setPrefWidth(200); name.setCellValueFactory(new PropertyValueFactory("modelName")); - TableColumn date = new TableColumn<>("Date"); + TableColumn date = new TableColumn<>("Date"); date.setCellValueFactory((cdf) -> { Instant instant = cdf.getValue().getStartDate(); - ZonedDateTime time = instant.atZone(ZoneId.systemDefault()); - DateTimeFormatter dtf = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.MEDIUM); - return new SimpleStringProperty(dtf.format(time)); + return new SimpleObjectProperty(instant); + }); + date.setCellFactory(new Callback, TableCell>() { + @Override + public TableCell call(TableColumn param) { + TableCell cell = new TableCell() { + @Override + protected void updateItem(Instant instant, boolean empty) { + if(empty || instant == null) { + setText(null); + } else { + ZonedDateTime time = instant.atZone(ZoneId.systemDefault()); + DateTimeFormatter dtf = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.MEDIUM); + setText(dtf.format(time)); + } + } + }; + return cell; + } }); date.setPrefWidth(200); TableColumn status = new TableColumn<>("Status"); diff --git a/src/main/java/ctbrec/ui/SettingsTab.java b/src/main/java/ctbrec/ui/SettingsTab.java index ae5ce0c2..f0066613 100644 --- a/src/main/java/ctbrec/ui/SettingsTab.java +++ b/src/main/java/ctbrec/ui/SettingsTab.java @@ -20,6 +20,7 @@ import javafx.beans.value.ObservableValue; import javafx.geometry.HPos; import javafx.geometry.Insets; import javafx.scene.Node; +import javafx.scene.control.Accordion; import javafx.scene.control.Alert; import javafx.scene.control.Button; import javafx.scene.control.CheckBox; @@ -64,9 +65,11 @@ public class SettingsTab extends Tab implements TabSelectionListener { private RadioButton recordRemote; private ToggleGroup recordLocation; private ProxySettingsPane proxySettingsPane; + private ComboBox maxResolution; private ComboBox splitAfter; private List sites; private Label restartLabel; + private Accordion credentialsAccordion = new Accordion(); public SettingsTab(List sites) { this.sites = sites; @@ -113,14 +116,16 @@ public class SettingsTab extends Tab implements TabSelectionListener { //right side rightSide.getChildren().add(createSiteSelectionPanel()); - for (Site site : sites) { + rightSide.getChildren().add(credentialsAccordion); + for (int i = 0; i < sites.size(); i++) { + Site site = sites.get(i); Node siteConfig = site.getConfigurationGui(); if(siteConfig != null) { TitledPane pane = new TitledPane(site.getName(), siteConfig); - pane.setCollapsible(false); - rightSide.getChildren().add(pane); + credentialsAccordion.getPanes().add(pane); } } + credentialsAccordion.setExpandedPane(credentialsAccordion.getPanes().get(0)); } private Node createSiteSelectionPanel() { @@ -227,8 +232,8 @@ public class SettingsTab extends Tab implements TabSelectionListener { keyDialog.show(); } }); - GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, CHECKBOX_MARGIN, 0, 0)); - GridPane.setMargin(secureCommunication, new Insets(CHECKBOX_MARGIN, 0, 0, 0)); + GridPane.setMargin(l, new Insets(4, CHECKBOX_MARGIN, 0, 0)); + GridPane.setMargin(secureCommunication, new Insets(4, 0, 0, 0)); layout.add(secureCommunication, 1, 3); TitledPane recordLocation = new TitledPane("Record Location", layout); @@ -245,6 +250,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { GridPane.setFillWidth(recordingsDirectory, true); GridPane.setHgrow(recordingsDirectory, Priority.ALWAYS); GridPane.setColumnSpan(recordingsDirectory, 2); + GridPane.setMargin(recordingsDirectory, new Insets(0, 0, 0, CHECKBOX_MARGIN)); layout.add(recordingsDirectory, 1, 0); recordingsDirectoryButton = createRecordingsBrowseButton(); layout.add(recordingsDirectoryButton, 3, 0); @@ -255,19 +261,10 @@ public class SettingsTab extends Tab implements TabSelectionListener { GridPane.setFillWidth(mediaPlayer, true); GridPane.setHgrow(mediaPlayer, Priority.ALWAYS); GridPane.setColumnSpan(mediaPlayer, 2); + GridPane.setMargin(mediaPlayer, new Insets(0, 0, 0, CHECKBOX_MARGIN)); layout.add(mediaPlayer, 1, 1); layout.add(createMpvBrowseButton(), 3, 1); - Label l = new Label("Allow multiple players"); - layout.add(l, 0, 2); - multiplePlayers.setSelected(!Config.getInstance().getSettings().singlePlayer); - multiplePlayers.setOnAction((e) -> Config.getInstance().getSettings().singlePlayer = !multiplePlayers.isSelected()); - GridPane.setMargin(recordingsDirectory, new Insets(0, 0, 0, CHECKBOX_MARGIN)); - GridPane.setMargin(mediaPlayer, new Insets(0, 0, 0, CHECKBOX_MARGIN)); - GridPane.setMargin(l, new Insets(3, 0, 0, 0)); - GridPane.setMargin(multiplePlayers, new Insets(3, 0, 0, CHECKBOX_MARGIN)); - layout.add(multiplePlayers, 1, 2); - TitledPane locations = new TitledPane("Locations", layout); locations.setCollapsible(false); return locations; @@ -275,8 +272,9 @@ public class SettingsTab extends Tab implements TabSelectionListener { private Node createGeneralPanel() { GridPane layout = createGridLayout(); + int row = 0; Label l = new Label("Display stream resolution in overview"); - layout.add(l, 0, 0); + layout.add(l, 0, row); loadResolution = new CheckBox(); loadResolution.setSelected(Config.getInstance().getSettings().determineResolution); loadResolution.setOnAction((e) -> { @@ -287,18 +285,41 @@ public class SettingsTab extends Tab implements TabSelectionListener { }); //GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0)); GridPane.setMargin(loadResolution, new Insets(0, 0, 0, CHECKBOX_MARGIN)); - layout.add(loadResolution, 1, 0); + layout.add(loadResolution, 1, row++); + + l = new Label("Allow multiple players"); + layout.add(l, 0, row); + multiplePlayers.setSelected(!Config.getInstance().getSettings().singlePlayer); + multiplePlayers.setOnAction((e) -> Config.getInstance().getSettings().singlePlayer = !multiplePlayers.isSelected()); + GridPane.setMargin(l, new Insets(3, 0, 0, 0)); + GridPane.setMargin(multiplePlayers, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN)); + layout.add(multiplePlayers, 1, row++); l = new Label("Manually select stream quality"); - layout.add(l, 0, 1); + layout.add(l, 0, row); chooseStreamQuality.setSelected(Config.getInstance().getSettings().chooseStreamQuality); chooseStreamQuality.setOnAction((e) -> Config.getInstance().getSettings().chooseStreamQuality = chooseStreamQuality.isSelected()); GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0)); GridPane.setMargin(chooseStreamQuality, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN)); - layout.add(chooseStreamQuality, 1, 1); + layout.add(chooseStreamQuality, 1, row++); + + l = new Label("Maximum resolution (0 = unlimited)"); + layout.add(l, 0, row); + List resolutionOptions = new ArrayList<>(); + resolutionOptions.add(1080); + resolutionOptions.add(720); + resolutionOptions.add(600); + resolutionOptions.add(480); + resolutionOptions.add(0); + maxResolution = new ComboBox<>(new ObservableListWrapper<>(resolutionOptions)); + setMaxResolutionValue(); + maxResolution.setOnAction((e) -> Config.getInstance().getSettings().maximumResolution = maxResolution.getSelectionModel().getSelectedItem()); + layout.add(maxResolution, 1, row++); + GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0)); + GridPane.setMargin(maxResolution, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN)); l = new Label("Split recordings after (minutes)"); - layout.add(l, 0, 2); + layout.add(l, 0, row); List options = new ArrayList<>(); options.add(new SplitAfterOption("disabled", 0)); options.add(new SplitAfterOption("10 min", 10 * 60)); @@ -307,11 +328,12 @@ public class SettingsTab extends Tab implements TabSelectionListener { options.add(new SplitAfterOption("30 min", 30 * 60)); options.add(new SplitAfterOption("60 min", 60 * 60)); splitAfter = new ComboBox<>(new ObservableListWrapper<>(options)); - layout.add(splitAfter, 1, 2); + layout.add(splitAfter, 1, row++); setSplitAfterValue(); splitAfter.setOnAction((e) -> Config.getInstance().getSettings().splitRecordings = splitAfter.getSelectionModel().getSelectedItem().getValue()); - GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0)); - GridPane.setMargin(splitAfter, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN)); + GridPane.setMargin(l, new Insets(0, 0, 0, 0)); + GridPane.setMargin(splitAfter, new Insets(0, 0, 0, CHECKBOX_MARGIN)); + maxResolution.prefWidthProperty().bind(splitAfter.widthProperty()); TitledPane general = new TitledPane("General", layout); general.setCollapsible(false); @@ -327,6 +349,15 @@ public class SettingsTab extends Tab implements TabSelectionListener { } } + private void setMaxResolutionValue() { + int value = Config.getInstance().getSettings().maximumResolution; + for (Integer option : maxResolution.getItems()) { + if(option == value) { + maxResolution.getSelectionModel().select(option); + } + } + } + void showRestartRequired() { restartLabel.setVisible(true); } @@ -346,6 +377,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { recordingsDirectory.setDisable(!local); recordingsDirectoryButton.setDisable(!local); splitAfter.setDisable(!local); + maxResolution.setDisable(!local); } private ChangeListener createRecordingsDirectoryFocusListener() { diff --git a/src/main/java/ctbrec/ui/ThumbCell.java b/src/main/java/ctbrec/ui/ThumbCell.java index e7306d3b..f579a948 100644 --- a/src/main/java/ctbrec/ui/ThumbCell.java +++ b/src/main/java/ctbrec/ui/ThumbCell.java @@ -1,5 +1,6 @@ package ctbrec.ui; +import java.io.EOFException; import java.io.IOException; import java.util.Collections; import java.util.List; @@ -207,10 +208,16 @@ public class ThumbCell extends StackPane { LOG.trace("Removing invalid resolution value for {}", model.getName()); model.invalidateCacheEntries(); } - + Thread.sleep(500); - } catch (ExecutionException | IOException | InterruptedException e1) { + } catch (IOException | InterruptedException e1) { LOG.warn("Couldn't update resolution tag for model {}", model.getName(), e1); + } catch(ExecutionException e) { + if(e.getCause() instanceof EOFException) { + LOG.warn("Couldn't update resolution tag for model {}. Playlist empty", model.getName()); + } else { + LOG.warn("Couldn't update resolution tag for model {}", model.getName(), e); + } } finally { ThumbOverviewTab.resolutionProcessing.remove(model); } @@ -485,13 +492,13 @@ public class ThumbCell extends StackPane { nameBackground.setWidth(w); nameBackground.setHeight(20); topicBackground.setWidth(w); - topicBackground.setHeight(h-nameBackground.getHeight()); - topic.prefHeight(h-25); - topic.maxHeight(h-25); + topicBackground.setHeight(getHeight()-nameBackground.getHeight()); + topic.prefHeight(getHeight()-25); + topic.maxHeight(getHeight()-25); int margin = 4; topic.maxWidth(w-margin*2); topic.setWrappingWidth(w-margin*2); selectionOverlay.setWidth(w); - selectionOverlay.setHeight(h); + selectionOverlay.setHeight(getHeight()); } } diff --git a/src/main/java/ctbrec/ui/TokenLabel.java b/src/main/java/ctbrec/ui/TokenLabel.java index a3123a34..30471ab8 100644 --- a/src/main/java/ctbrec/ui/TokenLabel.java +++ b/src/main/java/ctbrec/ui/TokenLabel.java @@ -13,6 +13,7 @@ import ctbrec.sites.Site; import javafx.application.Platform; import javafx.concurrent.Task; import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; public class TokenLabel extends Label { @@ -26,10 +27,10 @@ public class TokenLabel extends Label { CamrecApplication.bus.register(new Object() { @Subscribe public void tokensUpdates(Map e) { - if(Objects.equals("tokens", e.get("event"))) { + if (Objects.equals("tokens", e.get("event"))) { tokens = (int) e.get("amount"); updateText(); - } else if(Objects.equals("tokens.sent", e.get("event"))) { + } else if (Objects.equals("tokens.sent", e.get("event"))) { int _tokens = (int) e.get("amount"); tokens -= _tokens; updateText(); @@ -70,7 +71,10 @@ public class TokenLabel extends Label { update(tokens); } catch (InterruptedException | ExecutionException e) { LOG.error("Couldn't retrieve account balance", e); - Platform.runLater(() -> setText("Tokens: error")); + Platform.runLater(() -> { + setText("Tokens: error"); + setTooltip(new Tooltip(e.getMessage())); + }); } } }; diff --git a/src/main/resources/image_not_found.png b/src/main/resources/image_not_found.png new file mode 100644 index 00000000..7ab180c6 Binary files /dev/null and b/src/main/resources/image_not_found.png differ diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 6c731cec..4642d547 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -41,7 +41,7 @@ - +