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;
+ }
}