diff --git a/app/build.gradle b/app/build.gradle index 9ff0c9693..ae8f18993 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -81,6 +81,7 @@ android { } compileOptions { + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 } @@ -101,6 +102,7 @@ dependencies { implementation 'com.github.bumptech.glide:okhttp3-integration:4.16.0' implementation 'com.github.jahirfiquitiva:TextDrawable:1.0.3' implementation 'com.github.thegrizzlylabs:sardine-android:0.9' + implementation 'com.github.teamnewpipe:NewPipeExtractor:v0.24.5' implementation 'com.google.android.material:material:1.12.0' implementation 'com.google.zxing:core:3.3.0' implementation 'com.guolindev.permissionx:permissionx:1.8.0' @@ -123,4 +125,5 @@ dependencies { annotationProcessor 'androidx.room:room-compiler:2.6.1' annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' annotationProcessor 'org.greenrobot:eventbus-annotation-processor:3.3.1' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.4' } \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 72ad61d3f..51adc1085 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -54,6 +54,13 @@ # Nano -keep class fi.iki.elonen.** { *; } +# NewPipeExtractor +-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; } +-keep class org.mozilla.javascript.** { *; } +-keep class org.mozilla.classfile.ClassFileWriter +-dontwarn org.mozilla.javascript.tools.** +-dontwarn java.beans.** + # QuickJS -keep class com.fongmi.quickjs.method.** { *; } diff --git a/app/src/main/java/com/fongmi/android/tv/impl/NewPipeImpl.java b/app/src/main/java/com/fongmi/android/tv/impl/NewPipeImpl.java new file mode 100644 index 000000000..292b75077 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/impl/NewPipeImpl.java @@ -0,0 +1,69 @@ +package com.fongmi.android.tv.impl; + +import androidx.annotation.NonNull; + +import com.github.catvod.net.OkHttp; +import com.github.catvod.utils.Util; + +import org.schabi.newpipe.extractor.downloader.Downloader; +import org.schabi.newpipe.extractor.downloader.Request; +import org.schabi.newpipe.extractor.downloader.Response; +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import okhttp3.RequestBody; +import okhttp3.ResponseBody; + +public final class NewPipeImpl extends Downloader { + + private static class Loader { + static volatile NewPipeImpl INSTANCE = new NewPipeImpl(); + } + + public static NewPipeImpl get() { + return Loader.INSTANCE; + } + + @Override + public Response execute(@NonNull Request request) throws IOException, ReCaptchaException { + String httpMethod = request.httpMethod(); + String url = request.url(); + Map> headers = request.headers(); + byte[] dataToSend = request.dataToSend(); + + RequestBody requestBody = null; + if (dataToSend != null) { + requestBody = RequestBody.create(null, dataToSend); + } + + okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder().method(httpMethod, requestBody).url(url).addHeader("User-Agent", Util.CHROME); + + for (Map.Entry> pair : headers.entrySet()) { + String headerName = pair.getKey(); + List headerValueList = pair.getValue(); + if (headerValueList.size() > 1) { + requestBuilder.removeHeader(headerName); + for (String headerValue : headerValueList) { + requestBuilder.addHeader(headerName, headerValue); + } + } else if (headerValueList.size() == 1) { + requestBuilder.header(headerName, headerValueList.get(0)); + } + } + + okhttp3.Response response = OkHttp.client().newCall(requestBuilder.build()).execute(); + + if (response.code() == 429) { + response.close(); + throw new ReCaptchaException("reCaptcha Challenge requested", url); + } + + ResponseBody body = response.body(); + String responseBodyToReturn = body.string(); + String latestUrl = response.request().url().toString(); + return new Response(response.code(), response.message(), response.headers().toMultimap(), responseBodyToReturn, latestUrl); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/player/Source.java b/app/src/main/java/com/fongmi/android/tv/player/Source.java index 120556426..fe7094f38 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/Source.java +++ b/app/src/main/java/com/fongmi/android/tv/player/Source.java @@ -60,6 +60,9 @@ public class Source { if (Thunder.Parser.match(url)) { items.add(Thunder.Parser.get(url)); iterator.remove(); + } else if (Youtube.Parser.match(url)) { + items.add(Youtube.Parser.get(url)); + iterator.remove(); } } diff --git a/app/src/main/java/com/fongmi/android/tv/player/extractor/Youtube.java b/app/src/main/java/com/fongmi/android/tv/player/extractor/Youtube.java index 4987b14cc..a3a818f24 100644 --- a/app/src/main/java/com/fongmi/android/tv/player/extractor/Youtube.java +++ b/app/src/main/java/com/fongmi/android/tv/player/extractor/Youtube.java @@ -1,20 +1,43 @@ package com.fongmi.android.tv.player.extractor; +import android.util.Base64; + +import com.fongmi.android.tv.bean.Episode; +import com.fongmi.android.tv.impl.NewPipeImpl; import com.fongmi.android.tv.player.Source; -import com.github.catvod.net.OkHttp; -import com.github.catvod.utils.Json; -import com.github.catvod.utils.Util; -import com.google.common.net.HttpHeaders; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import okhttp3.Headers; +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.linkhandler.LinkHandler; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubePlaylistExtractor; +import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor; +import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubePlaylistLinkHandlerFactory; +import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeStreamLinkHandlerFactory; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.extractor.stream.VideoStream; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.Callable; +import java.util.regex.Pattern; public class Youtube implements Source.Extractor { + private static final String MPD = "\n" + "\n" + "%s\n" + "%s\n" + "\n" + ""; + private static final String ADAPT = "\n" + "\n" + "\n" + "%s\n" + "\n" + "\n" + "\n" + "\n" + ""; + private static final Pattern PATTERN_LIST = Pattern.compile("(youtube\\.com|youtu\\.be).*list="); + + public Youtube() { + NewPipe.init(NewPipeImpl.get(), Localization.fromLocale(Locale.getDefault())); + } + @Override public boolean match(String scheme, String host) { return host.contains("youtube.com") || host.contains("youtu.be"); @@ -22,17 +45,52 @@ public class Youtube implements Source.Extractor { @Override public String fetch(String url) throws Exception { - String html = OkHttp.newCall(url, Headers.of(HttpHeaders.USER_AGENT, Util.CHROME)).execute().body().string(); - Matcher matcher = Pattern.compile("var ytInitialPlayerResponse =(.*?\\});").matcher(html); - if (matcher.find()) return getHlsManifestUrl(matcher); - return ""; + LinkHandler handler = YoutubeStreamLinkHandlerFactory.getInstance().fromUrl(url); + YoutubeStreamExtractor extractor = new YoutubeStreamExtractor(ServiceList.YouTube, handler); + extractor.forceLocalization(NewPipe.getPreferredLocalization()); + extractor.fetchPage(); + return StreamType.LIVE_STREAM.equals(extractor.getStreamType()) ? extractor.getHlsUrl() : getMpd(extractor); + } + + private String getMpd(YoutubeStreamExtractor extractor) throws Exception { + StringBuilder video = new StringBuilder(); + StringBuilder audio = new StringBuilder(); + List audioFormats = extractor.getAudioStreams(); + List videoFormats = extractor.getVideoOnlyStreams(); + for (AudioStream format : audioFormats) audio.append(getAdaptationSet(format, getAudioParam(format))); + for (VideoStream format : videoFormats) video.append(getAdaptationSet(format, getVideoParam(format))); + String mpd = String.format(Locale.getDefault(), MPD, extractor.getLength(), extractor.getLength(), video, audio); + return "data:application/dash+xml;base64," + Base64.encodeToString(mpd.getBytes(), Base64.DEFAULT); } - private String getHlsManifestUrl(Matcher matcher) { - JsonObject object = Json.parse(matcher.group(1)).getAsJsonObject(); - JsonElement hlsManifestUrl = object.get("streamingData").getAsJsonObject().get("hlsManifestUrl"); - if (hlsManifestUrl.isJsonArray()) return hlsManifestUrl.getAsJsonArray().get(0).getAsString(); - return hlsManifestUrl.getAsString(); + private String getVideoParam(VideoStream format) { + return String.format(Locale.getDefault(), "height='%d' width='%d' frameRate='%d' maxPlayoutRate='1' startWithSAP='1'", format.getHeight(), format.getWidth(), format.getFps()); + } + + private String getAudioParam(AudioStream format) { + return String.format(Locale.getDefault(), "subsegmentAlignment='true' audioSamplingRate='%d'", format.getItagItem().getSampleRate()); + } + + private String getAdaptationSet(VideoStream format, String param) { + int iTag = format.getItag(); + int bitrate = format.getBitrate(); + String codecs = format.getCodec(); + String mimeType = format.getFormat().getMimeType(); + String url = format.getContent().replace("&", "&"); + String initRange = format.getInitStart() + "-" + format.getInitEnd(); + String indexRange = format.getIndexStart() + "-" + format.getIndexEnd(); + return String.format(Locale.getDefault(), ADAPT, "video", iTag, bitrate, codecs, mimeType, param, url, indexRange, initRange); + } + + private String getAdaptationSet(AudioStream format, String param) { + int iTag = format.getItag(); + int bitrate = format.getBitrate(); + String codecs = format.getCodec(); + String mimeType = format.getFormat().getMimeType(); + String url = format.getContent().replace("&", "&"); + String initRange = format.getInitStart() + "-" + format.getInitEnd(); + String indexRange = format.getIndexStart() + "-" + format.getIndexEnd(); + return String.format(Locale.getDefault(), ADAPT, "audio", iTag, bitrate, codecs, mimeType, param, url, indexRange, initRange); } @Override @@ -42,4 +100,50 @@ public class Youtube implements Source.Extractor { @Override public void exit() { } + + public static class Parser implements Callable> { + + private YoutubePlaylistExtractor extractor; + private final String url; + + public static boolean match(String url) { + return PATTERN_LIST.matcher(url).find(); + } + + public static Parser get(String url) { + return new Parser(url); + } + + public Parser(String url) { + this.url = url; + } + + @Override + public List call() { + try { + ListLinkHandler handler = YoutubePlaylistLinkHandlerFactory.getInstance().fromUrl(url); + extractor = new YoutubePlaylistExtractor(ServiceList.YouTube, handler); + extractor.forceLocalization(NewPipe.getPreferredLocalization()); + extractor.fetchPage(); + List episodes = new ArrayList<>(); + add(episodes, extractor.getInitialPage()); + return episodes; + } catch (Exception e) { + return Collections.emptyList(); + } + } + + private void add(List episodes, ListExtractor.InfoItemsPage page) { + for (StreamInfoItem item : page.getItems()) { + episodes.add(Episode.create(item.getName(), item.getUrl())); + } + if (page.hasNextPage()) { + try { + add(episodes, extractor.getPage(page.getNextPage())); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + } }