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/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/extractors/YoutubeCommentsExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeCommentsExtractor.java index 8667768a4b..23f5c1f729 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,16 @@ public class YoutubeCommentsExtractor extends CommentsExtractor { */ private JsonObject ajaxJson; + /** + * Live chat continuation token, used when regular comments are disabled. + */ + private String liveChatContinuation; + + /** + * Whether this video is / was a live stream. + */ + private boolean isLiveStream; + public YoutubeCommentsExtractor( final StreamingService service, final ListLinkHandler uiHandler) { @@ -54,6 +65,10 @@ public YoutubeCommentsExtractor( public InfoItemsPage getInitialPage() throws IOException, ExtractionException { + if (liveChatContinuation != null) { + return fetchLiveChat(liveChatContinuation); + } + if (commentsDisabled) { return getInfoItemsPageForDisabledComments(); } @@ -194,6 +209,11 @@ 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) { + isLiveStream = true; + return fetchLiveChat(page.getId()); + } + if (commentsDisabled) { return getInfoItemsPageForDisabledComments(); } @@ -351,8 +371,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; @@ -369,10 +389,110 @@ 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 { + isLiveStream = true; + 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/" + + (isLiveStream ? "get_live_chat" : "get_live_chat_replay"); + 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..7c52046984 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeLiveChatInfoItemExtractor.java @@ -0,0 +1,186 @@ +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")) { + final JsonObject emoji = run.getObject("emoji"); + final String emojiText = extractEmojiText(emoji); + if (emojiText != null) { + textBuilder.append(emojiText); + } + } + } + + 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 it is an ID, but still better than nothing. + if (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 0fa6ac21d8..d204d87b70 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 @@ -879,6 +879,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) 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..a8774c7bf0 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,22 @@ 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 liveChatContinuation != null; + } + + /** + * @return the live chat continuation token, or {@code null} if not available + */ + public String getLiveChatContinuation() { + return liveChatContinuation; + } + + protected String liveChatContinuation = 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. 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 9a0ea72f61..80f5a36a1d 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 @@ -353,6 +353,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)); @@ -406,6 +412,8 @@ private static void extractOptionalData(final StreamInfo streamInfo, 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 @@ -761,4 +769,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..dfe562e5d2 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 @@ -83,6 +83,7 @@ public abstract class DefaultStreamExtractorTest extends DefaultExtractorTest 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 @@ -480,4 +481,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 42fd7ee920..6e12985f59 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 @@ -427,4 +427,50 @@ public void testGetCommentsFormatting() throws IOException, ExtractionException assertContains("", firstComment.getCommentText().getContent()); } } + + /** + * 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"; + + @org.junit.jupiter.api.BeforeAll + static void setUp() { + org.schabi.newpipe.extractor.InitNewPipeTest.initEmpty(); + org.schabi.newpipe.extractor.NewPipe.init(new org.schabi.newpipe.extractor.downloader.Downloader() { + @org.jetbrains.annotations.NotNull + @Override + public org.schabi.newpipe.extractor.downloader.Response execute( + @org.jetbrains.annotations.NotNull final org.schabi.newpipe.extractor.downloader.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()); + } + } } 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 159db3086b..063759e7bf 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.junit.jupiter.api.Test; @@ -38,7 +40,15 @@ public void testUploaderName() 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() {