Skip to content

Commit 183563c

Browse files
committed
[YouTube] Add support for playlists lockupViewModels
This new data type, A/B tested or rolled out at the time the changes are commited, is present on multiple surfaces.
1 parent f52d226 commit 183563c

4 files changed

Lines changed: 244 additions & 0 deletions

File tree

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,12 @@ private Optional<JsonObject> collectItem(@Nonnull final MultiInfoItemsCollector
311311
} else if (item.has("expandedShelfContentsRenderer")) {
312312
return collectItemsFrom(collector, item.getObject("expandedShelfContentsRenderer")
313313
.getArray("items"), channelVerifiedStatus, channelName, channelUrl);
314+
} else if (item.has("lockupViewModel")) {
315+
final JsonObject lockupViewModel = item.getObject("lockupViewModel");
316+
if ("LOCKUP_CONTENT_TYPE_PLAYLIST".equals(lockupViewModel.getString("contentType"))) {
317+
commitPlaylistLockup(collector, lockupViewModel, channelVerifiedStatus,
318+
channelName, channelUrl);
319+
}
314320
} else if (item.has("continuationItemRenderer")) {
315321
return Optional.ofNullable(item.getObject("continuationItemRenderer"));
316322
}
@@ -366,6 +372,37 @@ public boolean isUploaderVerified() {
366372
});
367373
}
368374

