package org.schabi.newpipe.extractor.services.youtube.extractors; import com.grack.nanojson.JsonArray; import com.grack.nanojson.JsonObject; import com.grack.nanojson.JsonWriter; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.MultiInfoItemsCollector; import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.channel.tabs.ChannelTabExtractor; import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs; import org.schabi.newpipe.extractor.downloader.Downloader; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ParsingException; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.localization.TimeAgoParser; import org.schabi.newpipe.extractor.services.youtube.YoutubeChannelHelper; import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeChannelTabLinkHandlerFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Optional; import static org.schabi.newpipe.extractor.services.youtube.YoutubeChannelHelper.getChannelResponse; import static org.schabi.newpipe.extractor.services.youtube.YoutubeChannelHelper.resolveChannelId; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.DISABLE_PRETTY_PRINT_PARAMETER; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.YOUTUBEI_V1_URL; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getJsonPostResponse; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getTextFromObject; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.prepareDesktopJsonBuilder; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; /** * A {@link ChannelTabExtractor} implementation for the YouTube service. * *

* It currently supports {@code Videos}, {@code Shorts}, {@code Live}, {@code Playlists} and * {@code Channels} tabs. *

*/ public class YoutubeChannelTabExtractor extends ChannelTabExtractor { /** * Whether the visitor data extracted from the initial channel response is required to be used * for continuations. * *

* A valid {@code visitorData} is required to get continuations of shorts in channels. *

* *

* It should be not used when it is not needed, in order to reduce YouTube's tracking. *

*/ private final boolean useVisitorData; private JsonObject jsonResponse; private String channelId; @Nullable private String visitorData; public YoutubeChannelTabExtractor(final StreamingService service, final ListLinkHandler linkHandler) { super(service, linkHandler); useVisitorData = getName().equals(ChannelTabs.SHORTS); } @Nonnull private String getChannelTabsParameters() throws ParsingException { final String name = getName(); switch (name) { case ChannelTabs.VIDEOS: return "EgZ2aWRlb3PyBgQKAjoA"; case ChannelTabs.SHORTS: return "EgZzaG9ydHPyBgUKA5oBAA%3D%3D"; case ChannelTabs.LIVESTREAMS: return "EgdzdHJlYW1z8gYECgJ6AA%3D%3D"; case ChannelTabs.ALBUMS: return "EghyZWxlYXNlc_IGBQoDsgEA"; case ChannelTabs.PLAYLISTS: return "EglwbGF5bGlzdHPyBgQKAkIA"; default: throw new ParsingException("Unsupported channel tab: " + name); } } @Override public void onFetchPage(@Nonnull final Downloader downloader) throws IOException, ExtractionException { channelId = resolveChannelId(super.getId()); final String params = getChannelTabsParameters(); final YoutubeChannelHelper.ChannelResponseData data = getChannelResponse(channelId, params, getExtractorLocalization(), getExtractorContentCountry()); jsonResponse = data.jsonResponse; channelId = data.channelId; if (useVisitorData) { visitorData = jsonResponse.getObject("responseContext").getString("visitorData"); } } @Nonnull @Override public String getUrl() throws ParsingException { try { return YoutubeChannelTabLinkHandlerFactory.getInstance() .getUrl("channel/" + getId(), List.of(getName()), ""); } catch (final ParsingException e) { return super.getUrl(); } } @Nonnull @Override public String getId() throws ParsingException { final String id = jsonResponse.getObject("header") .getObject("c4TabbedHeaderRenderer") .getString("channelId", ""); if (!id.isEmpty()) { return id; } final Optional carouselHeaderId = jsonResponse.getObject("header") .getObject("carouselHeaderRenderer") .getArray("contents") .stream() .filter(JsonObject.class::isInstance) .map(JsonObject.class::cast) .filter(item -> item.has("topicChannelDetailsRenderer")) .findFirst() .flatMap(item -> Optional.ofNullable(item.getObject("topicChannelDetailsRenderer") .getObject("navigationEndpoint") .getObject("browseEndpoint") .getString("browseId"))); if (carouselHeaderId.isPresent()) { return carouselHeaderId.get(); } if (!isNullOrEmpty(channelId)) { return channelId; } else { throw new ParsingException("Could not get channel ID"); } } protected String getChannelName() { final String metadataName = jsonResponse.getObject("metadata") .getObject("channelMetadataRenderer") .getString("title"); if (!isNullOrEmpty(metadataName)) { return metadataName; } return YoutubeChannelHelper.getChannelHeader(jsonResponse) .map(header -> { final Object title = header.json.get("title"); if (title instanceof String) { return (String) title; } else if (title instanceof JsonObject) { final String headerName = getTextFromObject((JsonObject) title); if (!isNullOrEmpty(headerName)) { return headerName; } } return ""; }) .orElse(""); } @Nonnull @Override public InfoItemsPage getInitialPage() throws IOException, ExtractionException { final MultiInfoItemsCollector collector = new MultiInfoItemsCollector(getServiceId()); JsonArray items = new JsonArray(); final Optional tab = getTabData(); if (tab.isPresent()) { final JsonObject tabContent = tab.get().getObject("content"); items = tabContent.getObject("sectionListRenderer") .getArray("contents") .getObject(0) .getObject("itemSectionRenderer") .getArray("contents") .getObject(0) .getObject("gridRenderer") .getArray("items"); if (items.isEmpty()) { items = tabContent.getObject("richGridRenderer") .getArray("contents"); if (items.isEmpty()) { items = tabContent.getObject("sectionListRenderer") .getArray("contents"); } } } // If a channel tab is fetched, the next page requires channel ID and name, as channel // streams don't have their channel specified. // We also need to set the visitor data here when it should be enabled, as it is required // to get continuations on some channel tabs, and we need a way to pass it between pages final List channelIds = useVisitorData && !isNullOrEmpty(visitorData) ? List.of(getChannelName(), getUrl(), visitorData) : List.of(getChannelName(), getUrl()); final JsonObject continuation = collectItemsFrom(collector, items, channelIds) .orElse(null); final Page nextPage = getNextPageFrom(continuation, channelIds); return new InfoItemsPage<>(collector, nextPage); } @Override public InfoItemsPage getPage(final Page page) throws IOException, ExtractionException { if (page == null || isNullOrEmpty(page.getUrl())) { throw new IllegalArgumentException("Page doesn't contain an URL"); } final List channelIds = page.getIds(); final MultiInfoItemsCollector collector = new MultiInfoItemsCollector(getServiceId()); final JsonObject ajaxJson = getJsonPostResponse("browse", page.getBody(), getExtractorLocalization()); final JsonObject sectionListContinuation = ajaxJson.getArray("onResponseReceivedActions") .stream() .filter(JsonObject.class::isInstance) .map(JsonObject.class::cast) .filter(jsonObject -> jsonObject.has("appendContinuationItemsAction")) .map(jsonObject -> jsonObject.getObject("appendContinuationItemsAction")) .findFirst() .orElse(new JsonObject()); final JsonObject continuation = collectItemsFrom(collector, sectionListContinuation.getArray("continuationItems"), channelIds) .orElse(null); return new InfoItemsPage<>(collector, getNextPageFrom(continuation, channelIds)); } Optional getTabData() { final String urlSuffix = YoutubeChannelTabLinkHandlerFactory.getUrlSuffix(getName()); return jsonResponse.getObject("contents") .getObject("twoColumnBrowseResultsRenderer") .getArray("tabs") .stream() .filter(JsonObject.class::isInstance) .map(JsonObject.class::cast) .filter(tab -> tab.has("tabRenderer")) .map(tab -> tab.getObject("tabRenderer")) .filter(tabRenderer -> tabRenderer.getObject("endpoint") .getObject("commandMetadata").getObject("webCommandMetadata") .getString("url", "").endsWith(urlSuffix)) .findFirst() // Check if tab has no content .filter(tabRenderer -> { final JsonArray tabContents = tabRenderer.getObject("content") .getObject("sectionListRenderer") .getArray("contents") .getObject(0) .getObject("itemSectionRenderer") .getArray("contents"); return tabContents.size() != 1 || !tabContents.getObject(0).has("messageRenderer"); }); } private Optional collectItemsFrom(@Nonnull final MultiInfoItemsCollector collector, @Nonnull final JsonArray items, @Nonnull final List channelIds) { return items.stream() .filter(JsonObject.class::isInstance) .map(JsonObject.class::cast) .map(item -> collectItem(collector, item, channelIds)) .reduce(Optional.empty(), (c1, c2) -> c1.or(() -> c2)); } private Optional collectItem(@Nonnull final MultiInfoItemsCollector collector, @Nonnull final JsonObject item, @Nonnull final List channelIds) { final TimeAgoParser timeAgoParser = getTimeAgoParser(); if (item.has("richItemRenderer")) { final JsonObject richItem = item.getObject("richItemRenderer") .getObject("content"); if (richItem.has("videoRenderer")) { getCommitVideoConsumer(collector, timeAgoParser, channelIds, richItem.getObject("videoRenderer")); } else if (richItem.has("reelItemRenderer")) { getCommitReelItemConsumer(collector, channelIds, richItem.getObject("reelItemRenderer")); } else if (richItem.has("playlistRenderer")) { getCommitPlaylistConsumer(collector, channelIds, richItem.getObject("playlistRenderer")); } } else if (item.has("gridVideoRenderer")) { getCommitVideoConsumer(collector, timeAgoParser, channelIds, item.getObject("gridVideoRenderer")); } else if (item.has("gridPlaylistRenderer")) { getCommitPlaylistConsumer(collector, channelIds, item.getObject("gridPlaylistRenderer")); } else if (item.has("shelfRenderer")) { return collectItem(collector, item.getObject("shelfRenderer") .getObject("content"), channelIds); } else if (item.has("itemSectionRenderer")) { return collectItemsFrom(collector, item.getObject("itemSectionRenderer") .getArray("contents"), channelIds); } else if (item.has("horizontalListRenderer")) { return collectItemsFrom(collector, item.getObject("horizontalListRenderer") .getArray("items"), channelIds); } else if (item.has("expandedShelfContentsRenderer")) { return collectItemsFrom(collector, item.getObject("expandedShelfContentsRenderer") .getArray("items"), channelIds); } else if (item.has("continuationItemRenderer")) { return Optional.ofNullable(item.getObject("continuationItemRenderer")); } return Optional.empty(); } private void getCommitVideoConsumer(@Nonnull final MultiInfoItemsCollector collector, @Nonnull final TimeAgoParser timeAgoParser, @Nonnull final List channelIds, @Nonnull final JsonObject jsonObject) { collector.commit( new YoutubeStreamInfoItemExtractor(jsonObject, timeAgoParser) { @Override public String getUploaderName() throws ParsingException { if (channelIds.size() >= 2) { return channelIds.get(0); } return super.getUploaderName(); } @Override public String getUploaderUrl() throws ParsingException { if (channelIds.size() >= 2) { return channelIds.get(1); } return super.getUploaderUrl(); } }); } private void getCommitReelItemConsumer(@Nonnull final MultiInfoItemsCollector collector, @Nonnull final List channelIds, @Nonnull final JsonObject jsonObject) { collector.commit( new YoutubeReelInfoItemExtractor(jsonObject) { @Override public String getUploaderName() throws ParsingException { if (channelIds.size() >= 2) { return channelIds.get(0); } return super.getUploaderName(); } @Override public String getUploaderUrl() throws ParsingException { if (channelIds.size() >= 2) { return channelIds.get(1); } return super.getUploaderUrl(); } }); } private void getCommitPlaylistConsumer(@Nonnull final MultiInfoItemsCollector collector, @Nonnull final List channelIds, @Nonnull final JsonObject jsonObject) { collector.commit( new YoutubePlaylistInfoItemExtractor(jsonObject) { @Override public String getUploaderName() throws ParsingException { if (channelIds.size() >= 2) { return channelIds.get(0); } return super.getUploaderName(); } @Override public String getUploaderUrl() throws ParsingException { if (channelIds.size() >= 2) { return channelIds.get(1); } return super.getUploaderUrl(); } }); } @Nullable private Page getNextPageFrom(final JsonObject continuations, final List channelIds) throws IOException, ExtractionException { if (isNullOrEmpty(continuations)) { return null; } final JsonObject continuationEndpoint = continuations.getObject("continuationEndpoint"); final String continuation = continuationEndpoint.getObject("continuationCommand") .getString("token"); final byte[] body = JsonWriter.string(prepareDesktopJsonBuilder(getExtractorLocalization(), getExtractorContentCountry(), useVisitorData && channelIds.size() >= 3 ? channelIds.get(2) : null) .value("continuation", continuation) .done()) .getBytes(StandardCharsets.UTF_8); return new Page(YOUTUBEI_V1_URL + "browse?" + DISABLE_PRETTY_PRINT_PARAMETER, null, channelIds, null, body); } /** * A {@link YoutubeChannelTabExtractor} for the {@code Videos} tab, if it has been already * fetched. */ public static final class VideosTabExtractor extends YoutubeChannelTabExtractor { private final JsonObject tabRenderer; private final String channelName; private final String channelId; private final String channelUrl; VideosTabExtractor(final StreamingService service, final ListLinkHandler linkHandler, final JsonObject tabRenderer, final String channelName, final String channelId, final String channelUrl) { super(service, linkHandler); this.tabRenderer = tabRenderer; this.channelName = channelName; this.channelId = channelId; this.channelUrl = channelUrl; } @Override public void onFetchPage(@Nonnull final Downloader downloader) { // Nothing to do, the initial data was already fetched and is stored in the link handler } @Nonnull @Override public String getId() throws ParsingException { return channelId; } @Nonnull @Override public String getUrl() throws ParsingException { return channelUrl; } @Override protected String getChannelName() { return channelName; } @Override Optional getTabData() { return Optional.of(tabRenderer); } } }