Skip to content

Commit 8be6457

Browse files
committed
[YouTube] Support pageHeader on user channels
Also move duplicate strings into constants and add a missing default switch case.
1 parent df26bad commit 8be6457

2 files changed

Lines changed: 125 additions & 155 deletions

File tree

extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeChannelExtractor.java

Lines changed: 120 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
import static org.schabi.newpipe.extractor.services.youtube.YoutubeChannelHelper.getChannelResponse;
2424
import static org.schabi.newpipe.extractor.services.youtube.YoutubeChannelHelper.resolveChannelId;
2525
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getTextFromObject;
26-
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
2726

2827
import com.grack.nanojson.JsonArray;
2928
import com.grack.nanojson.JsonObject;
@@ -59,6 +58,19 @@
5958

6059
public class YoutubeChannelExtractor extends ChannelExtractor {
6160

61+
// Constants of objects used multiples from channel responses
62+
private static final String IMAGE = "image";
63+
private static final String CONTENTS = "contents";
64+
private static final String CONTENT_PREVIEW_IMAGE_VIEW_MODEL = "contentPreviewImageViewModel";
65+
private static final String PAGE_HEADER_VIEW_MODEL = "pageHeaderViewModel";
66+
private static final String TAB_RENDERER = "tabRenderer";
67+
private static final String CONTENT = "content";
68+
private static final String METADATA = "metadata";
69+
private static final String AVATAR = "avatar";
70+
private static final String THUMBNAILS = "thumbnails";
71+
private static final String SOURCES = "sources";
72+
private static final String BANNER = "banner";
73+
6274
private JsonObject jsonResponse;
6375

6476
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@@ -95,28 +107,7 @@ public void onFetchPage(@Nonnull final Downloader downloader)
95107
jsonResponse = data.jsonResponse;
96108
channelHeader = YoutubeChannelHelper.getChannelHeader(jsonResponse);
97109
channelId = data.channelId;
98-
channelAgeGateRenderer = getChannelAgeGateRenderer();
99-
}
100-
101-
@Nullable
102-
private JsonObject getChannelAgeGateRenderer() {
103-
return jsonResponse.getObject("contents")
104-
.getObject("twoColumnBrowseResultsRenderer")
105-
.getArray("tabs")
106-
.stream()
107-
.filter(JsonObject.class::isInstance)
108-
.map(JsonObject.class::cast)
109-
.flatMap(tab -> tab.getObject("tabRenderer")
110-
.getObject("content")
111-
.getObject("sectionListRenderer")
112-
.getArray("contents")
113-
.stream()
114-
.filter(JsonObject.class::isInstance)
115-
.map(JsonObject.class::cast))
116-
.filter(content -> content.has("channelAgeGateRenderer"))
117-
.map(content -> content.getObject("channelAgeGateRenderer"))
118-
.findFirst()
119-
.orElse(null);
110+
channelAgeGateRenderer = YoutubeChannelHelper.getChannelAgeGateRenderer(jsonResponse);
120111
}
121112

