diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/comments/CommentsExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/comments/CommentsExtractor.java index 1402b1d234..c5de3f4bf9 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/comments/CommentsExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/comments/CommentsExtractor.java @@ -22,6 +22,23 @@ public boolean isCommentsDisabled() throws ExtractionException { return false; } + /** + * @apiNote Warning: This method is experimental and may get removed in a future release. + * @return true if the comments source is a live chat + * otherwise false (default) + */ + public boolean isLiveChat() throws ExtractionException { + return false; + } + + /** + * @apiNote Warning: This method is experimental and may get removed in a future release. + * Configures this extractor to fetch live chat messages using the given continuation. + */ + public void setLiveChatContinuation(final String continuation) { + // no-op by default + } + /** * @return the total number of comments */ diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/comments/CommentsInfo.java b/extractor/src/main/java/org/schabi/newpipe/extractor/comments/CommentsInfo.java index f50d6bd998..5dcae1ffc2 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/comments/CommentsInfo.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/comments/CommentsInfo.java @@ -47,6 +47,7 @@ public static CommentsInfo getInfo(final CommentsExtractor commentsExtractor) final InfoItemsPage initialCommentsPage = ExtractorHelper.getItemsPageOrLogError(commentsInfo, commentsExtractor); commentsInfo.setCommentsDisabled(commentsExtractor.isCommentsDisabled()); + commentsInfo.setLiveChat(commentsExtractor.isLiveChat()); commentsInfo.setRelatedItems(initialCommentsPage.getItems()); try { commentsInfo.setCommentsCount(commentsExtractor.getCommentsCount()); @@ -81,6 +82,7 @@ public static InfoItemsPage getMoreItems( private transient CommentsExtractor commentsExtractor; private boolean commentsDisabled = false; + private boolean liveChat = false; private int commentsCount; public CommentsExtractor getCommentsExtractor() { @@ -106,6 +108,22 @@ public void setCommentsDisabled(final boolean commentsDisabled) { this.commentsDisabled = commentsDisabled; } + /** + * @apiNote Warning: This method is experimental and may get removed in a future release. + * @return {@code true} if the comments are from a live chat otherwise {@code false} (default) + */ + public boolean isLiveChat() { + return liveChat; + } + + /** + * @apiNote Warning: This method is experimental and may get removed in a future release. + * @param liveChat {@code true} if the comments are from a live chat otherwise {@code false} + */ + public void setLiveChat(final boolean liveChat) { + this.liveChat = liveChat; + } + /** * Returns the total number of comments. * diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/ClientsConstants.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/ClientsConstants.java index b5b7324797..f5ee793252 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/ClientsConstants.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/ClientsConstants.java @@ -110,6 +110,16 @@ private ClientsConstants() { */ static final String ANDROID_CLIENT_VERSION = "21.03.36"; + // ANDROID_VR (Meta Quest YouTube app) client fields + + static final String ANDROID_VR_CLIENT_ID = "28"; + static final String ANDROID_VR_CLIENT_NAME = "ANDROID_VR"; + static final String ANDROID_VR_CLIENT_VERSION = "1.65.10"; + static final String ANDROID_VR_DEVICE_MAKE = "Oculus"; + static final String ANDROID_VR_DEVICE_MODEL = "Quest 3"; + static final String ANDROID_VR_OS_VERSION = "12L"; + static final int ANDROID_VR_SDK_VERSION = 32; + // visionOS client fields static final String VISIONOS_CLIENT_ID = "101"; diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/InnertubeClientRequestInfo.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/InnertubeClientRequestInfo.java index 80f84bde83..b2dcd7a183 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/InnertubeClientRequestInfo.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/InnertubeClientRequestInfo.java @@ -6,6 +6,13 @@ import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.ANDROID_CLIENT_ID; import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.ANDROID_CLIENT_NAME; import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.ANDROID_CLIENT_VERSION; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.ANDROID_VR_CLIENT_ID; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.ANDROID_VR_CLIENT_NAME; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.ANDROID_VR_CLIENT_VERSION; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.ANDROID_VR_DEVICE_MAKE; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.ANDROID_VR_DEVICE_MODEL; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.ANDROID_VR_OS_VERSION; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.ANDROID_VR_SDK_VERSION; import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.DESKTOP_CLIENT_PLATFORM; import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.EMBED_CLIENT_SCREEN; import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.IOS_CLIENT_ID; @@ -148,6 +155,17 @@ public static InnertubeClientRequestInfo ofIosClient() { IOS_DEVICE_MODEL, "iOS", IOS_OS_VERSION, -1)); } + @Nonnull + public static InnertubeClientRequestInfo ofAndroidVRClient() { + return new InnertubeClientRequestInfo( + new InnertubeClientRequestInfo.ClientInfo(ANDROID_VR_CLIENT_NAME, + ANDROID_VR_CLIENT_VERSION, ANDROID_VR_CLIENT_ID, + WATCH_CLIENT_SCREEN, null), + new InnertubeClientRequestInfo.DeviceInfo(MOBILE_CLIENT_PLATFORM, + ANDROID_VR_DEVICE_MAKE, ANDROID_VR_DEVICE_MODEL, + "Android", ANDROID_VR_OS_VERSION, ANDROID_VR_SDK_VERSION)); + } + @Nonnull public static InnertubeClientRequestInfo ofVisionOsClient() { return new InnertubeClientRequestInfo( diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeService.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeService.java index 7127453fda..9bbdea131b 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeService.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeService.java @@ -82,7 +82,6 @@ */ public class YoutubeService extends StreamingService { - public YoutubeService(final int id) { super(id, "YouTube", EnumSet.of(AUDIO, VIDEO, LIVE, COMMENTS)); } diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamHelper.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamHelper.java index af0b747fe6..6bc75670e7 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamHelper.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamHelper.java @@ -229,6 +229,25 @@ public static JsonObject getIosPlayerResponse(@Nonnull final ContentCountry cont getDownloader().postWithContentTypeJson(url, headers, body, localization))); } + public static JsonObject getAndroidVRPlayerResponse( + @Nonnull final ContentCountry contentCountry, + @Nonnull final Localization localization, + @Nonnull final String videoId, + @Nonnull final String cpn) throws IOException, ExtractionException { + final InnertubeClientRequestInfo innertubeClientRequestInfo = + InnertubeClientRequestInfo.ofAndroidVRClient(); + + final Map> headers = + getMobileClientHeaders(getAndroidUserAgent(localization)); + + innertubeClientRequestInfo.clientInfo.visitorData = + YoutubeParsingHelper.getVisitorDataFromInnertube(innertubeClientRequestInfo, + localization, contentCountry, headers, YOUTUBEI_V1_GAPIS_URL, null, false); + + return postPlayerRequest(localization, contentCountry, videoId, cpn, + innertubeClientRequestInfo, headers); + } + public static JsonObject getVisionOsPlayerResponse(@Nonnull final ContentCountry contentCountry, @Nonnull final Localization localization, @Nonnull final String videoId, @@ -246,6 +265,17 @@ public static JsonObject getVisionOsPlayerResponse(@Nonnull final ContentCountry YoutubeParsingHelper.getVisitorDataFromInnertube(innertubeClientRequestInfo, localization, contentCountry, headers, YOUTUBEI_V1_URL, null, false); + return postPlayerRequest(localization, contentCountry, videoId, cpn, + innertubeClientRequestInfo, headers); + } + + private static JsonObject postPlayerRequest( + @Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String videoId, + @Nonnull final String cpn, + @Nonnull final InnertubeClientRequestInfo innertubeClientRequestInfo, + @Nonnull final Map> headers) throws IOException, ExtractionException { final JsonBuilder builder = prepareJsonBuilder(localization, contentCountry, innertubeClientRequestInfo, null); diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeCommentsExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeCommentsExtractor.java index a4e5d498ee..a806f4c654 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeCommentsExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeCommentsExtractor.java @@ -29,6 +29,7 @@ import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; public class YoutubeCommentsExtractor extends CommentsExtractor { + private static final String TAG = YoutubeCommentsExtractor.class.getSimpleName(); private static final String COMMENT_VIEW_MODEL_KEY = "commentViewModel"; private static final String COMMENT_RENDERER_KEY = "commentRenderer"; @@ -43,6 +44,11 @@ public class YoutubeCommentsExtractor extends CommentsExtractor { */ private JsonObject ajaxJson; + /** + * Live chat continuation token, used when regular comments are disabled. + */ + private String liveChatContinuation; + public YoutubeCommentsExtractor( final StreamingService service, final ListLinkHandler uiHandler) { @@ -54,6 +60,10 @@ public YoutubeCommentsExtractor( public InfoItemsPage getInitialPage() throws IOException, ExtractionException { + if (liveChatContinuation != null) { + return fetchLiveChat(liveChatContinuation); + } + if (commentsDisabled) { return getInfoItemsPageForDisabledComments(); } @@ -189,6 +199,10 @@ private Page getNextPage(final String continuation) throws ParsingException { public InfoItemsPage getPage(final Page page) throws IOException, ExtractionException { + if ("live_chat".equals(page.getUrl()) || liveChatContinuation != null) { + return fetchLiveChat(page.getId()); + } + if (commentsDisabled) { return getInfoItemsPageForDisabledComments(); } @@ -346,8 +360,8 @@ public void onFetchPage(@Nonnull final Downloader downloader) .getBytes(StandardCharsets.UTF_8); // @formatter:on - final String initialToken = - findInitialCommentsToken(getJsonPostResponse("next", body, localization)); + final JsonObject nextResponse = getJsonPostResponse("next", body, localization); + final String initialToken = findInitialCommentsToken(nextResponse); if (initialToken == null) { return; @@ -364,10 +378,108 @@ public void onFetchPage(@Nonnull final Downloader downloader) ajaxJson = getJsonPostResponse("next", ajaxBody, localization); } + /** + * Configures this extractor to fetch live chat messages. + */ + @Override + public void setLiveChatContinuation(final String continuation) { + this.liveChatContinuation = continuation; + } + + /** + * Fetches live chat messages and converts them to CommentsInfoItem. + */ + private InfoItemsPage fetchLiveChat(final String chatContinuation) + throws IOException, ExtractionException { + final Localization localization = getExtractorLocalization(); + final byte[] json = JsonWriter.string( + prepareDesktopJsonBuilder(localization, getExtractorContentCountry()) + .value("continuation", chatContinuation) + .object("currentPlayerState") + .value("playerOffsetMs", "0") + .end() + .done()) + .getBytes(StandardCharsets.UTF_8); + + final String endpoint = "live_chat/get_live_chat"; + final JsonObject result = getJsonPostResponse(endpoint, json, localization); + + return extractLiveChatComments(result); + } + + /** + * Extracts live chat actions into CommentsInfoItem objects. + */ + private InfoItemsPage extractLiveChatComments( + final JsonObject result) throws ExtractionException { + final CommentsInfoItemsCollector collector = new CommentsInfoItemsCollector( + getServiceId()); + + try { + final JsonObject chatContinuation = result + .getObject("continuationContents") + .getObject("liveChatContinuation"); + final JsonArray actions = chatContinuation.getArray("actions"); + + for (int i = 0; i < actions.size(); i++) { + final JsonObject action = actions.getObject(i); + final JsonObject item; + if (action.has("addChatItemAction")) { + item = action.getObject("addChatItemAction") + .getObject("item"); + } else if (action.has("replayChatItemAction")) { + item = action.getObject("replayChatItemAction") + .getArray("actions").getObject(0) + .getObject("addChatItemAction") + .getObject("item"); + } else { + continue; + } + + if (item.has("liveChatTextMessageRenderer")) { + collector.commit(new YoutubeLiveChatInfoItemExtractor( + item.getObject("liveChatTextMessageRenderer"))); + } + } + + // Extract next continuation + final JsonArray continuations = chatContinuation + .getArray("continuations"); + final Page nextPage; + if (!continuations.isEmpty()) { + final JsonObject contObj = continuations.getObject( + continuations.size() - 1); + String nextCont = null; + if (contObj.has("timedContinuationData")) { + nextCont = contObj.getObject("timedContinuationData") + .getString("continuation"); + } else if (contObj.has("invalidationContinuationData")) { + nextCont = contObj.getObject("invalidationContinuationData") + .getString("continuation"); + } else if (contObj.has("liveChatReplayContinuationData")) { + nextCont = contObj.getObject("liveChatReplayContinuationData") + .getString("continuation"); + } + nextPage = nextCont != null ? new Page("live_chat", nextCont) : null; + } else { + nextPage = null; + } + + return new InfoItemsPage<>(collector, nextPage); + } catch (final Exception e) { + return getInfoItemsPageForDisabledComments(); + } + } + @Override public boolean isCommentsDisabled() { - return commentsDisabled; + return commentsDisabled && !isLiveChat(); + } + + @Override + public boolean isLiveChat() { + return liveChatContinuation != null; } @Override diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeLiveChatInfoItemExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeLiveChatInfoItemExtractor.java new file mode 100644 index 0000000000..d0296399a7 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeLiveChatInfoItemExtractor.java @@ -0,0 +1,183 @@ +package org.schabi.newpipe.extractor.services.youtube.extractors; + +import com.grack.nanojson.JsonArray; +import com.grack.nanojson.JsonObject; + +import org.schabi.newpipe.extractor.Image; +import org.schabi.newpipe.extractor.comments.CommentsInfoItemExtractor; +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; +import org.schabi.newpipe.extractor.stream.Description; + +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; + +/** + * Extracts comment info from a YouTube live chat message. + */ +public class YoutubeLiveChatInfoItemExtractor implements CommentsInfoItemExtractor { + + private final JsonObject chatMessage; + + public YoutubeLiveChatInfoItemExtractor(final JsonObject chatMessage) { + this.chatMessage = chatMessage; + } + + @Nonnull + @Override + public Description getCommentText() throws ParsingException { + final String text = extractChatMessageText(chatMessage.getObject("message")); + return new Description(text, Description.PLAIN_TEXT); + } + + /** + * Extracts text from a live chat message, handling both regular text and emojis. + * YouTube live chat messages use {@code runs} array where each element has either + * {@code text} or {@code emoji}. + */ + @Nonnull + private static String extractChatMessageText(final JsonObject message) { + if (message == null || message.isEmpty()) { + return ""; + } + + if (message.has("simpleText")) { + return message.getString("simpleText", ""); + } + + final JsonArray runs = message.getArray("runs"); + if (runs.isEmpty()) { + return ""; + } + + final StringBuilder textBuilder = new StringBuilder(); + for (int i = 0; i < runs.size(); i++) { + final JsonObject run = runs.getObject(i); + if (run.has("text")) { + final String text = run.getString("text", ""); + textBuilder.append(text); + } else if (run.has("emoji")) { + textBuilder.append(extractEmojiText(run.getObject("emoji"))); + } + } + + return textBuilder.toString(); + } + + /** + * Extracts a textual representation of a YouTube live chat emoji. + * For standard emojis, {@code emojiId} contains the Unicode character. + * For custom emojis, uses the first shortcut (e.g. {@code :wave:}) if available. + */ + @Nonnull + private static String extractEmojiText(final JsonObject emoji) { + if (emoji == null || emoji.isEmpty()) { + return ""; + } + + // For standard emojis, emojiId is the Unicode character itself. + // For custom emojis, emojiId is an internal opaque ID that should not be displayed. + // TODO: Consider rendering custom emoji thumbnails as tags. + if (!emoji.getBoolean("isCustomEmoji", false) && emoji.has("emojiId")) { + final String emojiId = emoji.getString("emojiId", ""); + if (!emojiId.isEmpty()) { + return emojiId; + } + } + + // Try to get shortcuts like ":wave:", ":heart:", ":face-blue-smiling:" + if (emoji.has("shortcuts")) { + final JsonArray shortcuts = emoji.getArray("shortcuts"); + for (int i = 0; i < shortcuts.size(); i++) { + final String shortcut = shortcuts.getString(i, ""); + if (!shortcut.isEmpty()) { + return shortcut; + } + } + } + + // Fallback: try searchTerms + if (emoji.has("searchTerms")) { + final JsonArray searchTerms = emoji.getArray("searchTerms"); + for (int i = 0; i < searchTerms.size(); i++) { + final String term = searchTerms.getString(i, ""); + if (!term.isEmpty()) { + return ":" + term + ":"; + } + } + } + + return "[emoji]"; + } + + @Override + public String getCommentId() throws ParsingException { + return chatMessage.getString("id", ""); + } + + @Override + public String getUploaderName() throws ParsingException { + return YoutubeParsingHelper.getTextFromObject( + chatMessage.getObject("authorName")); + } + + @Nonnull + @Override + public List getUploaderAvatars() throws ParsingException { + try { + final JsonArray thumbnails = chatMessage.getObject("authorPhoto") + .getArray("thumbnails"); + return YoutubeParsingHelper.getImagesFromThumbnailsArray(thumbnails); + } catch (final Exception e) { + return List.of(); + } + } + + @Override + public String getUploaderUrl() throws ParsingException { + try { + return YoutubeParsingHelper.getUrlFromNavigationEndpoint( + chatMessage.getObject("authorEndpoint")); + } catch (final Exception e) { + return ""; + } + } + + @Override + public boolean isChannelOwner() throws ParsingException { + final JsonArray badges = chatMessage.getArray("authorBadges"); + for (int i = 0; i < badges.size(); i++) { + final JsonObject badge = badges.getObject(i); + if (badge.has("liveChatAuthorBadgeRenderer")) { + final JsonObject renderer = badge.getObject( + "liveChatAuthorBadgeRenderer"); + if (renderer.has("icon")) { + final String iconType = renderer.getObject("icon") + .getString("iconType", ""); + if ("owner".equals(iconType)) { + return true; + } + } + } + } + return false; + } + + @Nonnull + @Override + public List getThumbnails() throws ParsingException { + return Collections.emptyList(); + } + + @Override + public String getName() throws ParsingException { + return getUploaderName(); + } + + @Override + public String getUrl() throws ParsingException { + return ""; + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java index 966568fce8..e850539b87 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java @@ -133,6 +133,8 @@ public class YoutubeStreamExtractor extends StreamExtractor { private JsonObject iosStreamingData; @Nullable private JsonObject androidStreamingData; + @Nullable + private JsonObject androidVRStreamingData; private JsonObject videoPrimaryInfoRenderer; private JsonObject videoSecondaryInfoRenderer; @@ -149,6 +151,9 @@ public class YoutubeStreamExtractor extends StreamExtractor { private String visionOsCpn; private String iosCpn; private String androidCpn; + private String androidVRCpn; + @Nullable + private String liveChatContinuation; @Nullable private String androidStreamingUrlsPoToken; @@ -331,7 +336,7 @@ public long getLength() throws ParsingException { return Long.parseLong(duration); } catch (final Exception e) { return getDurationFromFirstAdaptiveFormat(Arrays.asList( - androidStreamingData, iosStreamingData)); + androidStreamingData, androidVRStreamingData, iosStreamingData)); } } @@ -629,7 +634,8 @@ public String getDashMpdUrl() throws ParsingException { // There is no DASH manifest available with the iOS and visionOS clients return getManifestUrl( "dash", - List.of(new Pair<>(androidStreamingData, androidStreamingUrlsPoToken)), + List.of(new Pair<>(androidStreamingData, androidStreamingUrlsPoToken), + new Pair<>(androidVRStreamingData, (String) null)), // Return version 7 of the DASH manifest, which is the latest one, reducing // manifest size and allowing playback with some DASH players "mpd_version=7"); @@ -648,7 +654,8 @@ public String getHlsUrl() throws ParsingException { "hls", List.of(new Pair<>(visionOsStreamingData, null), new Pair<>(iosStreamingData, iosStreamingUrlsPoToken), - new Pair<>(androidStreamingData, androidStreamingUrlsPoToken)), + new Pair<>(androidStreamingData, androidStreamingUrlsPoToken), + new Pair<>(androidVRStreamingData, (String) null)), ""); } @@ -749,6 +756,17 @@ public StreamType getStreamType() { return streamType; } + @Override + public boolean hasLiveChat() { + return liveChatContinuation != null; + } + + @Override + @Nullable + public String getLiveChatContinuation() { + return liveChatContinuation; + } + private void setStreamType() { if (playerResponse.getObject(PLAYABILITY_STATUS).has("liveStreamability")) { streamType = StreamType.LIVE_STREAM; @@ -848,9 +866,17 @@ public void onFetchPage(@Nonnull final Downloader downloader) fetchAndroidClient(localization, contentCountry, videoId, androidPoTokenResult); + if (isSabrOnlyStreamingData(androidStreamingData)) { + fetchAndroidVRClient(localization, contentCountry, videoId); + } + setStreamType(); - if (fetchIosClient) { + // Always fetch the iOS client for livestreams, since its player response returns + // adaptiveFormats with actual stream URLs, unlike the Android client which only + // returns format metadata without URLs for livestreams. + if (fetchIosClient || streamType == StreamType.LIVE_STREAM + || streamType == StreamType.POST_LIVE_STREAM) { final PoTokenResult iosPoTokenResult = noPoTokenProviderSet ? null : poTokenProviderInstance.getIosClientPoToken(videoId); fetchIosClient(localization, contentCountry, videoId, iosPoTokenResult); @@ -868,6 +894,26 @@ public void onFetchPage(@Nonnull final Downloader downloader) .done()) .getBytes(StandardCharsets.UTF_8); nextResponse = getJsonPostResponse(NEXT, nextBody, localization); + + // Check for live chat availability + findLiveChatContinuation(nextResponse); + } + + private void findLiveChatContinuation(final JsonObject response) { + try { + final JsonObject liveChatRenderer = response + .getObject("contents") + .getObject("twoColumnWatchNextResults") + .getObject("conversationBar") + .getObject("liveChatRenderer"); + liveChatContinuation = liveChatRenderer + .getArray("continuations") + .getObject(0) + .getObject("reloadContinuationData") + .getString("continuation"); + } catch (final Exception e) { + liveChatContinuation = null; + } } private static void checkPlayabilityStatus(@Nonnull final JsonObject playabilityStatus) @@ -956,6 +1002,38 @@ private void fetchAndroidClient(@Nonnull final Localization localization, } } + private static boolean isSabrOnlyStreamingData(@Nullable final JsonObject streamingData) { + if (streamingData == null) { + return true; + } + final JsonArray adaptiveFormats = streamingData.getArray(ADAPTIVE_FORMATS); + if (adaptiveFormats == null || adaptiveFormats.isEmpty()) { + return true; + } + for (int i = 0; i < adaptiveFormats.size(); i++) { + final JsonObject fmt = adaptiveFormats.getObject(i); + if (fmt.has("url") || fmt.has(SIGNATURE_CIPHER) || fmt.has(CIPHER)) { + return false; + } + } + return true; + } + + private void fetchAndroidVRClient(@Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String videoId) { + try { + androidVRCpn = generateContentPlaybackNonce(); + final JsonObject vrPlayerResponse = + YoutubeStreamHelper.getAndroidVRPlayerResponse( + contentCountry, localization, videoId, androidVRCpn); + if (!isPlayerResponseNotValid(vrPlayerResponse, videoId)) { + androidVRStreamingData = vrPlayerResponse.getObject(STREAMING_DATA); + } + } catch (final Exception ignored) { + } + } + private void fetchIosClient(@Nonnull final Localization localization, @Nonnull final ContentCountry contentCountry, @Nonnull final String videoId, @@ -1137,6 +1215,8 @@ private List getItags( new Pair<>(androidStreamingData, new Pair<>(androidCpn, androidStreamingUrlsPoToken)), new Pair<>(visionOsStreamingData, new Pair<>(visionOsCpn, (String) null)), + new Pair<>(androidVRStreamingData, + new Pair<>(androidVRCpn, (String) null)), new Pair<>(iosStreamingData, new Pair<>(iosCpn, iosStreamingUrlsPoToken))) .flatMap(pair -> getStreamsFromStreamingDataKey( @@ -1547,6 +1627,93 @@ public List getTags() { .getArray("keywords")); } + private void findHeatMapMarkers(final List results, final Object current) { + if (current instanceof JsonObject) { + final JsonObject obj = (JsonObject) current; + if (obj.has("markerType") && "MARKER_TYPE_HEATMAP" + .equals(obj.getString("markerType")) && obj.has("markers")) { + final JsonArray markersArray = obj.getArray("markers"); + if (markersArray != null) { + for (final Object item : markersArray) { + if (item instanceof JsonObject) { + // YouTube's newer array style, or wrapped heatMarkerRenderer + final JsonObject markerObj = (JsonObject) item; + if (markerObj.has("heatMarkerRenderer")) { + results.add(markerObj.getObject("heatMarkerRenderer")); + } else { + results.add(markerObj); + } + } + } + } + } else if (obj.has("heatMarkerRenderer")) { + results.add(obj.getObject("heatMarkerRenderer")); + } else { + for (final String key : obj.keySet()) { + findHeatMapMarkers(results, obj.get(key)); + } + } + } else if (current instanceof JsonArray) { + for (final Object item : ((JsonArray) current)) { + findHeatMapMarkers(results, item); + } + } + } + + @Nonnull + @Override + public List + getStreamHeatmap() { + final List markers = new java.util.ArrayList<>(); + if (playerResponse != null) { + findHeatMapMarkers(markers, playerResponse); + } + if (markers.isEmpty() && nextResponse != null) { + findHeatMapMarkers(markers, nextResponse); + } + + if (markers.isEmpty()) { + return Collections.emptyList(); + } + + final List entries = + new java.util.ArrayList<>(markers.size()); + for (final JsonObject marker : markers) { + try { + // If it fails to parse as Long normally, fallback to parsing as Double and casting + long startMillis = -1L; + if (marker.has("startMillis")) { + startMillis = Long.parseLong(marker.getString("startMillis", "-1")); + } else if (marker.has("timeRangeStartMillis")) { + startMillis = marker.getLong("timeRangeStartMillis", -1L); + } + + long durationMillis = -1L; + if (marker.has("durationMillis")) { + durationMillis = Long.parseLong(marker.getString("durationMillis", "-1")); + } else if (marker.has("markerDurationMillis")) { + durationMillis = marker.getLong("markerDurationMillis", -1L); + } + + double intensity = 0.0; + if (marker.has("intensityScoreNormalized")) { + intensity = marker.getDouble("intensityScoreNormalized", 0.0); + } else if (marker.has("heatMarkerIntensityScoreNormalized")) { + intensity = marker.getDouble("heatMarkerIntensityScoreNormalized", 0.0); + } + + if (startMillis >= 0 && durationMillis > 0) { + entries.add(new org.schabi.newpipe.extractor.stream.StreamHeatmapEntry( + startMillis, durationMillis, intensity)); + } + } catch (final Exception ignored) { + // Ignore missing or malformed properties and continue + } + } + + return entries; + } + @Nonnull @Override public List getStreamSegments() throws ParsingException { diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamExtractor.java index 63650a7906..496f7d8f7a 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamExtractor.java @@ -56,6 +56,21 @@ public StreamExtractor(final StreamingService service, final LinkHandler linkHan super(service, linkHandler); } + /** + * @return {@code true} if this stream has a live chat available + */ + public boolean hasLiveChat() { + return false; + } + + /** + * @return the live chat continuation token, or {@code null} if not available + */ + @Nullable + public String getLiveChatContinuation() { + return null; + } + /** * The original textual date provided by the service. Should be used as a fallback if * {@link #getUploadDate()} isn't provided by the service, or it fails for some reason. @@ -551,6 +566,17 @@ public List getStreamSegments() throws ParsingException { return Collections.emptyList(); } + /** + * The heatmap segments representing the most replayed parts of the stream. + * If the heatmap list is not available you can simply return an empty list. + * + * @return The list of heatmap entries of the stream or an empty list. + */ + @Nonnull + public List getStreamHeatmap() throws ParsingException { + return Collections.emptyList(); + } + /** * Meta information about the stream. *

diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamHeatmapEntry.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamHeatmapEntry.java new file mode 100644 index 0000000000..f8a49c4e31 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamHeatmapEntry.java @@ -0,0 +1,49 @@ +package org.schabi.newpipe.extractor.stream; + +import java.io.Serializable; +import java.util.Objects; + +public class StreamHeatmapEntry implements Serializable { + private final long startTimeMillis; + private final long durationMillis; + private final double heatIntensity; + + public StreamHeatmapEntry(final long startTimeMillis, + final long durationMillis, + final double heatIntensity) { + this.startTimeMillis = startTimeMillis; + this.durationMillis = durationMillis; + this.heatIntensity = heatIntensity; + } + + public long getStartTimeMillis() { + return startTimeMillis; + } + + public long getDurationMillis() { + return durationMillis; + } + + public double getHeatIntensity() { + return heatIntensity; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final StreamHeatmapEntry that = (StreamHeatmapEntry) o; + return startTimeMillis == that.startTimeMillis + && durationMillis == that.durationMillis + && Double.compare(that.heatIntensity, heatIntensity) == 0; + } + + @Override + public int hashCode() { + return Objects.hash(startTimeMillis, durationMillis, heatIntensity); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamInfo.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamInfo.java index f2719dec08..5677f9c8eb 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamInfo.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamInfo.java @@ -336,6 +336,11 @@ private static void extractOptionalData(final StreamInfo streamInfo, } catch (final Exception e) { streamInfo.addError(e); } + try { + streamInfo.setStreamHeatmap(extractor.getStreamHeatmap()); + } catch (final Exception e) { + streamInfo.addError(e); + } try { streamInfo.setMetaInfo(extractor.getMetaInfo()); } catch (final Exception e) { @@ -356,6 +361,12 @@ private static void extractOptionalData(final StreamInfo streamInfo, } catch (final Exception e) { streamInfo.addError(e); } + try { + streamInfo.setLiveChat(extractor.hasLiveChat()); + streamInfo.setLiveChatContinuation(extractor.getLiveChatContinuation()); + } catch (final Exception e) { + streamInfo.addError(e); + } streamInfo.setRelatedItems(ExtractorHelper.getRelatedItemsOrLogError(streamInfo, extractor)); @@ -405,10 +416,13 @@ private static void extractOptionalData(final StreamInfo streamInfo, private Locale language = null; private List tags = List.of(); private List streamSegments = List.of(); + private List streamHeatmap = List.of(); private List metaInfo = List.of(); private boolean shortFormContent = false; @Nonnull private ContentAvailability contentAvailability = ContentAvailability.AVAILABLE; + private boolean liveChat = false; + private String liveChatContinuation = null; /** * Preview frames, e.g. for the storyboard / seekbar thumbnail preview @@ -731,6 +745,14 @@ public void setStreamSegments(final List streamSegments) { this.streamSegments = streamSegments; } + public List getStreamHeatmap() { + return streamHeatmap; + } + + public void setStreamHeatmap(final List streamHeatmap) { + this.streamHeatmap = streamHeatmap; + } + public void setMetaInfo(final List metaInfo) { this.metaInfo = metaInfo; } @@ -764,4 +786,20 @@ public ContentAvailability getContentAvailability() { public void setContentAvailability(@Nonnull final ContentAvailability availability) { this.contentAvailability = availability; } + + public boolean hasLiveChat() { + return liveChat; + } + + public void setLiveChat(final boolean liveChat) { + this.liveChat = liveChat; + } + + public String getLiveChatContinuation() { + return liveChatContinuation; + } + + public void setLiveChatContinuation(final String liveChatContinuation) { + this.liveChatContinuation = liveChatContinuation; + } } diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/DefaultStreamExtractorTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/DefaultStreamExtractorTest.java index 12e19273ae..fcb631e519 100644 --- a/extractor/src/test/java/org/schabi/newpipe/extractor/services/DefaultStreamExtractorTest.java +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/DefaultStreamExtractorTest.java @@ -81,8 +81,10 @@ public abstract class DefaultStreamExtractorTest extends DefaultExtractorTest expectedTags() { return Collections.emptyList(); } // default: no tags public String expectedSupportInfo() { return ""; } // default: no support info available public int expectedStreamSegmentsCount() { return -1; } // return 0 or greater to test (default is -1 to ignore) + public int expectedStreamHeatmapCount() { return -1; } // return 0 or greater to test (default is -1 to ignore) public List expectedMetaInfo() throws MalformedURLException { return Collections.emptyList(); } // default: no metadata info available public ContentAvailability expectedContentAvailability() { return ContentAvailability.UNKNOWN; } // default: unknown content availability + public boolean expectedHasLiveChat() { return false; } // default: no live chat available @Test @Override @@ -441,6 +443,12 @@ public void testStreamSegmentsCount() throws Exception { assertEquals(expectedStreamSegmentsCount(), extractor().getStreamSegments().size()); } } + @Test + public void testStreamHeatmapCount() throws Exception { + if (expectedStreamHeatmapCount() >= 0) { + assertEquals(expectedStreamHeatmapCount(), extractor().getStreamHeatmap().size()); + } + } /** * @see DefaultSearchExtractorTest#testMetaInfo() @@ -480,4 +488,9 @@ public void testMetaInfo() throws Exception { public void testContentAvailability() throws Exception { assertEquals(expectedContentAvailability(), extractor().getContentAvailability()); } + + @Test + public void testHasLiveChat() throws Exception { + assertEquals(expectedHasLiveChat(), extractor().hasLiveChat()); + } } diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeCommentsExtractorTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeCommentsExtractorTest.java index 713fb23611..35c29d7ae9 100644 --- a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeCommentsExtractorTest.java +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeCommentsExtractorTest.java @@ -10,11 +10,17 @@ import static org.schabi.newpipe.extractor.ServiceList.YouTube; import static org.schabi.newpipe.extractor.comments.CommentsInfoItem.UNKNOWN_REPLY_COUNT; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage; import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.comments.CommentsInfo; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; +import org.schabi.newpipe.extractor.InitNewPipeTest; +import org.schabi.newpipe.extractor.NewPipe; +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.ExtractionException; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.services.DefaultSimpleExtractorTest; @@ -26,6 +32,8 @@ import java.util.List; import java.util.Locale; +import javax.annotation.Nonnull; + public class YoutubeCommentsExtractorTest { abstract static class Base extends DefaultSimpleExtractorTest @@ -428,6 +436,50 @@ public void testGetCommentsFormatting() throws IOException, ExtractionException } } + /** + * Test live chat mode behavior on a regular video extractor. + * Does not extend {@link Base} because these tests do not need network/mock data. + */ + public static class LiveChatMode { + private static final String URL = "https://www.youtube.com/watch?v=D00Au7k3i6o"; + + @BeforeAll + static void setUp() { + InitNewPipeTest.initEmpty(); + NewPipe.init(new Downloader() { + @Nonnull + @Override + public Response execute(@Nonnull final Request request) { + throw new UnsupportedOperationException("No communication expected"); + } + }); + } + + private YoutubeCommentsExtractor createExtractor() throws Exception { + return (YoutubeCommentsExtractor) YouTube.getCommentsExtractor(URL); + } + + @Test + void testIsLiveChatDefaultFalse() throws Exception { + assertFalse(createExtractor().isLiveChat()); + } + + @Test + void testSetLiveChatContinuationActivatesLiveChat() throws Exception { + final YoutubeCommentsExtractor extractor = createExtractor(); + assertFalse(extractor.isLiveChat()); + extractor.setLiveChatContinuation("test-continuation"); + assertTrue(extractor.isLiveChat()); + } + + @Test + void testCommentsDisabledIsFalseInLiveChatMode() throws Exception { + final YoutubeCommentsExtractor extractor = createExtractor(); + extractor.setLiveChatContinuation("test-continuation"); + assertTrue(extractor.isLiveChat()); + assertFalse(extractor.isCommentsDisabled()); + } + } public static class EditedCommentTest extends Base { diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/stream/YoutubeStreamExtractorDefaultTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/stream/YoutubeStreamExtractorDefaultTest.java index ab3ffd7a81..29221ce5d5 100644 --- a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/stream/YoutubeStreamExtractorDefaultTest.java +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/stream/YoutubeStreamExtractorDefaultTest.java @@ -622,4 +622,5 @@ void testCheckDescriptiveAudio() throws Exception { .anyMatch(s -> s.getAudioTrackType() == AudioTrackType.DESCRIPTIVE)); } } + } diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/stream/YoutubeStreamExtractorLivestreamTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/stream/YoutubeStreamExtractorLivestreamTest.java index c1e2a1ea48..1fb2cc7891 100644 --- a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/stream/YoutubeStreamExtractorLivestreamTest.java +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/stream/YoutubeStreamExtractorLivestreamTest.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.extractor.services.youtube.stream; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.schabi.newpipe.extractor.ServiceList.YouTube; import org.schabi.newpipe.extractor.StreamingService; @@ -31,7 +33,15 @@ protected StreamExtractor createExtractor() throws Exception { @Override public String expectedOriginalUrlContains() { return URL; } @Override public StreamType expectedStreamType() { return StreamType.LIVE_STREAM; } + @Override public boolean expectedHasLiveChat() { return true; } @Override public String expectedUploaderName() { return "Lofi Girl"; } + + @Test + void testLiveChatContinuationNotNull() throws Exception { + final String continuation = extractor().getLiveChatContinuation(); + assertNotNull(continuation); + assertFalse(continuation.isEmpty()); + } @Override public String expectedUploaderUrl() { return "https://www.youtube.com/channel/UCSJ4gkVC6NrvII8umztf0Ow"; } @Override public long expectedUploaderSubscriberCountAtLeast() { return 9_800_000; } @Override public List expectedDescriptionContains() {