Skip to content

Commit a9a4181

Browse files
authored
Merge pull request TeamNewPipe#1104 from AudricV/yt_continuations-playlists-shorts-ui
2 parents a94a6e0 + a3e2d48 commit a9a4181

49 files changed

Lines changed: 5061 additions & 2387 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

build.gradle

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,20 @@ dependencies {
4040
}
4141

4242
subprojects {
43-
task sourcesJar(type: Jar, dependsOn: classes) {
43+
tasks.register('sourcesJar', Jar) {
44+
dependsOn classes
4445
archiveClassifier.set('sources')
4546
from sourceSets.main.allSource
4647
}
4748

48-
tasks.withType(Test) {
49+
// Protobuf files would uselessly end up in the JAR otherwise, see
50+
// https://github.com/google/protobuf-gradle-plugin/issues/390
51+
tasks.withType(Jar).configureEach {
52+
exclude '**/*.proto'
53+
includeEmptyDirs false
54+
}
55+
56+
tasks.withType(Test).configureEach {
4957
testLogging {
5058
events "skipped", "failed"
5159
showStandardStreams = true
@@ -59,8 +67,8 @@ subprojects {
5967
}
6068

6169
// https://discuss.gradle.org/t/best-approach-gradle-multi-module-project-generate-just-one-global-javadoc/18657/21
62-
task aggregatedJavadocs(type: Javadoc, group: 'Documentation') {
63-
destinationDir = file("$buildDir/docs/javadoc")
70+
tasks.register('aggregatedJavadocs', Javadoc) {
71+
destinationDir = file("${layout.buildDirectory}/docs/javadoc")
6472
title = "$project.name $version"
6573
// options.memberLevel = JavadocMemberLevel.PRIVATE
6674
options.links 'https://docs.oracle.com/javase/8/docs/api/'

extractor/build.gradle

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
plugins {
2-
id 'checkstyle'
2+
id "checkstyle"
3+
id "com.google.protobuf" version "0.9.5"
34
}
45

56
test {
@@ -18,12 +19,16 @@ checkstyle {
1819
toolVersion checkstyleVersion
1920
}
2021

22+
// Exclude Protobuf generated files from Checkstyle
23+
checkstyleMain.exclude("org/schabi/newpipe/extractor/services/youtube/protos")
24+
2125
checkstyleTest {
2226
enabled false // do not checkstyle test files
2327
}
2428

2529
ext {
2630
rhinoVersion = '1.8.0'
31+
protobufVersion = '4.30.2'
2732
}
2833

2934
dependencies {
@@ -32,6 +37,7 @@ dependencies {
3237
implementation "com.github.TeamNewPipe:nanojson:$nanojsonVersion"
3338
implementation 'org.jsoup:jsoup:1.21.1'
3439
implementation "com.google.code.findbugs:jsr305:$jsr305Version"
40+
implementation "com.google.protobuf:protobuf-javalite:$protobufVersion"
3541

3642
implementation "org.mozilla:rhino:$rhinoVersion"
3743
implementation "org.mozilla:rhino-engine:$rhinoVersion"
@@ -47,3 +53,19 @@ dependencies {
4753
testImplementation "com.squareup.okhttp3:okhttp:4.12.0"
4854
testImplementation 'com.google.code.gson:gson:2.13.1'
4955
}
56+
57+
protobuf {
58+
protoc {
59+
artifact = "com.google.protobuf:protoc:$protobufVersion"
60+
}
61+
62+
generateProtoTasks {
63+
all().configureEach { task ->
64+
task.builtins {
65+
java {
66+
option "lite"
67+
}
68+
}
69+
}
70+
}
71+
}

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,9 +1017,9 @@ public static String getValidJsonResponseBody(@Nonnull final Response response)
10171017
return responseBody;
10181018
}
10191019

1020-
public static JsonObject getJsonPostResponse(final String endpoint,
1020+
public static JsonObject getJsonPostResponse(@Nonnull final String endpoint,
10211021
final byte[] body,
1022-
final Localization localization)
1022+
@Nonnull final Localization localization)
10231023
throws IOException, ExtractionException {
10241024
final var headers = getYouTubeHeaders();
10251025

@@ -1028,6 +1028,26 @@ public static JsonObject getJsonPostResponse(final String endpoint,
10281028
+ DISABLE_PRETTY_PRINT_PARAMETER, headers, body, localization)));
10291029
}
10301030

1031+
public static JsonObject getJsonPostResponse(@Nonnull final String endpoint,
1032+
@Nonnull final List<String> queryParameters,
1033+
final byte[] body,
1034+
@Nonnull final Localization localization)
1035+
throws IOException, ExtractionException {
1036+
final var headers = getYouTubeHeaders();
1037+
1038+
final String queryParametersString;
1039+
if (queryParameters.isEmpty()) {
1040+
queryParametersString = "?" + DISABLE_PRETTY_PRINT_PARAMETER;
1041+
} else {
1042+
queryParametersString = "?" + String.join("&", queryParameters)
1043+
+ "&" + DISABLE_PRETTY_PRINT_PARAMETER;
1044+
}
1045+
1046+
return JsonUtils.toJsonObject(getValidJsonResponseBody(
1047+
getDownloader().postWithContentTypeJson(YOUTUBEI_V1_URL + endpoint
1048+
+ queryParametersString, headers, body, localization)));
1049+
}
1050+
10311051
@Nonnull
10321052
public static JsonBuilder<JsonObject> prepareDesktopJsonBuilder(
10331053
@Nonnull final Localization localization,

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

Lines changed: 47 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getImagesFromThumbnailsArray;
99
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getUrlFromNavigationEndpoint;
1010
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.prepareDesktopJsonBuilder;
11+
import static org.schabi.newpipe.extractor.services.youtube.protos.playlist.PlaylistProtobufContinuation.ContinuationParams;
12+
import static org.schabi.newpipe.extractor.services.youtube.protos.playlist.PlaylistProtobufContinuation.PlaylistContinuation;
1113
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
1214

1315
import com.grack.nanojson.JsonArray;
@@ -33,6 +35,7 @@
3335

3436
import java.io.IOException;
3537
import java.nio.charset.StandardCharsets;
38+
import java.util.Base64;
3639
import java.util.List;
3740

3841
import javax.annotation.Nonnull;
@@ -41,14 +44,17 @@
4144
public class YoutubePlaylistExtractor extends PlaylistExtractor {
4245
// Names of some objects in JSON response frequently used in this class
4346
private static final String PLAYLIST_VIDEO_RENDERER = "playlistVideoRenderer";
44-
private static final String PLAYLIST_VIDEO_LIST_RENDERER = "playlistVideoListRenderer";
45-
private static final String RICH_GRID_RENDERER = "richGridRenderer";
4647
private static final String RICH_ITEM_RENDERER = "richItemRenderer";
4748
private static final String REEL_ITEM_RENDERER = "reelItemRenderer";
4849
private static final String SIDEBAR = "sidebar";
50+
private static final String HEADER = "header";
4951
private static final String VIDEO_OWNER_RENDERER = "videoOwnerRenderer";
52+
private static final String MICROFORMAT = "microformat";
53+
// Continuation properties requesting first page and showing unavailable videos
54+
private static final String PLAYLIST_CONTINUATION_PROPERTIES_BASE64 = "CADCBgIIAA%3D%3D";
5055

51-
private JsonObject browseResponse;
56+
private JsonObject browseMetadataResponse;
57+
private JsonObject initialBrowseContinuationResponse;
5258

5359
private JsonObject playlistInfo;
5460
private JsonObject uploaderInfo;
@@ -64,17 +70,40 @@ public YoutubePlaylistExtractor(final StreamingService service,
6470
@Override
6571
public void onFetchPage(@Nonnull final Downloader downloader) throws IOException,
6672
ExtractionException {
73+
final String playlistId = getId();
74+
6775
final Localization localization = getExtractorLocalization();
6876
final byte[] body = JsonWriter.string(prepareDesktopJsonBuilder(localization,
6977
getExtractorContentCountry())
70-
.value("browseId", "VL" + getId())
78+
.value("browseId", "VL" + playlistId)
7179
.value("params", "wgYCCAA%3D") // Show unavailable videos
7280
.done())
7381
.getBytes(StandardCharsets.UTF_8);
7482

75-
browseResponse = getJsonPostResponse("browse", body, localization);
76-
YoutubeParsingHelper.defaultAlertsCheck(browseResponse);
83+
browseMetadataResponse = getJsonPostResponse("browse",
84+
List.of("$fields=" + SIDEBAR + "," + HEADER + "," + MICROFORMAT + ",alerts"),
85+
body,
86+
localization);
87+
88+
YoutubeParsingHelper.defaultAlertsCheck(browseMetadataResponse);
7789
isNewPlaylistInterface = checkIfResponseIsNewPlaylistInterface();
90+
91+
final PlaylistContinuation playlistContinuation = PlaylistContinuation.newBuilder()
92+
.setParameters(ContinuationParams.newBuilder()
93+
.setBrowseId("VL" + playlistId)
94+
.setPlaylistId(playlistId)
95+
.setContinuationProperties(PLAYLIST_CONTINUATION_PROPERTIES_BASE64)
96+
.build())
97+
.build();
98+
99+
initialBrowseContinuationResponse = getJsonPostResponse("browse",
100+
JsonWriter.string(prepareDesktopJsonBuilder(localization,
101+
getExtractorContentCountry())
102+
.value("continuation", Utils.encodeUrlUtf8(Base64.getUrlEncoder()
103+
.encodeToString(playlistContinuation.toByteArray())))
104+
.done())
105+
.getBytes(StandardCharsets.UTF_8),
106+
localization);
78107
}
79108

80109
/**
@@ -93,13 +122,13 @@ public void onFetchPage(@Nonnull final Downloader downloader) throws IOException
93122
*/
94123
private boolean checkIfResponseIsNewPlaylistInterface() {
95124
// The "old" playlist UI can be also returned with the new one
96-
return browseResponse.has("header") && !browseResponse.has(SIDEBAR);
125+
return browseMetadataResponse.has(HEADER) && !browseMetadataResponse.has(SIDEBAR);
97126
}
98127

99128
@Nonnull
100129
private JsonObject getUploaderInfo() throws ParsingException {
101130
if (uploaderInfo == null) {
102-
uploaderInfo = browseResponse.getObject(SIDEBAR)
131+
uploaderInfo = browseMetadataResponse.getObject(SIDEBAR)
103132
.getObject("playlistSidebarRenderer")
104133
.getArray("items")
105134
.stream()
@@ -121,7 +150,7 @@ private JsonObject getUploaderInfo() throws ParsingException {
121150
@Nonnull
122151
private JsonObject getPlaylistInfo() throws ParsingException {
123152
if (playlistInfo == null) {
124-
playlistInfo = browseResponse.getObject(SIDEBAR)
153+
playlistInfo = browseMetadataResponse.getObject(SIDEBAR)
125154
.getObject("playlistSidebarRenderer")
126155
.getArray("items")
127156
.stream()
@@ -139,7 +168,7 @@ private JsonObject getPlaylistInfo() throws ParsingException {
139168
@Nonnull
140169
private JsonObject getPlaylistHeader() {
141170
if (playlistHeader == null) {
142-
playlistHeader = browseResponse.getObject("header")
171+
playlistHeader = browseMetadataResponse.getObject(HEADER)
143172
.getObject("playlistHeaderRenderer");
144173
}
145174

@@ -154,7 +183,7 @@ public String getName() throws ParsingException {
154183
return name;
155184
}
156185

157-
return browseResponse.getObject("microformat")
186+
return browseMetadataResponse.getObject(MICROFORMAT)
158187
.getObject("microformatDataRenderer")
159188
.getString("title");
160189
}
@@ -180,7 +209,7 @@ public List<Image> getThumbnails() throws ParsingException {
180209
}
181210

182211
// This data structure is returned in both layouts
183-
final JsonArray microFormatThumbnailsArray = browseResponse.getObject("microformat")
212+
final JsonArray microFormatThumbnailsArray = browseMetadataResponse.getObject(MICROFORMAT)
184213
.getObject("microformatDataRenderer")
185214
.getObject("thumbnail")
186215
.getArray("thumbnails");
@@ -302,45 +331,16 @@ public Description getDescription() throws ParsingException {
302331
@Override
303332
public InfoItemsPage<StreamInfoItem> getInitialPage() throws IOException, ExtractionException {
304333
final StreamInfoItemsCollector collector = new StreamInfoItemsCollector(getServiceId());
305-
Page nextPage = null;
306334

307-
final JsonArray contents = browseResponse.getObject("contents")
308-
.getObject("twoColumnBrowseResultsRenderer")
309-
.getArray("tabs")
335+
final JsonArray initialItems = initialBrowseContinuationResponse
336+
.getArray("onResponseReceivedActions")
310337
.getObject(0)
311-
.getObject("tabRenderer")
312-
.getObject("content")
313-
.getObject("sectionListRenderer")
314-
.getArray("contents");
315-
316-
final JsonObject videoPlaylistObject = contents.stream()
317-
.filter(JsonObject.class::isInstance)
318-
.map(JsonObject.class::cast)
319-
.map(content -> content.getObject("itemSectionRenderer")
320-
.getArray("contents")
321-
.getObject(0))
322-
.filter(content -> content.has(PLAYLIST_VIDEO_LIST_RENDERER)
323-
|| content.has(RICH_GRID_RENDERER))
324-
.findFirst()
325-
.orElse(null);
326-
327-
if (videoPlaylistObject != null) {
328-
final JsonObject renderer;
329-
if (videoPlaylistObject.has(PLAYLIST_VIDEO_LIST_RENDERER)) {
330-
renderer = videoPlaylistObject.getObject(PLAYLIST_VIDEO_LIST_RENDERER);
331-
} else if (videoPlaylistObject.has(RICH_GRID_RENDERER)) {
332-
renderer = videoPlaylistObject.getObject(RICH_GRID_RENDERER);
333-
} else {
334-
return new InfoItemsPage<>(collector, null);
335-
}
336-
337-
final JsonArray videosArray = renderer.getArray("contents");
338-
collectStreamsFrom(collector, videosArray);
338+
.getObject("reloadContinuationItemsCommand")
339+
.getArray("continuationItems");
339340

340-
nextPage = getNextPageFrom(videosArray);
341-
}
341+
collectStreamsFrom(collector, initialItems);
342342

343-
return new InfoItemsPage<>(collector, nextPage);
343+
return new InfoItemsPage<>(collector, getNextPageFrom(initialItems));
344344
}
345345

346346
@Override
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
syntax = "proto3";
2+
3+
package youtube.playlists;
4+
5+
option java_outer_classname = "PlaylistProtobufContinuation";
6+
option java_multiple_files = false;
7+
option java_package = "org.schabi.newpipe.extractor.services.youtube.protos.playlist";
8+
option optimize_for = LITE_RUNTIME;
9+
10+
message PlaylistContinuation {
11+
ContinuationParams parameters = 80226972;
12+
}
13+
14+
message ContinuationParams {
15+
// The playlist ID as a browse one (it should be "VL" + playlist ID)
16+
string browseId = 2;
17+
// A PlaylistContinuationProperties message safe-encoded as a Base64 string
18+
string continuationProperties = 3;
19+
string playlistId = 35;
20+
}

extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubePlaylistExtractorTest.java

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -481,17 +481,12 @@ public void testOriginalUrl() throws Exception {
481481
extractor.getOriginalUrl());
482482
}
483483

484-
@Disabled("Known problem, see https://github.com/TeamNewPipe/NewPipeExtractor/issues/1273")
485484
@Test
486485
@Override
487486
public void testRelatedItems() throws Exception {
488487
defaultTestRelatedItems(extractor);
489488
}
490489

491-
// TODO: enable test when continuations are available
492-
@Disabled("Shorts UI doesn't return any continuation, even if when there are more than 100 "
493-
+ "items: this is a bug on YouTube's side, which is not related to the requirement "
494-
+ "of a valid visitorData like it is for Shorts channel tab")
495490
@Test
496491
@Override
497492
public void testMoreRelatedItems() throws Exception {

extractor/src/test/resources/org/schabi/newpipe/extractor/services/youtube/extractor/channelTabs/ageRestrictedTabsShorts/generated_mock_0.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@
4444
"same-origin; report-to\u003d\"youtube_main\""
4545
],
4646
"date": [
47-
"Sun, 10 Nov 2024 17:48:21 GMT"
47+
"Fri, 18 Apr 2025 14:18:26 GMT"
4848
],
4949
"expires": [
50-
"Sun, 10 Nov 2024 17:48:21 GMT"
50+
"Fri, 18 Apr 2025 14:18:26 GMT"
5151
],
5252
"origin-trial": [
5353
"AmhMBR6zCLzDDxpW+HfpP67BqwIknWnyMOXOQGfzYswFmJe+fgaI6XZgAzcxOrzNtP7hEDsOo1jdjFnVr2IdxQ4AAAB4eyJvcmlnaW4iOiJodHRwczovL3lvdXR1YmUuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJWaWV3WFJlcXVlc3RlZFdpdGhEZXByZWNhdGlvbiIsImV4cGlyeSI6MTc1ODA2NzE5OSwiaXNTdWJkb21haW4iOnRydWV9"
@@ -65,8 +65,8 @@
6565
"ESF"
6666
],
6767
"set-cookie": [
68-
"YSC\u003d4hCTcf7rUXA; Domain\u003d.youtube.com; Path\u003d/; Secure; HttpOnly; SameSite\u003dnone",
69-
"VISITOR_INFO1_LIVE\u003d; Domain\u003d.youtube.com; Expires\u003dMon, 14-Feb-2022 17:48:21 GMT; Path\u003d/; Secure; HttpOnly; SameSite\u003dnone"
68+
"YSC\u003dQCKW-aKaIuY; Domain\u003d.youtube.com; Path\u003d/; Secure; HttpOnly; SameSite\u003dnone",
69+
"VISITOR_INFO1_LIVE\u003d; Domain\u003d.youtube.com; Expires\u003dSat, 23-Jul-2022 14:18:26 GMT; Path\u003d/; Secure; HttpOnly; SameSite\u003dnone"
7070
],
7171
"strict-transport-security": [
7272
"max-age\u003d31536000"

0 commit comments

Comments
 (0)