diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index 91cc6f10..27ceef98 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -45,6 +45,7 @@ import ctbrec.recorder.OnlineMonitor; import ctbrec.recorder.Recorder; import ctbrec.recorder.RemoteRecorder; import ctbrec.sites.Site; +import ctbrec.sites.amateurtv.AmateurTv; import ctbrec.sites.bonga.BongaCams; import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; @@ -154,6 +155,7 @@ public class CamrecApplication extends Application { } private void initSites() { + sites.add(new AmateurTv()); sites.add(new BongaCams()); sites.add(new Cam4()); sites.add(new Camsoda()); diff --git a/client/src/main/java/ctbrec/ui/SiteUiFactory.java b/client/src/main/java/ctbrec/ui/SiteUiFactory.java index 2e371467..2f0fa57b 100644 --- a/client/src/main/java/ctbrec/ui/SiteUiFactory.java +++ b/client/src/main/java/ctbrec/ui/SiteUiFactory.java @@ -1,6 +1,7 @@ package ctbrec.ui; import ctbrec.sites.Site; +import ctbrec.sites.amateurtv.AmateurTv; import ctbrec.sites.bonga.BongaCams; import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; @@ -13,6 +14,7 @@ import ctbrec.sites.mfc.MyFreeCams; import ctbrec.sites.showup.Showup; import ctbrec.sites.streamate.Streamate; import ctbrec.sites.stripchat.Stripchat; +import ctbrec.ui.sites.amateurtv.AmateurTvSiteUi; import ctbrec.ui.sites.bonga.BongaCamsSiteUi; import ctbrec.ui.sites.cam4.Cam4SiteUi; import ctbrec.ui.sites.camsoda.CamsodaSiteUi; @@ -28,6 +30,7 @@ import ctbrec.ui.sites.stripchat.StripchatSiteUi; public class SiteUiFactory { + private static AmateurTvSiteUi amateurTvUi; private static BongaCamsSiteUi bongaSiteUi; private static Cam4SiteUi cam4SiteUi; private static CamsodaSiteUi camsodaSiteUi; @@ -44,7 +47,12 @@ public class SiteUiFactory { private SiteUiFactory () {} public static synchronized SiteUI getUi(Site site) { - if (site instanceof BongaCams) { + if (site instanceof AmateurTv) { + if (amateurTvUi == null) { + amateurTvUi = new AmateurTvSiteUi((AmateurTv) site); + } + return amateurTvUi; + } else if (site instanceof BongaCams) { if (bongaSiteUi == null) { bongaSiteUi = new BongaCamsSiteUi((BongaCams) site); } diff --git a/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvSiteUi.java b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvSiteUi.java new file mode 100644 index 00000000..ddd1c6fa --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvSiteUi.java @@ -0,0 +1,34 @@ +package ctbrec.ui.sites.amateurtv; + +import java.io.IOException; + +import ctbrec.sites.amateurtv.AmateurTv; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +public class AmateurTvSiteUi extends AbstractSiteUi { + + private final AmateurTvTabProvider tabProvider; + private final AmateurTv amateurTv; + + public AmateurTvSiteUi(AmateurTv amateurTv) { + this.amateurTv = amateurTv; + tabProvider = new AmateurTvTabProvider(amateurTv); + } + + @Override + public TabProvider getTabProvider() { + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + return null; + } + + @Override + public synchronized boolean login() throws IOException { + return amateurTv.login(); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvTabProvider.java b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvTabProvider.java new file mode 100644 index 00000000..7dff848e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvTabProvider.java @@ -0,0 +1,52 @@ +package ctbrec.ui.sites.amateurtv; + +import java.util.ArrayList; +import java.util.List; + +import ctbrec.recorder.Recorder; +import ctbrec.sites.amateurtv.AmateurTv; +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 AmateurTvTabProvider implements TabProvider { + + private AmateurTv amateurTv; + private Recorder recorder; + + public AmateurTvTabProvider(AmateurTv amateurTv) { + this.amateurTv = amateurTv; + this.recorder = amateurTv.getRecorder(); + } + + @Override + public List getTabs(Scene scene) { + List tabs = new ArrayList<>(); + + // female + String url = AmateurTv.baseUrl + "/v3/readmodel/cache/cams/W"; + var updateService = new AmateurTvUpdateService(amateurTv, url); + tabs.add(createTab("Female", updateService)); + + // male + url = AmateurTv.baseUrl + "/v3/readmodel/cache/cams/M"; + updateService = new AmateurTvUpdateService(amateurTv, url); + tabs.add(createTab("Male", updateService)); + + return tabs; + } + + private Tab createTab(String title, PaginatedScheduledService updateService) { + var tab = new ThumbOverviewTab(title, updateService, amateurTv); + tab.setRecorder(recorder); + return tab; + } + + @Override + public Tab getFollowedTab() { + return null; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvUpdateService.java b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvUpdateService.java new file mode 100644 index 00000000..8c5fccca --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvUpdateService.java @@ -0,0 +1,84 @@ +package ctbrec.ui.sites.amateurtv; + +import static ctbrec.io.HttpConstants.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.sites.amateurtv.AmateurTv; +import ctbrec.sites.amateurtv.AmateurTvModel; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; + +public class AmateurTvUpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(AmateurTvUpdateService.class); + private static final int ITEMS_PER_PAGE = 50; + + private AmateurTv site; + private String url; + + public AmateurTvUpdateService(AmateurTv site, String url) { + this.site = site; + this.url = url; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + return loadModelList(); + } + + }; + } + + private List loadModelList() throws IOException { + int offset = page - 1; + String pageUrl = url + '/' + offset * ITEMS_PER_PAGE + '/' + ITEMS_PER_PAGE + "/es"; + LOG.debug("Fetching page {}", pageUrl); + var 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()) + .build(); + try (var response = site.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + var content = response.body().string(); + List models = new ArrayList<>(); + var json = new JSONObject(content); + var modelNodes = json.getJSONObject("cams").getJSONArray("nodes"); + parseModels(modelNodes, models); + return models; + } else { + int code = response.code(); + throw new IOException("HTTP status " + code); + } + } + } + + private void parseModels(JSONArray jsonModels, List models) { + for (var i = 0; i < jsonModels.length(); i++) { + var m = jsonModels.getJSONObject(i); + var user = m.getJSONObject("user"); + var name = user.optString("username"); + AmateurTvModel model = (AmateurTvModel) site.createModel(name); + model.setPreview(m.optString("imageURL")); + model.setDescription(m.optJSONObject("topic").optString("text")); + models.add(model); + } + } +} diff --git a/common/src/main/java/ctbrec/sites/amateurtv/AmateurTv.java b/common/src/main/java/ctbrec/sites/amateurtv/AmateurTv.java new file mode 100644 index 00000000..11bf2f63 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/amateurtv/AmateurTv.java @@ -0,0 +1,132 @@ +package ctbrec.sites.amateurtv; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Model; +import ctbrec.io.HttpClient; +import ctbrec.sites.AbstractSite; + +public class AmateurTv extends AbstractSite { + + private static final Logger LOG = LoggerFactory.getLogger(AmateurTv.class); + + public static String baseUrl = "https://www.amateur.tv"; + + private AmateurTvHttpClient httpClient; + + + @Override + public void init() throws IOException { + // nothing to do + } + + @Override + public String getName() { + return "Amateur.tv"; + } + + @Override + public String getBaseUrl() { + return baseUrl; + } + + @Override + public Model createModel(String name) { + AmateurTvModel model = new AmateurTvModel(); + model.setName(name); + model.setUrl(baseUrl + '/' + name); + model.setDescription(""); + model.setSite(this); + return model; + } + + @Override + public Double getTokenBalance() throws IOException { + return Double.valueOf(0); + } + + @Override + public String getBuyTokensLink() { + return getAffiliateLink(); + } + + @Override + public synchronized boolean login() throws IOException { + return credentialsAvailable() && getHttpClient().login(); + } + + @Override + public HttpClient getHttpClient() { + if (httpClient == null) { + httpClient = new AmateurTvHttpClient(); + } + return httpClient; + } + + @Override + public void shutdown() { + if (httpClient != null) { + httpClient.shutdown(); + } + } + + @Override + public boolean supportsTips() { + return true; + } + + @Override + public boolean supportsFollow() { + return true; + } + + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public boolean searchRequiresLogin() { + return true; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + return Collections.emptyList(); + } + + @Override + public boolean isSiteForModel(Model m) { + return m instanceof AmateurTvModel; + } + + @Override + public boolean credentialsAvailable() { + //String username = Config.getInstance().getSettings().bongaUsername; + //return username != null && !username.trim().isEmpty(); + return false; + } + + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https?://.*?amateur.tv/.*").matcher(url); + if(m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } else { + return super.createModelFromUrl(url); + } + } + + @Override + public String getAffiliateLink() { + return baseUrl; + } +} diff --git a/common/src/main/java/ctbrec/sites/amateurtv/AmateurTvHttpClient.java b/common/src/main/java/ctbrec/sites/amateurtv/AmateurTvHttpClient.java new file mode 100644 index 00000000..74d23d4f --- /dev/null +++ b/common/src/main/java/ctbrec/sites/amateurtv/AmateurTvHttpClient.java @@ -0,0 +1,22 @@ +package ctbrec.sites.amateurtv; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.io.HttpClient; + +public class AmateurTvHttpClient extends HttpClient { + + private static final Logger LOG = LoggerFactory.getLogger(AmateurTvHttpClient.class); + + public AmateurTvHttpClient() { + super("amateurtv"); + } + + @Override + public boolean login() throws IOException { + return false; + } +} diff --git a/common/src/main/java/ctbrec/sites/amateurtv/AmateurTvModel.java b/common/src/main/java/ctbrec/sites/amateurtv/AmateurTvModel.java new file mode 100644 index 00000000..832caebf --- /dev/null +++ b/common/src/main/java/ctbrec/sites/amateurtv/AmateurTvModel.java @@ -0,0 +1,155 @@ +package ctbrec.sites.amateurtv; + +import static ctbrec.Model.State.*; +import static ctbrec.io.HttpConstants.*; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import javax.xml.bind.JAXBException; + +import org.json.JSONObject; +import org.jsoup.nodes.Element; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.iheartradio.m3u8.Encoding; +import com.iheartradio.m3u8.Format; +import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.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 com.iheartradio.m3u8.data.StreamInfo; + +import ctbrec.AbstractModel; +import ctbrec.Config; +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import ctbrec.recorder.download.StreamSource; +import okhttp3.Request; +import okhttp3.Response; + +public class AmateurTvModel extends AbstractModel { + + private static final Logger LOG = LoggerFactory.getLogger(AmateurTvModel.class); + + private boolean online = false; + + @Override + public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { + if (ignoreCache) { + JSONObject json = getModelInfo(); + online = json.optString("status").equalsIgnoreCase("online"); + onlineState = online ? ONLINE : OFFLINE; + } + return online; + } + + @Override + public State getOnlineState(boolean failFast) throws IOException, ExecutionException { + if (failFast) { + return onlineState; + } else { + try { + isOnline(true); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + onlineState = OFFLINE; + } catch (IOException | ExecutionException e) { + onlineState = OFFLINE; + } + return onlineState; + } + } + + @Override + public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException, JAXBException { + List streamSources = new ArrayList<>(); + String streamUrl = getStreamUrl(); + Request req = new Request.Builder().url(streamUrl).build(); + try (Response response = site.getHttpClient().execute(req)) { + if (response.isSuccessful()) { + InputStream inputStream = response.body().byteStream(); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); + Playlist playlist = parser.parse(); + MasterPlaylist master = playlist.getMasterPlaylist(); + for (PlaylistData playlistData : master.getPlaylists()) { + StreamSource streamsource = new StreamSource(); + Element img = new Element("img"); + img.setBaseUri(streamUrl); + img.attr("src", playlistData.getUri()); + streamsource.mediaPlaylistUrl = img.absUrl("src"); + if (playlistData.hasStreamInfo()) { + StreamInfo info = playlistData.getStreamInfo(); + streamsource.bandwidth = info.getBandwidth(); + streamsource.width = info.hasResolution() ? info.getResolution().width : 0; + streamsource.height = info.hasResolution() ? info.getResolution().height : 0; + } else { + streamsource.bandwidth = 0; + streamsource.width = 0; + streamsource.height = 0; + } + streamSources.add(streamsource); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + return streamSources; + } + + private String getStreamUrl() throws IOException { + JSONObject json = getModelInfo(); + JSONObject videoTech = json.getJSONObject("videoTechnologies"); + return videoTech.getString("hlsV2"); + } + + @Override + public void invalidateCacheEntries() { + // nothing to do + } + + @Override + public void receiveTip(Double tokens) throws IOException { + // not supported + } + + @Override + public int[] getStreamResolution(boolean failFast) throws ExecutionException { + try { + return new int[] { getStreamSources().get(0).width, getStreamSources().get(0).height }; + } catch (Exception e) { + throw new ExecutionException(e); + } + } + + @Override + public boolean follow() throws IOException { + return false; + } + + @Override + public boolean unfollow() throws IOException { + return false; + } + + private JSONObject getModelInfo() throws IOException { + String url = AmateurTv.baseUrl + "/v3/readmodel/show/" + getName() + "/es"; + Request req = new Request.Builder().url(url) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, "en") + .header(REFERER, getSite().getBaseUrl() + '/' + getName()) + .build(); + try (Response resp = site.getHttpClient().execute(req)) { + JSONObject json = new JSONObject(HttpClient.bodyToJsonObject(resp)); + return json; + } + } +} diff --git a/server/src/main/java/ctbrec/recorder/server/HttpServer.java b/server/src/main/java/ctbrec/recorder/server/HttpServer.java index f17f753b..252f1b82 100644 --- a/server/src/main/java/ctbrec/recorder/server/HttpServer.java +++ b/server/src/main/java/ctbrec/recorder/server/HttpServer.java @@ -65,6 +65,7 @@ import ctbrec.recorder.OnlineMonitor; import ctbrec.recorder.Recorder; import ctbrec.servlet.StaticFileServlet; import ctbrec.sites.Site; +import ctbrec.sites.amateurtv.AmateurTv; import ctbrec.sites.bonga.BongaCams; import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; @@ -142,6 +143,7 @@ public class HttpServer { } private void createSites() { + sites.add(new AmateurTv()); sites.add(new BongaCams()); sites.add(new Cam4()); sites.add(new Camsoda());