package ctbrec.sites.winktv; 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.io.HttpException; import ctbrec.recorder.download.RecordingProcess; import ctbrec.recorder.download.StreamSource; import ctbrec.recorder.download.hls.HlsdlDownload; import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload; // import lombok.Getter; // import lombok.Setter; import lombok.extern.slf4j.Slf4j; import okhttp3.FormBody; import okhttp3.Request; import okhttp3.Response; import org.json.JSONObject; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutionException; import static ctbrec.Model.State.*; import static ctbrec.io.HttpConstants.*; import java.nio.charset.StandardCharsets; @Slf4j public class WinkTvModel extends AbstractModel { private static final String KEY_MEDIA = "media"; private int[] resolution = new int[]{0, 0}; private boolean adult = false; private transient JSONObject modelInfo; private transient Instant lastInfoRequest = Instant.EPOCH; @Override public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { if (ignoreCache) { try { JSONObject json = getModelInfo(); log.debug("WinkTvModel-isOnline: {}", json.toString()); if (json.has(KEY_MEDIA)) { JSONObject media = json.getJSONObject(KEY_MEDIA); boolean isLive = media.optBoolean("isLive"); String meType = media.optString("type"); if (isLive && meType.equals("free")) { setOnlineState(ONLINE); } else { setOnlineState(PRIVATE); } } else { setOnlineState(OFFLINE); } } catch (Exception e) { setOnlineState(UNKNOWN); } } return onlineState == ONLINE; } @Override public State getOnlineState(boolean failFast) throws IOException, ExecutionException { if (failFast && onlineState != UNKNOWN) { return onlineState; } try { onlineState = isOnline(true) ? ONLINE : OFFLINE; } catch (InterruptedException e) { Thread.currentThread().interrupt(); onlineState = OFFLINE; } catch (IOException | ExecutionException e) { onlineState = OFFLINE; } return onlineState; } @Override public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { String url = getMasterPlaylistUrl(); MasterPlaylist masterPlaylist = getMasterPlaylist(url); List streamSources = extractStreamSources(masterPlaylist); return streamSources; } private List extractStreamSources(MasterPlaylist masterPlaylist) { List sources = new ArrayList<>(); for (PlaylistData playlist : masterPlaylist.getPlaylists()) { if (playlist.hasStreamInfo()) { StreamSource src = new StreamSource(); src.setBandwidth(playlist.getStreamInfo().getBandwidth()); src.setHeight(Optional.ofNullable(playlist.getStreamInfo().getResolution()) .map(res -> res.height) .orElse(0)); src.setWidth(Optional.ofNullable(playlist.getStreamInfo().getResolution()) .map(res -> res.width) .orElse(0)); src.setMediaPlaylistUrl(playlist.getUri()); log.trace("Media playlist {}", src.getMediaPlaylistUrl()); sources.add(src); } } return sources; } private MasterPlaylist getMasterPlaylist(String url) throws IOException, ParseException, PlaylistException { log.trace("Loading master playlist {}", url); Request req = new Request.Builder() .url(url) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(ACCEPT, "*/*") .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) .header(REFERER, this.getUrl()) .header(ORIGIN, this.getSite().getBaseUrl()) .build(); try (Response response = getSite().getHttpClient().execute(req)) { if (response.isSuccessful()) { String body = response.body().string(); InputStream inputStream = new ByteArrayInputStream(body.getBytes(StandardCharsets.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()); } } } private String getMasterPlaylistUrl() throws IOException { JSONObject json = getModelInfo(); if (json.has("PlayList")) { JSONObject playlist = json.getJSONObject("PlayList"); JSONObject hls = playlist.getJSONArray("hls").getJSONObject(0); String hlsUrl = hls.optString("url") + "&player_version=1.20.0"; return hlsUrl; } throw new IOException("Master playlist URL not found"); } @Override public int[] getStreamResolution(boolean failFast) throws ExecutionException { if (!failFast) { try { List sources = getStreamSources(); if (!sources.isEmpty()) { StreamSource best = sources.get(sources.size() - 1); resolution = new int[]{best.getWidth(), best.getHeight()}; } } catch (IOException | ParseException | PlaylistException e) { throw new ExecutionException(e); } } return resolution; } private JSONObject getModelInfo() throws IOException { if (Objects.nonNull(modelInfo) && Duration.between(lastInfoRequest, Instant.now()).getSeconds() < 5L) { return modelInfo; } lastInfoRequest = Instant.now(); modelInfo = loadModelInfo(); return modelInfo; } private JSONObject loadModelInfo() throws IOException { String url = "https://api.winktv.co.kr/v1/live/play"; FormBody body = new FormBody.Builder() .add("userId", getName()) .add("action", "watch") .add("password", "") .add("shareLinkType", "") .build(); Request req = new Request.Builder() .url(url) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(ACCEPT, MIMETYPE_APPLICATION_JSON) .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) .header(REFERER, getUrl()) .header(ORIGIN, getSite().getBaseUrl()) .post(body) .build(); try (Response response = getSite().getHttpClient().execute(req)) { if (response.isSuccessful()) { JSONObject jsonResponse = new JSONObject(response.body().string()); log.debug("WinkTvModel-loadModelInfo: {}", jsonResponse.toString()); return jsonResponse; } else { throw new HttpException(response.code(), response.message()); } } } public String getPreviewURL() throws IOException { JSONObject json = this.getModelInfo(); if (json.has("media")) { JSONObject media = json.getJSONObject("media"); return media.optString("ivsThumbnail"); } if (json.has("bjInfo")) { JSONObject info = json.getJSONObject("bjInfo"); return info.optString("thumbUrl"); } return ""; } @Override public boolean follow() throws IOException { return false; } @Override public boolean unfollow() throws IOException { return false; } public boolean isAdult() { return adult; } public void setAdult(boolean a) { adult = a; } @Override public void receiveTip(Double tokens) throws IOException { // not implemented } @Override public void invalidateCacheEntries() { resolution = new int[]{0, 0}; lastInfoRequest = Instant.EPOCH; modelInfo = null; } @Override public RecordingProcess createDownload() { if (Config.getInstance().getSettings().useHlsdl) { return new HlsdlDownload(); } return new MergedFfmpegHlsDownload(new WinkTvHttpClient(Config.getInstance())); } }