Add initial implementation for cherry.tv

This commit is contained in:
0xb00bface 2021-10-23 17:19:44 +02:00
parent 1142a15e9f
commit 5a86cfa85e
14 changed files with 835 additions and 131 deletions

View File

@ -22,6 +22,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import ctbrec.sites.cherrytv.CherryTv;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -174,6 +175,7 @@ public class CamrecApplication extends Application {
sites.add(new Cam4());
sites.add(new Camsoda());
sites.add(new Chaturbate());
sites.add(new CherryTv());
sites.add(new Fc2Live());
sites.add(new Flirt4Free());
sites.add(new LiveJasmin());
@ -193,7 +195,7 @@ public class CamrecApplication extends Application {
}
private void initSites() {
sites.stream().forEach(site -> {
sites.forEach(site -> {
try {
site.setRecorder(recorder);
site.setConfig(config);

View File

@ -6,6 +6,7 @@ import ctbrec.sites.bonga.BongaCams;
import ctbrec.sites.cam4.Cam4;
import ctbrec.sites.camsoda.Camsoda;
import ctbrec.sites.chaturbate.Chaturbate;
import ctbrec.sites.cherrytv.CherryTv;
import ctbrec.sites.fc2live.Fc2Live;
import ctbrec.sites.flirt4free.Flirt4Free;
import ctbrec.sites.jasmin.LiveJasmin;
@ -20,6 +21,7 @@ import ctbrec.ui.sites.bonga.BongaCamsSiteUi;
import ctbrec.ui.sites.cam4.Cam4SiteUi;
import ctbrec.ui.sites.camsoda.CamsodaSiteUi;
import ctbrec.ui.sites.chaturbate.ChaturbateSiteUi;
import ctbrec.ui.sites.cherrytv.CherryTvSiteUi;
import ctbrec.ui.sites.fc2live.Fc2LiveSiteUi;
import ctbrec.ui.sites.flirt4free.Flirt4FreeSiteUi;
import ctbrec.ui.sites.jasmin.LiveJasminSiteUi;
@ -37,6 +39,7 @@ public class SiteUiFactory {
private static Cam4SiteUi cam4SiteUi;
private static CamsodaSiteUi camsodaSiteUi;
private static ChaturbateSiteUi ctbSiteUi;
private static CherryTvSiteUi cherryTvSiteUi;
private static Fc2LiveSiteUi fc2SiteUi;
private static Flirt4FreeSiteUi flirt4FreeSiteUi;
private static LiveJasminSiteUi jasminSiteUi;
@ -49,7 +52,7 @@ public class SiteUiFactory {
private SiteUiFactory () {}
public static synchronized SiteUI getUi(Site site) {
public static synchronized SiteUI getUi(Site site) { // NOSONAR
if (site instanceof AmateurTv) {
if (amateurTvUi == null) {
amateurTvUi = new AmateurTvSiteUi((AmateurTv) site);
@ -75,6 +78,11 @@ public class SiteUiFactory {
ctbSiteUi = new ChaturbateSiteUi((Chaturbate) site);
}
return ctbSiteUi;
} else if (site instanceof CherryTv) {
if (cherryTvSiteUi == null) {
cherryTvSiteUi = new CherryTvSiteUi((CherryTv) site);
}
return cherryTvSiteUi;
} else if (site instanceof Fc2Live) {
if (fc2SiteUi == null) {
fc2SiteUi = new Fc2LiveSiteUi((Fc2Live) site);

View File

@ -0,0 +1,89 @@
package ctbrec.ui.sites.cherrytv;
import ctbrec.Config;
import ctbrec.sites.cam4.Cam4;
import ctbrec.sites.cherrytv.CherryTv;
import ctbrec.ui.DesktopIntegration;
import ctbrec.ui.settings.SettingsTab;
import ctbrec.ui.sites.AbstractConfigUI;
import javafx.geometry.Insets;
import javafx.scene.Parent;
import javafx.scene.control.*;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Priority;
public class CherryTvConfigUI extends AbstractConfigUI {
private final CherryTv site;
public CherryTvConfigUI(CherryTv cherryTv) {
this.site = cherryTv;
}
@Override
public Parent createConfigPanel() {
var layout = SettingsTab.createGridLayout();
var settings = Config.getInstance().getSettings();
var row = 0;
var l = new Label("Active");
layout.add(l, 0, row);
var enabled = new CheckBox();
enabled.setSelected(!settings.disabledSites.contains(site.getName()));
enabled.setOnAction(e -> {
if(enabled.isSelected()) {
settings.disabledSites.remove(site.getName());
} else {
settings.disabledSites.add(site.getName());
}
save();
});
GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
layout.add(enabled, 1, row++);
layout.add(new Label("Cam4 User"), 0, row);
var username = new TextField(Config.getInstance().getSettings().cam4Username);
username.textProperty().addListener((ob, o, n) -> {
if(!n.equals(Config.getInstance().getSettings().cam4Username)) {
Config.getInstance().getSettings().cam4Username = username.getText();
site.getHttpClient().logout();
save();
}
});
GridPane.setFillWidth(username, true);
GridPane.setHgrow(username, Priority.ALWAYS);
GridPane.setColumnSpan(username, 2);
layout.add(username, 1, row++);
layout.add(new Label("Cam4 Password"), 0, row);
var password = new PasswordField();
password.setText(Config.getInstance().getSettings().cam4Password);
password.textProperty().addListener((ob, o, n) -> {
if(!n.equals(Config.getInstance().getSettings().cam4Password)) {
Config.getInstance().getSettings().cam4Password = password.getText();
site.getHttpClient().logout();
save();
}
});
GridPane.setFillWidth(password, true);
GridPane.setHgrow(password, Priority.ALWAYS);
GridPane.setColumnSpan(password, 2);
layout.add(password, 1, row++);
var createAccount = new Button("Create new Account");
createAccount.setOnAction(e -> DesktopIntegration.open(Cam4.AFFILIATE_LINK));
layout.add(createAccount, 1, row++);
GridPane.setColumnSpan(createAccount, 2);
var deleteCookies = new Button("Delete Cookies");
deleteCookies.setOnAction(e -> site.getHttpClient().clearCookies());
layout.add(deleteCookies, 1, row);
GridPane.setColumnSpan(deleteCookies, 2);
GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
return layout;
}
}

View File

@ -0,0 +1,46 @@
package ctbrec.ui.sites.cherrytv;
import ctbrec.sites.cam4.Cam4;
import ctbrec.sites.cam4.Cam4HttpClient;
import ctbrec.sites.cherrytv.CherryTv;
import ctbrec.ui.controls.Dialogs;
import ctbrec.ui.sites.AbstractSiteUi;
import ctbrec.ui.sites.ConfigUI;
import ctbrec.ui.tabs.TabProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
public class CherryTvSiteUi extends AbstractSiteUi {
private static final Logger LOG = LoggerFactory.getLogger(CherryTvSiteUi.class);
private final CherryTv cherryTv;
private CherryTvTabProvider tabProvider;
private CherryTvConfigUI configUi;
public CherryTvSiteUi(CherryTv cherryTv) {
this.cherryTv = cherryTv;
}
@Override
public TabProvider getTabProvider() {
if (tabProvider == null) {
tabProvider = new CherryTvTabProvider(cherryTv);
}
return tabProvider;
}
@Override
public ConfigUI getConfigUI() {
if (configUi == null) {
configUi = new CherryTvConfigUI(cherryTv);
}
return configUi;
}
@Override
public synchronized boolean login() throws IOException {
return cherryTv.login();
}
}

View File

@ -0,0 +1,50 @@
package ctbrec.ui.sites.cherrytv;
import ctbrec.recorder.Recorder;
import ctbrec.sites.cherrytv.CherryTv;
import ctbrec.ui.tabs.TabProvider;
import ctbrec.ui.tabs.ThumbOverviewTab;
import javafx.scene.Scene;
import javafx.scene.control.Tab;
import java.util.ArrayList;
import java.util.List;
public class CherryTvTabProvider implements TabProvider {
private final CherryTv site;
private final Recorder recorder;
public CherryTvTabProvider(CherryTv cherryTv) {
this.site = cherryTv;
this.recorder = cherryTv.getRecorder();
}
@Override
public List<Tab> getTabs(Scene scene) {
List<Tab> tabs = new ArrayList<>();
tabs.add(createTab("Female", site.getBaseUrl() + "/graphql?operationName=findBroadcastsByPage&variables={\"slug\":\"female\",\"tag\":null,\"following\":null,\"limit\":50}&extensions={\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"f1e214ca901e525301fcc6966cf081ee95ef777974ec184897c4a2cf15e9ac6f\"}}"));
// tabs.add(createTab("Male", site.getBaseUrl() + "/directoryResults?online=true&gender=male&orderBy=MOST_VIEWERS"));
// tabs.add(createTab("Couples", site.getBaseUrl() + "/directoryResults?online=true&broadcastType=male_group&broadcastType=female_group&broadcastType=male_female_group&orderBy=MOST_VIEWERS"));
// tabs.add(createTab("HD", site.getBaseUrl() + "/directoryResults?online=true&hd=true&orderBy=MOST_VIEWERS"));
// tabs.add(createTab("New", site.getBaseUrl() + "/directoryResults?online=true&gender=female&orderBy=MOST_VIEWERS&newPerformer=true"));
return tabs;
}
@Override
public Tab getFollowedTab() {
return null;
}
private Tab createTab(String name, String url) {
var updateService = new CherryTvUpdateService(url, site);
var tab = new ThumbOverviewTab(name, updateService, site);
tab.setImageAspectRatio(9.0 / 16.0);
tab.preserveAspectRatioProperty().set(false);
tab.setRecorder(recorder);
return tab;
}
}

View File

@ -0,0 +1,115 @@
package ctbrec.ui.sites.cherrytv;
import com.apollographql.apollo.ApolloClient;
import ctbrec.Config;
import ctbrec.Model;
import ctbrec.io.HtmlParser;
import ctbrec.io.HttpException;
import ctbrec.sites.cherrytv.CherryTv;
import ctbrec.sites.cherrytv.CherryTvModel;
import ctbrec.ui.tabs.PaginatedScheduledService;
import javafx.concurrent.Task;
import okhttp3.Request;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.jsoup.nodes.Element;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static ctbrec.Model.State.*;
import static ctbrec.io.HttpConstants.ACCEPT_LANGUAGE;
import static ctbrec.io.HttpConstants.USER_AGENT;
public class CherryTvUpdateService extends PaginatedScheduledService {
private static final Logger LOG = LoggerFactory.getLogger(CherryTvUpdateService.class);
private String url;
private final CherryTv site;
private ApolloClient apolloClient;
public CherryTvUpdateService(String url, CherryTv site) {
this.site = site;
this.url = url;
ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
var t = new Thread(r);
t.setDaemon(true);
t.setName("CherryTvUpdateService");
return t;
});
setExecutor(executor);
apolloClient = ApolloClient.builder()
.serverUrl(site.getBaseUrl() + "/graphql")
.build();
}
@Override
protected Task<List<Model>> createTask() {
return new Task<>() {
@Override
public List<Model> call() throws IOException {
String pageUrl = CherryTvUpdateService.this.url;
LOG.debug("Fetching page {}", pageUrl);
apolloClient.
var request = new Request.Builder()
.url(pageUrl)
.header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage())
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
.build();
try (var response = site.getHttpClient().execute(request)) {
if (response.isSuccessful()) {
return parseModels(Objects.requireNonNull(response.body()).string());
} else {
throw new HttpException(response.code(), response.message());
}
}
}
};
}
private List<Model> parseModels(String body) {
var json = new JSONObject(body);
// LOG.debug(json.toString(2));
List<Model> models = new ArrayList<>();
try {
JSONArray broadcasts = json.getJSONObject("data").getJSONObject("broadcasts").getJSONArray("broadcasts");
for (int i = 0; i < broadcasts.length(); i++) {
JSONObject broadcast = broadcasts.getJSONObject(i);
CherryTvModel model = site.createModel(broadcast.optString("username"));
model.setDisplayName(broadcast.optString("title"));
model.setDescription(broadcast.optString("description"));
model.setPreview(broadcast.optString("thumbnailUrl"));
var online = broadcast.optString("showStatus").equalsIgnoreCase("Public")
&& broadcast.optString("broadcastStatus").equalsIgnoreCase("Live");
model.setOnline(online);
model.setOnlineState(online ? ONLINE : OFFLINE);
JSONArray tags = broadcast.optJSONArray("tags");
if (tags != null) {
for (int j = 0; j < tags.length(); j++) {
model.getTags().add(tags.getString(j));
}
}
models.add(model);
}
} catch (JSONException e) {
LOG.error("Couldn't parse JSON, the structure might have changed", e);
}
return models;
}
public void setUrl(String url) {
this.url = url;
}
}

View File

@ -50,6 +50,11 @@
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
</dependency>
<dependency>
<groupId>com.apollographql.apollo</groupId>
<artifactId>apollo-runtime</artifactId>
<version>2.5.9</version>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>

View File

@ -49,9 +49,9 @@ public class Config {
private static Config instance;
private Settings settings;
private String filename;
private List<Site> sites;
private File configDir;
private final String filename;
private final List<Site> sites;
private final File configDir;
/**
* to temporarily disable saving of the config
* this is useful for the SettingsTab, because setting the initial values of some components causes an immediate save
@ -96,7 +96,7 @@ public class Config {
fileContent[2] = ' ';
}
String json = new String(fileContent, UTF_8).trim();
settings = adapter.fromJson(json);
settings = Objects.requireNonNull(adapter.fromJson(json));
settings.httpTimeout = Math.max(settings.httpTimeout, 10_000);
if (settings.recordingsDir.endsWith("/")) {
settings.recordingsDir = settings.recordingsDir.substring(0, settings.recordingsDir.length() - 1);

View File

@ -19,7 +19,7 @@ public class Settings {
ONE_PER_MODEL("one directory for each model"),
ONE_PER_RECORDING("one directory for each recording");
private String description;
private final String description;
DirectoryStructure(String description) {
this.description = description;
}

View File

@ -0,0 +1,162 @@
package ctbrec.sites.cherrytv;
import ctbrec.Model;
import ctbrec.io.HttpClient;
import ctbrec.io.HttpException;
import ctbrec.sites.AbstractSite;
import okhttp3.HttpUrl;
import okhttp3.Request;
import okhttp3.Response;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static ctbrec.io.HttpConstants.*;
public class CherryTv extends AbstractSite {
private static final Logger LOG = LoggerFactory.getLogger(CherryTv.class);
public static final String BASE_URL = "https://cherry.tv";
private CherryTvHttpClient httpClient;
@Override
public String getName() {
return "CherryTV";
}
@Override
public String getBaseUrl() {
return BASE_URL;
}
@Override
public String getAffiliateLink() {
return getBaseUrl();
}
@Override
public CherryTvModel createModel(String name) {
CherryTvModel model = new CherryTvModel();
model.setName(name);
model.setUrl(getBaseUrl() + '/' + name);
model.setDescription("");
model.setSite(this);
return model;
}
@Override
public Double getTokenBalance() throws IOException {
return 0d;
}
@Override
public String getBuyTokensLink() {
return getAffiliateLink();
}
@Override
public synchronized boolean login() throws IOException {
return false;
}
@Override
public HttpClient getHttpClient() {
if (httpClient == null) {
httpClient = new CherryTvHttpClient(getConfig());
}
return httpClient;
}
@Override
public void init() throws IOException {
// nothing to do
}
@Override
public void shutdown() {
if (httpClient != null) {
httpClient.shutdown();
}
}
@Override
public boolean supportsTips() {
return false;
}
@Override
public boolean supportsFollow() {
return false;
}
@Override
public boolean supportsSearch() {
return true;
}
@Override
public boolean searchRequiresLogin() {
return false;
}
@Override
public List<Model> search(String q) throws IOException, InterruptedException {
JSONObject variables = new JSONObject().put("slug", q).put("limit", 10);
JSONObject persistedQuery = new JSONObject().put("persistedQuery", new JSONObject().put("version", 1).put("sha256Hash", ""));
Request req = new Request.Builder()
.url(new HttpUrl.Builder()
.scheme("https")
.host("cherry.tv")
.addPathSegment("qraphql")
.addQueryParameter("operationName", "Search")
.addQueryParameter("variables", variables.toString())
.addQueryParameter("extensions", persistedQuery.toString())
.build()
)
.header(USER_AGENT, getConfig().getSettings().httpUserAgent)
.header(ACCEPT, MIMETYPE_APPLICATION_JSON)
.header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage())
.header(REFERER, getBaseUrl())
.header(X_REQUESTED_WITH, XML_HTTP_REQUEST)
.build();
try (Response response = getHttpClient().execute(req)) {
if (response.isSuccessful()) {
LOG.debug("Response: {}", Objects.requireNonNull(response.body()).string());
} else {
throw new HttpException(response.code(), response.message());
}
}
return Collections.emptyList();
}
@Override
public boolean isSiteForModel(Model m) {
return m instanceof CherryTvModel;
}
@Override
public boolean credentialsAvailable() {
return false;
}
@Override
public Model createModelFromUrl(String url) {
Matcher m = Pattern.compile("https?://.*?cherry\\.tv/([^/]*?)/?").matcher(url);
if (m.matches()) {
String modelName = m.group(1);
return createModel(modelName);
} else {
return super.createModelFromUrl(url);
}
}
}

View File

@ -0,0 +1,18 @@
package ctbrec.sites.cherrytv;
import ctbrec.Config;
import ctbrec.io.HttpClient;
import java.io.IOException;
public class CherryTvHttpClient extends HttpClient {
public CherryTvHttpClient(Config config) {
super("cherrytv", config);
}
@Override
public synchronized boolean login() throws IOException {
return false;
}
}

View File

@ -0,0 +1,225 @@
package ctbrec.sites.cherrytv;
import com.iheartradio.m3u8.*;
import com.iheartradio.m3u8.data.MasterPlaylist;
import com.iheartradio.m3u8.data.Playlist;
import com.iheartradio.m3u8.data.PlaylistData;
import ctbrec.AbstractModel;
import ctbrec.Config;
import ctbrec.NotImplementedExcetion;
import ctbrec.io.HttpException;
import ctbrec.recorder.download.StreamSource;
import okhttp3.Request;
import okhttp3.Response;
import org.json.JSONException;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static ctbrec.Model.State.*;
import static ctbrec.io.HttpConstants.*;
import static java.nio.charset.StandardCharsets.UTF_8;
public class CherryTvModel extends AbstractModel {
private static final Logger LOG = LoggerFactory.getLogger(CherryTvModel.class);
private static final Pattern NEXT_DATA = Pattern.compile("<script id=\"__NEXT_DATA__\" type=\"application/json\">(.*?)</script>");
private boolean online = false;
private int[] resolution;
private String masterPlaylistUrl;
@Override
public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException {
if (ignoreCache) {
String url = getUrl();
Request req = new Request.Builder().url(url)
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
.header(ACCEPT, "*")
.header(ACCEPT_LANGUAGE, "en")
.header(REFERER, getSite().getBaseUrl())
.build();
try (Response resp = site.getHttpClient().execute(req)) {
String body = Objects.requireNonNull(resp.body()).string();
Files.write(Paths.get("/tmp/mdl.html"), body.getBytes(StandardCharsets.UTF_8));
Matcher m = NEXT_DATA.matcher(body);
if (m.find()) {
JSONObject json = new JSONObject(m.group(1));
JSONObject apolloState = json.getJSONObject("props").getJSONObject("pageProps").getJSONObject("apolloState");
online = false;
onlineState = OFFLINE;
for (Iterator<String> iter = apolloState.keys(); iter.hasNext();) {
String key = iter.next();
if (key.startsWith("Broadcast:")) {
JSONObject broadcast = apolloState.getJSONObject(key);
setDisplayName(broadcast.optString("title"));
// id = broadcast.getString("id");
// roomId = broadcast.getString("roomId");
online = broadcast.optString("showStatus").equalsIgnoreCase("Public")
&& broadcast.optString("broadcastStatus").equalsIgnoreCase("Live");
onlineState = online ? ONLINE : OFFLINE;
masterPlaylistUrl = broadcast.optString("pullUrl", null);
break;
}
}
} else {
LOG.error("NEXT_DATA not found in model page {}", getUrl());
return false;
}
} catch (JSONException e) {
LOG.error("Unable to determine online state for {}. Probably the JSON structure in NEXT_DATA changed", getName());
}
}
return online;
}
public void setOnline(boolean online) {
this.online = online;
}
@Override
public State getOnlineState(boolean failFast) throws IOException, ExecutionException {
if (!failFast) {
try {
isOnline(true);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
onlineState = OFFLINE;
} catch (IOException | ExecutionException e) {
onlineState = OFFLINE;
}
}
return onlineState;
}
@Override
public void setOnlineState(State onlineState) {
this.onlineState = onlineState;
}
@Override
public List<StreamSource> getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException {
try {
isOnline(true);
MasterPlaylist masterPlaylist = getMasterPlaylist();
List<StreamSource> 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;
String masterUrl = masterPlaylistUrl;
String baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf('/') + 1);
String segmentUri = baseUrl + playlist.getUri();
src.mediaPlaylistUrl = segmentUri;
if(src.mediaPlaylistUrl.contains("?")) {
src.mediaPlaylistUrl = src.mediaPlaylistUrl.substring(0, src.mediaPlaylistUrl.lastIndexOf('?'));
}
LOG.trace("Media playlist {}", src.mediaPlaylistUrl);
sources.add(src);
}
}
return sources;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ExecutionException(e);
}
}
private MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException {
LOG.trace("Loading master playlist {}", masterPlaylistUrl);
Request req = new Request.Builder()
.url(masterPlaylistUrl)
.header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent)
.build();
try (Response response = getSite().getHttpClient().execute(req)) {
if (response.isSuccessful()) {
String body = Objects.requireNonNull(response.body()).string();
LOG.trace(body);
InputStream inputStream = new ByteArrayInputStream(body.getBytes(UTF_8));
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());
}
}
}
@Override
public void invalidateCacheEntries() {
resolution = null;
}
@Override
public void receiveTip(Double tokens) throws IOException {
throw new NotImplementedExcetion();
}
@Override
public int[] getStreamResolution(boolean failFast) throws ExecutionException {
if(resolution == null) {
if(failFast) {
return new int[2];
}
try {
if(!isOnline()) {
return new int[2];
}
List<StreamSource> sources = getStreamSources();
Collections.sort(sources);
StreamSource best = sources.get(sources.size()-1);
resolution = new int[] {best.width, best.height};
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage());
resolution = new int[2];
} catch (ExecutionException | IOException | ParseException | PlaylistException e) {
LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage());
resolution = new int[2];
}
return resolution;
} else {
return resolution;
}
}
@Override
public boolean follow() throws IOException {
return false;
}
@Override
public boolean unfollow() throws IOException {
return false;
}
public void mapOnlineState(String roomState) {
switch (roomState) {
case "private":
case "fullprivate":
setOnlineState(PRIVATE);
break;
case "group":
case "public":
setOnlineState(ONLINE);
setOnline(true);
break;
default:
LOG.debug(roomState);
setOnlineState(OFFLINE);
}
}
}

View File

@ -1,58 +1,5 @@
package ctbrec.recorder.server;
import static java.nio.charset.StandardCharsets.*;
import static javax.servlet.http.HttpServletResponse.*;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Writer;
import java.net.BindException;
import java.net.URL;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import javax.servlet.DispatcherType;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jetty.security.ConstraintMapping;
import org.eclipse.jetty.security.ConstraintSecurityHandler;
import org.eclipse.jetty.security.HashLoginService;
import org.eclipse.jetty.security.SecurityHandler;
import org.eclipse.jetty.security.UserStore;
import org.eclipse.jetty.security.authentication.BasicAuthenticator;
import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.HttpConfiguration;
import org.eclipse.jetty.server.HttpConnectionFactory;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.server.handler.ErrorHandler;
import org.eclipse.jetty.server.handler.HandlerList;
import org.eclipse.jetty.server.handler.SecuredRedirectHandler;
import org.eclipse.jetty.servlet.FilterHolder;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.eclipse.jetty.util.security.Constraint;
import org.eclipse.jetty.util.security.Credential;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.base.Objects;
import ctbrec.Config;
import ctbrec.GlobalThreadPool;
import ctbrec.NotLoggedInExcetion;
@ -70,6 +17,7 @@ import ctbrec.sites.bonga.BongaCams;
import ctbrec.sites.cam4.Cam4;
import ctbrec.sites.camsoda.Camsoda;
import ctbrec.sites.chaturbate.Chaturbate;
import ctbrec.sites.cherrytv.CherryTv;
import ctbrec.sites.fc2live.Fc2Live;
import ctbrec.sites.flirt4free.Flirt4Free;
import ctbrec.sites.jasmin.LiveJasmin;
@ -79,17 +27,45 @@ import ctbrec.sites.showup.Showup;
import ctbrec.sites.streamate.Streamate;
import ctbrec.sites.stripchat.Stripchat;
import ctbrec.sites.xlovecam.XloveCam;
import org.eclipse.jetty.security.*;
import org.eclipse.jetty.security.authentication.BasicAuthenticator;
import org.eclipse.jetty.server.*;
import org.eclipse.jetty.server.handler.ErrorHandler;
import org.eclipse.jetty.server.handler.HandlerList;
import org.eclipse.jetty.server.handler.SecuredRedirectHandler;
import org.eclipse.jetty.servlet.FilterHolder;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.eclipse.jetty.util.security.Constraint;
import org.eclipse.jetty.util.security.Credential;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.*;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.BindException;
import java.net.URL;
import java.util.*;
import static java.nio.charset.StandardCharsets.UTF_8;
import static javax.servlet.http.HttpServletResponse.SC_MOVED_PERMANENTLY;
import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
public class HttpServer {
private static final Logger LOG = LoggerFactory.getLogger(HttpServer.class);
private Recorder recorder;
private OnlineMonitor onlineMonitor;
private Config config;
private final Recorder recorder;
private final OnlineMonitor onlineMonitor;
private final Config config;
private final List<Site> sites = new ArrayList<>();
private Server server = new Server();
private List<Site> sites = new ArrayList<>();
public HttpServer() throws Exception {
public HttpServer() throws IOException {
logEnvironment();
createSites();
System.setProperty("ctbrec.server.mode", "1");
@ -156,6 +132,7 @@ public class HttpServer {
sites.add(new Cam4());
sites.add(new Camsoda());
sites.add(new Chaturbate());
sites.add(new CherryTv());
sites.add(new Fc2Live());
sites.add(new Flirt4Free());
sites.add(new LiveJasmin());
@ -168,32 +145,29 @@ public class HttpServer {
}
private void addShutdownHook() {
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
LOG.info("Shutting down");
if (onlineMonitor != null) {
onlineMonitor.shutdown();
}
if (recorder != null) {
recorder.shutdown(false);
}
try {
server.stop();
} catch (Exception e) {
LOG.error("Couldn't stop HTTP server", e);
}
try {
Config.getInstance().save();
} catch (IOException e) {
LOG.error("Couldn't save configuration", e);
}
LOG.info("Goodbye!");
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOG.info("Shutting down");
if (onlineMonitor != null) {
onlineMonitor.shutdown();
}
});
if (recorder != null) {
recorder.shutdown(false);
}
try {
server.stop();
} catch (Exception e) {
LOG.error("Couldn't stop HTTP server", e);
}
try {
Config.getInstance().save();
} catch (IOException e) {
LOG.error("Couldn't save configuration", e);
}
LOG.info("Goodbye!");
}));
}
private void startHttpServer() throws Exception {
private void startHttpServer() {
server = new Server();
HttpConfiguration httpConfig = new HttpConfiguration();
@ -204,7 +178,7 @@ public class HttpServer {
SslContextFactory sslContextFactory = new SslContextFactory.Server();
URL keyStoreUrl = getClass().getResource("/keystore.pkcs12");
String keyStoreSrc = System.getProperty("keystore.file", keyStoreUrl.toExternalForm());
String keyStoreSrc = System.getProperty("keystore.file", Objects.requireNonNull(keyStoreUrl).toExternalForm());
String keyStorePassword = System.getProperty("keystore.password", "ctbrecsucks");
sslContextFactory.setKeyStorePath(keyStoreSrc);
sslContextFactory.setKeyStorePassword(keyStorePassword);
@ -244,35 +218,7 @@ public class HttpServer {
defaultContext.addServlet(holder, "/hls/*");
if (this.config.getSettings().webinterface) {
StaticFileServlet staticFileServlet = new StaticFileServlet("/html");
holder = new ServletHolder(staticFileServlet);
String staticFileContext = "/static/*";
defaultContext.addServlet(holder, staticFileContext);
LOG.info("Register static file servlet under {}", defaultContext.getContextPath()+staticFileContext);
// servlet to retrieve the HMAC (secured by basic auth if an hmac key is set in the config)
String username = this.config.getSettings().webinterfaceUsername;
String password = this.config.getSettings().webinterfacePassword;
if (config.getSettings().key != null && config.getSettings().key.length > 0) {
basicAuthContext.setSecurityHandler(basicAuth(username, password, "CTB Recorder"));
}
basicAuthContext.addServlet(new ServletHolder(new HttpServlet() {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException {
if (Objects.equal(username, req.getRemoteUser())) {
resp.setStatus(HttpServletResponse.SC_OK);
resp.setContentType("application/json");
byte[] hmac = Optional.ofNullable(HttpServer.this.config.getSettings().key).orElse(new byte[0]);
try {
JSONObject response = new JSONObject();
response.put("hmac", new String(hmac, UTF_8));
resp.getOutputStream().println(response.toString());
} catch (Exception e) {
resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
}
}), "/hmac");
startWebInterface(defaultContext, basicAuthContext);
}
server.addConnector(http);
@ -291,6 +237,10 @@ public class HttpServer {
} catch (BindException e) {
LOG.error("Port {} is already in use", http.getPort(), e);
System.exit(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.error("Server start failed", e);
System.exit(1);
} catch (Exception e) {
LOG.error("Server start failed", e);
System.exit(1);
@ -298,6 +248,38 @@ public class HttpServer {
}
}
private void startWebInterface(ServletContextHandler defaultContext, ServletContextHandler basicAuthContext) {
StaticFileServlet staticFileServlet = new StaticFileServlet("/html");
ServletHolder holder = new ServletHolder(staticFileServlet);
String staticFileContext = "/static/*";
defaultContext.addServlet(holder, staticFileContext);
LOG.info("Register static file servlet under {}", defaultContext.getContextPath()+staticFileContext);
// servlet to retrieve the HMAC (secured by basic auth if a hmac key is set in the config)
String username = this.config.getSettings().webinterfaceUsername;
String password = this.config.getSettings().webinterfacePassword;
if (config.getSettings().key != null && config.getSettings().key.length > 0) {
basicAuthContext.setSecurityHandler(basicAuth(username, password));
}
basicAuthContext.addServlet(new ServletHolder(new HttpServlet() {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
if (Objects.equals(username, req.getRemoteUser())) {
resp.setStatus(HttpServletResponse.SC_OK);
resp.setContentType("application/json");
byte[] hmac = Optional.ofNullable(HttpServer.this.config.getSettings().key).orElse(new byte[0]);
try {
JSONObject response = new JSONObject();
response.put("hmac", new String(hmac, UTF_8));
resp.getOutputStream().println(response.toString());
} catch (Exception e) {
resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
}
}), "/hmac");
}
private ErrorHandler createErrorHandler(String contextPath) {
return new ErrorHandler() {
@Override
@ -325,7 +307,7 @@ public class HttpServer {
private void addHttpHeaderFilter(ServletContextHandler defaultContext) {
FilterHolder httpHeaderFilter = new FilterHolder(new Filter() {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
public void init(FilterConfig filterConfig) {
// noop
}
@ -343,7 +325,8 @@ public class HttpServer {
defaultContext.addFilter(httpHeaderFilter, "/*", EnumSet.of(DispatcherType.REQUEST, DispatcherType.INCLUDE));
}
private static final SecurityHandler basicAuth(String username, String password, String realm) {
private static SecurityHandler basicAuth(String username, String password) {
String realm = "CTB Recorder";
UserStore userStore = new UserStore();
userStore.addUser(username, Credential.getCredential(password), new String[] { "user" });
HashLoginService l = new HashLoginService();
@ -361,7 +344,7 @@ public class HttpServer {
ConstraintSecurityHandler csh = new ConstraintSecurityHandler();
csh.setAuthenticator(new BasicAuthenticator());
csh.setRealmName("myrealm");
csh.setRealmName(realm);
csh.addConstraintMapping(cm);
csh.setLoginService(l);
@ -389,7 +372,7 @@ public class HttpServer {
private Version getVersion() throws IOException {
try (InputStream is = getClass().getClassLoader().getResourceAsStream("version")) {
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
BufferedReader reader = new BufferedReader(new InputStreamReader(Objects.requireNonNull(is)));
String versionString = reader.readLine();
Version version = Version.of(versionString);
return version;

View File

@ -162,13 +162,14 @@
<td data-bind="text: ko_progressString"></td>
<td data-bind="text: ko_size"></td>
<td>
<button class="btn btn-secondary fa fa-play" title="Play recording" data-bind="enable: ko_status() == 'FINISHED', click: play"></button>
<button class="btn btn-secondary fa fa-play" title="Play recording" data-bind="click: play"></button>
</td>
<td>
<button class="btn btn-secondary fa fa-download" title="Download recording" data-bind="enable: ko_status() == 'FINISHED' && singleFile, click: download"></button>
</td>
<td>
<button class="btn btn-secondary fa fa-trash" title="Delete recording" data-bind="enable: (ko_status() == 'FINISHED' || ko_status() == 'WAITING'), click: ctbrec.deleteRecording"></button>
<button class="btn btn-secondary fa fa-trash" title="Delete recording" data-bind="enable: (ko_status() == 'FINISHED' || ko_status() == 'WAITING' || ko_status() == 'FAILED'),
click: ctbrec.deleteRecording"></button>
</td>
</tr>
</tbody>
@ -259,8 +260,8 @@
let observableRecordingsArray = ko.observableArray();
let observableSettingsArray = ko.observableArray();
let space = {
free: ko.observable(0),
total: ko.observable(0),
free: ko.observable(0),
total: ko.observable(0),
percent: ko.observable(0),
text: ko.observable('')
};
@ -280,8 +281,8 @@
});
} else {
$('#addModelByUrl').autocomplete({
source: ["BongaCams:", "Cam4:", "Camsoda:", "Chaturbate:", "Fc2Live:", "Flirt4Free:", "LiveJasmin:", "MyFreeCams:", "MVLive:", "Showup:", "Streamate:", "Stripchat:"]
});
source: ["BongaCams:", "Cam4:", "Camsoda:", "Chaturbate:", "CherryTv:", "Fc2Live:", "Flirt4Free:", "LiveJasmin:", "MyFreeCams:", "MVLive:", "Showup:", "Streamate:", "Stripchat:"]
});
}
}
@ -291,7 +292,7 @@
let model = {
type: null,
name: '',
url: input
url: input
};
if(console) console.log(model);
@ -442,7 +443,7 @@
$(document).ready(function() {
if (localStorage !== undefined && localStorage.hmac !== undefined) {
if(console) console.log('using hmac from local storage');
hmac = localStorage.hmac;
hmac = localStorage.hmac;
} else {
if(console) console.log('hmac not found in local storage. requesting hmac from server');
$.ajax({
@ -461,7 +462,7 @@
})
.fail(function(jqXHR, textStatus, errorThrown) {
if(console) console.log(textStatus, errorThrown);
$.notify('Couldn\'t get HMAC', 'error');
$.notify('Could not get HMAC', 'error');
hmac = '';
});
}