44import com .grack .nanojson .JsonWriter ;
55import org .schabi .newpipe .extractor .exceptions .ContentNotAvailableException ;
66import org .schabi .newpipe .extractor .exceptions .ExtractionException ;
7+ import org .schabi .newpipe .extractor .exceptions .ParsingException ;
78import org .schabi .newpipe .extractor .localization .ContentCountry ;
89import org .schabi .newpipe .extractor .localization .Localization ;
910
1011import javax .annotation .Nonnull ;
12+ import javax .annotation .Nullable ;
1113import java .io .IOException ;
1214import java .nio .charset .StandardCharsets ;
1315import java .util .Optional ;
1416
1517import static org .schabi .newpipe .extractor .services .youtube .YoutubeParsingHelper .defaultAlertsCheck ;
1618import static org .schabi .newpipe .extractor .services .youtube .YoutubeParsingHelper .getJsonPostResponse ;
19+ import static org .schabi .newpipe .extractor .services .youtube .YoutubeParsingHelper .getTextFromObject ;
1720import static org .schabi .newpipe .extractor .services .youtube .YoutubeParsingHelper .prepareDesktopJsonBuilder ;
1821import static org .schabi .newpipe .extractor .utils .Utils .isNullOrEmpty ;
1922
2023/**
2124 * Shared functions for extracting YouTube channel pages and tabs.
2225 */
2326public 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