diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index 91cc6f10..a47c1304 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -57,6 +57,7 @@ import ctbrec.sites.mfc.MyFreeCams; import ctbrec.sites.showup.Showup; import ctbrec.sites.streamate.Streamate; import ctbrec.sites.stripchat.Stripchat; +import ctbrec.sites.xlovecam.XloveCam; import ctbrec.ui.controls.Dialogs; import ctbrec.ui.news.NewsTab; import ctbrec.ui.settings.SettingsTab; @@ -166,6 +167,7 @@ public class CamrecApplication extends Application { sites.add(new Showup()); sites.add(new Streamate()); sites.add(new Stripchat()); + sites.add(new XloveCam()); } private void registerClipboardListener() { diff --git a/client/src/main/java/ctbrec/ui/SiteUiFactory.java b/client/src/main/java/ctbrec/ui/SiteUiFactory.java index 2e371467..9553a8ed 100644 --- a/client/src/main/java/ctbrec/ui/SiteUiFactory.java +++ b/client/src/main/java/ctbrec/ui/SiteUiFactory.java @@ -13,6 +13,7 @@ import ctbrec.sites.mfc.MyFreeCams; import ctbrec.sites.showup.Showup; import ctbrec.sites.streamate.Streamate; import ctbrec.sites.stripchat.Stripchat; +import ctbrec.sites.xlovecam.XloveCam; import ctbrec.ui.sites.bonga.BongaCamsSiteUi; import ctbrec.ui.sites.cam4.Cam4SiteUi; import ctbrec.ui.sites.camsoda.CamsodaSiteUi; @@ -25,6 +26,7 @@ import ctbrec.ui.sites.myfreecams.MyFreeCamsSiteUi; import ctbrec.ui.sites.showup.ShowupSiteUi; import ctbrec.ui.sites.streamate.StreamateSiteUi; import ctbrec.ui.sites.stripchat.StripchatSiteUi; +import ctbrec.ui.sites.xlovecam.XloveCamSiteUi; public class SiteUiFactory { @@ -40,6 +42,7 @@ public class SiteUiFactory { private static ShowupSiteUi showupSiteUi; private static StreamateSiteUi streamateSiteUi; private static StripchatSiteUi stripchatSiteUi; + private static XloveCamSiteUi xloveCamSiteUi; private SiteUiFactory () {} @@ -104,6 +107,11 @@ public class SiteUiFactory { stripchatSiteUi = new StripchatSiteUi((Stripchat) site); } return stripchatSiteUi; + } else if (site instanceof XloveCam) { + if (xloveCamSiteUi == null) { + xloveCamSiteUi = new XloveCamSiteUi((XloveCam) site); + } + return xloveCamSiteUi; } throw new RuntimeException("Unknown site " + site.getName()); } diff --git a/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamSiteUi.java b/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamSiteUi.java new file mode 100644 index 00000000..36f585f6 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamSiteUi.java @@ -0,0 +1,34 @@ +package ctbrec.ui.sites.xlovecam; + +import java.io.IOException; + +import ctbrec.sites.xlovecam.XloveCam; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +public class XloveCamSiteUi extends AbstractSiteUi { + + private final XloveCamTabProvider tabProvider; + private final XloveCam site; + + public XloveCamSiteUi(XloveCam xloveCam) { + this.site = xloveCam; + tabProvider = new XloveCamTabProvider(xloveCam); + } + + @Override + public TabProvider getTabProvider() { + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + return null; + } + + @Override + public synchronized boolean login() throws IOException { + return site.getHttpClient().login(); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamTabProvider.java b/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamTabProvider.java new file mode 100644 index 00000000..91215a4b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamTabProvider.java @@ -0,0 +1,76 @@ +package ctbrec.ui.sites.xlovecam; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import ctbrec.recorder.Recorder; +import ctbrec.sites.xlovecam.XloveCam; +import ctbrec.ui.tabs.PaginatedScheduledService; +import ctbrec.ui.tabs.TabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +public class XloveCamTabProvider implements TabProvider { + + private XloveCam site; + private Recorder recorder; + + public XloveCamTabProvider(XloveCam xloveCam) { + this.site = xloveCam; + this.recorder = xloveCam.getRecorder(); + } + + @Override + public List getTabs(Scene scene) { + List tabs = new ArrayList<>(); + + // all + var updateService = new XloveCamUpdateService(site, Collections.emptyMap()); + tabs.add(createTab("All", updateService)); + + // Young Women + updateService = new XloveCamUpdateService(site, Map.of("config[filter][10][]", "1")); + tabs.add(createTab("Young Women", updateService)); + + // Ladies + updateService = new XloveCamUpdateService(site, Map.of("config[filter][10][]", "13")); + tabs.add(createTab("Ladies", updateService)); + + // Mature + updateService = new XloveCamUpdateService(site, Map.of("config[filter][10][]", "6")); + tabs.add(createTab("Mature Female", updateService)); + + // Couples + updateService = new XloveCamUpdateService(site, Map.of("config[filter][10][]", "2")); + tabs.add(createTab("Couples", updateService)); + + // Lesbian + updateService = new XloveCamUpdateService(site, Map.of("config[filter][10][]", "3")); + tabs.add(createTab("Lesbian", updateService)); + + // Male + updateService = new XloveCamUpdateService(site, Map.of("config[filter][10][]", "7")); + tabs.add(createTab("Male", updateService)); + + // Trans + updateService = new XloveCamUpdateService(site, Map.of("config[filter][10][]", "5")); + tabs.add(createTab("Trans", updateService)); + + return tabs; + } + + @Override + public Tab getFollowedTab() { + return null; + } + + private Tab createTab(String title, PaginatedScheduledService updateService) { + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamUpdateService.java b/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamUpdateService.java new file mode 100644 index 00000000..a64bc6cc --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamUpdateService.java @@ -0,0 +1,42 @@ +package ctbrec.ui.sites.xlovecam; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import ctbrec.Model; +import ctbrec.sites.xlovecam.XloveCam; +import ctbrec.sites.xlovecam.XloveCamModelLoader; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; + +public class XloveCamUpdateService extends PaginatedScheduledService { + + private XloveCamModelLoader loader; + private Map filter; + + public XloveCamUpdateService(XloveCam xloveCam, Map filter) { + this.loader = new XloveCamModelLoader(xloveCam); + this.filter = new HashMap<>(filter); + this.filter.putAll(Map.of( // @formatter:off + "config[nickname]", "", + "config[favorite]", "0", + "config[recent]", "0", + "config[vip]", "0", + "origin", "postop-eol", + "stat", "0" + )); // @formatter:on + + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + return loader.loadModelList(page, filter); + } + }; + } +} diff --git a/common/src/main/java/ctbrec/sites/xlovecam/XloveCam.java b/common/src/main/java/ctbrec/sites/xlovecam/XloveCam.java new file mode 100644 index 00000000..48c947b8 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/xlovecam/XloveCam.java @@ -0,0 +1,127 @@ +package ctbrec.sites.xlovecam; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import ctbrec.Model; +import ctbrec.io.HttpClient; +import ctbrec.sites.AbstractSite; + +public class XloveCam extends AbstractSite { + + public static String baseUrl = "https://mobile.xlovecam.com"; + private HttpClient httpClient; + + @Override + public void init() throws IOException { + httpClient = new XloveCamHttpClient(); + } + + @Override + public String getName() { + return "XloveCam"; + } + + @Override + public String getBaseUrl() { + return baseUrl; + } + + @Override + public String getAffiliateLink() { + return getBaseUrl(); + } + + @Override + public String getBuyTokensLink() { + return getAffiliateLink(); + } + + @Override + public XloveCamModel createModel(String name) { + XloveCamModel model = new XloveCamModel(); + model.setName(name); + model.setUrl(getBaseUrl() + "/en/model/" + name); + model.setSite(this); + return model; + } + + @Override + public Double getTokenBalance() throws IOException { + return Double.valueOf(0); + } + + @Override + public synchronized boolean login() throws IOException { + return credentialsAvailable() && getHttpClient().login(); + } + + @Override + public HttpClient getHttpClient() { + if (httpClient == null) { + httpClient = new XloveCamHttpClient(); + } + return httpClient; + } + + @Override + public void shutdown() { + if (httpClient != null) { + httpClient.shutdown(); + } + } + + @Override + public boolean supportsTips() { + return false; + } + + @Override + public boolean supportsFollow() { + return false; + } + + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + Map filter = new HashMap<>(); + filter.put("config[nickname]", q); + filter.put("config[favorite]", "0"); + filter.put("config[recent]", "0"); + filter.put("config[vip]", "0"); + filter.put("origin", "filter-chg"); + filter.put("stat", "0"); + return new XloveCamModelLoader(this).loadModelList(1, filter); + } + + @Override + public boolean isSiteForModel(Model m) { + return m instanceof XloveCamModel; + } + + @Override + public boolean credentialsAvailable() { + return false; + } + + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https://(?:[a-z]+.)xlovecam.com/[a-z]{2}/model/(.*?)(?:/.*)*$").matcher(url); + if (m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } else { + return super.createModelFromUrl(url); + } + } + + +} diff --git a/common/src/main/java/ctbrec/sites/xlovecam/XloveCamHttpClient.java b/common/src/main/java/ctbrec/sites/xlovecam/XloveCamHttpClient.java new file mode 100644 index 00000000..56176376 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/xlovecam/XloveCamHttpClient.java @@ -0,0 +1,17 @@ +package ctbrec.sites.xlovecam; + +import java.io.IOException; + +import ctbrec.io.HttpClient; + +public class XloveCamHttpClient extends HttpClient { + + public XloveCamHttpClient() { + super("xlovecam"); + } + + @Override + public boolean login() throws IOException { + return false; + } +} diff --git a/common/src/main/java/ctbrec/sites/xlovecam/XloveCamModel.java b/common/src/main/java/ctbrec/sites/xlovecam/XloveCamModel.java new file mode 100644 index 00000000..d53c14c1 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/xlovecam/XloveCamModel.java @@ -0,0 +1,141 @@ +package ctbrec.sites.xlovecam; + +import static ctbrec.Model.State.*; +import static ctbrec.io.HttpConstants.*; +import static java.nio.charset.StandardCharsets.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.xml.bind.JAXBException; + +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.ParsingMode; +import com.iheartradio.m3u8.PlaylistException; +import com.iheartradio.m3u8.PlaylistParser; +import com.iheartradio.m3u8.data.MasterPlaylist; +import com.iheartradio.m3u8.data.Playlist; +import com.iheartradio.m3u8.data.PlaylistData; + +import ctbrec.AbstractModel; +import ctbrec.Config; +import ctbrec.io.HttpException; +import ctbrec.recorder.download.StreamSource; +import okhttp3.Request; +import okhttp3.Response; + +public class XloveCamModel extends AbstractModel { + + private static final Logger LOG = LoggerFactory.getLogger(XloveCamModel.class); + private static final Pattern HLS_PLAYLIST_PATTERN = Pattern.compile("\"hlsPlaylist\":\"(.*?)\","); + + private boolean online = false; + + @Override + public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { + if (ignoreCache || onlineState == UNKNOWN) { + String body = getModelPage(); + Matcher m = HLS_PLAYLIST_PATTERN.matcher(body); + online = m.find(); + onlineState = online ? ONLINE : OFFLINE; + } + return online; + } + + @Override + public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException, JAXBException { + 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 = Optional.ofNullable(playlist.getStreamInfo().getResolution()).map(r -> r.height).orElse(0); + src.mediaPlaylistUrl = playlist.getUri(); + LOG.trace("Media playlist {}", src.mediaPlaylistUrl); + sources.add(src); + } + } + return sources; + } + + private MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException { + String modelPage = getModelPage(); + Matcher m = HLS_PLAYLIST_PATTERN.matcher(modelPage); + if (m.find() && m.groupCount() > 0) { + String hlsPlaylist = m.group(1); + Request req = new Request.Builder() + .url(hlsPlaylist) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .build(); + try (Response response = getSite().getHttpClient().execute(req)) { + if (response.isSuccessful()) { + String body = response.body().string(); + LOG.trace(body); + InputStream inputStream = new ByteArrayInputStream(body.getBytes(UTF_8)); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); + Playlist playlist = parser.parse(); + MasterPlaylist master = playlist.getMasterPlaylist(); + return master; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } else { + throw new HttpException(404, "HLS playlist not found"); + } + } + + private String getModelPage() throws IOException { + String url = XloveCam.baseUrl + "/en/model/" + getName() + '/'; + Request req = new Request.Builder() + .url(url) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .build(); + try (Response response = getSite().getHttpClient().execute(req)) { + if (response.isSuccessful()) { + return response.body().string(); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + @Override + public void invalidateCacheEntries() { + // nothing to do + } + + @Override + public void receiveTip(Double tokens) throws IOException { + // not implemented yet + } + + @Override + public int[] getStreamResolution(boolean failFast) throws ExecutionException { + return new int[] {0, 0}; + } + + @Override + public boolean follow() throws IOException { + return false; + } + + @Override + public boolean unfollow() throws IOException { + return false; + } + +} diff --git a/common/src/main/java/ctbrec/sites/xlovecam/XloveCamModelLoader.java b/common/src/main/java/ctbrec/sites/xlovecam/XloveCamModelLoader.java new file mode 100644 index 00000000..72bb85e6 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/xlovecam/XloveCamModelLoader.java @@ -0,0 +1,85 @@ +package ctbrec.sites.xlovecam; + +import static ctbrec.io.HttpConstants.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Model; +import okhttp3.FormBody; +import okhttp3.FormBody.Builder; +import okhttp3.Request; +import okhttp3.Response; + +public class XloveCamModelLoader { + + private static final Logger LOG = LoggerFactory.getLogger(XloveCamModelLoader.class); + private static final int ITEMS_PER_PAGE = 35; + private static final int CAM_RANK = 35; + + private XloveCam site; + + public XloveCamModelLoader(XloveCam xloveCam) { + this.site = xloveCam; + } + + public List loadModelList(int page, Map filterOptions) throws IOException { + String pageUrl = "https://mobile.xlovecam.com/en/performerAction/onlineList/?x-req=" + ITEMS_PER_PAGE + "&x-off-s=" + ((page - 1) * ITEMS_PER_PAGE); + LOG.debug("Fetching page {}", pageUrl); + Builder form = new FormBody.Builder() + .add("config[sort][id]", Integer.toString(CAM_RANK)) + .add("offset[from]", Integer.toString((page - 1) * ITEMS_PER_PAGE)) + .add("offset[length]", Integer.toString(ITEMS_PER_PAGE)); + for (Entry entry : filterOptions.entrySet()) { + form.add(entry.getKey(), entry.getValue()); + } + + Request request = new Request.Builder() + .url(pageUrl) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT, Locale.ENGLISH.getLanguage()) + .header(REFERER, site.getBaseUrl()) + .header(ORIGIN, site.getBaseUrl()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .post(form.build()) + .build(); + try (Response response = site.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + String body = response.body().string(); + List models = new ArrayList<>(); + JSONObject json = new JSONObject(body); + if(json.has("content")) { + parseModels(json, models); + } + return models; + } else { + int code = response.code(); + throw new IOException("HTTP status " + code); + } + } + } + + private void parseModels(JSONObject json, List models) { + JSONObject content = json.getJSONObject("content"); + if (content.has("performerList")) { + JSONArray performers = content.getJSONArray("performerList"); + for (int i = 0; i < performers.length(); i++) { + JSONObject performer = performers.getJSONObject(i); + XloveCamModel model = site.createModel(performer.getString("nickname")); + model.setPreview("https:" + performer.optString("liveImg")); + models.add(model); + } + } + } +}