package ctbrec.sites.mfc; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import org.jsoup.nodes.Element; 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.io.HtmlParser; import ctbrec.io.HttpException; import ctbrec.recorder.download.StreamSource; import okhttp3.FormBody; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; public class MyFreeCamsModel extends AbstractModel { private static final transient Logger LOG = LoggerFactory.getLogger(MyFreeCamsModel.class); private int uid = -1; // undefined private String hlsUrl; private double camScore; private int viewerCount; private ctbrec.sites.mfc.State state; private int resolution[] = new int[2]; /** * This constructor exists only for deserialization. Please don't call it directly */ public MyFreeCamsModel() {} MyFreeCamsModel(MyFreeCams site) { this.site = site; } @Override public boolean isOnline() throws IOException, ExecutionException, InterruptedException { MyFreeCamsClient.getInstance().update(this); return state == ctbrec.sites.mfc.State.ONLINE; } @Override public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { return isOnline(); } @Override public State getOnlineState(boolean failFast) throws IOException, ExecutionException { if(state == null) { return State.UNKNOWN; } switch(state) { case ONLINE: case RECORDING: return ctbrec.Model.State.ONLINE; case AWAY: return ctbrec.Model.State.AWAY; case PRIVATE: return ctbrec.Model.State.PRIVATE; case GROUP_SHOW: return ctbrec.Model.State.GROUP; case OFFLINE: case CAMOFF: return ctbrec.Model.State.OFFLINE; default: LOG.debug("State {} is not mapped", this.state); return ctbrec.Model.State.UNKNOWN; } } @Override public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { MasterPlaylist masterPlaylist = getMasterPlaylist(); List sources = new ArrayList<>(); for (PlaylistData playlist : masterPlaylist.getPlaylists()) { if (playlist.hasStreamInfo()) { StreamSource src = new StreamSource(); src.bandwidth = playlist.getStreamInfo().getBandwidth(); if(playlist.getStreamInfo().getResolution() != null) { src.width = playlist.getStreamInfo().getResolution().width; src.height = playlist.getStreamInfo().getResolution().height; } else { src.width = Integer.MAX_VALUE; src.height = Integer.MAX_VALUE; } String masterUrl = hlsUrl; String baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf('/') + 1); String segmentUri = baseUrl + playlist.getUri(); src.mediaPlaylistUrl = segmentUri; LOG.trace("Media playlist {}", src.mediaPlaylistUrl); sources.add(src); } } if(Config.getInstance().getSettings().mfcIgnoreUpscaled) { return sources.stream() .filter(src -> src.height != 960) .collect(Collectors.toList()); } else { return sources; } } private MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException { if(getHlsUrl() == null) { throw new IllegalStateException("Stream url unknown"); } LOG.trace("Loading master playlist {}", hlsUrl); Request req = new Request.Builder().url(hlsUrl).build(); try(Response response = site.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()); } } } private String getHlsUrl() { if(hlsUrl == null) { MyFreeCams mfc = (MyFreeCams) getSite(); mfc.getClient().update(this); } return hlsUrl; } @Override public void invalidateCacheEntries() { resolution = null; } @Override public void receiveTip(int tokens) throws IOException { String tipUrl = MyFreeCams.baseUrl + "/php/tip.php"; String initUrl = tipUrl + "?request=tip&username="+getName()+"&broadcaster_id="+getUid(); Request req = new Request.Builder().url(initUrl).build(); try(Response resp = site.getHttpClient().execute(req)) { if(resp.isSuccessful()) { String page = resp.body().string(); Element hiddenInput = HtmlParser.getTag(page, "input[name=token]"); String token = hiddenInput.attr("value"); if (!Objects.equals(System.getenv("CTBREC_DEV"), "1")) { RequestBody body = new FormBody.Builder() .add("token", token) .add("broadcaster_id", Integer.toString(uid)) .add("tip_value", Integer.toString(tokens)) .add("submit_tip", "1") .add("anonymous", "") .add("public", "1") .add("public_comment", "1") .add("hide_amount", "0") .add("silent", "") .add("comment", "") .add("mode", "") .add("submit", " Confirm & Close Window") .build(); req = new Request.Builder() .url(tipUrl) .post(body) .addHeader("Referer", initUrl) .build(); try(Response response = site.getHttpClient().execute(req)) { if(!response.isSuccessful()) { throw new HttpException(response.code(), response.message()); } } } } else { throw new HttpException(resp.code(), resp.message()); } } } @Override public int[] getStreamResolution(boolean failFast) throws ExecutionException { if (!failFast && hlsUrl != null) { try { List streamSources = getStreamSources(); Collections.sort(streamSources); StreamSource best = streamSources.get(streamSources.size() - 1); resolution = new int[] { best.width, best.height }; } catch (ParseException | PlaylistException e) { LOG.warn("Couldn't determine stream resolution - {}", e.getMessage()); } catch (ExecutionException | IOException e) { LOG.error("Couldn't determine stream resolution", e); } } return resolution; } public void setStreamUrl(String hlsUrl) { this.hlsUrl = hlsUrl; } public String getStreamUrl() { return hlsUrl; } public double getCamScore() { return camScore; } public void setCamScore(double camScore) { this.camScore = camScore; } public void setMfcState(ctbrec.sites.mfc.State state) { this.state = state; } @Override public void setName(String name) { if(getName() != null && name != null && !getName().equals(name)) { LOG.debug("Model name changed {} -> {}", getName(), name); setUrl("https://profiles.myfreecams.com/" + name); } super.setName(name); } public void update(SessionState state, String streamUrl) { uid = Integer.parseInt(state.getUid().toString()); setName(state.getNm()); setMfcState(ctbrec.sites.mfc.State.of(state.getVs())); setStreamUrl(streamUrl); Optional camScore = Optional.ofNullable(state.getM()).map(m -> m.getCamscore()); setCamScore(camScore.orElse(0.0)); // preview String uid = state.getUid().toString(); String uidStart = uid.substring(0, 3); String previewUrl = "https://img.mfcimg.com/photos2/"+uidStart+'/'+uid+"/avatar.300x300.jpg"; if(MyFreeCamsModel.this.state == ctbrec.sites.mfc.State.ONLINE) { try { previewUrl = getLivePreviewUrl(state); } catch(Exception e) { LOG.error("Couldn't get live preview. Falling back to avatar", e); } } setPreview(previewUrl); // tags Optional.ofNullable(state.getM()).map((m) -> m.getTags()).ifPresent((tags) -> { ArrayList t = new ArrayList<>(); t.addAll(tags); setTags(t); }); // description Optional.ofNullable(state.getM()).map((m) -> m.getTopic()).ifPresent((topic) -> { try { setDescription(URLDecoder.decode(topic, "utf-8")); } catch (UnsupportedEncodingException e) { LOG.warn("Couldn't url decode topic", e); } }); viewerCount = Optional.ofNullable(state.getM()).map((m) -> m.getRc()).orElseGet(() -> 0); } private String getLivePreviewUrl(SessionState state) { String previewUrl; int userChannel = 100000000 + state.getUid(); int camserv = state.getU().getCamserv(); String server = Integer.toString(camserv); ServerConfig sc = ((MyFreeCams)site).getClient().getServerConfig(); if(sc.isOnNgServer(state)) { server = sc.ngVideoServers.get(Integer.toString(camserv)); camserv = Integer.parseInt(server.replaceAll("[^0-9]+", "")); previewUrl = "https://snap.mfcimg.com/snapimg/" + camserv + "/320x240/mfc_" + state.getU().getPhase()+ '_' + userChannel; } else if(sc.isOnWzObsVideoServer(state)) { server = sc.wzobsServers.get(Integer.toString(camserv)); camserv = Integer.parseInt(server.replaceAll("[^0-9]+", "")); previewUrl = "https://snap.mfcimg.com/snapimg/" + camserv + "/320x240/mfc_" + state.getU().getPhase()+ '_' + userChannel; } else if(sc.isOnHtml5VideoServer(state)) { server = sc.h5Servers.get(Integer.toString(camserv)); camserv = Integer.parseInt(server.replaceAll("[^0-9]+", "")); previewUrl = "https://snap.mfcimg.com/snapimg/" + camserv + "/320x240/mfc_" + userChannel; } else { if(camserv > 500) camserv -= 500; previewUrl = "https://snap.mfcimg.com/snapimg/" + camserv + "/320x240/mfc_" + userChannel; } return previewUrl; } @Override public boolean follow() { return ((MyFreeCams)site).getClient().follow(getUid()); } @Override public boolean unfollow() { return ((MyFreeCams)site).getClient().unfollow(getUid()); } public int getUid() { return uid; } public void setUid(int uid) { this.uid = uid; } public int getViewerCount() { return viewerCount; } public void setViewerCount(int viewerCount) { this.viewerCount = viewerCount; } @Override public void readSiteSpecificData(JsonReader reader) throws IOException { reader.nextName(); uid = reader.nextInt(); } @Override public void writeSiteSpecificData(JsonWriter writer) throws IOException { writer.name("uid").value(uid); } }