diff --git a/CHANGELOG.md b/CHANGELOG.md index 8772852d..ecb5996d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +1.5.3 +======================== +* Recording time is now converted to local timezone and formatted nicely +* The state is now displayed in the resolution tag, if the room is not + public (e.g. private, group, offline, away) +* You can now filter for public rooms with the keyword "public", if + the display of resolution is enabled +* Added possibility to switch between online and offline models in the + followed tab +* Added possibility to send tips + 1.5.2 ======================== * Added possibility to select multiple models in the overview tabs by diff --git a/docs/img/token.png b/docs/img/token.png new file mode 100644 index 00000000..7212e417 Binary files /dev/null and b/docs/img/token.png differ diff --git a/docs/index.html b/docs/index.html index 23057f5d..b8558a68 100644 --- a/docs/index.html +++ b/docs/index.html @@ -135,7 +135,7 @@

Donate


-
+

CTB Recorder is free and open source. I'm a student and wrote this software in my spare time. @@ -144,37 +144,51 @@

-
-

- Buy me a coffee
- - Buy a coffee - -

-

+
+
+

+ + Buy Chaturbate tokens +
+ +

+
+
+

+ + Buy a coffee +
+ +

+
-
-

- Bitcoin
- bitcoin:15sLWZon8diPqAX4UdPQU1DcaPuvZs2GgA

- -

+

+
+
+

+ Bitcoin
+ bitcoin:15sLWZon8diPqAX4UdPQU1DcaPuvZs2GgA

+ +

+
+
+

+ Ethereum
+ ethereum:0x996041638eEAE7E31f39Ef6e82068d69bA7C090e

+ +

+
+
+

+ Monero
+ monero:448ZQZpzvT4iRNAVBr7CMQBfEbN3H8uAF2BWabtqVRckgTY3GQJkUgydjotEPaGvpzJboUpe39J8rPBkWZaUbrQa31FoSMj

+ +

+
-
-

- Ethereum
- ethereum:0x996041638eEAE7E31f39Ef6e82068d69bA7C090e

- -

-
-
-

- Monero
- monero:448ZQZpzvT4iRNAVBr7CMQBfEbN3H8uAF2BWabtqVRckgTY3GQJkUgydjotEPaGvpzJboUpe39J8rPBkWZaUbrQa31FoSMj

- -

