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