Added support for new "secure" stream URLs format for Flirt4Free

This commit is contained in:
0xb00bface 2023-12-31 11:43:42 +01:00
parent 1d88bca3d5
commit b39fc69299
5 changed files with 184 additions and 190 deletions

View File

@ -15,6 +15,7 @@
* Cam4: Fixed stream URLs search. Slightly increased chances to find good one.
* Camsoda: Added "Voyeur" tab
* Chaturbate: Added "Gaming" tab
* Flirt4Free: Added support for new "secure" stream URLs format.
* Streamate:
- Fixed "Couldn't load model ID" error while adding models by URL or by
nickname

View File

@ -1,17 +1,5 @@
package ctbrec.ui.sites.flirt4free;
import static ctbrec.io.HttpClient.*;
import static ctbrec.io.HttpConstants.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutionException;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import ctbrec.Config;
import ctbrec.Model;
import ctbrec.io.HtmlParser;
@ -22,9 +10,20 @@ import ctbrec.ui.SiteUiFactory;
import ctbrec.ui.tabs.PaginatedScheduledService;
import javafx.concurrent.Task;
import okhttp3.Request;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutionException;
import static ctbrec.io.HttpClient.gunzipBody;
import static ctbrec.io.HttpConstants.*;
public class Flirt4FreeFavoritesUpdateService extends PaginatedScheduledService {
private Flirt4Free flirt4free;
private final Flirt4Free flirt4free;
public Flirt4FreeFavoritesUpdateService(Flirt4Free flirt4free) {
this.flirt4free = flirt4free;
@ -32,7 +31,7 @@ public class Flirt4FreeFavoritesUpdateService extends PaginatedScheduledService
@Override
protected Task<List<Model>> createTask() {
return new Task<List<Model>>() {
return new Task<>() {
@Override
public List<Model> call() throws IOException {
return loadModelList();
@ -65,7 +64,6 @@ public class Flirt4FreeFavoritesUpdateService extends PaginatedScheduledService
model.setDisplayName(img.attr("alt"));
model.setPreview(img.attr("src"));
model.setDescription("");
model.setOnline(modelHtml.contains("I'm Online"));
try {
model.setOnlineState(model.isOnline() ? Model.State.ONLINE : Model.State.OFFLINE);
} catch (InterruptedException e) {

View File

@ -18,7 +18,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static ctbrec.io.HttpClient.gunzipBody;
import static ctbrec.io.HttpConstants.*;
@ -62,7 +61,7 @@ public class Flirt4FreeUpdateService extends PaginatedScheduledService {
private List<Model> parseResponse(String body) throws IOException {
List<Flirt4FreeModel> models = new ArrayList<>();
var m = Pattern.compile("window\\.__homePageData__ = (\\{.*\\})", Pattern.DOTALL).matcher(body);
var m = Pattern.compile("window\\.__homePageData__ = (\\{.*})", Pattern.DOTALL).matcher(body);
if (m.find()) {
var data = new JSONObject(m.group(1));
var modelData = data.getJSONArray("models");
@ -80,7 +79,8 @@ public class Flirt4FreeUpdateService extends PaginatedScheduledService {
.filter(filter)
.skip((page - 1) * (long) MODELS_PER_PAGE)
.limit(MODELS_PER_PAGE)
.collect(Collectors.toList());
.map(Model.class::cast)
.toList();
} else {
throw new IOException("Pattern didn't match model JSON data");
}
@ -100,7 +100,6 @@ public class Flirt4FreeUpdateService extends PaginatedScheduledService {
model.setStreamUrl(streamUrl);
model.setPreview("https://live-screencaps.vscdns.com/" + modelId + "-desktop.jpg");
model.setOnlineState(ctbrec.Model.State.ONLINE);
model.setOnline(true);
if (modelData.has("category_id")) {
model.getCategories().add(modelData.getString("category_id"));
}

View File

@ -14,6 +14,7 @@ public class HttpConstants {
public static final String CONTENT_TYPE = "Content-Type";
public static final String COOKIE = "Cookie";
public static final String FORM_ENCODED = "application/x-www-form-urlencoded; charset=UTF-8";
public static final String HTTPS = "https";
public static final String KEEP_ALIVE = "keep-alive";
public static final String MIMETYPE_APPLICATION_JSON = "application/json";
public static final String MIMETYPE_IMAGE_JPG = "image/jpeg";

View File

@ -8,125 +8,125 @@ import com.iheartradio.m3u8.data.StreamInfo;
import ctbrec.AbstractModel;
import ctbrec.Config;
import ctbrec.Model;
import ctbrec.StringUtil;
import ctbrec.io.HttpException;
import ctbrec.recorder.download.StreamSource;
import ctbrec.sites.ModelOfflineException;
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.text.MessageFormat;
import java.time.Duration;
import java.time.Instant;
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 {
private static final String KEY_CONFIG = "config";
private static final String KEY_STATUS = "status";
@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;
private transient JSONObject modelInfo;
private transient JSONObject stateInfo;
private transient Instant lastInfoRequest = Instant.EPOCH;
private transient Instant lastStateRequest = Instant.EPOCH;
@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();
}
if (ignoreCache) {
JSONObject info = getStateInfo();
parseOnlineState(info);
}
return online;
return onlineState == Model.State.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")) {
private void parseOnlineState(JSONObject json) throws IOException {
if (json.optString(KEY_STATUS).equals("failed")) {
if (json.optString("message").equals("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;
int modelId = json.optInt("model_id");
if (modelId > 0) {
id = String.valueOf(modelId);
}
if (json.optString(KEY_STATUS).equals("online") && modelId > 0) {
getModelInfo();
} else {
onlineState = Model.State.OFFLINE;
}
}
private JSONObject getStateInfo() throws IOException {
if (Objects.nonNull(stateInfo) && Duration.between(lastStateRequest, Instant.now()).getSeconds() < 5) {
return stateInfo;
}
lastStateRequest = Instant.now();
stateInfo = loadStateInfo();
return stateInfo;
}
private JSONObject loadStateInfo() throws IOException {
String url = HTTPS + "://ws.vs3.com/rooms/check-model-status.php?model_name=" + getName();
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());
return json;
} else {
throw new HttpException(response.code(), response.message());
}
}
}
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 JSONObject getModelInfo() throws IOException {
if (Objects.nonNull(modelInfo) && Duration.between(lastInfoRequest, Instant.now()).getSeconds() < 5) {
return modelInfo;
}
lastInfoRequest = Instant.now();
modelInfo = loadModelInfo();
return modelInfo;
}
private void loadModelInfo() throws IOException {
private JSONObject 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()
@ -140,25 +140,16 @@ public class Flirt4FreeModel extends AbstractModel {
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");
if (json.optString(KEY_STATUS).equals("success")) {
JSONObject config = json.getJSONObject(KEY_CONFIG);
setDisplayName(config.getJSONObject("performer").optString("name", getName()));
userIp = config.getJSONObject("user").getString("ip");
onlineState = mapStatus(config.getJSONObject("room").optString(KEY_STATUS));
} else {
log.trace("Loading model info failed. Assuming model {} is offline", getName());
online = false;
onlineState = Model.State.OFFLINE;
}
return json;
} else {
throw new HttpException(response.code(), response.message());
}
@ -174,6 +165,7 @@ public class Flirt4FreeModel extends AbstractModel {
};
}
@Override
public List<StreamSource> getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException {
MasterPlaylist masterPlaylist;
try {
@ -197,7 +189,7 @@ public class Flirt4FreeModel extends AbstractModel {
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());
src.setMediaPlaylistUrl(HTTPS + "://" + masterPlaylistUrl.host() + '/' + playlist.getUri());
log.trace("Media playlist {}", src.getMediaPlaylistUrl());
sources.add(src);
}
@ -231,105 +223,102 @@ public class Flirt4FreeModel extends AbstractModel {
}
}
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();
private void loadStreamUrl() throws InterruptedException {
try {
JSONObject info = getModelInfo();
streamUrl = "";
getSite().getHttpClient().newWebSocket(req, new WebSocketListener() {
@Override
public void onOpen(WebSocket webSocket, Response response) {
log.trace("Chat websocket for {} opened", getName());
}
String chatHost = info.getJSONObject(KEY_CONFIG).getJSONObject("room").getString("host").replace("chat", "chat-vip");
String chatPort = info.getJSONObject(KEY_CONFIG).getJSONObject("room").getString("port");
String chatToken = info.getString("token_enc");
String url = HTTPS + "://" + chatHost + "/chat?token=" + URLEncoder.encode(chatToken, UTF_8) + "&port_to_be=" + chatPort;
@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;
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 onMessage(WebSocket webSocket, String text) {
log.trace("Chat websocket for {}: {}", getName(), text);
JSONObject json = new JSONObject(text);
if (json.optString("command").equals("8011")) {
try {
JSONObject data = json.getJSONObject("data");
JSONObject stream = data.getJSONObject("video_info")
.getJSONObject("hls")
.getJSONArray("providers")
.getJSONObject(0);
String streamHost = stream.optString("stream_host", "hls.vscdns.com");
String streamName = stream.optString("stream_name", "manifest.m3u8");
String streamKey = stream.optString("stream_key", "&");
if (!streamKey.startsWith("&")) {
streamUrl = MessageFormat.format(HTTPS + "://{0}/{1}?key={2}", streamHost, streamName, streamKey);
}
onlineState = mapStatus(data.optString("room_state"));
resolution[0] = Integer.parseInt(json.optString("stream_width", "0"));
resolution[1] = Integer.parseInt(json.optString("stream_height", "0"));
} catch (Exception e) {
log.trace("Can not get stream info from WS", e);
} finally {
webSocket.close(1000, "");
}
}
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);
}
@Override
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
log.trace("Chat websocket for {} failed", getName(), t);
if (response != null) {
response.close();
}
synchronized (monitor) {
monitor.notifyAll();
}
webSocket.close(1000, "");
}
@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 (StringUtil.isBlank(streamUrl)) {
if (isOnline(false)) {
String cdn = info.getJSONObject(KEY_CONFIG).getJSONObject("env").getString("cdn").split("\\.")[0].replace(HTTPS + "://", "");
streamUrl = MessageFormat.format(HTTPS + "://hls.vscdns.com/manifest.m3u8?key=nil&provider={0}&model_id={1}", cdn, id);
} else {
throw new ModelOfflineException(this);
}
}
log.debug("Stream URL is {}", streamUrl);
}
@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);
}
}
} catch (InterruptedException e) {
log.error("Interrupted while loading stream url");
Thread.currentThread().interrupt();
} catch (Exception e) {
throw new RuntimeException("Couldn't determine stream URL for model " + getName());
}
}
@Override
public void invalidateCacheEntries() {
// nothing to do here
stateInfo = null;
modelInfo = null;
lastInfoRequest = Instant.EPOCH;
lastStateRequest = Instant.EPOCH;
}
@Override
@ -340,10 +329,10 @@ public class Flirt4FreeModel extends AbstractModel {
fetchStreamUrl();
// send the tip
int giftId = isInteractiveShow ? 775 : 171;
int giftId = 171;
int amount = tokens.intValue();
log.debug("Sending tip of {} to {}", amount, getName());
String url = "https://ws.vs3.com/rooms/send-tip.php?" +
String url = HTTPS + "://ws.vs3.com/rooms/send-tip.php?" +
"gift_id=" + giftId +
"&num_credits=" + amount +
"&userId=" + getUserIdt() +
@ -432,7 +421,7 @@ public class Flirt4FreeModel extends AbstractModel {
try {
List<StreamSource> streamSources = getStreamSources();
Collections.sort(streamSources);
StreamSource best = streamSources.get(streamSources.size() - 1);
StreamSource best = streamSources.getLast();
resolution = new int[]{best.getHeight(), best.getHeight()};
} catch (IOException | ParseException | PlaylistException e) {
throw new ExecutionException("Couldn't determine stream resolution", e);
@ -451,7 +440,7 @@ public class Flirt4FreeModel extends AbstractModel {
return changeFavoriteStatus(true);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Couldn't change follow status for model " + getName(), e);
throw new IOException("Couldn't follow model " + getName(), e);
}
}
@ -462,9 +451,9 @@ public class Flirt4FreeModel extends AbstractModel {
return changeFavoriteStatus(false);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Couldn't change follow status for model " + getName(), e);
throw new IOException("Couldn't unfollow model " + getName(), e);
} catch (ExecutionException e) {
throw new IOException("Couldn't change follow status for model " + getName(), e);
throw new IOException("Couldn't unfollow model " + getName(), e);
}
}
@ -472,7 +461,7 @@ public class Flirt4FreeModel extends AbstractModel {
getSite().login();
acquireSlot();
try {
loadModelInfo();
getModelInfo();
} finally {
releaseSlot();
}
@ -528,6 +517,12 @@ public class Flirt4FreeModel extends AbstractModel {
return fixed;
}
@Override
public Instant getLastSeen() {
Instant lastSeen = super.getLastSeen();
return (lastSeen.equals(Instant.EPOCH)) ? getAddedTimestamp() : lastSeen;
}
private void acquireSlot() throws InterruptedException {
requestThrottle.acquire();
long now = System.currentTimeMillis();