package ctbrec.sites.flirt4free; import com.iheartradio.m3u8.*; 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.Model; import ctbrec.io.HttpException; import ctbrec.recorder.download.StreamSource; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import okhttp3.*; import org.json.JSONArray; import org.json.JSONObject; import java.io.IOException; import java.io.InputStream; import java.net.URLEncoder; import java.util.*; 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 static ctbrec.StringConstants.MODEL_ID; import static ctbrec.StringConstants.STATUS; import static ctbrec.io.HttpConstants.*; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Locale.ENGLISH; @Slf4j public class Flirt4FreeModel extends AbstractModel { @Setter private String id; private String chatHost; private String chatPort; private String chatToken; private String streamHost; @Setter private String streamUrl; int[] resolution = new int[2]; private final transient Object monitor = new Object(); @Getter private final transient List categories = new LinkedList<>(); @Setter private boolean online = false; private boolean isInteractiveShow = false; private boolean isNew = false; private String userIdt = ""; private String userIp = "0.0.0.0"; private static final 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, ENGLISH.getLanguage()) .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()) { parseOnlineState(response.body().string()); } else { throw new HttpException(response.code(), response.message()); } } } finally { lastOnlineRequest = System.currentTimeMillis(); releaseSlot(); } } return online; } private void parseOnlineState(String body) { if (body.trim().isEmpty()) { return; } JSONObject json = new JSONObject(body); if (Objects.equals(json.optString("status"), "failed")) { if (Objects.equals(json.optString("message"), "Model is inactive")) { log.debug("Model inactive or deleted: {}", getName()); setMarkedForLaterRecording(true); } online = false; onlineState = Model.State.OFFLINE; return; } online = Objects.equals(json.optString(STATUS), "online"); // online is true, even if the model is in private or away updateModelId(json); if (online) { try { loadModelInfo(); } catch (Exception e) { online = false; onlineState = Model.State.OFFLINE; } } } private void updateModelId(JSONObject json) { if (json.has(MODEL_ID)) { Object modelId = json.get(MODEL_ID); if (modelId instanceof Number n && n.intValue() > 0) { id = String.valueOf(json.get(MODEL_ID)); } } } private void loadModelInfo() throws IOException { 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, ENGLISH.getLanguage()) .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")) { JSONObject config = json.getJSONObject("config"); JSONObject performer = config.getJSONObject("performer"); setUrl(getSite().getBaseUrl() + "/rooms/" + getName() + '/'); setDisplayName(performer.optString("name", getName())); JSONObject room = config.getJSONObject("room"); chatHost = room.getString("host"); chatPort = room.getString("port_to_be"); chatToken = json.getString("token_enc"); String status = room.optString(STATUS); setOnlineState(mapStatus(status)); online = onlineState == State.ONLINE; 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()); } } } private State mapStatus(String status) { return switch (status) { case "P", "F" -> State.PRIVATE; case "A" -> State.AWAY; case "O" -> State.ONLINE; default -> State.UNKNOWN; }; } public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { MasterPlaylist masterPlaylist; try { acquireSlot(); try { loadStreamUrl(); } finally { releaseSlot(); } masterPlaylist = getMasterPlaylist(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new ExecutionException(e); } List sources = new ArrayList<>(); for (PlaylistData playlist : masterPlaylist.getPlaylists()) { if (playlist.hasStreamInfo()) { StreamSource src = new StreamSource(); StreamInfo info = playlist.getStreamInfo(); src.setBandwidth(info.getBandwidth()); src.setHeight((info.hasResolution()) ? info.getResolution().height : 0); src.setWidth((info.hasResolution()) ? info.getResolution().width : 0); HttpUrl masterPlaylistUrl = HttpUrl.parse(streamUrl); src.setMediaPlaylistUrl("https://" + masterPlaylistUrl.host() + '/' + playlist.getUri()); log.trace("Media playlist {}", src.getMediaPlaylistUrl()); 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, ENGLISH.getLanguage()) .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.replace("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, ENGLISH.getLanguage()) .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); if (json.optString("command").equals("8011")) { JSONObject data = json.getJSONObject("data"); log.trace("stream info:\n{}", data.toString(2)); streamHost = data.getString("stream_host"); online = true; isInteractiveShow = data.optString("devices").equals("1"); String roomState = data.optString("room_state"); onlineState = mapStatus(roomState); online = onlineState == State.ONLINE; 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.notifyAll(); } if (response != null) { response.close(); } } @Override public void onClosed(WebSocket webSocket, int code, String reason) { log.trace("Chat websocket for {} closed {} {}", getName(), code, reason); synchronized (monitor) { monitor.notifyAll(); } } }); synchronized (monitor) { monitor.wait(10_000); if (streamHost == null) { throw new RuntimeException("Couldn't determine streaming server for model " + getName()); } else { url = getSite().getBaseUrl() + "/ws/chat/get-stream-urls.php?" + "model_id=" + id + "&video_host=" + streamHost + "&t=" + System.currentTimeMillis(); log.debug("Loading master playlist information: {}", url); req = new Request.Builder() .url(url) .header(ACCEPT, "*/*") .header(ACCEPT_LANGUAGE, ENGLISH.getLanguage()) .header(REFERER, getUrl()) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(REFERER, getUrl()) .build(); try (Response response = getSite().getHttpClient().execute(req)) { JSONObject json = new JSONObject(Objects.requireNonNull(response.body(), "HTTP response body is null").string()); JSONArray hls = json.getJSONObject("data").getJSONArray("hls"); streamUrl = "https:" + hls.getJSONObject(0).getString("url"); log.debug("Stream URL is {}", streamUrl); } } } } @Override public void invalidateCacheEntries() { // nothing to do here } @Override public void receiveTip(Double tokens) throws IOException { try { // make sure we are logged in and all necessary model data is available getSite().login(); fetchStreamUrl(); // 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, ENGLISH.getLanguage()) .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) { Thread.currentThread().interrupt(); throw new IOException("Couldn't acquire request slot", e); } } private void fetchStreamUrl() throws InterruptedException, IOException { acquireSlot(); try { loadStreamUrl(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("Couldn't send tip", e); } finally { releaseSlot(); } } 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 && streamUrl != null && resolution[0] == 0) { try { List streamSources = getStreamSources(); Collections.sort(streamSources); StreamSource best = streamSources.get(streamSources.size() - 1); resolution = new int[]{best.getHeight(), best.getHeight()}; } catch (IOException | ParseException | PlaylistException e) { throw new ExecutionException("Couldn't determine stream resolution", e); } } return resolution; } public void setStreamResolution(int[] res) { this.resolution = res; } @Override public boolean follow() throws IOException { try { return changeFavoriteStatus(true); } catch (InterruptedException e) { Thread.currentThread().interrupt(); 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 (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("Couldn't change follow status for model " + getName(), e); } catch (ExecutionException 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=" + getName() + "&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()); } } } @Override public void readSiteSpecificData(Map data) { id = data.get("id"); } @Override public void writeSiteSpecificData(Map data) { data.put("id", id); } public boolean isNew() { return isNew; } public void setNew(boolean isNew) { this.isNew = isNew; } @Override public String getName() { String original = super.getName(); String fixed = original.toLowerCase().replace(" ", "-").replace("_", "-"); if (!fixed.equals(original)) { setName(fixed); } return fixed; } private void acquireSlot() throws InterruptedException { requestThrottle.acquire(); long now = System.currentTimeMillis(); long millisSinceLastRequest = now - lastRequest; if (millisSinceLastRequest < 500) { Thread.sleep(500 - millisSinceLastRequest); } } private void releaseSlot() { lastRequest = System.currentTimeMillis(); requestThrottle.release(); } }