122113
@Nonnull
@@ -133,94 +124,60 @@ public String getUrl() throws ParsingException {
133124
@Override
134125
public String getId() throws ParsingException {
135126
assertPageFetched();
136-
return channelHeader.map(header -> header.json)
137-
.flatMap(header -> Optional.ofNullable(header.getString("channelId"))
138-
.or(() -> Optional.ofNullable(header.getObject("navigationEndpoint")
139-
.getObject("browseEndpoint")
140-
.getString("browseId"))
141-
))
142-
.or(() -> Optional.ofNullable(channelId))
143-
.orElseThrow(() -> new ParsingException("Could not get channel ID"));
127+
return YoutubeChannelHelper.getChannelId(channelHeader, jsonResponse, channelId);
144128
}
145129

146130
@Nonnull
147131
@Override
148132
public String getName() throws ParsingException {
149133
assertPageFetched();
150-
if (channelAgeGateRenderer != null) {
151-
final String title = channelAgeGateRenderer.getString("channelTitle");
152-
if (isNullOrEmpty(title)) {
153-
throw new ParsingException("Could not get channel name");
154-
}
155-
return title;
156-
}
157-
158-
final String metadataRendererTitle = jsonResponse.getObject("metadata")
159-
.getObject("channelMetadataRenderer")
160-
.getString("title");
161-
if (!isNullOrEmpty(metadataRendererTitle)) {
162-
return metadataRendererTitle;
163-
}
164-
165-
return channelHeader.map(header -> {
166-
final JsonObject channelJson = header.json;
167-
switch (header.headerType) {
168-
case PAGE:
169-
return channelJson.getObject("content")
170-
.getObject("pageHeaderViewModel")
171-
.getObject("title")
172-
.getObject("dynamicTextViewModel")
173-
.getObject("text")
174-
.getString("content", channelJson.getString("pageTitle"));
175-
176-
case CAROUSEL:
177-
case INTERACTIVE_TABBED:
178-
return getTextFromObject(channelJson.getObject("title"));
179-
180-
case C4_TABBED:
181-
default:
182-
return channelJson.getString("title");
183-
}
184-
})
185-
// The channel name from a microformatDataRenderer may be different from the one displayed,
186-
// especially for auto-generated channels, depending on the language requested for the
187-
// interface (hl parameter of InnerTube requests' payload)
188-
.or(() -> Optional.ofNullable(jsonResponse.getObject("microformat")
189-
.getObject("microformatDataRenderer")
190-
.getString("title")))
191-
.orElseThrow(() -> new ParsingException("Could not get channel name"));
134+
return YoutubeChannelHelper.getChannelName(
135+
channelHeader, jsonResponse, channelAgeGateRenderer);
192136
}
193137

194138
@Nonnull
195139
@Override
196140
public List<Image> getAvatars() throws ParsingException {
197141
assertPageFetched();
198142
if (channelAgeGateRenderer != null) {
199-
return Optional.ofNullable(channelAgeGateRenderer.getObject("avatar")
200-
.getArray("thumbnails"))
143+
return Optional.ofNullable(channelAgeGateRenderer.getObject(AVATAR)
144+
.getArray(THUMBNAILS))
201145
.map(YoutubeParsingHelper::getImagesFromThumbnailsArray)
202146
.orElseThrow(() -> new ParsingException("Could not get avatars"));
203147
}
204148

205149
return channelHeader.map(header -> {
206150
switch (header.headerType) {
207151
case PAGE:
208-
return header.json.getObject("content")
209-
.getObject("pageHeaderViewModel")
210-
.getObject("image")
211-
.getObject("contentPreviewImageViewModel")
212-
.getObject("image")
213-
.getArray("sources");
152+
final JsonObject imageObj = header.json.getObject(CONTENT)
153+
.getObject(PAGE_HEADER_VIEW_MODEL)
154+
.getObject(IMAGE);
155+
156+
if (imageObj.has(CONTENT_PREVIEW_IMAGE_VIEW_MODEL)) {
157+
return imageObj.getObject(CONTENT_PREVIEW_IMAGE_VIEW_MODEL)
158+
.getObject(IMAGE)
159+
.getArray(SOURCES);
160+
}
214161

162+
if (imageObj.has("decoratedAvatarViewModel")) {
163+
return imageObj.getObject("decoratedAvatarViewModel")
164+
.getObject(AVATAR)
165+
.getObject("avatarViewModel")
166+
.getObject(IMAGE)
167+
.getArray(SOURCES);
168+
}
169+
170+
// Return an empty avatar array as a fallback
171+
return new JsonArray();
215172
case INTERACTIVE_TABBED:
216173
return header.json.getObject("boxArt")
217-
.getArray("thumbnails");
174+
.getArray(THUMBNAILS);
218175

219176
case C4_TABBED:
220177
case CAROUSEL:
221178
default:
222-
return header.json.getObject("avatar")
223-
.getArray("thumbnails");
179+
return header.json.getObject(AVATAR)
180+
.getArray(THUMBNAILS);
224181
}
225182
})
226183
.map(YoutubeParsingHelper::getImagesFromThumbnailsArray)
@@ -235,10 +192,27 @@ public List<Image> getBanners() {
235192
return List.of();
236193
}
237194

238-
// No banner is available on pageHeaderRenderer headers
239-
return channelHeader.filter(header -> header.headerType != HeaderType.PAGE)
240-
.map(header -> header.json.getObject("banner")
241-
.getArray("thumbnails"))
195+
return channelHeader.map(header -> {
196+
if (header.headerType == HeaderType.PAGE) {
197+
final JsonObject pageHeaderViewModel = header.json.getObject(CONTENT)
198+
.getObject(PAGE_HEADER_VIEW_MODEL);
199+
200+
if (pageHeaderViewModel.has(BANNER)) {
201+
return pageHeaderViewModel.getObject(BANNER)
202+
.getObject("imageBannerViewModel")
203+
.getObject(IMAGE)
204+
.getArray(SOURCES);
205+
}
206+
207+
// No banner is available (this should happen on pageHeaderRenderers of
208+
// system channels), use an empty JsonArray instead
209+
return new JsonArray();
210+
}
211+
212+
return header.json
213+
.getObject(BANNER)
214+
.getArray(THUMBNAILS);
215+
})
242216
.map(YoutubeParsingHelper::getImagesFromThumbnailsArray)
243217
.orElse(List.of());
244218
}
@@ -264,14 +238,16 @@ public long getSubscriberCount() throws ParsingException {
264238
if (channelHeader.isPresent()) {
265239
final ChannelHeader header = channelHeader.get();
266240

267-
if (header.headerType == HeaderType.INTERACTIVE_TABBED
268-
|| header.headerType == HeaderType.PAGE) {
269-
// No subscriber count is available on interactiveTabbedHeaderRenderer and
270-
// pageHeaderRenderer headers
241+
if (header.headerType == HeaderType.INTERACTIVE_TABBED) {
242+
// No subscriber count is available on interactiveTabbedHeaderRenderer header
271243
return UNKNOWN_SUBSCRIBER_COUNT;
272244
}
273245

274246
final JsonObject headerJson = header.json;
247+
if (header.headerType == HeaderType.PAGE) {
248+
return getSubscriberCountFromPageChannelHeader(headerJson);
249+
}
250+
275251
JsonObject textObject = null;
276252

277253
if (headerJson.has("subscriberCountText")) {
@@ -292,6 +268,51 @@ public long getSubscriberCount() throws ParsingException {
292268
return UNKNOWN_SUBSCRIBER_COUNT;
293269
}
294270

271+
private long getSubscriberCountFromPageChannelHeader(@Nonnull final JsonObject headerJson)
272+
throws ParsingException {
273+
final JsonObject metadataObject = headerJson.getObject(CONTENT)
274+
.getObject(PAGE_HEADER_VIEW_MODEL)
275+
.getObject(METADATA);
276+
if (metadataObject.has("contentMetadataViewModel")) {
277+
final JsonArray metadataPart = metadataObject.getObject("contentMetadataViewModel")
278+
.getArray("metadataRows")
279+
.stream()
280+
.filter(JsonObject.class::isInstance)
281+
.map(JsonObject.class::cast)
282+
.map(metadataRow -> metadataRow.getArray("metadataParts"))
283+
/*
284+
Find metadata parts which have two elements: channel handle and subscriber
285+
count.
286+
287+
On autogenerated music channels, the subscriber count is not shown with this
288+
header.
289+
290+
Use the first metadata parts object found.
291+
*/
292+
.filter(metadataParts -> metadataParts.size() == 2)
293+
.findFirst()
294+
.orElse(null);
295+
if (metadataPart == null) {
296+
// As the parsing of the metadata parts object needed to get the subscriber count
297+
// is fragile, return UNKNOWN_SUBSCRIBER_COUNT when it cannot be got
298+
return UNKNOWN_SUBSCRIBER_COUNT;
299+
}
300+
301+
try {
302+
// The subscriber count is at the same position for all languages as of 02/03/2024
303+
return Utils.mixedNumberWordToLong(metadataPart.getObject(0)
304+
.getObject("text")
305+
.getString(CONTENT));
306+
} catch (final NumberFormatException e) {
307+
throw new ParsingException("Could not get subscriber count", e);
308+
}
309+
}
310+
311+
// If the channel header has no contentMetadataViewModel (which is the case for system
312+
// channels using this header), return UNKNOWN_SUBSCRIBER_COUNT
313+
return UNKNOWN_SUBSCRIBER_COUNT;
314+
}
315+
295316
@Override
296317
public String getDescription() throws ParsingException {
297318
assertPageFetched();
@@ -302,12 +323,6 @@ public String getDescription() throws ParsingException {
302323
try {
303324
if (channelHeader.isPresent()) {
304325
final ChannelHeader header = channelHeader.get();
305-
306-
if (header.headerType == HeaderType.PAGE) {
307-
// A pageHeaderRenderer doesn't contain a description
308-
return null;
309-
}
310-
311326
if (header.headerType == HeaderType.INTERACTIVE_TABBED) {
312327
/*
313328
In an interactiveTabbedHeaderRenderer, the real description, is only available
@@ -322,7 +337,7 @@ public String getDescription() throws ParsingException {
322337
}
323338

324339
// The description is cut and the original one can be only accessed from the About tab
325-
return jsonResponse.getObject("metadata")
340+
return jsonResponse.getObject("title")
326341
.getObject("channelMetadataRenderer")
327342
.getString("description");
328343
} catch (final Exception e) {
@@ -371,7 +386,7 @@ public List<ListLinkHandler> getTabs() throws ParsingException {
371386

372387
@Nonnull
373388
private List<ListLinkHandler> getTabsForNonAgeRestrictedChannels() throws ParsingException {
374-
final JsonArray responseTabs = jsonResponse.getObject("contents")
389+
final JsonArray responseTabs = jsonResponse.getObject(CONTENTS)
375390
.getObject("twoColumnBrowseResultsRenderer")
376391
.getArray("tabs");
377392

@@ -392,8 +407,8 @@ private List<ListLinkHandler> getTabsForNonAgeRestrictedChannels() throws Parsin
392407
responseTabs.stream()
393408
.filter(JsonObject.class::isInstance)
394409
.map(JsonObject.class::cast)
395-
.filter(tab -> tab.has("tabRenderer"))
396-
.map(tab -> tab.getObject("tabRenderer"))
410+
.filter(tab -> tab.has(TAB_RENDERER))
411+
.map(tab -> tab.getObject(TAB_RENDERER))
397412
.forEach(tabRenderer -> {
398413
final String tabUrl = tabRenderer.getObject("endpoint")
399414
.getObject("commandMetadata")
@@ -432,6 +447,9 @@ private List<ListLinkHandler> getTabsForNonAgeRestrictedChannels() throws Parsin
432447
case "playlists":
433448
addNonVideosTab.accept(ChannelTabs.PLAYLISTS);
434449
break;
450+
default:
451+
// Unsupported channel tab, ignore it
452+
break;
435453
}
436454
}
437455
});

0 commit comments

Comments
 (0)