jafea7-ctbrec-v5.3.0-based/common/src/main/java/ctbrec/sites/flirt4free/Flirt4FreeModel.java

545 lines
22 KiB
Java

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<String> 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<StreamSource> 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<StreamSource> 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<StreamSource> 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<String, String> data) {
id = data.get("id");
}
@Override
public void writeSiteSpecificData(Map<String, String> 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();
}
}