package ctbrec.sites.flirt4free; import static ctbrec.io.HttpConstants.*; import java.io.IOException; import java.io.InputStream; import java.net.URLEncoder; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.concurrent.ExecutionException; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; 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.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.squareup.moshi.JsonReader; import com.squareup.moshi.JsonWriter; import ctbrec.AbstractModel; import ctbrec.Config; import ctbrec.Model; import ctbrec.io.HttpException; import ctbrec.recorder.download.StreamSource; import okhttp3.Request; import okhttp3.Response; import okhttp3.WebSocket; import okhttp3.WebSocketListener; public class Flirt4FreeModel extends AbstractModel { private static final transient Logger LOG = LoggerFactory.getLogger(Flirt4FreeModel.class); private String id; private String chatHost; private String chatPort; private String chatToken; private String streamHost; private String streamUrl; int[] resolution = new int[2]; private Object monitor = new Object(); private boolean online = false; private boolean isInteractiveShow = false; private boolean isNew = false; private String userIdt = ""; private String userIp = "0.0.0.0"; private static Semaphore requestThrottle = new Semaphore(2, true); private static volatile long lastRequest = 0; private long lastOnlineRequest = 0; @Override public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { long now = System.currentTimeMillis(); long timeSinceLastCheck = now - lastOnlineRequest; if (ignoreCache && timeSinceLastCheck > TimeUnit.MINUTES.toMillis(1)) { String url = "https://ws.vs3.com/rooms/check-model-status.php?model_name=" + getName(); acquireSlot(); try { Request request = new Request.Builder() .url(url) .header(ACCEPT, "*/*") .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") .header(REFERER, getUrl()) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) .build(); try (Response response = getSite().getHttpClient().execute(request)) { if (response.isSuccessful()) { String body = response.body().string(); if (body.trim().isEmpty()) { return false; } JSONObject json = new JSONObject(body); //LOG.debug("check model status: {}", json.toString(2)); online = Objects.equals(json.optString("status"), "online"); id = String.valueOf(json.get("model_id")); if (online) { try { loadStreamUrl(); } catch (Exception e) { online = false; onlineState = Model.State.OFFLINE; } } } else { throw new HttpException(response.code(), response.message()); } } } finally { lastOnlineRequest = System.currentTimeMillis(); releaseSlot(); } } return online; } private void loadModelInfo() throws IOException, InterruptedException { String url = getSite().getBaseUrl() + "/webservices/chat-room-interface.php?a=login_room&model_id=" + id; LOG.trace("Loading url {}", url); Request request = new Request.Builder() .url(url) .header(ACCEPT, "*/*") .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") .header(REFERER, getUrl()) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) .build(); try (Response response = getSite().getHttpClient().execute(request)) { if (response.isSuccessful()) { JSONObject json = new JSONObject(response.body().string()); if (json.optString("status").equals("success")) { // LOG.debug("chat-room-interface {}", json.toString(2)); JSONObject config = json.getJSONObject("config"); JSONObject performer = config.getJSONObject("performer"); setName(performer.optString("name_seo", "n/a")); setDisplayName(performer.optString("name", "n/a")); setUrl(getSite().getBaseUrl() + "/rooms/" + getName() + '/'); JSONObject room = config.getJSONObject("room"); chatHost = room.getString("host"); chatPort = room.getString("port_to_be"); chatToken = json.getString("token_enc"); JSONObject user = config.getJSONObject("user"); userIp = user.getString("ip"); } else { LOG.trace("Loading model info failed. Assuming model {} is offline", getName()); online = false; onlineState = Model.State.OFFLINE; } } else { throw new HttpException(response.code(), response.message()); } } } @Override public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { return getStreamSources(true); } private List getStreamSources(boolean withWebsocket) throws IOException, ExecutionException, ParseException, PlaylistException { MasterPlaylist masterPlaylist = null; try { if (withWebsocket) { acquireSlot(); try { loadStreamUrl(); } finally { releaseSlot(); } } masterPlaylist = getMasterPlaylist(); } catch (InterruptedException e) { throw new ExecutionException(e); } 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; src.mediaPlaylistUrl = "https://manifest.vscdns.com/" + playlist.getUri(); LOG.trace("Media playlist {}", src.mediaPlaylistUrl); sources.add(src); } } return sources; } public MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException, InterruptedException { LOG.trace("Loading master playlist {}", streamUrl); Request req = new Request.Builder() .url(streamUrl) .header(ACCEPT, "*/*") .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") .header(REFERER, getUrl()) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) .build(); acquireSlot(); try (Response response = getSite().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(); return master; } else { throw new HttpException(response.code(), response.message()); } } finally { releaseSlot(); } } private void loadStreamUrl() throws IOException, InterruptedException { loadModelInfo(); Objects.requireNonNull(chatHost, "chatHost is null"); String h = chatHost.replaceAll("chat", "chat-vip"); String url = "https://" + h + "/chat?token=" + URLEncoder.encode(chatToken, "utf-8") + "&port_to_be=" + chatPort; LOG.trace("Opening chat websocket {}", url); Request req = new Request.Builder() .url(url) .header(ACCEPT, "*/*") .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") .header(REFERER, getUrl()) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) .build(); getSite().getHttpClient().newWebSocket(req, new WebSocketListener() { @Override public void onOpen(WebSocket webSocket, Response response) { LOG.trace("Chat websocket for {} opened", getName()); } @Override public void onMessage(WebSocket webSocket, String text) { LOG.trace("Chat wbesocket for {}: {}", getName(), text); JSONObject json = new JSONObject(text); //LOG.debug("WS {}", text); if (json.optString("command").equals("8011")) { JSONObject data = json.getJSONObject("data"); streamHost = data.getString("stream_host"); // TODO look, if the stream_host is equal to the one encoded in base64 in some of the ajax requests (parameters) online = true; isInteractiveShow = data.optString("devices").equals("1"); if(data.optString("room_state").equals("P")) { onlineState = Model.State.PRIVATE; online = false; } if(data.optString("room_state").equals("0") && data.optString("login_group_id").equals("14")) { onlineState = Model.State.GROUP; online = false; } try { resolution[0] = Integer.parseInt(data.getString("stream_width")); resolution[1] = Integer.parseInt(data.getString("stream_height")); } catch(Exception e) { LOG.warn("Couldn't determine stream resolution", e); } webSocket.close(1000, ""); } } @Override public void onFailure(WebSocket webSocket, Throwable t, Response response) { LOG.error("Chat websocket for {} failed", getName(), t); synchronized (monitor) { monitor.notify(); } response.close(); } @Override public void onClosed(WebSocket webSocket, int code, String reason) { LOG.trace("Chat websocket for {} closed {} {}", getName(), code, reason); synchronized (monitor) { monitor.notify(); } } }); synchronized (monitor) { monitor.wait(10_000); if (streamHost == null) { throw new RuntimeException("Couldn't determine streaming server for model " + getName()); } else { streamUrl = "https://manifest.vscdns.com/manifest.m3u8.m3u8?key=nil&provider=level3&secure=true&host=" + streamHost + "&model_id=" + id; } } } @Override public void invalidateCacheEntries() { } @Override public void receiveTip(Double tokens) throws IOException { try { // if(tokens < 50 || tokens > 750000) { // throw new RuntimeException("Tip amount has to be between 50 and 750000"); // } // make sure we are logged in and all necessary model data is available getSite().login(); acquireSlot(); try { loadStreamUrl(); } catch (InterruptedException e) { throw new IOException("Couldn't send tip", e); } finally { releaseSlot(); } // send the tip int giftId = isInteractiveShow ? 775 : 171; int amount = tokens.intValue(); LOG.debug("Sending tip of {} to {}", amount, getName()); String url = "https://ws.vs3.com/rooms/send-tip.php?" + "gift_id=" + giftId + "&num_credits=" + amount + "&userId=" + getUserIdt() + "&username=" + Config.getInstance().getSettings().flirt4freeUsername + "&userIP=" + userIp + "&anonymous=N&response_type=json" + "&t=" + System.currentTimeMillis(); LOG.debug("Trying to send tip: {}", url); Request req = new Request.Builder() .url(url) .header(ACCEPT, "*/*") .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") .header(REFERER, getUrl()) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(REFERER, getUrl()) .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) .build(); try (Response response = getSite().getHttpClient().execute(req)) { if (response.isSuccessful()) { JSONObject json = new JSONObject(response.body().string()); if (json.optInt("success") != 1) { String msg = json.optString("message"); if (json.has("error_message")) { msg = json.getString("error_message"); } LOG.error("Sending tip failed: {}", msg); LOG.debug("Response: {}", json.toString(2)); throw new IOException(msg); } } else { throw new HttpException(response.code(), response.message()); } } } catch (InterruptedException e) { throw new IOException("Couldn't acquire request slot", e); } } private String getUserIdt() throws IOException, InterruptedException { if (userIdt.isEmpty()) { acquireSlot(); try { Request req = new Request.Builder() .url(getUrl()) .header(ACCEPT, "*/*") .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") .header(REFERER, getUrl()) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .build(); try (Response response = getSite().getHttpClient().execute(req)) { if (response.isSuccessful()) { String body = response.body().string(); Matcher m = Pattern.compile("idt\\s*:\\s*'(.*?)',").matcher(body); if (m.find()) { userIdt = m.group(1); } else { throw new IOException("userIdt not found on HTML page"); } } else { throw new HttpException(response.code(), response.message()); } } } finally { releaseSlot(); } } return userIdt; } @Override public int[] getStreamResolution(boolean failFast) throws ExecutionException { if(failFast) { return resolution; } else { if(streamUrl != null) { try { List streamSources = getStreamSources(false); Collections.sort(streamSources); StreamSource best = streamSources.get(streamSources.size()-1); resolution = new int[] {best.width, best.height}; } catch (IOException | ParseException | PlaylistException e) { throw new ExecutionException("Couldn't determine stream resolution", e); } } return resolution; } } @Override public boolean follow() throws IOException { try { return changeFavoriteStatus(true); } catch (InterruptedException e) { throw new IOException("Couldn't change follow status for model " + getName(), e); } } @Override public boolean unfollow() throws IOException { try { isOnline(true); return changeFavoriteStatus(false); } catch (ExecutionException | InterruptedException e) { throw new IOException("Couldn't change follow status for model " + getName(), e); } } private boolean changeFavoriteStatus(boolean add) throws IOException, InterruptedException { getSite().login(); acquireSlot(); try { loadModelInfo(); } finally { releaseSlot(); } String url = getSite().getBaseUrl() + "/external.php?a=" + (add ? "add_favorite" : "delete_favorite") + "&id=" + id + "&name=" + getDisplayName() + "&t=" + System.currentTimeMillis(); LOG.debug("Sending follow/unfollow request: {}", url); Request req = new Request.Builder() .url(url) .header(ACCEPT, "*/*") .header(ACCEPT_LANGUAGE, "en-US,en;q=0.5") .header(REFERER, getUrl()) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .build(); try (Response response = getSite().getHttpClient().execute(req)) { if (response.isSuccessful()) { String body = response.body().string(); LOG.debug("Follow/Unfollow response: {}", body); return Objects.equals(body, "1"); } else { throw new HttpException(response.code(), response.message()); } } } public void setId(String id) { this.id = id; } @Override public void readSiteSpecificData(JsonReader reader) throws IOException { reader.nextName(); id = reader.nextString(); } @Override public void writeSiteSpecificData(JsonWriter writer) throws IOException { writer.name("id").value(id); } public void setStreamUrl(String streamUrl) { this.streamUrl = streamUrl; } public void setOnline(boolean b) { online = b; } public boolean isNew() { return isNew; } public void setNew(boolean isNew) { this.isNew = isNew; } private void acquireSlot() throws InterruptedException { //LOG.debug("Acquire: {}", requestThrottle.availablePermits()); requestThrottle.acquire(); long now = System.currentTimeMillis(); long millisSinceLastRequest = now - lastRequest; if(millisSinceLastRequest < 500) { //LOG.debug("Sleeping: {}", (500-millisSinceLastRequest)); Thread.sleep(500 - millisSinceLastRequest); } } private void releaseSlot() { lastRequest = System.currentTimeMillis(); requestThrottle.release(); //LOG.debug("Release: {}", requestThrottle.availablePermits()); } }