Skip to content

Commit 7366eab

Browse files
AudricVTheta-DevStypox
committed
[YouTube] Add support for channel tabs and tags and age-restricted channels
Support of tags and videos, shorts, live, playlists and channels tabs has been added for non-age restricted channels. Age-restricted channels are now also supported and always returned the videos, shorts and live tabs, accessible using system playlists. These tabs are the only ones which can be accessed using YouTube's desktop website without being logged-in. The videos channel tab parameter has been updated to the one used by the desktop website and when a channel extraction is fetched, this tab is returned in the list of tabs as a cached one in the corresponding link handler. Visitor data support per request has been added, as a valid visitor data is required to fetch continuations with contents on the shorts tab. It is only used in this case to enhance privacy. A dedicated shorts UI elements (reelItemRenderers) extractor has been added, YoutubeReelInfoItemExtractor. These elements do not provide the exact view count, any uploader info (name, URL, avatar, verified status) and the upload date. All service's LinkHandlers are now using the singleton pattern and some code has been also improved on the files changed. Co-authored-by: ThetaDev <t.testboy@gmail.com> Co-authored-by: Stypox <stypox@pm.me>
1 parent 4586067 commit 7366eab

15 files changed

Lines changed: 1492 additions & 373 deletions
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
package org.schabi.newpipe.extractor.services.youtube;
2+
3+
import com.grack.nanojson.JsonObject;
4+
import com.grack.nanojson.JsonWriter;
5+
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
6+
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
7+
import org.schabi.newpipe.extractor.localization.ContentCountry;
8+
import org.schabi.newpipe.extractor.localization.Localization;
9+
10+
import javax.annotation.Nonnull;
11+
import java.io.IOException;
12+
import java.nio.charset.StandardCharsets;
13+
import java.util.Optional;
14+
15+
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.defaultAlertsCheck;
16+
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getJsonPostResponse;
17+
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.prepareDesktopJsonBuilder;
18+
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
19+
20+
/**
21+
* Shared functions for extracting YouTube channel pages and tabs.
22+
*/
23+
public final class YoutubeChannelHelper {
24+
private YoutubeChannelHelper() {
25+
}
26+
27+
/**
28+
* Take a YouTube channel ID or URL path, resolve it if necessary and return a channel ID.
29+
*
30+
* @param idOrPath a YouTube channel ID or URL path
31+
* @return a YouTube channel ID
32+
* @throws IOException if a channel resolve request failed
33+
* @throws ExtractionException if a channel resolve request response could not be parsed or is
34+
* invalid
35+
*/
36+
@Nonnull
37+
public static String resolveChannelId(@Nonnull final String idOrPath)
38+
throws ExtractionException, IOException {
39+
final String[] channelId = idOrPath.split("/");
40+
41+
if (channelId[0].startsWith("UC")) {
42+
return channelId[0];
43+
}
44+
45+
// If the URL is not a /channel URL, we need to use the navigation/resolve_url endpoint of
46+
// the InnerTube API to get the channel id.
47+
// Otherwise, we couldn't get information about the channel associated with this URL, if
48+
// there is one.
49+
if (!channelId[0].equals("channel")) {
50+
final byte[] body = JsonWriter.string(
51+
prepareDesktopJsonBuilder(Localization.DEFAULT, ContentCountry.DEFAULT)
52+
.value("url", "https://www.youtube.com/" + idOrPath)
53+
.done())
54+
.getBytes(StandardCharsets.UTF_8);
55+
56+
final JsonObject jsonResponse = getJsonPostResponse(
57+
"navigation/resolve_url", body, Localization.DEFAULT);
58+
59+
checkIfChannelResponseIsValid(jsonResponse);
60+
61+
final JsonObject endpoint = jsonResponse.getObject("endpoint");
62+
63+
final String webPageType = endpoint.getObject("commandMetadata")
64+
.getObject("webCommandMetadata")
65+
.getString("webPageType", "");
66+
67+
final JsonObject browseEndpoint = endpoint.getObject("browseEndpoint");
68+
final String browseId = browseEndpoint.getString("browseId", "");
69+
70+
if (webPageType.equalsIgnoreCase("WEB_PAGE_TYPE_BROWSE")
71+
|| webPageType.equalsIgnoreCase("WEB_PAGE_TYPE_CHANNEL")
72+
&& !browseId.isEmpty()) {
73+
if (!browseId.startsWith("UC")) {
74+
throw new ExtractionException("Redirected id is not pointing to a channel");
75+
}
76+
77+
return browseId;
78+
}
79+
}
80+
81+
return channelId[1];
82+
}
83+
84+
/**
85+
* Response data object for {@link #getChannelResponse(String, String, Localization,
86+
* ContentCountry)}, after any redirection in the allowed redirects count ({@code 3}).
87+
*/
88+
public static final class ChannelResponseData {
89+
90+
/**
91+
* The channel response as a JSON object, after all redirects.
92+
*/
93+
@Nonnull
94+
public final JsonObject jsonResponse;
95+
96+
/**
97+
* The channel ID after all redirects.
98+
*/
99+
@Nonnull
100+
public final String channelId;
101+
102+
private ChannelResponseData(@Nonnull final JsonObject jsonResponse,
103+
@Nonnull final String channelId) {
104+
this.jsonResponse = jsonResponse;
105+
this.channelId = channelId;
106+
}
107+
}
108+
109+
/**
110+
* Fetch a YouTube channel tab response, using the given channel ID and tab parameters.
111+
*
112+
* <p>
113+
* Redirections to other channels such as are supported to up to 3 redirects, which could
114+
* happen for instance for localized channels or auto-generated ones such as the {@code Movies
115+
* and Shows} (channel IDs {@code UCuJcl0Ju-gPDoksRjK1ya-w}, {@code UChBfWrfBXL9wS6tQtgjt_OQ}
116+
* and {@code UCok7UTQQEP1Rsctxiv3gwSQ} of this channel redirect to the
117+
* {@code UClgRkhTL3_hImCAmdLfDE4g} one).
118+
* </p>
119+
*
120+
* @param channelId a valid YouTube channel ID
121+
* @param parameters the parameters to specify the YouTube channel tab; if invalid ones are
122+
* specified, YouTube should return the {@code Home} tab
123+
* @param localization the {@link Localization} to use
124+
* @param country the {@link ContentCountry} to use
125+
* @return a {@link ChannelResponseData channel response data}
126+
* @throws IOException if a channel request failed
127+
* @throws ExtractionException if a channel request response could not be parsed or is invalid
128+
*/
129+
@Nonnull
130+
public static ChannelResponseData getChannelResponse(@Nonnull final String channelId,
131+
@Nonnull final String parameters,
132+
@Nonnull final Localization localization,
133+
@Nonnull final ContentCountry country)
134+
throws ExtractionException, IOException {
135+
String id = channelId;
136+
JsonObject ajaxJson = null;
137+
138+
int level = 0;
139+
while (level < 3) {
140+
final byte[] body = JsonWriter.string(prepareDesktopJsonBuilder(
141+
localization, country)
142+
.value("browseId", id)
143+
.value("params", parameters)
144+
.done())
145+
.getBytes(StandardCharsets.UTF_8);
146+
147+
final JsonObject jsonResponse = getJsonPostResponse(
148+
"browse", body, localization);
149+
150+
checkIfChannelResponseIsValid(jsonResponse);
151+
152+
final JsonObject endpoint = jsonResponse.getArray("onResponseReceivedActions")
153+
.getObject(0)
154+
.getObject("navigateAction")
155+
.getObject("endpoint");
156+
157+
final String webPageType = endpoint.getObject("commandMetadata")
158+
.getObject("webCommandMetadata")
159+
.getString("webPageType", "");
160+
161+
final String browseId = endpoint.getObject("browseEndpoint")
162+
.getString("browseId", "");
163+
164+
if (webPageType.equalsIgnoreCase("WEB_PAGE_TYPE_BROWSE")
165+
|| webPageType.equalsIgnoreCase("WEB_PAGE_TYPE_CHANNEL")
166+
&& !browseId.isEmpty()) {
167+
if (!browseId.startsWith("UC")) {
168+
throw new ExtractionException("Redirected id is not pointing to a channel");
169+
}
170+
171+
id = browseId;
172+
level++;
173+
} else {
174+
ajaxJson = jsonResponse;
175+
break;
176+
}
177+
}
178+
179+
if (ajaxJson == null) {
180+
throw new ExtractionException("Got no channel response");
181+
}
182+
183+
defaultAlertsCheck(ajaxJson);
184+
185+
return new ChannelResponseData(ajaxJson, id);
186+
}
187+
188+
/**
189+
* Assert that a channel JSON response does not contain an {@code error} JSON object.
190+
*
191+
* @param jsonResponse a channel JSON response
192+
* @throws ContentNotAvailableException if the channel was not found
193+
*/
194+
private static void checkIfChannelResponseIsValid(@Nonnull final JsonObject jsonResponse)
195+
throws ContentNotAvailableException {
196+
if (!isNullOrEmpty(jsonResponse.getObject("error"))) {
197+
final JsonObject errorJsonObject = jsonResponse.getObject("error");
198+
final int errorCode = errorJsonObject.getInt("code");
199+
if (errorCode == 404) {
200+
throw new ContentNotAvailableException("This channel doesn't exist.");
201+
} else {
202+
throw new ContentNotAvailableException("Got error:\""
203+
+ errorJsonObject.getString("status") + "\": "
204+
+ errorJsonObject.getString("message"));
205+
}
206+
}
207+
}
208+
209+
/**
210+
* A channel header response.
211+
*
212+
* <p>
213+
* This class allows the distinction between a classic header and a carousel one, used for
214+
* auto-generated ones like the gaming or music topic channels and for big events such as the
215+
* Coachella music festival, which have a different data structure and do not return the same
216+
* properties.
217+
* </p>
218+
*/
219+
public static final class ChannelHeader {
220+
221+
/**
222+
* The channel header JSON response.
223+
*/
224+
@Nonnull
225+
public final JsonObject json;
226+
227+
/**
228+
* Whether the header is a {@code carouselHeaderRenderer}.
229+
*
230+
* <p>
231+
* See the class documentation for more details.
232+
* </p>
233+
*/
234+
public final boolean isCarouselHeader;
235+
236+
private ChannelHeader(@Nonnull final JsonObject json, final boolean isCarouselHeader) {
237+
this.json = json;
238+
this.isCarouselHeader = isCarouselHeader;
239+
}
240+
}
241+
242+
/**
243+
* Get a channel header as an {@link Optional} it if exists.
244+
*
245+
* @param channelResponse a full channel JSON response
246+
* @return an {@link Optional} containing a {@link ChannelHeader} or an empty {@link Optional}
247+
* if no supported header has been found
248+
*/
249+
@Nonnull
250+
public static Optional<ChannelHeader> getChannelHeader(
251+
@Nonnull final JsonObject channelResponse) {
252+
final JsonObject header = channelResponse.getObject("header");
253+
254+
if (header.has("c4TabbedHeaderRenderer")) {
255+
return Optional.of(header.getObject("c4TabbedHeaderRenderer"))
256+
.map(json -> new ChannelHeader(json, false));
257+
} else if (header.has("carouselHeaderRenderer")) {
258+
return header.getObject("carouselHeaderRenderer")
259+
.getArray("contents")
260+
.stream()
261+
.filter(JsonObject.class::isInstance)
262+
.map(JsonObject.class::cast)
263+
.filter(item -> item.has("topicChannelDetailsRenderer"))
264+
.findFirst()
265+
.map(item -> item.getObject("topicChannelDetailsRenderer"))
266+
.map(json -> new ChannelHeader(json, true));
267+
} else {
268+
return Optional.empty();
269+
}
270+
}
271+
}

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,17 +1230,31 @@ public static JsonBuilder<JsonObject> prepareDesktopJsonBuilder(
12301230
@Nonnull final Localization localization,
12311231
@Nonnull final ContentCountry contentCountry)
12321232
throws IOException, ExtractionException {
1233+
return prepareDesktopJsonBuilder(localization, contentCountry, null);
1234+
}
1235+
1236+
@Nonnull
1237+
public static JsonBuilder<JsonObject> prepareDesktopJsonBuilder(
1238+
@Nonnull final Localization localization,
1239+
@Nonnull final ContentCountry contentCountry,
1240+
@Nullable final String visitorData)
1241+
throws IOException, ExtractionException {
12331242
// @formatter:off
1234-
return JsonObject.builder()
1243+
final JsonBuilder<JsonObject> builder = JsonObject.builder()
12351244
.object("context")
12361245
.object("client")
12371246
.value("hl", localization.getLocalizationCode())
12381247
.value("gl", contentCountry.getCountryCode())
12391248
.value("clientName", "WEB")
12401249
.value("clientVersion", getClientVersion())
12411250
.value("originalUrl", "https://www.youtube.com")
1242-
.value("platform", "DESKTOP")
1243-
.end()
1251+
.value("platform", "DESKTOP");
1252+
1253+
if (visitorData != null) {
1254+
builder.value("visitorData", visitorData);
1255+
}
1256+
1257+
return builder.end()
12441258
.object("request")
12451259
.array("internalExperimentFlags")
12461260
.end()

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import org.schabi.newpipe.extractor.StreamingService;
1010
import org.schabi.newpipe.extractor.channel.ChannelExtractor;
11+
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabExtractor;
1112
import org.schabi.newpipe.extractor.comments.CommentsExtractor;
1213
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
1314
import org.schabi.newpipe.extractor.feed.FeedExtractor;
@@ -16,13 +17,15 @@
1617
import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory;
1718
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
1819
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory;
20+
import org.schabi.newpipe.extractor.linkhandler.ReadyChannelTabListLinkHandler;
1921
import org.schabi.newpipe.extractor.linkhandler.SearchQueryHandler;
2022
import org.schabi.newpipe.extractor.linkhandler.SearchQueryHandlerFactory;
2123
import org.schabi.newpipe.extractor.localization.ContentCountry;
2224
import org.schabi.newpipe.extractor.localization.Localization;
2325
import org.schabi.newpipe.extractor.playlist.PlaylistExtractor;
2426
import org.schabi.newpipe.extractor.search.SearchExtractor;
2527
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeChannelExtractor;
28+
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeChannelTabExtractor;
2629
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeCommentsExtractor;
2730
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeFeedExtractor;
2831
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeMixPlaylistExtractor;
@@ -34,6 +37,7 @@
3437
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeSuggestionExtractor;
3538
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeTrendingExtractor;
3639
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeChannelLinkHandlerFactory;
40+
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeChannelTabLinkHandlerFactory;
3741
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeCommentsLinkHandlerFactory;
3842
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubePlaylistLinkHandlerFactory;
3943
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory;
@@ -88,6 +92,11 @@ public ListLinkHandlerFactory getChannelLHFactory() {
8892
return YoutubeChannelLinkHandlerFactory.getInstance();
8993
}
9094

95+
@Override
96+
public ListLinkHandlerFactory getChannelTabLHFactory() {
97+
return YoutubeChannelTabLinkHandlerFactory.getInstance();
98+
}
99+
91100
@Override
92101
public ListLinkHandlerFactory getPlaylistLHFactory() {
93102
return YoutubePlaylistLinkHandlerFactory.getInstance();
@@ -108,6 +117,15 @@ public ChannelExtractor getChannelExtractor(final ListLinkHandler linkHandler) {
108117
return new YoutubeChannelExtractor(this, linkHandler);
109118
}
110119

120+
@Override
121+
public ChannelTabExtractor getChannelTabExtractor(final ListLinkHandler linkHandler) {
122+
if (linkHandler instanceof ReadyChannelTabListLinkHandler) {
123+
return ((ReadyChannelTabListLinkHandler) linkHandler).getChannelTabExtractor(this);
124+
} else {
125+
return new YoutubeChannelTabExtractor(this, linkHandler);
126+
}
127+
}
128+
111129
@Override
112130
public PlaylistExtractor getPlaylistExtractor(final ListLinkHandler linkHandler) {
113131
if (YoutubeParsingHelper.isYoutubeMixId(linkHandler.getId())) {
@@ -136,16 +154,17 @@ public SuggestionExtractor getSuggestionExtractor() {
136154
@Override
137155
public KioskList getKioskList() throws ExtractionException {
138156
final KioskList list = new KioskList(this);
157+
final ListLinkHandlerFactory h = YoutubeTrendingLinkHandlerFactory.getInstance();
139158

140159
// add kiosks here e.g.:
141160
try {
142161
list.addKioskEntry(
143162
(streamingService, url, id) -> new YoutubeTrendingExtractor(
144163
YoutubeService.this,
145-
new YoutubeTrendingLinkHandlerFactory().fromUrl(url),
164+
h.fromUrl(url),
146165
id
147166
),
148-
new YoutubeTrendingLinkHandlerFactory(),
167+
h,
149168
YoutubeTrendingExtractor.KIOSK_ID
150169
);
151170
list.setDefaultKiosk(YoutubeTrendingExtractor.KIOSK_ID);

0 commit comments

Comments
 (0)