Skip to content

Commit 9d76862

Browse files
committed
[YouTube] Workaround Shorts UI for playlists by using a continuation for initial items
YouTube don't return currently a continuation, if applicable, for the Shorts UI in playlists, restricting access to the 100th first items. The reel items returned don't give also upload date, uploader info and precise view count. Using a continuation which requests the first page of the playlist allows currently to get access to continuations, if applicable, and also standard video elements instead of Shorts ones, making extraction of upload date, uploader info and precise view count again possible. This continuation is only requested and used when the data structure should be the one of the Shorts UI. A fallback to the standard response is made in the cases where fetching or parsing the continuation response fails, or when no stream items are extracted. This change required to: - add a protocol buffer definition file for which its structure is based on reverse engineering of playlists continuations sent by WEB InnerTube client, received from InnerTube responses; - add a dependency on Protobuf Java Lite; - generate Java classes from the definition file with the protoc version corresponding to the one of the Protobuf Java Lite dependency used (see https://protobuf.dev/support/version-support/ for more details); they are excluded from Checkstyle checks as they do not follow our style rules at all; - add a dependency on Apache Commons Codec, as Java's Base64 class desugaring for Android is not released yet.
1 parent 1f08d28 commit 9d76862

4 files changed

Lines changed: 2329 additions & 12 deletions

File tree

extractor/build.gradle

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ checkstyle {
1818
toolVersion checkstyleVersion
1919
}
2020

21+
// Exclude Protobuf generated files from Checkstyle
22+
checkstyleMain.exclude('org/schabi/newpipe/extractor/services/youtube/protos')
23+
2124
checkstyleTest {
2225
enabled false // do not checkstyle test files
2326
}
@@ -28,6 +31,11 @@ dependencies {
2831
implementation "com.github.TeamNewPipe:nanojson:$nanojsonVersion"
2932
implementation 'org.jsoup:jsoup:1.16.1'
3033
implementation "com.github.spotbugs:spotbugs-annotations:$spotbugsVersion"
34+
implementation "com.google.protobuf:protobuf-javalite:3.24.3"
35+
36+
// TODO: remove this dependency used for Base64 encoding and use Java's Base64 once its
37+
// Android desugarging support has been added
38+
implementation 'commons-codec:commons-codec:1.16.0'
3139

3240
// do not upgrade to 1.7.14, since in 1.7.14 Rhino uses the `SourceVersion` class, which is not
3341
// available on Android (even when using desugaring), and `NoClassDefFoundError` is thrown

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

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import com.grack.nanojson.JsonObject;
1616
import com.grack.nanojson.JsonWriter;
1717

18+
import org.apache.commons.codec.binary.Base64;
1819
import org.schabi.newpipe.extractor.Image;
1920
import org.schabi.newpipe.extractor.Page;
2021
import org.schabi.newpipe.extractor.StreamingService;
@@ -27,19 +28,43 @@
2728
import org.schabi.newpipe.extractor.playlist.PlaylistExtractor;
2829
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
2930
import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper;
31+
import org.schabi.newpipe.extractor.services.youtube.protos.playlist.PlaylistProtobufContinuation.ContinuationParams;
32+
import org.schabi.newpipe.extractor.services.youtube.protos.playlist.PlaylistProtobufContinuation.PlaylistContentFiltersParams;
33+
import org.schabi.newpipe.extractor.services.youtube.protos.playlist.PlaylistProtobufContinuation.PlaylistContinuation;
34+
import org.schabi.newpipe.extractor.services.youtube.protos.playlist.PlaylistProtobufContinuation.PlaylistContinuationProperties;
3035
import org.schabi.newpipe.extractor.stream.Description;
3136
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
3237
import org.schabi.newpipe.extractor.stream.StreamInfoItemsCollector;
3338
import org.schabi.newpipe.extractor.utils.Utils;
3439

3540
import java.io.IOException;
41+
import java.io.UnsupportedEncodingException;
3642
import java.nio.charset.StandardCharsets;
3743
import java.util.List;
3844

3945
import javax.annotation.Nonnull;
4046
import javax.annotation.Nullable;
4147

4248
public class YoutubePlaylistExtractor extends PlaylistExtractor {
49+
50+
private static final String PLAYLIST_CONTINUATION_PROPERTIES_BASE64;
51+
52+
static {
53+
try {
54+
PLAYLIST_CONTINUATION_PROPERTIES_BASE64 = Utils.encodeUrlUtf8(
55+
Base64.encodeBase64String(
56+
PlaylistContinuationProperties.newBuilder()
57+
.setRequestCount(0)
58+
.setContentFilters(PlaylistContentFiltersParams.newBuilder()
59+
.setHideUnavailableVideos(false)
60+
.build())
61+
.build()
62+
.toByteArray()));
63+
} catch (final UnsupportedEncodingException e) {
64+
throw new RuntimeException("Couldn't encode playlist continuation properties", e);
65+
}
66+
}
67+
4368
// Names of some objects in JSON response frequently used in this class
4469
private static final String PLAYLIST_VIDEO_RENDERER = "playlistVideoRenderer";
4570
private static final String PLAYLIST_VIDEO_LIST_RENDERER = "playlistVideoListRenderer";
@@ -50,6 +75,7 @@ public class YoutubePlaylistExtractor extends PlaylistExtractor {
5075
private static final String VIDEO_OWNER_RENDERER = "videoOwnerRenderer";
5176

5277
private JsonObject browseResponse;
78+
private JsonObject initialContinuationResponse;
5379

5480
private JsonObject playlistInfo;
5581
private JsonObject uploaderInfo;
@@ -330,6 +356,17 @@ public InfoItemsPage<StreamInfoItem> getInitialPage() throws IOException, Extrac
330356
if (videoPlaylistObject.has(PLAYLIST_VIDEO_LIST_RENDERER)) {
331357
renderer = videoPlaylistObject.getObject(PLAYLIST_VIDEO_LIST_RENDERER);
332358
} else if (videoPlaylistObject.has(RICH_GRID_RENDERER)) {
359+
// richGridRenderer objects are returned for playlists with a Shorts UI
360+
// As of 09/12/2023 (American English date format), an initial continuation allows
361+
// to get continuations of playlists with a Shorts UI and regular playlist video
362+
// renderers
363+
final InfoItemsPage<StreamInfoItem> continuationPage = getInitialContinuationPage();
364+
if (!continuationPage.getItems().isEmpty()) {
365+
return continuationPage;
366+
}
367+
368+
// If no items could be extracted from the continuation, fall back to the shorts UI
369+
// renderers, no continuation is provided
333370
renderer = videoPlaylistObject.getObject(RICH_GRID_RENDERER);
334371
} else {
335372
return new InfoItemsPage<>(collector, null);
@@ -375,25 +412,66 @@ private Page getNextPageFrom(final JsonArray contents)
375412

376413
final JsonObject lastElement = contents.getObject(contents.size() - 1);
377414
if (lastElement.has("continuationItemRenderer")) {
378-
final String continuation = lastElement
379-
.getObject("continuationItemRenderer")
415+
return getPageFromContinuation(lastElement.getObject("continuationItemRenderer")
380416
.getObject("continuationEndpoint")
381417
.getObject("continuationCommand")
382-
.getString("token");
383-
384-
final byte[] body = JsonWriter.string(prepareDesktopJsonBuilder(
385-
getExtractorLocalization(), getExtractorContentCountry())
386-
.value("continuation", continuation)
387-
.done())
388-
.getBytes(StandardCharsets.UTF_8);
389-
390-
return new Page(YOUTUBEI_V1_URL + "browse?key=" + getKey()
391-
+ DISABLE_PRETTY_PRINT_PARAMETER, body);
418+
.getString("token"));
392419
} else {
393420
return null;
394421
}
395422
}
396423

424+
@Nonnull
425+
private Page getPageFromContinuation(@Nonnull final String continuation)
426+
throws IOException, ExtractionException {
427+
final byte[] body = JsonWriter.string(prepareDesktopJsonBuilder(
428+
getExtractorLocalization(), getExtractorContentCountry())
429+
.value("continuation", continuation)
430+
.done())
431+
.getBytes(StandardCharsets.UTF_8);
432+
433+
return new Page(YOUTUBEI_V1_URL + "browse?key=" + getKey()
434+
+ DISABLE_PRETTY_PRINT_PARAMETER, body);
435+
}
436+
437+
@Nonnull
438+
private InfoItemsPage<StreamInfoItem> getInitialContinuationPage()
439+
throws IOException, ExtractionException {
440+
if (initialContinuationResponse == null) {
441+
final String playlistId = getId();
442+
final PlaylistContinuation playlistContinuation = PlaylistContinuation.newBuilder()
443+
.setParameters(ContinuationParams.newBuilder()
444+
.setBrowseId("VL" + playlistId)
445+
.setPlaylistId(playlistId)
446+
.setContinuationProperties(PLAYLIST_CONTINUATION_PROPERTIES_BASE64)
447+
.build())
448+
.build();
449+
450+
final String initialContinuation = Utils.encodeUrlUtf8(
451+
Base64.encodeBase64String(playlistContinuation.toByteArray()));
452+
final Page page = getPageFromContinuation(initialContinuation);
453+
454+
try {
455+
initialContinuationResponse = getJsonPostResponse("browse", page.getBody(),
456+
getExtractorLocalization());
457+
} catch (final Exception e) {
458+
return InfoItemsPage.emptyPage();
459+
}
460+
}
461+
462+
final StreamInfoItemsCollector collector = new StreamInfoItemsCollector(getServiceId());
463+
464+
final JsonArray initialItems = initialContinuationResponse
465+
.getArray("onResponseReceivedActions")
466+
.getObject(0)
467+
.getObject("reloadContinuationItemsCommand")
468+
.getArray("continuationItems");
469+
470+
collectStreamsFrom(collector, initialItems);
471+
472+
return new InfoItemsPage<>(collector, getNextPageFrom(initialItems));
473+
}
474+
397475
private void collectStreamsFrom(@Nonnull final StreamInfoItemsCollector collector,
398476
@Nonnull final JsonArray videos) {
399477
final TimeAgoParser timeAgoParser = getTimeAgoParser();

0 commit comments

Comments
 (0)