Skip to content

Commit df26bad

Browse files
committed
[YouTube] Add common methods to get ID, name and age gate object of channels
Also move duplicate strings into constants and support pageHeader channel header in user channels on YoutubeChannelHelper methods.
1 parent 5a6da5f commit df26bad

1 file changed

Lines changed: 229 additions & 27 deletions

File tree

extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeChannelHelper.java

Lines changed: 229 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,39 @@
44
import com.grack.nanojson.JsonWriter;
55
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
66
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
7+
import org.schabi.newpipe.extractor.exceptions.ParsingException;
78
import org.schabi.newpipe.extractor.localization.ContentCountry;
89
import org.schabi.newpipe.extractor.localization.Localization;
910

1011
import javax.annotation.Nonnull;
12+
import javax.annotation.Nullable;
1113
import java.io.IOException;
1214
import java.nio.charset.StandardCharsets;
1315
import java.util.Optional;
1416

1517
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.defaultAlertsCheck;
1618
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getJsonPostResponse;
19+
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getTextFromObject;
1720
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.prepareDesktopJsonBuilder;
1821
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
1922

2023
/**
2124
* Shared functions for extracting YouTube channel pages and tabs.
2225
*/
2326
public final class YoutubeChannelHelper {
27+
28+
private static final String BROWSE_ENDPOINT = "browseEndpoint";
29+
private static final String BROWSE_ID = "browseId";
30+
private static final String CAROUSEL_HEADER_RENDERER = "carouselHeaderRenderer";
31+
private static final String C4_TABBED_HEADER_RENDERER = "c4TabbedHeaderRenderer";
32+
private static final String CONTENT = "content";
33+
private static final String CONTENTS = "contents";
34+
private static final String HEADER = "header";
35+
private static final String PAGE_HEADER_VIEW_MODEL = "pageHeaderViewModel";
36+
private static final String TAB_RENDERER = "tabRenderer";
37+
private static final String TITLE = "title";
38+
private static final String TOPIC_CHANNEL_DETAILS_RENDERER = "topicChannelDetailsRenderer";
39+
2440
private YoutubeChannelHelper() {
2541
}
2642

@@ -64,8 +80,8 @@ public static String resolveChannelId(@Nonnull final String idOrPath)
6480
.getObject("webCommandMetadata")
6581
.getString("webPageType", "");
6682

67-
final JsonObject browseEndpoint = endpoint.getObject("browseEndpoint");
68-
final String browseId = browseEndpoint.getString("browseId", "");
83+
final JsonObject browseEndpoint = endpoint.getObject(BROWSE_ENDPOINT);
84+
final String browseId = browseEndpoint.getString(BROWSE_ID, "");
6985

7086
if (webPageType.equalsIgnoreCase("WEB_PAGE_TYPE_BROWSE")
7187
|| webPageType.equalsIgnoreCase("WEB_PAGE_TYPE_CHANNEL")
@@ -140,7 +156,7 @@ public static ChannelResponseData getChannelResponse(@Nonnull final String chann
140156
while (level < 3) {
141157
final byte[] body = JsonWriter.string(prepareDesktopJsonBuilder(
142158
localization, country)
143-
.value("browseId", id)
159+
.value(BROWSE_ID, id)
144160
.value("params", parameters)
145161
.done())
146162
.getBytes(StandardCharsets.UTF_8);
@@ -159,8 +175,8 @@ public static ChannelResponseData getChannelResponse(@Nonnull final String chann
159175
.getObject("webCommandMetadata")
160176
.getString("webPageType", "");
161177

162-
final String browseId = endpoint.getObject("browseEndpoint")
163-
.getString("browseId", "");
178+
final String browseId = endpoint.getObject(BROWSE_ENDPOINT)
179+
.getString(BROWSE_ID, "");
164180

165181
if (webPageType.equalsIgnoreCase("WEB_PAGE_TYPE_BROWSE")
166182
|| webPageType.equalsIgnoreCase("WEB_PAGE_TYPE_CHANNEL")
@@ -257,7 +273,7 @@ public enum HeaderType {
257273
* A {@code pageHeaderRenderer} channel header type.
258274
*
259275
* <p>
260-
* This header returns only the channel's name and its avatar.
276+
* This header returns only the channel's name and its avatar for system channels.
261277
* </p>
262278
*/
263279
PAGE
@@ -294,20 +310,20 @@ private ChannelHeader(@Nonnull final JsonObject json, final HeaderType headerTyp
294310
@Nonnull
295311
public static Optional<ChannelHeader> getChannelHeader(
296312
@Nonnull final JsonObject channelResponse) {
297-
final JsonObject header = channelResponse.getObject("header");
313+
final JsonObject header = channelResponse.getObject(HEADER);
298314

299-
if (header.has("c4TabbedHeaderRenderer")) {
300-
return Optional.of(header.getObject("c4TabbedHeaderRenderer"))
315+
if (header.has(C4_TABBED_HEADER_RENDERER)) {
316+
return Optional.of(header.getObject(C4_TABBED_HEADER_RENDERER))
301317
.map(json -> new ChannelHeader(json, ChannelHeader.HeaderType.C4_TABBED));
302-
} else if (header.has("carouselHeaderRenderer")) {
303-
return header.getObject("carouselHeaderRenderer")
304-
.getArray("contents")
318+
} else if (header.has(CAROUSEL_HEADER_RENDERER)) {
319+
return header.getObject(CAROUSEL_HEADER_RENDERER)
320+
.getArray(CONTENTS)
305321
.stream()
306322
.filter(JsonObject.class::isInstance)
307323
.map(JsonObject.class::cast)
308-
.filter(item -> item.has("topicChannelDetailsRenderer"))
324+
.filter(item -> item.has(TOPIC_CHANNEL_DETAILS_RENDERER))
309325
.findFirst()
310-
.map(item -> item.getObject("topicChannelDetailsRenderer"))
326+
.map(item -> item.getObject(TOPIC_CHANNEL_DETAILS_RENDERER))
311327
.map(json -> new ChannelHeader(json, ChannelHeader.HeaderType.CAROUSEL));
312328
} else if (header.has("pageHeaderRenderer")) {
313329
return Optional.of(header.getObject("pageHeaderRenderer"))
@@ -333,22 +349,208 @@ public static Optional<ChannelHeader> getChannelHeader(
333349
* @return whether the channel is verified
334350
*/
335351
public static boolean isChannelVerified(@Nonnull final ChannelHeader channelHeader) {
336-
// carouselHeaderRenderer and pageHeaderRenderer does not contain any verification
337-
// badges
338-
// Since they are only shown on YouTube internal channels or on channels of large
339-
// organizations broadcasting live events, we can assume the channel to be verified
340-
if (channelHeader.headerType == ChannelHeader.HeaderType.CAROUSEL
341-
|| channelHeader.headerType == ChannelHeader.HeaderType.PAGE) {
342-
return true;
352+
switch (channelHeader.headerType) {
353+
// carouselHeaderRenderers do not contain any verification badges
354+
// Since they are only shown on YouTube internal channels or on channels of large
355+
// organizations broadcasting live events, we can assume the channel to be verified
356+
case CAROUSEL:
357+
return true;
358+
case PAGE:
359+
final JsonObject pageHeaderViewModel = channelHeader.json.getObject(CONTENT)
360+
.getObject(PAGE_HEADER_VIEW_MODEL);
361+
362+
final boolean hasCircleOrMusicIcon = pageHeaderViewModel.getObject(TITLE)
363+
.getObject("dynamicTextViewModel")
364+
.getObject("text")
365+
.getArray("attachmentRuns")
366+
.stream()
367+
.filter(JsonObject.class::isInstance)
368+
.map(JsonObject.class::cast)
369+
.anyMatch(attachmentRun -> attachmentRun.getObject("element")
370+
.getObject("type")
371+
.getObject("imageType")
372+
.getObject("image")
373+
.getArray("sources")
374+
.stream()
375+
.filter(JsonObject.class::isInstance)
376+
.map(JsonObject.class::cast)
377+
.anyMatch(source -> {
378+
final String imageName = source.getObject("clientResource")
379+
.getString("imageName");
380+
return "CHECK_CIRCLE_FILLED".equals(imageName)
381+
|| "MUSIC_FILLED".equals(imageName);
382+
}));
383+
if (!hasCircleOrMusicIcon && pageHeaderViewModel.getObject("image")
384+
.has("contentPreviewImageViewModel")) {
385+
// If a pageHeaderRenderer has no object in which a check verified may be
386+
// contained and if it has a contentPreviewImageViewModel, it should mean
387+
// that the header is coming from a system channel, which we can assume to
388+
// be verified
389+
return true;
390+
}
391+
392+
return hasCircleOrMusicIcon;
393+
case INTERACTIVE_TABBED:
394+
// If the header has an autoGenerated property, it should mean that the channel has
395+
// been auto generated by YouTube: we can assume the channel to be verified in this
396+
// case
397+
return channelHeader.json.has("autoGenerated");
398+
default:
399+
return YoutubeParsingHelper.isVerified(channelHeader.json.getArray("badges"));
343400
}
401+
}
344402

345-
if (channelHeader.headerType == ChannelHeader.HeaderType.INTERACTIVE_TABBED) {
346-
// If the header has an autoGenerated property, it should mean that the channel has
347-
// been auto generated by YouTube: we can assume the channel to be verified in this
348-
// case
349-
return channelHeader.json.has("autoGenerated");
403+
/**
404+
* Get the ID of a channel from its response.
405+
*
406+
* <p>
407+
* For {@link ChannelHeader.HeaderType#C4_TABBED c4TabbedHeaderRenderer} and
408+
* {@link ChannelHeader.HeaderType#CAROUSEL carouselHeaderRenderer} channel headers, the ID is
409+
* get from the header.
410+
* </p>
411+
*
412+
* <p>
413+
* For other headers or if it cannot be got, the ID from the {@code channelMetadataRenderer}
414+
* in the channel response is used.
415+
* </p>
416+
*
417+
* <p>
418+
* If the ID cannot still be get, the fallback channel ID, if provided, will be used.
419+
* </p>
420+
*
421+
* @param header the channel header
422+
* @param fallbackChannelId the fallback channel ID, which can be null
423+
* @return the ID of the channel
424+
* @throws ParsingException if the channel ID cannot be got from the channel header, the
425+
* channel response and the fallback channel ID
426+
*/
427+
@Nonnull
428+
public static String getChannelId(
429+
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
430+
@Nonnull final Optional<ChannelHeader> header,
431+
@Nonnull final JsonObject jsonResponse,
432+
@Nullable final String fallbackChannelId) throws ParsingException {
433+
if (header.isPresent()) {
434+
final ChannelHeader channelHeader = header.get();
435+
switch (channelHeader.headerType) {
436+
case C4_TABBED:
437+
final String channelId = channelHeader.json.getObject(HEADER)
438+
.getObject(C4_TABBED_HEADER_RENDERER)
439+
.getString("channelId", "");
440+
if (!isNullOrEmpty(channelId)) {
441+
return channelId;
442+
}
443+
final String navigationC4TabChannelId = channelHeader.json
444+
.getObject("navigationEndpoint")
445+
.getObject(BROWSE_ENDPOINT)
446+
.getString(BROWSE_ID);
447+
if (!isNullOrEmpty(navigationC4TabChannelId)) {
448+
return navigationC4TabChannelId;
449+
}
450+
break;
451+
case CAROUSEL:
452+
final String navigationCarouselChannelId = channelHeader.json.getObject(HEADER)
453+
.getObject(CAROUSEL_HEADER_RENDERER)
454+
.getArray(CONTENTS)
455+
.stream()
456+
.filter(JsonObject.class::isInstance)
457+
.map(JsonObject.class::cast)
458+
.filter(item -> item.has(TOPIC_CHANNEL_DETAILS_RENDERER))
459+
.findFirst()
460+
.orElse(new JsonObject())
461+
.getObject(TOPIC_CHANNEL_DETAILS_RENDERER)
462+
.getObject("navigationEndpoint")
463+
.getObject(BROWSE_ENDPOINT)
464+
.getString(BROWSE_ID);
465+
if (!isNullOrEmpty(navigationCarouselChannelId)) {
466+
return navigationCarouselChannelId;
467+
}
468+
break;
469+
default:
470+
break;
471+
}
472+
}
473+
474+
final String externalChannelId = jsonResponse.getObject("metadata")
475+
.getObject("channelMetadataRenderer")
476+
.getString("externalChannelId");
477+
if (!isNullOrEmpty(externalChannelId)) {
478+
return externalChannelId;
479+
}
480+
481+
if (!isNullOrEmpty(fallbackChannelId)) {
482+
return fallbackChannelId;
483+
} else {
484+
throw new ParsingException("Could not get channel ID");
485+
}
486+
}
487+
488+
@Nonnull
489+
public static String getChannelName(@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
490+
@Nonnull final Optional<ChannelHeader> channelHeader,
491+
@Nonnull final JsonObject jsonResponse,
492+
@Nullable final JsonObject channelAgeGateRenderer)
493+
throws ParsingException {
494+
if (channelAgeGateRenderer != null) {
495+
final String title = channelAgeGateRenderer.getString("channelTitle");
496+
if (isNullOrEmpty(title)) {
497+
throw new ParsingException("Could not get channel name");
498+
}
499+
return title;
350500
}
351501

352-
return YoutubeParsingHelper.isVerified(channelHeader.json.getArray("badges"));
502+
final String metadataRendererTitle = jsonResponse.getObject("metadata")
503+
.getObject("channelMetadataRenderer")
504+
.getString(TITLE);
505+
if (!isNullOrEmpty(metadataRendererTitle)) {
506+
return metadataRendererTitle;
507+
}
508+
509+
return channelHeader.map(header -> {
510+
final JsonObject channelJson = header.json;
511+
switch (header.headerType) {
512+
case PAGE:
513+
return channelJson.getObject(CONTENT)
514+
.getObject(PAGE_HEADER_VIEW_MODEL)
515+
.getObject(TITLE)
516+
.getObject("dynamicTextViewModel")
517+
.getObject("text")
518+
.getString(CONTENT, channelJson.getString("pageTitle"));
519+
case CAROUSEL:
520+
case INTERACTIVE_TABBED:
521+
return getTextFromObject(channelJson.getObject(TITLE));
522+
case C4_TABBED:
523+
default:
524+
return channelJson.getString(TITLE);
525+
}
526+
})
527+
// The channel name from a microformatDataRenderer may be different from the one
528+
// displayed, especially for auto-generated channels, depending on the language
529+
// requested for the interface (hl parameter of InnerTube requests' payload)
530+
.or(() -> Optional.ofNullable(jsonResponse.getObject("microformat")
531+
.getObject("microformatDataRenderer")
532+
.getString(TITLE)))
533+
.orElseThrow(() -> new ParsingException("Could not get channel name"));
534+
}
535+
536+
@Nullable
537+
public static JsonObject getChannelAgeGateRenderer(@Nonnull final JsonObject jsonResponse) {
538+
return jsonResponse.getObject(CONTENTS)
539+
.getObject("twoColumnBrowseResultsRenderer")
540+
.getArray("tabs")
541+
.stream()
542+
.filter(JsonObject.class::isInstance)
543+
.map(JsonObject.class::cast)
544+
.flatMap(tab -> tab.getObject(TAB_RENDERER)
545+
.getObject(CONTENT)
546+
.getObject("sectionListRenderer")
547+
.getArray(CONTENTS)
548+
.stream()
549+
.filter(JsonObject.class::isInstance)
550+
.map(JsonObject.class::cast))
551+
.filter(content -> content.has("channelAgeGateRenderer"))
552+
.map(content -> content.getObject("channelAgeGateRenderer"))
553+
.findFirst()
554+
.orElse(null);
353555
}
354556
}

0 commit comments

Comments
 (0)