375+
private void commitPlaylistLockup(@Nonnull final MultiInfoItemsCollector collector,
376+
@Nonnull final JsonObject playlistLockupViewModel,
377+
@Nonnull final VerifiedStatus channelVerifiedStatus,
378+
@Nullable final String channelName,
379+
@Nullable final String channelUrl) {
380+
collector.commit(
381+
new YoutubeMixOrPlaylistLockupInfoItemExtractor(playlistLockupViewModel) {
382+
@Override
383+
public String getUploaderName() throws ParsingException {
384+
return isNullOrEmpty(channelName) ? super.getUploaderName() : channelName;
385+
}
386+
387+
@Override
388+
public String getUploaderUrl() throws ParsingException {
389+
return isNullOrEmpty(channelUrl) ? super.getUploaderName() : channelUrl;
390+
}
391+
392+
@Override
393+
public boolean isUploaderVerified() throws ParsingException {
394+
switch (channelVerifiedStatus) {
395+
case VERIFIED:
396+
return true;
397+
case UNVERIFIED:
398+
return false;
399+
default:
400+
return super.isUploaderVerified();
401+
}
402+
}
403+
});
404+
}
405+
369406
private void commitVideo(@Nonnull final MultiInfoItemsCollector collector,
370407
@Nonnull final TimeAgoParser timeAgoParser,
371408
@Nonnull final JsonObject jsonObject,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package org.schabi.newpipe.extractor.services.youtube.extractors;
2+
3+
import com.grack.nanojson.JsonObject;
4+
import org.schabi.newpipe.extractor.Image;
5+
import org.schabi.newpipe.extractor.ListExtractor;
6+
import org.schabi.newpipe.extractor.exceptions.ParsingException;
7+
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
8+
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItemExtractor;
9+
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubePlaylistLinkHandlerFactory;
10+
import org.schabi.newpipe.extractor.utils.Utils;
11+
12+
import javax.annotation.Nonnull;
13+
import java.util.List;
14+
15+
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.extractPlaylistTypeFromPlaylistId;
16+
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getImagesFromThumbnailsArray;
17+
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getUrlFromNavigationEndpoint;
18+
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.hasArtistOrVerifiedIconBadgeAttachment;
19+
20+
public class YoutubeMixOrPlaylistLockupInfoItemExtractor implements PlaylistInfoItemExtractor {
21+
22+
@Nonnull
23+
private final JsonObject lockupViewModel;
24+
@Nonnull
25+
private final JsonObject thumbnailViewModel;
26+
@Nonnull
27+
private final JsonObject lockupMetadataViewModel;
28+
@Nonnull
29+
private final JsonObject firstMetadataRow;
30+
@Nonnull
31+
private PlaylistInfo.PlaylistType playlistType;
32+
33+
public YoutubeMixOrPlaylistLockupInfoItemExtractor(@Nonnull final JsonObject lockupViewModel) {
34+
this.lockupViewModel = lockupViewModel;
35+
this.thumbnailViewModel = lockupViewModel.getObject("contentImage")
36+
.getObject("collectionThumbnailViewModel")
37+
.getObject("primaryThumbnail")
38+
.getObject("thumbnailViewModel");
39+
this.lockupMetadataViewModel = lockupViewModel.getObject("metadata")
40+
.getObject("lockupMetadataViewModel");
41+
/*
42+
The metadata rows are structured in the following way:
43+
1st part: uploader info, playlist type, playlist updated date
44+
2nd part: space row
45+
3rd element: first video
46+
4th (not always returned for playlists with less than 2 items?): second video
47+
5th element (always returned, but at a different index for playlists with less than 2
48+
items?): Show full playlist
49+
50+
The first metadata row has the following structure:
51+
1st array element: uploader info
52+
2nd element: playlist type (course, playlist, podcast)
53+
3rd element (not always returned): playlist updated date
54+
*/
55+
this.firstMetadataRow = lockupMetadataViewModel.getObject("metadata")
56+
.getObject("contentMetadataViewModel")
57+
.getArray("metadataRows")
58+
.getObject(0);
59+
60+
try {
61+
this.playlistType = extractPlaylistTypeFromPlaylistId(getPlaylistId());
62+
} catch (final ParsingException e) {
63+
// If we cannot extract the playlist type, fall back to the normal one
64+
this.playlistType = PlaylistInfo.PlaylistType.NORMAL;
65+
}
66+
}
67+
68+
@Override
69+
public String getUploaderName() throws ParsingException {
70+
return firstMetadataRow.getArray("metadataParts")
71+
.getObject(0)
72+
.getObject("text")
73+
.getString("content");
74+
}
75+
76+
@Override
77+
public String getUploaderUrl() throws ParsingException {
78+
if (playlistType != PlaylistInfo.PlaylistType.NORMAL) {
79+
// If the playlist is a mix, there is no uploader as they are auto-generated
80+
return null;
81+
}
82+
83+
return getUrlFromNavigationEndpoint(
84+
firstMetadataRow.getArray("metadataParts")
85+
.getObject(0)
86+
.getObject("text")
87+
.getArray("commandRuns")
88+
.getObject(0)
89+
.getObject("onTap")
90+
.getObject("innertubeCommand"));
91+
}
92+
93+
@Override
94+
public boolean isUploaderVerified() throws ParsingException {
95+
if (playlistType != PlaylistInfo.PlaylistType.NORMAL) {
96+
// If the playlist is a mix, there is no uploader as they are auto-generated
97+
return false;
98+
}
99+
100+
return hasArtistOrVerifiedIconBadgeAttachment(
101+
firstMetadataRow.getArray("metadataParts")
102+
.getObject(0)
103+
.getObject("text")
104+
.getArray("attachmentRuns"));
105+
}
106+
107+
@Override
108+
public long getStreamCount() throws ParsingException {
109+
if (playlistType != PlaylistInfo.PlaylistType.NORMAL) {
110+
// If the playlist is a mix, we are not able to get its stream count
111+
return ListExtractor.ITEM_COUNT_INFINITE;
112+
}
113+
114+
try {
115+
return Long.parseLong(Utils.removeNonDigitCharacters(
116+
thumbnailViewModel.getArray("overlays")
117+
.stream()
118+
.filter(JsonObject.class::isInstance)
119+
.map(JsonObject.class::cast)
120+
.filter(overlay -> overlay.has("thumbnailOverlayBadgeViewModel"))
121+
.findFirst()
122+
.orElseThrow(() -> new ParsingException(
123+
"Could not get thumbnailOverlayBadgeViewModel"))
124+
.getObject("thumbnailOverlayBadgeViewModel")
125+
.getArray("thumbnailBadges")
126+
.stream()
127+
.filter(JsonObject.class::isInstance)
128+
.map(JsonObject.class::cast)
129+
.filter(badge -> badge.has("thumbnailBadgeViewModel"))
130+
.findFirst()
131+
.orElseThrow(() ->
132+
new ParsingException("Could not get thumbnailBadgeViewModel"))
133+
.getObject("thumbnailBadgeViewModel")
134+
.getString("text")));
135+
} catch (final Exception e) {
136+
throw new ParsingException("Could not get playlist stream count", e);
137+
}
138+
}
139+
140+
@Override
141+
public String getName() throws ParsingException {
142+
return lockupMetadataViewModel.getObject("title")
143+
.getString("content");
144+
}
145+
146+
@Override
147+
public String getUrl() throws ParsingException {
148+
// If the playlist item is a mix, we cannot return just its playlist ID as mix playlists
149+
// are not viewable in playlist pages
150+
// Use directly getUrlFromNavigationEndpoint in this case, which returns the watch URL with
151+
// the mix playlist
152+
if (playlistType == PlaylistInfo.PlaylistType.NORMAL) {
153+
try {
154+
return YoutubePlaylistLinkHandlerFactory.getInstance().getUrl(getPlaylistId());
155+
} catch (final Exception ignored) {
156+
}
157+
}
158+
159+
return getUrlFromNavigationEndpoint(lockupViewModel.getObject("rendererContext")
160+
.getObject("commandContext")
161+
.getObject("onTap")
162+
.getObject("innertubeCommand"));
163+
}
164+
165+
@Nonnull
166+
@Override
167+
public List<Image> getThumbnails() throws ParsingException {
168+
return getImagesFromThumbnailsArray(thumbnailViewModel.getObject("image")
169+
.getArray("sources"));
170+
}
171+
172+
@Nonnull
173+
@Override
174+
public PlaylistInfo.PlaylistType getPlaylistType() throws ParsingException {
175+
return playlistType;
176+
}
177+
178+
private String getPlaylistId() throws ParsingException {
179+
String id = lockupViewModel.getString("contentId");
180+
if (Utils.isNullOrEmpty(id)) {
181+
id = lockupViewModel.getObject("rendererContext")
182+
.getObject("commandContext")
183+
.getObject("watchEndpoint")
184+
.getString("playlistId");
185+
}
186+
187+
if (Utils.isNullOrEmpty(id)) {
188+
throw new ParsingException("Could not get playlist ID");
189+
}
190+
191+
return id;
192+
}
193+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,13 @@ private void collectStreamsFrom(final MultiInfoItemsCollector collector,
245245
} else if (item.has("showRenderer")) {
246246
collector.commit(new YoutubeShowRendererInfoItemExtractor(
247247
item.getObject("showRenderer")));
248+
} else if (item.has("lockupViewModel")) {
249+
final JsonObject lockupViewModel = item.getObject("lockupViewModel");
250+
if ("LOCKUP_CONTENT_TYPE_PLAYLIST".equals(
251+
lockupViewModel.getString("contentType"))) {
252+
collector.commit(
253+
new YoutubeMixOrPlaylistLockupInfoItemExtractor(lockupViewModel));
254+
}
248255
}
249256
}
250257
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,13 @@ public MultiInfoItemsCollector getRelatedItems() throws ExtractionException {
726726
} else if (result.has("compactPlaylistRenderer")) {
727727
return new YoutubeMixOrPlaylistInfoItemExtractor(
728728
result.getObject("compactPlaylistRenderer"));
729+
} else if (result.has("lockupViewModel")) {
730+
final JsonObject lockupViewModel = result.getObject("lockupViewModel");
731+
if ("LOCKUP_CONTENT_TYPE_PLAYLIST".equals(
732+
lockupViewModel.getString("contentType"))) {
733+
return new YoutubeMixOrPlaylistLockupInfoItemExtractor(
734+
lockupViewModel);
735+
}
729736
}
730737
return null;
731738
})

0 commit comments

Comments
 (0)