package ctbrec.sites.manyvids; import ctbrec.Model; import ctbrec.Model.State; import ctbrec.io.HtmlParser; import ctbrec.io.HttpClient; import ctbrec.io.HttpException; import ctbrec.sites.AbstractSite; import okhttp3.*; import org.json.JSONArray; import org.json.JSONObject; import org.jsoup.nodes.Element; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.net.URLDecoder; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; import static ctbrec.io.HttpConstants.*; import static java.nio.charset.StandardCharsets.UTF_8; public class MVLive extends AbstractSite { private static final Logger LOG = LoggerFactory.getLogger(MVLive.class); private static final String STARS = "stars"; public static final String WS_ORIGIN = "https://live.manyvids.com"; public static final String BASE_URL = "https://www.manyvids.com"; public static final String LIVE_URL = BASE_URL + "/mv-live/"; private final Pattern configPattern = Pattern.compile(""); private final Pattern graphQlUrlPattern = Pattern.compile("api:\\s*\"(https://.+?.appsync-api..+?.amazonaws.com/graphql)\","); private final Pattern graphQlApiKeyPattern = Pattern.compile("apiKey:\\s*\"(.*?)\""); private String graphqlBaseUri; private String apiKey; private MVLiveHttpClient httpClient; private String mvtoken; @Override public String getName() { return "MV Live"; } @Override public String getBaseUrl() { return LIVE_URL; } @Override public String getAffiliateLink() { return getBaseUrl() + "/Join-MV/1002294529"; } @Override public MVLiveModel createModel(String name) { MVLiveModel model = new MVLiveModel(); model.setName(name); model.setDescription(""); model.setSite(this); model.setUrl(WS_ORIGIN + '/' + name); return model; } @Override public Double getTokenBalance() throws IOException { return 0d; } @Override public String getBuyTokensLink() { return getAffiliateLink(); } @Override public boolean login() throws IOException { return false; } @Override public HttpClient getHttpClient() { if (httpClient == null) { httpClient = new MVLiveHttpClient(getConfig()); } 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 isSiteForModel(Model m) { return m instanceof MVLiveModel; } @Override public boolean credentialsAvailable() { return false; } @Override public void init() { // nothing to do } private String getGraphQlUrl() { if (graphqlBaseUri == null) { try { String spaBundleUrl = getSpaBundleUrl(); loadGraphqlApiConfig(spaBundleUrl); LOG.debug("Using graphql API at {}", graphqlBaseUri); } catch (IOException e) { LOG.error("Error while initializing {}", getName(), e); } } return graphqlBaseUri; } private void loadGraphqlApiConfig(String spaBundleUrl) throws IOException { Request request = new Request.Builder() .url(spaBundleUrl) .header(USER_AGENT, getConfig().getSettings().httpUserAgent) .header(ORIGIN, MVLive.BASE_URL) .header(REFERER, MVLive.BASE_URL) .build(); LOG.debug("Loading graphql config {}", spaBundleUrl); try (Response response = getHttpClient().execute(request)) { if (response.isSuccessful()) { String content = response.body().string(); Matcher m = graphQlUrlPattern.matcher(content); if (m.find()) { graphqlBaseUri = m.group(1); m = graphQlApiKeyPattern.matcher(content); if (m.find()) { apiKey = m.group(1); } else { throw new IllegalStateException("GraphQL API key not found"); } } else { throw new IllegalStateException("GraphQL URL not found"); } } else { throw new HttpException(response.code(), response.message()); } } } private String getSpaBundleUrl() throws IOException { Request request = new Request.Builder() .url(getBaseUrl()) .header(USER_AGENT, getConfig().getSettings().httpUserAgent) .header(REFERER, MVLive.BASE_URL) .build(); try (Response response = getHttpClient().execute(request)) { if (response.isSuccessful()) { String content = response.body().string(); Matcher m = configPattern.matcher(content); if (m.find()) { JSONObject apps = new JSONObject(m.group(1)).getJSONObject("apps"); JSONObject mvlive = apps.getJSONObject("@manyvids/live"); String spaBundle = mvlive.getString("spaBundle"); return spaBundle; } else { throw new IllegalStateException("config not found"); } } else { throw new HttpException(response.code(), response.message()); } } } public List getModels() throws IOException { String body = new JSONObject() .put("query", "query LanderLiveSessions($nextToken: String, $limit: Int, $country: String, $subdivision: String, $audience: String) { liveSessions( nextToken: $nextToken limit: $limit country: $country subdivision: $subdivision audience: $audience ) { presenters { id age avatarSrc city country name previewImgSrc starOfTheDay status sessionType streamingStartDate towerImgSrc profileHandle } nextToken }}") .put("variables", new JSONObject() .put("audience", "public") .put("country", Locale.getDefault().getDisplayCountry(Locale.ENGLISH)) .put("limit", 100) .put("nextToken", JSONObject.NULL) .put("subdivision", "") ).toString(2); RequestBody requestBody = RequestBody.Companion.create(body, MediaType.parse("application/json")); Request request = new Request.Builder() .url(getGraphQlUrl()) .header(ACCEPT, "*/*") .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) .header(USER_AGENT, getConfig().getSettings().httpUserAgent) .header(ORIGIN, MVLive.BASE_URL) .header(REFERER, MVLive.BASE_URL) .header("x-api-key", apiKey) .post(requestBody) .build(); try (Response response = getHttpClient().execute(request)) { String content = response.body().string(); if (response.isSuccessful()) { JSONObject responseBody = new JSONObject(content); JSONArray presenters = responseBody.getJSONObject("data").getJSONObject("liveSessions").getJSONArray("presenters"); List models = new LinkedList<>(); for (int i = 0; i < presenters.length(); i++) { JSONObject presenter = presenters.getJSONObject(i); try { MVLiveModel model = createModel(presenter.getString("name")); model.setId(presenter.getString("id")); model.setDisplayName(presenter.optString("name", model.getName())); model.setPreview(presenter.optString("towerImgSrc")); model.setOnlineState(mapState(presenter.getString("sessionType"))); models.add(model); } catch (Exception e) { LOG.error("Couldn't parse model: {}", presenter.toString(2), e); } } return models; } else { LOG.debug("Response: {}", content); throw new HttpException(response.code(), response.message()); } } } private State mapState(String status) { return switch (status) { case "PUBLIC" -> State.ONLINE; case "PRIVATE" -> State.PRIVATE; default -> State.OFFLINE; }; } @Override public boolean supportsSearch() { return true; } @Override public boolean searchRequiresLogin() { return false; } String getMvToken() throws IOException { if (mvtoken == null) { Request request = new Request.Builder() .url("https://www.manyvids.com/") .header(USER_AGENT, getConfig().getSettings().httpUserAgent) .build(); try (Response response = getHttpClient().execute(request)) { if (response.isSuccessful()) { Element tag = HtmlParser.getTag(response.body().string(), "html"); mvtoken = tag.attr("data-mvtoken"); } else { throw new HttpException(response.code(), response.message()); } } } return mvtoken; } @Override public List search(String q) throws IOException, InterruptedException { List result = new ArrayList<>(); RequestBody body = new FormBody.Builder() .add("mvtoken", getMvToken()) .add("type", "search") .add("category", STARS) .add("search", q) .build(); Request request = new Request.Builder() .url("https://www.manyvids.com/includes/filterSearch.php") .header(ACCEPT, MIMETYPE_APPLICATION_JSON) .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) .header(USER_AGENT, getConfig().getSettings().httpUserAgent) .header(ORIGIN, MVLive.BASE_URL) .header(REFERER, MVLive.BASE_URL) .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) .post(body) .build(); try (Response response = getHttpClient().execute(request)) { if (response.isSuccessful()) { String responseBody = response.body().string(); parseSearchResult(result, responseBody); } else { throw new HttpException(response.code(), response.message()); } } return result; } private void parseSearchResult(List result, String responseBody) { JSONObject json = new JSONObject(responseBody); if (json.has(STARS)) { JSONArray stars = json.getJSONArray(STARS); for (int i = 0; i < stars.length(); i++) { JSONObject star = stars.getJSONObject(i); String name = star.getString("label"); MVLiveModel model = createModel(name); long id = star.getLong("id"); String url = BASE_URL + "/Profile/" + id + '/' + model.getDisplayName().replace(" ", "-") + '/'; model.setUrl(url); model.setPreview(star.getString("img")); if (star.optString("is_live").equals("1")) { if (star.optString("is_private").equals("1")) { model.setOnlineState(State.PRIVATE); } else { model.setOnlineState(State.ONLINE); } } else { model.setOnlineState(State.OFFLINE); } result.add(model); } } } @Override public Model createModelFromUrl(String url) { Matcher m = Pattern.compile("https://live.manyvids.com/(?:stream/)?(.*?)(?:/.*?)?").matcher(url.trim()); if (m.matches()) { String modelName = URLDecoder.decode(m.group(1), UTF_8); return createModel(modelName); } m = Pattern.compile("https://www.manyvids.com/MVLive/(.*?)/\\d+/?").matcher(url.trim()); if (m.matches()) { String modelName = URLDecoder.decode(m.group(1), UTF_8); return createModel(modelName); } return super.createModelFromUrl(url); } }