-
-
diff --git a/src/main/java/ctbrec/Model.java b/src/main/java/ctbrec/Model.java index 7850459d..2af0b540 100644 --- a/src/main/java/ctbrec/Model.java +++ b/src/main/java/ctbrec/Model.java @@ -1,17 +1,47 @@ package ctbrec; +import java.io.EOFException; +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 java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +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.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; + +import ctbrec.recorder.StreamInfo; +import okhttp3.FormBody; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; public class Model { + + private static final transient Logger LOG = LoggerFactory.getLogger(Model.class); + private String url; private String name; private String preview; private String description; private List tags = new ArrayList<>(); - private boolean online = false; private int streamUrlIndex = -1; - private int streamResolution = -1; public String getUrl() { return url; @@ -45,12 +75,19 @@ public class Model { this.tags = tags; } - public boolean isOnline() { - return online; + public boolean isOnline() throws IOException, ExecutionException, InterruptedException { + return isOnline(false); } - public void setOnline(boolean online) { - this.online = online; + public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { + StreamInfo info; + if(ignoreCache) { + info = Chaturbate.INSTANCE.loadStreamInfo(getName()); + LOG.trace("Model {} room status: {}", getName(), info.room_status); + } else { + info = Chaturbate.INSTANCE.getStreamInfo(getName()); + } + return Objects.equals("public", info.room_status); } public String getDescription() { @@ -69,12 +106,46 @@ public class Model { this.streamUrlIndex = streamUrlIndex; } - public int getStreamResolution() { - return streamResolution; + public int[] getStreamResolution(boolean failFast) throws ExecutionException { + int[] resolution = Chaturbate.INSTANCE.streamResolutionCache.getIfPresent(getName()); + if(resolution != null) { + return Chaturbate.INSTANCE.getResolution(getName()); + } else { + return new int[2]; + } } - public void setStreamResolution(int streamResolution) { - this.streamResolution = streamResolution; + public int[] getStreamResolution() throws ExecutionException { + return Chaturbate.INSTANCE.getResolution(getName()); + } + + /** + * Invalidates the entries in StreamInfo and resolution cache for this model + * and thus causes causes the LoadingCache to update them + */ + public void invalidateCacheEntries() { + Chaturbate.INSTANCE.streamInfoCache.invalidate(getName()); + Chaturbate.INSTANCE.streamResolutionCache.invalidate(getName()); + } + + public String getOnlineState() throws IOException, ExecutionException { + return getOnlineState(false); + } + + public String getOnlineState(boolean failFast) throws IOException, ExecutionException { + StreamInfo info = Chaturbate.INSTANCE.streamInfoCache.getIfPresent(getName()); + return info != null ? info.room_status : "n/a"; + } + + public StreamInfo getStreamInfo() throws IOException, ExecutionException { + return Chaturbate.INSTANCE.getStreamInfo(getName()); + } + public MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException, ExecutionException { + return Chaturbate.INSTANCE.getMasterPlaylist(getName()); + } + + public void receiveTip(int tokens) throws IOException { + Chaturbate.INSTANCE.sendTip(getName(), tokens); } @Override @@ -110,12 +181,168 @@ public class Model { @Override public String toString() { - return name; + return getName(); } - public static void main(String[] args) { - Model model = new Model(); - model.name = "A"; - model.url = "url"; + private static class Chaturbate { + private static final transient Logger LOG = LoggerFactory.getLogger(Chaturbate.class); + + public static final Chaturbate INSTANCE = new Chaturbate(HttpClient.getInstance()); + + private HttpClient client; + + private static long lastRequest = System.currentTimeMillis(); + + private LoadingCache streamInfoCache = CacheBuilder.newBuilder() + .initialCapacity(10_000) + .maximumSize(10_000) + .expireAfterWrite(5, TimeUnit.MINUTES) + .build(new CacheLoader () { + @Override + public StreamInfo load(String model) throws Exception { + return loadStreamInfo(model); + } + }); + + private LoadingCache streamResolutionCache = CacheBuilder.newBuilder() + .initialCapacity(10_000) + .maximumSize(10_000) + .expireAfterWrite(5, TimeUnit.MINUTES) + .build(new CacheLoader () { + @Override + public int[] load(String model) throws Exception { + return loadResolution(model); + } + }); + + public Chaturbate(HttpClient client) { + this.client = client; + } + + public void sendTip(String name, int tokens) throws IOException { + RequestBody body = new FormBody.Builder() + .add("csrfmiddlewaretoken", client.getToken()) + .add("tip_amount", Integer.toString(tokens)) + .add("tip_room_type", "public") + .build(); + Request req = new Request.Builder() + .url("https://chaturbate.com/tipping/send_tip/"+name+"/") + .post(body) + .addHeader("Referer", "https://chaturbate.com/"+name+"/") + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + try(Response response = client.execute(req, true)) { + if(!response.isSuccessful()) { + throw new IOException(response.code() + " " + response.message()); + } + } + } + + private StreamInfo getStreamInfo(String modelName) throws IOException, ExecutionException { + return streamInfoCache.get(modelName); + } + + private StreamInfo loadStreamInfo(String modelName) throws IOException, InterruptedException { + throttleRequests(); + RequestBody body = new FormBody.Builder() + .add("room_slug", modelName) + .add("bandwidth", "high") + .build(); + Request req = new Request.Builder() + .url("https://chaturbate.com/get_edge_hls_url_ajax/") + .post(body) + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + Response response = client.execute(req); + try { + if(response.isSuccessful()) { + String content = response.body().string(); + LOG.trace("Raw stream info: {}", content); + Moshi moshi = new Moshi.Builder().build(); + JsonAdapter adapter = moshi.adapter(StreamInfo.class); + StreamInfo streamInfo = adapter.fromJson(content); + streamInfoCache.put(modelName, streamInfo); + return streamInfo; + } else { + int code = response.code(); + String message = response.message(); + throw new IOException("Server responded with " + code + " - " + message + " headers: [" + response.headers() + "]"); + } + } finally { + response.close(); + } + } + + public int[] getResolution(String modelName) throws ExecutionException { + return streamResolutionCache.get(modelName); + } + + private int[] loadResolution(String modelName) throws IOException, ParseException, PlaylistException, ExecutionException, InterruptedException { + int[] res = new int[2]; + StreamInfo streamInfo = getStreamInfo(modelName); + if(!streamInfo.url.startsWith("http")) { + return res; + } + + EOFException ex = null; + for(int i=0; i<2; i++) { + try { + MasterPlaylist master = getMasterPlaylist(modelName); + for (PlaylistData playlistData : master.getPlaylists()) { + if(playlistData.hasStreamInfo() && playlistData.getStreamInfo().hasResolution()) { + int h = playlistData.getStreamInfo().getResolution().height; + int w = playlistData.getStreamInfo().getResolution().width; + if(w > res[1]) { + res[0] = w; + res[1] = h; + } + } + } + ex = null; + break; // this attempt worked, exit loop + } catch(EOFException e) { + // the cause might be, that the playlist url in streaminfo is outdated, + // so let's remove it from cache and retry in the next iteration + streamInfoCache.invalidate(modelName); + ex = e; + } + } + + if(ex != null) { + throw ex; + } + + streamResolutionCache.put(modelName, res); + return res; + } + + private void throttleRequests() throws InterruptedException { + long now = System.currentTimeMillis(); + long diff = now - lastRequest; + if(diff < 500) { + Thread.sleep(diff); + } + lastRequest = now; + } + + public MasterPlaylist getMasterPlaylist(String modelName) throws IOException, ParseException, PlaylistException, ExecutionException { + StreamInfo streamInfo = getStreamInfo(modelName); + return getMasterPlaylist(streamInfo); + } + + public MasterPlaylist getMasterPlaylist(StreamInfo streamInfo) throws IOException, ParseException, PlaylistException { + LOG.trace("Loading master playlist {}", streamInfo.url); + Request req = new Request.Builder().url(streamInfo.url).build(); + Response response = client.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(); + } + } } } diff --git a/src/main/java/ctbrec/ModelParser.java b/src/main/java/ctbrec/ModelParser.java index 75b566ee..fc39c87d 100644 --- a/src/main/java/ctbrec/ModelParser.java +++ b/src/main/java/ctbrec/ModelParser.java @@ -4,7 +4,6 @@ import static ctbrec.ui.CtbrecApplication.BASE_URI; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; @@ -27,7 +26,6 @@ public class ModelParser { model.setPreview(HtmlParser.getTag(cellHtml, "a img").attr("src")); model.setUrl(BASE_URI + HtmlParser.getTag(cellHtml, "a").attr("href")); model.setDescription(HtmlParser.getText(cellHtml, "div.details ul.subject")); - model.setOnline(!Objects.equals("offline", HtmlParser.getText(cellHtml, "div.details li.cams"))); Elements tags = HtmlParser.getTags(cellHtml, "div.details ul.subject li a"); if(tags != null) { for (Element tag : tags) { diff --git a/src/main/java/ctbrec/recorder/Chaturbate.java b/src/main/java/ctbrec/recorder/Chaturbate.java deleted file mode 100644 index 19bde402..00000000 --- a/src/main/java/ctbrec/recorder/Chaturbate.java +++ /dev/null @@ -1,101 +0,0 @@ -package ctbrec.recorder; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Objects; - -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 com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; - -import ctbrec.HttpClient; -import ctbrec.Model; -import okhttp3.FormBody; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; - -public class Chaturbate { - private static final transient Logger LOG = LoggerFactory.getLogger(Chaturbate.class); - - public static StreamInfo getStreamInfo(Model model, HttpClient client) throws IOException { - RequestBody body = new FormBody.Builder() - .add("room_slug", model.getName()) - .add("bandwidth", "high") - .build(); - Request req = new Request.Builder() - .url("https://chaturbate.com/get_edge_hls_url_ajax/") - .post(body) - .addHeader("X-Requested-With", "XMLHttpRequest") - .build(); - Response response = client.execute(req); - try { - if(response.isSuccessful()) { - String content = response.body().string(); - LOG.debug("Raw stream info: {}", content); - Moshi moshi = new Moshi.Builder().build(); - JsonAdapter adapter = moshi.adapter(StreamInfo.class); - StreamInfo streamInfo = adapter.fromJson(content); - model.setOnline(Objects.equals(streamInfo.room_status, "public")); - return streamInfo; - } else { - int code = response.code(); - String message = response.message(); - throw new IOException("Server responded with " + code + " - " + message + " headers: [" + response.headers() + "]"); - } - } finally { - response.close(); - } - } - - public static int[] getResolution(Model model, HttpClient client) throws IOException, ParseException, PlaylistException { - int[] res = new int[2]; - StreamInfo streamInfo = getStreamInfo(model, client); - if(!streamInfo.url.startsWith("http")) { - return res; - } - - MasterPlaylist master = getMasterPlaylist(model, client); - for (PlaylistData playlistData : master.getPlaylists()) { - if(playlistData.hasStreamInfo() && playlistData.getStreamInfo().hasResolution()) { - int h = playlistData.getStreamInfo().getResolution().height; - int w = playlistData.getStreamInfo().getResolution().width; - if(w > res[1]) { - res[0] = w; - res[1] = h; - } - } - } - return res; - } - - public static MasterPlaylist getMasterPlaylist(Model model, HttpClient client) throws IOException, ParseException, PlaylistException { - StreamInfo streamInfo = getStreamInfo(model, client); - return getMasterPlaylist(streamInfo, client); - } - - public static MasterPlaylist getMasterPlaylist(StreamInfo streamInfo, HttpClient client) throws IOException, ParseException, PlaylistException { - LOG.trace("Loading master playlist {}", streamInfo.url); - Request req = new Request.Builder().url(streamInfo.url).build(); - Response response = client.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(); - } - } -} diff --git a/src/main/java/ctbrec/recorder/LocalRecorder.java b/src/main/java/ctbrec/recorder/LocalRecorder.java index 2f21da14..a5b2ae27 100644 --- a/src/main/java/ctbrec/recorder/LocalRecorder.java +++ b/src/main/java/ctbrec/recorder/LocalRecorder.java @@ -20,7 +20,6 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.NoSuchElementException; -import java.util.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,6 +43,7 @@ public class LocalRecorder implements Recorder { private static final transient Logger LOG = LoggerFactory.getLogger(LocalRecorder.class); + private static final boolean IGNORE_CACHE = true; private List followedModels = Collections.synchronizedList(new ArrayList<>()); private List models = Collections.synchronizedList(new ArrayList<>()); private Map recordingProcesses = Collections.synchronizedMap(new HashMap<>()); @@ -60,7 +60,6 @@ public class LocalRecorder implements Recorder { public LocalRecorder(Config config) { this.config = config; config.getSettings().models.stream().forEach((m) -> { - m.setOnline(false); models.add(m); }); @@ -94,7 +93,6 @@ public class LocalRecorder implements Recorder { } models.add(model); config.getSettings().models.add(model); - onlineMonitor.interrupt(); } } @@ -193,13 +191,6 @@ public class LocalRecorder implements Recorder { } } - private boolean checkIfOnline(Model model) throws IOException { - StreamInfo streamInfo = Chaturbate.getStreamInfo(model, client); - boolean online = Objects.equals(streamInfo.room_status, "public"); - model.setOnline(online); - return online; - } - private void tryRestartRecording(Model model) { if (!recording) { // recorder is not in recording state @@ -208,7 +199,7 @@ public class LocalRecorder implements Recorder { try { boolean modelInRecordingList = isRecording(model); - boolean online = checkIfOnline(model); + boolean online = model.isOnline(IGNORE_CACHE); if (modelInRecordingList && online) { LOG.info("Restarting recording for model {}", model); recordingProcesses.remove(model); @@ -240,7 +231,9 @@ public class LocalRecorder implements Recorder { LOG.debug("Recording terminated for model {}", m.getName()); iterator.remove(); restart.add(m); - finishRecording(d.getDirectory()); + try { + finishRecording(d.getDirectory()); + } catch(NullPointerException e) {}//fail silently } } for (Model m : restart) { @@ -354,7 +347,7 @@ public class LocalRecorder implements Recorder { for (Model model : getModelsRecording()) { try { if (!recordingProcesses.containsKey(model)) { - boolean isOnline = checkIfOnline(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); @@ -363,7 +356,6 @@ public class LocalRecorder implements Recorder { } } catch (Exception e) { LOG.error("Couldn't check if model {} is online", model.getName(), e); - model.setOnline(false); } } @@ -497,7 +489,7 @@ public class LocalRecorder implements Recorder { } recordings.add(recording); } catch (Exception e) { - LOG.debug("Ignoring {}", rec.getAbsolutePath()); + LOG.debug("Ignoring {} - {}", rec.getAbsolutePath(), e.getMessage()); } } } diff --git a/src/main/java/ctbrec/recorder/PlaylistGenerator.java b/src/main/java/ctbrec/recorder/PlaylistGenerator.java index df31463b..81f8f5e0 100644 --- a/src/main/java/ctbrec/recorder/PlaylistGenerator.java +++ b/src/main/java/ctbrec/recorder/PlaylistGenerator.java @@ -48,7 +48,7 @@ public class PlaylistGenerator { LOG.debug("Starting playlist generation for {}", directory); // get a list of all ts files and sort them by sequence File[] files = directory.listFiles((f) -> f.getName().endsWith(".ts")); - if(files.length == 0) { + if(files == null || files.length == 0) { LOG.debug("{} is empty. Not going to generate a playlist", directory); return null; } diff --git a/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java b/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java index acfa8990..05952252 100644 --- a/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java +++ b/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java @@ -1,5 +1,6 @@ package ctbrec.recorder.download; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -10,6 +11,9 @@ import java.util.List; 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; @@ -27,6 +31,8 @@ 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; @@ -40,9 +46,14 @@ public abstract class AbstractHlsDownload implements Download { String parseMaster(String url, int streamUrlIndex) throws IOException, ParseException, PlaylistException { Request request = new Request.Builder().url(url).addHeader("connection", "keep-alive").build(); Response response = client.execute(request); + String playlistContent = ""; try { - InputStream inputStream = response.body().byteStream(); - + if(response.code() != 200) { + LOG.debug("HTTP response {}, {}\n{}\n{}", response.code(), response.message(), response.headers(), response.body().string()); + throw new IOException("HTTP response " + response.code() + " " + response.message()); + } + playlistContent = response.body().string(); + InputStream inputStream = new ByteArrayInputStream(playlistContent.getBytes()); PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); Playlist playlist = parser.parse(); if(playlist.hasMasterPlaylist()) { @@ -62,6 +73,9 @@ public abstract class AbstractHlsDownload implements Download { } } return null; + } catch(Exception e) { + LOG.debug("Playlist: {}", playlistContent, e); + throw e; } finally { response.close(); } diff --git a/src/main/java/ctbrec/recorder/download/HlsDownload.java b/src/main/java/ctbrec/recorder/download/HlsDownload.java index 5ff433a0..5a2546a4 100644 --- a/src/main/java/ctbrec/recorder/download/HlsDownload.java +++ b/src/main/java/ctbrec/recorder/download/HlsDownload.java @@ -25,7 +25,6 @@ import com.iheartradio.m3u8.PlaylistException; import ctbrec.Config; import ctbrec.HttpClient; import ctbrec.Model; -import ctbrec.recorder.Chaturbate; import ctbrec.recorder.StreamInfo; import okhttp3.Request; import okhttp3.Response; @@ -42,21 +41,21 @@ public class HlsDownload extends AbstractHlsDownload { public void start(Model model, Config config) throws IOException { try { running = true; - StreamInfo streamInfo = Chaturbate.getStreamInfo(model, client); - if(!Objects.equals(streamInfo.room_status, "public")) { - throw new IOException(model.getName() +"'s room is not public"); - } - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd_HH-mm"); String startTime = sdf.format(new Date()); Path modelDir = FileSystems.getDefault().getPath(config.getSettings().recordingsDir, model.getName()); downloadDir = FileSystems.getDefault().getPath(modelDir.toString(), startTime); - if (!Files.exists(downloadDir, LinkOption.NOFOLLOW_LINKS)) { - Files.createDirectories(downloadDir); + + StreamInfo streamInfo = model.getStreamInfo(); + if(!Objects.equals(streamInfo.room_status, "public")) { + throw new IOException(model.getName() +"'s room is not public"); } String segments = parseMaster(streamInfo.url, model.getStreamUrlIndex()); if(segments != null) { + if (!Files.exists(downloadDir, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectories(downloadDir); + } int lastSegment = 0; int nextSegment = 0; while(running) { diff --git a/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java b/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java index 0497ef68..f5f655d9 100644 --- a/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java +++ b/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java @@ -20,7 +20,6 @@ import java.time.Duration; import java.time.ZonedDateTime; import java.util.Date; import java.util.LinkedList; -import java.util.Objects; import java.util.Queue; import java.util.concurrent.Callable; @@ -39,7 +38,6 @@ import ctbrec.Config; import ctbrec.HttpClient; import ctbrec.Model; import ctbrec.Recording; -import ctbrec.recorder.Chaturbate; import ctbrec.recorder.ProgressListener; import ctbrec.recorder.StreamInfo; import okhttp3.Request; @@ -48,6 +46,7 @@ import okhttp3.Response; public class MergedHlsDownload extends AbstractHlsDownload { private static final transient Logger LOG = LoggerFactory.getLogger(MergedHlsDownload.class); + private static final boolean IGNORE_CACHE = true; private BlockingMultiMTSSource multiSource; private Thread mergeThread; private Streamer streamer; @@ -64,6 +63,7 @@ public class MergedHlsDownload extends AbstractHlsDownload { public void start(String segmentPlaylistUri, File targetFile, ProgressListener progressListener) throws IOException { try { running = true; + downloadDir = targetFile.getParentFile().toPath(); mergeThread = createMergeThread(targetFile, progressListener, false); mergeThread.start(); downloadSegments(segmentPlaylistUri, false); @@ -84,17 +84,14 @@ public class MergedHlsDownload extends AbstractHlsDownload { try { running = true; startTime = ZonedDateTime.now(); - StreamInfo streamInfo = Chaturbate.getStreamInfo(model, client); - if(!Objects.equals(streamInfo.room_status, "public")) { - throw new IOException(model.getName() +"'s room is not public"); - } - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd_HH-mm"); String startTime = sdf.format(new Date()); Path modelDir = FileSystems.getDefault().getPath(config.getSettings().recordingsDir, model.getName()); downloadDir = FileSystems.getDefault().getPath(modelDir.toString(), startTime); - if (!Files.exists(downloadDir, LinkOption.NOFOLLOW_LINKS)) { - Files.createDirectories(downloadDir); + + StreamInfo streamInfo = model.getStreamInfo(); + if(!model.isOnline(IGNORE_CACHE)) { + throw new IOException(model.getName() +"'s room is not public"); } targetFile = Recording.mergedFileFromDirectory(downloadDir.toFile()); @@ -103,10 +100,10 @@ public class MergedHlsDownload extends AbstractHlsDownload { LOG.debug("Splitting recordings every {} seconds", config.getSettings().splitRecordings); target = new File(targetFile.getAbsolutePath().replaceAll("\\.ts", "-00000.ts")); } - mergeThread = createMergeThread(target, null, true); - mergeThread.start(); String segments = parseMaster(streamInfo.url, model.getStreamUrlIndex()); + mergeThread = createMergeThread(target, null, true); + mergeThread.start(); if(segments != null) { downloadSegments(segments, true); } else { @@ -123,7 +120,9 @@ public class MergedHlsDownload extends AbstractHlsDownload { throw new IOException("Couldn't download segment", e); } finally { alive = false; - streamer.stop(); + if(streamer != null) { + streamer.stop(); + } LOG.debug("Download for {} terminated", model); } } @@ -250,6 +249,9 @@ public class MergedHlsDownload extends AbstractHlsDownload { FileChannel channel = null; try { + if (!Files.exists(downloadDir, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectories(downloadDir); + } channel = FileChannel.open(targetFile.toPath(), CREATE, WRITE); MTSSink sink = ByteChannelSink.builder().setByteChannel(channel).build(); @@ -270,11 +272,8 @@ public class MergedHlsDownload extends AbstractHlsDownload { } catch(Exception e) { LOG.error("Error while saving stream to file", e); } finally { - try { - channel.close(); - } catch (IOException e) { - LOG.error("Error while closing file {}", targetFile); - } + closeFile(channel); + deleteEmptyRecording(targetFile); } }); t.setName("Segment Merger Thread"); @@ -282,6 +281,27 @@ public class MergedHlsDownload extends AbstractHlsDownload { return t; } + private void deleteEmptyRecording(File targetFile) { + try { + if (targetFile.exists() && targetFile.length() == 0) { + Files.delete(targetFile.toPath()); + Files.delete(targetFile.getParentFile().toPath()); + } + } catch (IOException e) { + LOG.error("Error while deleting empty recording {}", targetFile); + } + } + + private void closeFile(FileChannel channel) { + try { + if (channel != null) { + channel.close(); + } + } catch (IOException e) { + LOG.error("Error while closing file channel", e); + } + } + private static class SegmentDownload implements Callable { private URL url; private HttpClient client; diff --git a/src/main/java/ctbrec/ui/CtbrecApplication.java b/src/main/java/ctbrec/ui/CtbrecApplication.java index fd0ce230..c795515a 100644 --- a/src/main/java/ctbrec/ui/CtbrecApplication.java +++ b/src/main/java/ctbrec/ui/CtbrecApplication.java @@ -40,6 +40,7 @@ public class CtbrecApplication extends Application { static final transient Logger LOG = LoggerFactory.getLogger(CtbrecApplication.class); public static final String BASE_URI = "https://chaturbate.com"; + public static final String AFFILIATE_LINK = BASE_URI + "/in/?track=default&tour=LQps&campaign=55vTi&room=0xb00bface"; private Config config; private Recorder recorder; diff --git a/src/main/java/ctbrec/ui/DonateTabFx.java b/src/main/java/ctbrec/ui/DonateTabFx.java index 728f0e57..6d8ca1e8 100644 --- a/src/main/java/ctbrec/ui/DonateTabFx.java +++ b/src/main/java/ctbrec/ui/DonateTabFx.java @@ -17,6 +17,7 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.scene.text.Font; +import javafx.scene.text.TextAlignment; public class DonateTabFx extends Tab { @@ -41,6 +42,17 @@ public class DonateTabFx extends Tab { header.setPadding(new Insets(20, 0, 0, 0)); container.setTop(header); + ImageView tokenImage = new ImageView(getClass().getResource("/html/token.png").toString()); + Button tokenButton = new Button("Buy tokens"); + tokenButton.setOnAction((e) -> { DesktopIntergation.open(CtbrecApplication.AFFILIATE_LINK); }); + VBox tokenBox = new VBox(5); + tokenBox.setAlignment(Pos.TOP_CENTER); + Label tokenDesc = new Label("If you buy tokens by using this button,\n" + + "Chaturbate will award me 20% of the tokens' value for sending you over.\n" + + "You get the full tokens and it doesn't cost you any more!"); + tokenDesc.setTextAlignment(TextAlignment.CENTER); + tokenBox.getChildren().addAll(tokenImage, tokenButton, tokenDesc); + ImageView coffeeImage = new ImageView(getClass().getResource("/html/buymeacoffee-fancy.png").toString()); Button coffeeButton = new Button("Buy me a coffee"); coffeeButton.setOnMouseClicked((e) -> { DesktopIntergation.open("https://www.buymeacoffee.com/0xboobface"); }); @@ -79,13 +91,18 @@ public class DonateTabFx extends Tab { moneroBox.setAlignment(Pos.TOP_CENTER); moneroBox.getChildren().addAll(moneroLabel, moneroAddress, moneroQrCode); + HBox topBox = new HBox(5); + topBox.setAlignment(Pos.CENTER); + topBox.setSpacing(50); + topBox.getChildren().addAll(tokenBox, buyCoffeeBox); + HBox coinBox = new HBox(5); coinBox.setAlignment(Pos.CENTER); coinBox.setSpacing(50); coinBox.getChildren().addAll(bitcoinBox, ethereumBox, moneroBox); VBox centerBox = new VBox(50); - centerBox.getChildren().addAll(buyCoffeeBox, coinBox); + centerBox.getChildren().addAll(topBox, coinBox); container.setCenter(centerBox); } } diff --git a/src/main/java/ctbrec/ui/FollowedTab.java b/src/main/java/ctbrec/ui/FollowedTab.java index 9578feb7..40dbe3d6 100644 --- a/src/main/java/ctbrec/ui/FollowedTab.java +++ b/src/main/java/ctbrec/ui/FollowedTab.java @@ -1,20 +1,56 @@ package ctbrec.ui; 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 FollowedTab extends ThumbOverviewTab { private Label status; + private String onlineUrl; + private String offlineUrl; public FollowedTab(String title, String url) { super(title, url, true); + onlineUrl = url; + offlineUrl = url + "offline/"; + status = new Label("Logging in..."); grid.getChildren().add(status); } + @Override + 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) -> { + if(online.isSelected()) { + super.url = onlineUrl; + } else { + super.url = offlineUrl; + } + updateService.restart(); + }); + } + @Override protected void onSuccess() { grid.getChildren().remove(status); diff --git a/src/main/java/ctbrec/ui/JavaFxModel.java b/src/main/java/ctbrec/ui/JavaFxModel.java index f55b7a16..1e9d7579 100644 --- a/src/main/java/ctbrec/ui/JavaFxModel.java +++ b/src/main/java/ctbrec/ui/JavaFxModel.java @@ -1,6 +1,9 @@ package ctbrec.ui; +import java.io.IOException; import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutionException; import ctbrec.Model; import javafx.beans.property.BooleanProperty; @@ -16,7 +19,9 @@ public class JavaFxModel extends Model { public JavaFxModel(Model delegate) { this.delegate = delegate; - setOnline(delegate.isOnline()); + try { + onlineProperty.set(Objects.equals("public", delegate.getOnlineState(true))); + } catch (IOException | ExecutionException e) {} } @Override @@ -59,17 +64,6 @@ public class JavaFxModel extends Model { delegate.setTags(tags); } - @Override - public boolean isOnline() { - return delegate.isOnline(); - } - - @Override - public void setOnline(boolean online) { - delegate.setOnline(online); - this.onlineProperty.set(online); - } - @Override public int hashCode() { return delegate.hashCode(); diff --git a/src/main/java/ctbrec/ui/RecordedModelsTab.java b/src/main/java/ctbrec/ui/RecordedModelsTab.java index 02ba5209..9a0bc08d 100644 --- a/src/main/java/ctbrec/ui/RecordedModelsTab.java +++ b/src/main/java/ctbrec/ui/RecordedModelsTab.java @@ -5,9 +5,13 @@ import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.Iterator; import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.function.Function; @@ -50,6 +54,9 @@ import javafx.util.Duration; public class RecordedModelsTab extends Tab implements TabSelectionListener { private static final transient Logger LOG = LoggerFactory.getLogger(RecordedModelsTab.class); + static BlockingQueue queue = new LinkedBlockingQueue<>(); + static ExecutorService threadPool = new ThreadPoolExecutor(2, 2, 10, TimeUnit.MINUTES, queue); + private ScheduledService> updateService; private Recorder recorder; @@ -147,12 +154,19 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { if(models == null) { return; } + queue.clear(); for (Model model : models) { - if (!observableModels.contains(model)) { + int index = observableModels.indexOf(model); + if (index == -1) { observableModels.add(new JavaFxModel(model)); } else { - int index = observableModels.indexOf(model); - observableModels.get(index).setOnline(model.isOnline()); + // make sure to update the JavaFX online property, so that the table cell is updated + JavaFxModel javaFxModel = observableModels.get(index); + threadPool.submit(() -> { + try { + javaFxModel.getOnlineProperty().set(javaFxModel.isOnline()); + } catch (IOException | ExecutionException | InterruptedException e) {} + }); } } for (Iterator iterator = observableModels.iterator(); iterator.hasNext();) { @@ -233,11 +247,20 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { } private void switchStreamSource(JavaFxModel fxModel) { - if(!fxModel.isOnline()) { + try { + if(!fxModel.isOnline()) { + Alert alert = new AutosizeAlert(Alert.AlertType.INFORMATION); + alert.setTitle("Switch resolution"); + alert.setHeaderText("Couldn't switch stream resolution"); + alert.setContentText("The resolution can only be changed, when the model is online"); + alert.showAndWait(); + return; + } + } catch (IOException | ExecutionException | InterruptedException e1) { Alert alert = new AutosizeAlert(Alert.AlertType.INFORMATION); alert.setTitle("Switch resolution"); alert.setHeaderText("Couldn't switch stream resolution"); - alert.setContentText("The resolution can only be changed, when the model is online"); + alert.setContentText("An error occured while checking, if the model is online"); alert.showAndWait(); return; } diff --git a/src/main/java/ctbrec/ui/RecordingsTab.java b/src/main/java/ctbrec/ui/RecordingsTab.java index edbb502c..0cfa8a98 100644 --- a/src/main/java/ctbrec/ui/RecordingsTab.java +++ b/src/main/java/ctbrec/ui/RecordingsTab.java @@ -9,6 +9,11 @@ import java.io.IOException; import java.net.URL; 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.util.ArrayList; import java.util.Iterator; import java.util.List; @@ -31,6 +36,7 @@ import ctbrec.Recording.STATUS; import ctbrec.recorder.Recorder; import ctbrec.recorder.download.MergedHlsDownload; import javafx.application.Platform; +import javafx.beans.property.SimpleStringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.concurrent.ScheduledService; @@ -93,7 +99,12 @@ public class RecordingsTab extends Tab implements TabSelectionListener { name.setPrefWidth(200); name.setCellValueFactory(new PropertyValueFactory("modelName")); TableColumn date = new TableColumn<>("Date"); - date.setCellValueFactory(new PropertyValueFactory("startDate")); + 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)); + }); date.setPrefWidth(200); TableColumn status = new TableColumn<>("Status"); status.setCellValueFactory((cdf) -> cdf.getValue().getStatusProperty()); diff --git a/src/main/java/ctbrec/ui/SettingsTab.java b/src/main/java/ctbrec/ui/SettingsTab.java index 35b4c1ea..70e1f945 100644 --- a/src/main/java/ctbrec/ui/SettingsTab.java +++ b/src/main/java/ctbrec/ui/SettingsTab.java @@ -187,23 +187,29 @@ public class SettingsTab extends Tab implements TabSelectionListener { GridPane.setColumnSpan(password, 2); layout.add(password, 1, 1); + Button createAccount = new Button("Create new Account"); + createAccount.setOnAction((e) -> DesktopIntergation.open(CtbrecApplication.AFFILIATE_LINK)); + layout.add(createAccount, 1, 2); + GridPane.setColumnSpan(createAccount, 2); + l = new Label("Record all followed models"); - layout.add(l, 0, 2); + layout.add(l, 0, 3); autoRecordFollowed = new CheckBox(); autoRecordFollowed.setSelected(Config.getInstance().getSettings().recordFollowed); autoRecordFollowed.setOnAction((e) -> { Config.getInstance().getSettings().recordFollowed = autoRecordFollowed.isSelected(); showRestartRequired(); }); - layout.add(autoRecordFollowed, 1, 2); + layout.add(autoRecordFollowed, 1, 3); Label warning = new Label("Don't do this, if you follow many models. You have been warned ;) !"); warning.setTextFill(Color.RED); - layout.add(warning, 2, 2); + layout.add(warning, 2, 3); GridPane.setMargin(l, new Insets(3, 0, 0, 0)); GridPane.setMargin(warning, new Insets(3, 0, 0, 0)); GridPane.setMargin(autoRecordFollowed, new Insets(3, 0, 0, CHECKBOX_MARGIN)); GridPane.setMargin(username, new Insets(0, 0, 0, CHECKBOX_MARGIN)); GridPane.setMargin(password, new Insets(0, 0, 0, CHECKBOX_MARGIN)); + GridPane.setMargin(createAccount, new Insets(0, 0, 0, CHECKBOX_MARGIN)); ctb = new TitledPane("Chaturbate", layout); ctb.setCollapsible(false); diff --git a/src/main/java/ctbrec/ui/StreamSourceSelectionDialog.java b/src/main/java/ctbrec/ui/StreamSourceSelectionDialog.java index 73057f16..14db2b6b 100644 --- a/src/main/java/ctbrec/ui/StreamSourceSelectionDialog.java +++ b/src/main/java/ctbrec/ui/StreamSourceSelectionDialog.java @@ -14,7 +14,6 @@ import com.iheartradio.m3u8.data.PlaylistData; import ctbrec.HttpClient; import ctbrec.Model; -import ctbrec.recorder.Chaturbate; import ctbrec.recorder.StreamInfo; import ctbrec.recorder.download.StreamSource; import javafx.concurrent.Task; @@ -27,8 +26,9 @@ public class StreamSourceSelectionDialog { Task> selectStreamSource = new Task>() { @Override protected List call() throws Exception { - StreamInfo streamInfo = Chaturbate.getStreamInfo(model, client); - MasterPlaylist masterPlaylist = Chaturbate.getMasterPlaylist(streamInfo, client); + model.invalidateCacheEntries(); + StreamInfo streamInfo = model.getStreamInfo(); + MasterPlaylist masterPlaylist = model.getMasterPlaylist(); List sources = new ArrayList<>(); for (PlaylistData playlist : masterPlaylist.getPlaylists()) { if (playlist.hasStreamInfo()) { @@ -53,6 +53,7 @@ public class StreamSourceSelectionDialog { ChoiceDialog choiceDialog = new ChoiceDialog(sources.get(sources.size()-1), sources); choiceDialog.setTitle("Stream Quality"); choiceDialog.setHeaderText("Select your preferred stream quality"); + choiceDialog.setResizable(true); Optional selectedSource = choiceDialog.showAndWait(); if(selectedSource.isPresent()) { int index = sources.indexOf(selectedSource.get()); diff --git a/src/main/java/ctbrec/ui/ThumbCell.java b/src/main/java/ctbrec/ui/ThumbCell.java index 2ba2d8e8..2fe04130 100644 --- a/src/main/java/ctbrec/ui/ThumbCell.java +++ b/src/main/java/ctbrec/ui/ThumbCell.java @@ -1,21 +1,16 @@ package ctbrec.ui; import java.io.IOException; -import java.util.HashMap; -import java.util.Map; import java.util.Objects; +import java.util.concurrent.ExecutionException; import java.util.function.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.iheartradio.m3u8.ParseException; -import com.iheartradio.m3u8.PlaylistException; - import ctbrec.Config; import ctbrec.HttpClient; import ctbrec.Model; -import ctbrec.recorder.Chaturbate; import ctbrec.recorder.Recorder; import ctbrec.recorder.StreamInfo; import javafx.animation.FadeTransition; @@ -38,6 +33,7 @@ import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; import javafx.scene.shape.Circle; import javafx.scene.shape.Rectangle; import javafx.scene.shape.Shape; @@ -55,12 +51,11 @@ public class ThumbCell extends StackPane { public static int width = 180; private static final Duration ANIMATION_DURATION = new Duration(250); - // this acts like a cache, once the stream resolution for a model has been determined, we don't do it again (until ctbrec is restarted) - private static Map resolutions = new HashMap<>(); - private Model model; private ImageView iv; private Rectangle resolutionBackground; + private Paint resolutionOnlineColor = new Color(0.22, 0.8, 0.29, 1); + private Color resolutionOfflineColor = new Color(0.8, 0.28, 0.28, 1); private Rectangle nameBackground; private Rectangle topicBackground; private Rectangle selectionOverlay; @@ -106,7 +101,7 @@ public class ThumbCell extends StackPane { getChildren().add(topicBackground); resolutionBackground = new Rectangle(34, 16); - resolutionBackground.setFill(new Color(0.22, 0.8, 0.29, 1)); + resolutionBackground.setFill(resolutionOnlineColor ); resolutionBackground.setVisible(false); resolutionBackground.setArcHeight(5); resolutionBackground.setArcWidth(resolutionBackground.getArcHeight()); @@ -192,65 +187,59 @@ public class ThumbCell extends StackPane { private void determineResolution() { if(ThumbOverviewTab.resolutionProcessing.contains(model)) { - LOG.debug("Already fetching resolution for model {}. Queue size {}", model.getName(), ThumbOverviewTab.resolutionProcessing.size()); + LOG.trace("Already fetching resolution for model {}. Queue size {}", model.getName(), ThumbOverviewTab.resolutionProcessing.size()); return; } - ThumbOverviewTab.resolutionProcessing.add(model); - int[] res = resolutions.get(model.getName()); - if(res == null) { - ThumbOverviewTab.threadPool.submit(() -> { - try { - Thread.sleep(500); // throttle down, so that we don't do too many requests - int[] resolution = Chaturbate.getResolution(model, client); - resolutions.put(model.getName(), resolution); - if (resolution[1] > 0) { - LOG.trace("Model resolution {} {}x{}", model.getName(), resolution[0], resolution[1]); - LOG.trace("Resolution queue size: {}", ThumbOverviewTab.queue.size()); - final int w = resolution[1]; - Platform.runLater(() -> { - String _res = Integer.toString(w); - resolutionTag.setText(_res); - resolutionTag.setVisible(true); - resolutionBackground.setVisible(true); - resolutionBackground.setWidth(resolutionTag.getBoundsInLocal().getWidth() + 4); - model.setStreamResolution(w); - }); - } - } catch (IOException | ParseException | PlaylistException | InterruptedException e) { - LOG.error("Coulnd't get resolution for model {}", model, e); - } finally { - ThumbOverviewTab.resolutionProcessing.remove(model); - } - }); - } else { - ThumbOverviewTab.resolutionProcessing.remove(model); - String _res = Integer.toString(res[1]); - model.setStreamResolution(res[1]); - Platform.runLater(() -> { - resolutionTag.setText(_res); - resolutionTag.setVisible(true); - resolutionBackground.setVisible(true); - resolutionBackground.setWidth(resolutionTag.getBoundsInLocal().getWidth() + 4); - }); + ThumbOverviewTab.threadPool.submit(() -> { + try { + ThumbOverviewTab.resolutionProcessing.add(model); + int[] resolution = model.getStreamResolution(); + updateResolutionTag(resolution); - // the model is online, but the resolution is 0. probably something went wrong - // when we first requested the stream info, so we remove this invalid value from the "cache" - // so that it is requested again - if(model.isOnline() && res[1] == 0) { - ThumbOverviewTab.threadPool.submit(() -> { - try { - Chaturbate.getStreamInfo(model, client); - if(model.isOnline()) { - LOG.debug("Removing invalid resolution value for {}", model.getName()); - resolutions.remove(model.getName()); - } - } catch (IOException e) { - LOG.error("Coulnd't get resolution for model {}", model, e); + // the model is online, but the resolution is 0. probably something went wrong + // when we first requested the stream info, so we remove this invalid value from the "cache" + // so that it is requested again + try { + if (model.isOnline() && resolution[1] == 0) { + LOG.debug("Removing invalid resolution value for {}", model.getName()); + model.invalidateCacheEntries(); } - }); + } catch (IOException | ExecutionException | InterruptedException e) { + LOG.error("Coulnd't get resolution for model {}", model, e); + } + } catch (ExecutionException e1) { + LOG.warn("Couldn't update resolution tag for model {}", model.getName(), e1); + } catch (IOException e1) { + LOG.warn("Couldn't update resolution tag for model {}", model.getName(), e1); + } finally { + ThumbOverviewTab.resolutionProcessing.remove(model); } + }); + } + + private void updateResolutionTag(int[] resolution) throws IOException, ExecutionException { + String _res = "n/a"; + Paint resolutionBackgroundColor = resolutionOnlineColor; + String state = model.getOnlineState(); + if ("public".equals(state)) { + LOG.trace("Model resolution {} {}x{}", model.getName(), resolution[0], resolution[1]); + LOG.trace("Resolution queue size: {}", ThumbOverviewTab.queue.size()); + final int w = resolution[1]; + _res = w > 0 ? Integer.toString(w) : state; + } else { + _res = model.getOnlineState(); + resolutionBackgroundColor = resolutionOfflineColor; } + final String resText = _res; + final Paint c = resolutionBackgroundColor; + Platform.runLater(() -> { + resolutionTag.setText(resText); + resolutionTag.setVisible(true); + resolutionBackground.setVisible(true); + resolutionBackground.setWidth(resolutionTag.getBoundsInLocal().getWidth() + 4); + resolutionBackground.setFill(c); + }); } private void setImage(String url) { @@ -288,8 +277,8 @@ public class ThumbCell extends StackPane { // or maybe not, because the player should automatically switch between resolutions depending on the // network bandwidth try { - StreamInfo streamInfo = Chaturbate.getStreamInfo(model, client); - if(streamInfo.room_status.equals("public")) { + if(model.isOnline(true)) { + StreamInfo streamInfo = model.getStreamInfo(); LOG.debug("Playing {}", streamInfo.url); Player.play(streamInfo.url); } else { @@ -298,7 +287,7 @@ public class ThumbCell extends StackPane { alert.setHeaderText("Room is currently not public"); alert.showAndWait(); } - } catch (IOException e1) { + } catch (IOException | ExecutionException | InterruptedException e1) { LOG.error("Couldn't get stream information for model {}", model, e1); Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); alert.setTitle("Error"); @@ -350,8 +339,10 @@ public class ThumbCell extends StackPane { try { if(start) { recorder.startRecording(model); + setRecording(true); } else { recorder.stopRecording(model); + setRecording(false); } } catch (Exception e1) { LOG.error("Couldn't start/stop recording", e1); @@ -438,9 +429,7 @@ public class ThumbCell extends StackPane { //this.model = model; this.model.setName(model.getName()); this.model.setDescription(model.getDescription()); - this.model.setOnline(model.isOnline()); this.model.setPreview(model.getPreview()); - this.model.setStreamResolution(model.getStreamResolution()); this.model.setTags(model.getTags()); this.model.setUrl(model.getUrl()); @@ -459,8 +448,18 @@ public class ThumbCell extends StackPane { setRecording(recorder.isRecording(model)); setImage(model.getPreview()); topic.setText(model.getDescription()); - //Tooltip t = new Tooltip(model.getDescription()); - //Tooltip.install(this, t); + + // ThumbOverviewTab.threadPool.submit(() -> { + // StreamInfo streamInfo; + // try { + // streamInfo = Chaturbate.INSTANCE.getStreamInfo(model); + // model.setOnline(streamInfo.room_status.equals("public")); + // model.setOnlineState(streamInfo.room_status); + // } catch (IOException | ExecutionException e) { + // LOG.error("Couldn't retrieve stream information for model {}", model.getName()); + // } + // }); + if(Config.getInstance().getSettings().determineResolution) { determineResolution(); } else { diff --git a/src/main/java/ctbrec/ui/ThumbOverviewTab.java b/src/main/java/ctbrec/ui/ThumbOverviewTab.java index f1eaf052..708b373e 100644 --- a/src/main/java/ctbrec/ui/ThumbOverviewTab.java +++ b/src/main/java/ctbrec/ui/ThumbOverviewTab.java @@ -11,6 +11,7 @@ import java.util.Iterator; import java.util.List; import java.util.Set; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; @@ -77,6 +78,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { String url; boolean loginRequired; HttpClient client = HttpClient.getInstance(); + HBox pagination; int page = 1; TextField pageInput = new TextField(Integer.toString(page)); Button pagePrev = new Button("◀"); @@ -95,7 +97,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { initializeUpdateService(); } - private void createGui() { + void createGui() { grid.setPadding(new Insets(5)); grid.setHgap(5); grid.setVgap(5); @@ -107,12 +109,16 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { gridLock.lock(); try { filter(); + moveActiveRecordingsToFront(); } finally { gridLock.unlock(); } }); - search.setTooltip(new Tooltip("Filter the models by their name, stream description or #hashtags.\n\n"+"" - + "If the display of stream resolution is enabled, you can even filter by resolution. Try \"1080\" or \">720\"")); + Tooltip searchTooltip = new Tooltip("Filter the models by their name, stream description or #hashtags.\n\n" + + "If the display of stream resolution is enabled, you can even filter for public rooms or by resolution.\n\n" + + "Try \"1080\" or \">720\" or \"public\""); + search.setTooltip(searchTooltip); + BorderPane.setMargin(search, new Insets(5)); scrollPane.setContent(grid); @@ -120,7 +126,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { scrollPane.setFitToWidth(true); BorderPane.setMargin(scrollPane, new Insets(5)); - HBox pagination = new HBox(5); + pagination = new HBox(5); pagination.getChildren().add(pagePrev); pagination.getChildren().add(pageNext); pagination.getChildren().add(pageInput); @@ -320,6 +326,35 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { clipboard.setContent(content); }); + MenuItem sendTip = new MenuItem("Send Tip"); + sendTip.setOnAction((e) -> { + TipDialog tipDialog = new TipDialog(cell.getModel()); + tipDialog.showAndWait(); + String tipText = tipDialog.getResult(); + if(tipText != null) { + if(tipText.matches("[1-9]\\d*")) { + int tokens = Integer.parseInt(tipText); + try { + cell.getModel().receiveTip(tokens); + } catch (IOException e1) { + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); + alert.setTitle("Error"); + alert.setHeaderText("Couldn't send tip"); + alert.setContentText("An error occured while sending tip: " + e1.getLocalizedMessage()); + alert.showAndWait(); + } + } else { + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); + alert.setTitle("Error"); + alert.setHeaderText("Couldn't send tip"); + alert.setContentText("You entered an invalid amount of tokens"); + alert.showAndWait(); + } + } + }); + String username = Config.getInstance().getSettings().username; + sendTip.setDisable(username == null || username.trim().isEmpty()); + // check, if other cells are selected, too. in that case, we have to disable menu item, which make sense only for // single selections. but only do that, if the popup has been triggered on a selected cell. otherwise remove the // selection and show the normal menu @@ -329,6 +364,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { openInPlayer.setDisable(true); } copyUrl.setDisable(true); + sendTip.setDisable(true); } else { removeSelection(); } @@ -339,7 +375,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { contextMenu.setHideOnEscape(true); contextMenu.setAutoFix(true); MenuItem followOrUnFollow = this instanceof FollowedTab ? unfollow : follow; - contextMenu.getItems().addAll(openInPlayer, startStop , followOrUnFollow, copyUrl); + contextMenu.getItems().addAll(openInPlayer, startStop , followOrUnFollow, copyUrl, sendTip); return contextMenu; } @@ -479,32 +515,41 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { } private boolean matches(Model m, String filter) { - String[] tokens = filter.split(" "); - StringBuilder searchTextBuilder = new StringBuilder(m.getName()); - searchTextBuilder.append(' '); - for (String tag : m.getTags()) { - searchTextBuilder.append(tag).append(' '); - } - searchTextBuilder.append(m.getStreamResolution()); - String searchText = searchTextBuilder.toString().trim(); - //LOG.debug("{} -> {}", m.getName(), searchText); - boolean tokensMissing = false; - for (String token : tokens) { - if(token.matches(">\\d+")) { - int res = Integer.parseInt(token.substring(1)); - if(m.getStreamResolution() < res) { - tokensMissing = true; - } - } else if(token.matches("<\\d+")) { - int res = Integer.parseInt(token.substring(1)); - if(m.getStreamResolution() > res) { - tokensMissing = true; - } - } else if(!searchText.contains(token)) { - tokensMissing = true; + try { + String[] tokens = filter.split(" "); + StringBuilder searchTextBuilder = new StringBuilder(m.getName()); + searchTextBuilder.append(' '); + for (String tag : m.getTags()) { + searchTextBuilder.append(tag).append(' '); } + int[] resolution = m.getStreamResolution(true); + searchTextBuilder.append(resolution[1]); + String searchText = searchTextBuilder.toString().trim(); + boolean tokensMissing = false; + for (String token : tokens) { + if(token.matches(">\\d+")) { + int res = Integer.parseInt(token.substring(1)); + if(resolution[1] < res) { + tokensMissing = true; + } + } else if(token.matches("<\\d+")) { + int res = Integer.parseInt(token.substring(1)); + if(resolution[1] > res) { + tokensMissing = true; + } + } else if(token.equals("public")) { + if(!m.getOnlineState(true).equals(token)) { + tokensMissing = true; + } + } else if(!searchText.contains(token)) { + tokensMissing = true; + } + } + return !tokensMissing; + } catch (NumberFormatException | ExecutionException | IOException e) { + LOG.error("Error while filtering model list", e); + return false; } - return !tokensMissing; } private ScheduledService> createUpdateService() { @@ -550,6 +595,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { @Override public void selected() { + queue.clear(); if(updateService != null) { State s = updateService.getState(); if (s != State.SCHEDULED && s != State.RUNNING) { diff --git a/src/main/java/ctbrec/ui/TipDialog.java b/src/main/java/ctbrec/ui/TipDialog.java new file mode 100644 index 00000000..5eea4ee6 --- /dev/null +++ b/src/main/java/ctbrec/ui/TipDialog.java @@ -0,0 +1,97 @@ +package ctbrec.ui; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.HttpClient; +import ctbrec.Model; +import javafx.application.Platform; +import javafx.concurrent.Task; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.scene.control.TextInputDialog; +import okhttp3.Request; +import okhttp3.Response; + +public class TipDialog extends TextInputDialog { + + private static final transient Logger LOG = LoggerFactory.getLogger(TipDialog.class); + + public TipDialog(Model model) { + setTitle("Send Tip"); + loadTokenBalance(); + setHeaderText("Loading token balance…"); + setContentText("Amount of tokens to tip:"); + setResizable(true); + getEditor().setDisable(true); + } + + private void loadTokenBalance() { + Task task = new Task() { + @Override + protected Integer call() throws Exception { + String username = Config.getInstance().getSettings().username; + if (username == null || username.trim().isEmpty()) { + throw new IOException("Not logged in"); + } + + String url = "https://chaturbate.com/p/" + username + "/"; + HttpClient client = HttpClient.getInstance(); + Request req = new Request.Builder().url(url).build(); + Response resp = client.execute(req, true); + if (resp.isSuccessful()) { + String profilePage = resp.body().string(); + String tokenText = HtmlParser.getText(profilePage, "span.tokencount"); + int tokens = Integer.parseInt(tokenText); + return tokens; + } else { + throw new IOException("HTTP response: " + resp.code() + " - " + resp.message()); + } + } + + @Override + protected void done() { + try { + int tokens = get(); + Platform.runLater(() -> { + if (tokens <= 0) { + String msg = "Do you want to buy tokens now?\n\nIf you agree, Chaturbate will open in a browser. " + + "The used address is an affiliate link, which supports me, but doesn't cost you anything more."; + Alert buyTokens = new AutosizeAlert(Alert.AlertType.CONFIRMATION, msg, ButtonType.NO, ButtonType.YES); + buyTokens.setTitle("No tokens"); + buyTokens.setHeaderText("You don't have any tokens"); + buyTokens.showAndWait(); + TipDialog.this.close(); + if(buyTokens.getResult() == ButtonType.YES) { + DesktopIntergation.open(CtbrecApplication.AFFILIATE_LINK); + } + } else { + getEditor().setDisable(false); + setHeaderText("Current token balance: " + tokens); + } + }); + } catch (InterruptedException | ExecutionException e) { + LOG.error("Couldn't retrieve account balance", e); + showErrorDialog(e); + } + } + }; + new Thread(task).start(); + } + + private void showErrorDialog(Throwable throwable) { + Platform.runLater(() -> { + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); + alert.setTitle("Error"); + alert.setHeaderText("Couldn't retrieve token balance"); + alert.setContentText("Error while loading your token balance: " + throwable.getLocalizedMessage()); + alert.showAndWait(); + TipDialog.this.close(); + }); + } + +} diff --git a/src/main/resources/html/token.png b/src/main/resources/html/token.png new file mode 100644 index 00000000..7212e417 Binary files /dev/null and b/src/main/resources/html/token.png differ diff --git a/src/main/resources/html/token.xcf b/src/main/resources/html/token.xcf new file mode 100644 index 00000000..4d324925 Binary files /dev/null and b/src/main/resources/html/token.xcf differ