[YouTube] Support new playlist layout

This new layout doesn't provide author thumbnails and is completely different
for metadata, so the code to get them has been refactored.

The code of learning playlists video count check has been also removed, as it
seems to be not relevant anymore (the video count seems to be returned for
these playlists with both layouts).

Finally, unneeded overrides of subchannel methods, which don't apply to the
YouTube service, have been removed.
This commit is contained in:
AudricV 2022-10-29 17:34:35 +02:00
parent 4bc90cd9d8
commit 7258a53225
No known key found for this signature in database
GPG key ID: DA92EC7905614198

View file

@ -44,16 +44,19 @@ import javax.annotation.Nonnull;
import javax.annotation.Nullable; import javax.annotation.Nullable;
public class YoutubePlaylistExtractor extends PlaylistExtractor { public class YoutubePlaylistExtractor extends PlaylistExtractor {
// Minimum size of the stats array in the browse response which includes the streams count
private static final int STATS_ARRAY_WITH_STREAMS_COUNT_MIN_SIZE = 2;
// Names of some objects in JSON response frequently used in this class // Names of some objects in JSON response frequently used in this class
private static final String PLAYLIST_VIDEO_RENDERER = "playlistVideoRenderer"; private static final String PLAYLIST_VIDEO_RENDERER = "playlistVideoRenderer";
private static final String PLAYLIST_VIDEO_LIST_RENDERER = "playlistVideoListRenderer"; private static final String PLAYLIST_VIDEO_LIST_RENDERER = "playlistVideoListRenderer";
private static final String SIDEBAR = "sidebar";
private static final String VIDEO_OWNER_RENDERER = "videoOwnerRenderer"; private static final String VIDEO_OWNER_RENDERER = "videoOwnerRenderer";
private JsonObject browseResponse; private JsonObject browseResponse;
private JsonObject playlistInfo; private JsonObject playlistInfo;
private JsonObject uploaderInfo;
private JsonObject playlistHeader;
private boolean isNewPlaylistInterface;
public YoutubePlaylistExtractor(final StreamingService service, public YoutubePlaylistExtractor(final StreamingService service,
final ListLinkHandler linkHandler) { final ListLinkHandler linkHandler) {
@ -73,48 +76,86 @@ public class YoutubePlaylistExtractor extends PlaylistExtractor {
browseResponse = getJsonPostResponse("browse", body, localization); browseResponse = getJsonPostResponse("browse", body, localization);
YoutubeParsingHelper.defaultAlertsCheck(browseResponse); YoutubeParsingHelper.defaultAlertsCheck(browseResponse);
isNewPlaylistInterface = checkIfResponseIsNewPlaylistInterface();
playlistInfo = getPlaylistInfo();
} }
/**
* Whether the playlist response is using only the new playlist design.
*
* <p>
* This new response changes how metadata is returned, and does not provide author thumbnails.
* </p>
*
* <p>
* The new response can be detected by checking whether a header JSON object is returned in the
* browse response (the old returns instead a sidebar one).
* </p>
*
* <p>
* This new playlist UI is currently A/B tested.
* </p>
*
* @return Whether the playlist response is using only the new playlist design
*/
private boolean checkIfResponseIsNewPlaylistInterface() {
// The "old" playlist UI can be also returned with the new one
return browseResponse.has("header") && !browseResponse.has(SIDEBAR);
}
@Nonnull
private JsonObject getUploaderInfo() throws ParsingException { private JsonObject getUploaderInfo() throws ParsingException {
final JsonArray items = browseResponse.getObject("sidebar") if (uploaderInfo == null) {
.getObject("playlistSidebarRenderer") uploaderInfo = browseResponse.getObject(SIDEBAR)
.getArray("items");
JsonObject videoOwner = items.getObject(1)
.getObject("playlistSidebarSecondaryInfoRenderer")
.getObject("videoOwner");
if (videoOwner.has(VIDEO_OWNER_RENDERER)) {
return videoOwner.getObject(VIDEO_OWNER_RENDERER);
}
// we might want to create a loop here instead of using duplicated code
videoOwner = items.getObject(items.size())
.getObject("playlistSidebarSecondaryInfoRenderer")
.getObject("videoOwner");
if (videoOwner.has(VIDEO_OWNER_RENDERER)) {
return videoOwner.getObject(VIDEO_OWNER_RENDERER);
}
throw new ParsingException("Could not get uploader info");
}
private JsonObject getPlaylistInfo() throws ParsingException {
try {
return browseResponse.getObject("sidebar")
.getObject("playlistSidebarRenderer") .getObject("playlistSidebarRenderer")
.getArray("items") .getArray("items")
.getObject(0) .stream()
.getObject("playlistSidebarPrimaryInfoRenderer"); .filter(JsonObject.class::isInstance)
} catch (final Exception e) { .map(JsonObject.class::cast)
throw new ParsingException("Could not get PlaylistInfo", e); .filter(item -> item.getObject("playlistSidebarSecondaryInfoRenderer")
.getObject("videoOwner")
.has(VIDEO_OWNER_RENDERER))
.map(item -> item.getObject("playlistSidebarSecondaryInfoRenderer")
.getObject("videoOwner")
.getObject(VIDEO_OWNER_RENDERER))
.findFirst()
.orElseThrow(() -> new ParsingException("Could not get uploader info"));
} }
return uploaderInfo;
}
@Nonnull
private JsonObject getPlaylistInfo() throws ParsingException {
if (playlistInfo == null) {
playlistInfo = browseResponse.getObject(SIDEBAR)
.getObject("playlistSidebarRenderer")
.getArray("items")
.stream()
.filter(JsonObject.class::isInstance)
.map(JsonObject.class::cast)
.filter(item -> item.has("playlistSidebarPrimaryInfoRenderer"))
.map(item -> item.getObject("playlistSidebarPrimaryInfoRenderer"))
.findFirst()
.orElseThrow(() -> new ParsingException("Could not get playlist info"));
}
return playlistInfo;
}
@Nonnull
private JsonObject getPlaylistHeader() {
if (playlistHeader == null) {
playlistHeader = browseResponse.getObject("header")
.getObject("playlistHeaderRenderer");
}
return playlistHeader;
} }
@Nonnull @Nonnull
@Override @Override
public String getName() throws ParsingException { public String getName() throws ParsingException {
final String name = getTextFromObject(playlistInfo.getObject("title")); final String name = getTextFromObject(getPlaylistInfo().getObject("title"));
if (!isNullOrEmpty(name)) { if (!isNullOrEmpty(name)) {
return name; return name;
} }
@ -127,13 +168,24 @@ public class YoutubePlaylistExtractor extends PlaylistExtractor {
@Nonnull @Nonnull
@Override @Override
public String getThumbnailUrl() throws ParsingException { public String getThumbnailUrl() throws ParsingException {
String url = playlistInfo.getObject("thumbnailRenderer") String url;
.getObject("playlistVideoThumbnailRenderer") if (isNewPlaylistInterface) {
.getObject("thumbnail") url = getPlaylistHeader().getObject("playlistHeaderBanner")
.getArray("thumbnails") .getObject("heroPlaylistThumbnailRenderer")
.getObject(0) .getObject("thumbnail")
.getString("url"); .getArray("thumbnails")
.getObject(0)
.getString("url");
} else {
url = getPlaylistInfo().getObject("thumbnailRenderer")
.getObject("playlistVideoThumbnailRenderer")
.getObject("thumbnail")
.getArray("thumbnails")
.getObject(0)
.getString("url");
}
// This data structure is returned in both layouts
if (isNullOrEmpty(url)) { if (isNullOrEmpty(url)) {
url = browseResponse.getObject("microformat") url = browseResponse.getObject("microformat")
.getObject("microformatDataRenderer") .getObject("microformatDataRenderer")
@ -153,7 +205,12 @@ public class YoutubePlaylistExtractor extends PlaylistExtractor {
@Override @Override
public String getUploaderUrl() throws ParsingException { public String getUploaderUrl() throws ParsingException {
try { try {
return getUrlFromNavigationEndpoint(getUploaderInfo().getObject("navigationEndpoint")); return getUrlFromNavigationEndpoint(isNewPlaylistInterface
? getPlaylistHeader().getObject("ownerText")
.getArray("runs")
.getObject(0)
.getObject("navigationEndpoint")
: getUploaderInfo().getObject("navigationEndpoint"));
} catch (final Exception e) { } catch (final Exception e) {
throw new ParsingException("Could not get playlist uploader url", e); throw new ParsingException("Could not get playlist uploader url", e);
} }
@ -162,7 +219,9 @@ public class YoutubePlaylistExtractor extends PlaylistExtractor {
@Override @Override
public String getUploaderName() throws ParsingException { public String getUploaderName() throws ParsingException {
try { try {
return getTextFromObject(getUploaderInfo().getObject("title")); return getTextFromObject(isNewPlaylistInterface
? getPlaylistHeader().getObject("ownerText")
: getUploaderInfo().getObject("title"));
} catch (final Exception e) { } catch (final Exception e) {
throw new ParsingException("Could not get playlist uploader name", e); throw new ParsingException("Could not get playlist uploader name", e);
} }
@ -170,6 +229,11 @@ public class YoutubePlaylistExtractor extends PlaylistExtractor {
@Override @Override
public String getUploaderAvatarUrl() throws ParsingException { public String getUploaderAvatarUrl() throws ParsingException {
if (isNewPlaylistInterface) {
// The new playlist interface doesn't provide an uploader avatar
return "";
}
try { try {
final String url = getUploaderInfo() final String url = getUploaderInfo()
.getObject("thumbnail") .getObject("thumbnail")
@ -191,44 +255,50 @@ public class YoutubePlaylistExtractor extends PlaylistExtractor {
@Override @Override
public long getStreamCount() throws ParsingException { public long getStreamCount() throws ParsingException {
try { if (isNewPlaylistInterface) {
final JsonArray stats = playlistInfo.getArray("stats"); final String numVideosText =
// For unknown reasons, YouTube don't provide the stream count for learning playlists getTextFromObject(getPlaylistHeader().getObject("numVideosText"));
// on the desktop client but only the number of views and the playlist modified date if (numVideosText != null) {
// On normal playlists, at least 3 items are returned: the number of videos, the number try {
// of views and the playlist modification date return Long.parseLong(Utils.removeNonDigitCharacters(numVideosText));
// We can get it by using another client, however it seems we can't get the avatar } catch (final NumberFormatException ignored) {
// uploader URL with another client than the WEB client
if (stats.size() > STATS_ARRAY_WITH_STREAMS_COUNT_MIN_SIZE) {
final String videosText = getTextFromObject(playlistInfo.getArray("stats")
.getObject(0));
if (videosText != null) {
return Long.parseLong(Utils.removeNonDigitCharacters(videosText));
} }
} }
return ITEM_COUNT_UNKNOWN; final String firstByLineRendererText = getTextFromObject(
} catch (final Exception e) { getPlaylistHeader().getArray("byline")
throw new ParsingException("Could not get video count from playlist", e); .getObject(0)
.getObject("text"));
if (firstByLineRendererText != null) {
try {
return Long.parseLong(Utils.removeNonDigitCharacters(firstByLineRendererText));
} catch (final NumberFormatException ignored) {
}
}
} }
}
@Nonnull // These data structures are returned in both layouts
@Override final JsonArray briefStats =
public String getSubChannelName() { (isNewPlaylistInterface ? getPlaylistHeader() : getPlaylistInfo())
return ""; .getArray("briefStats");
} if (!briefStats.isEmpty()) {
final String briefsStatsText = getTextFromObject(briefStats.getObject(0));
if (briefsStatsText != null) {
return Long.parseLong(Utils.removeNonDigitCharacters(briefsStatsText));
}
}
@Nonnull final JsonArray stats = (isNewPlaylistInterface ? getPlaylistHeader() : getPlaylistInfo())
@Override .getArray("stats");
public String getSubChannelUrl() { if (!stats.isEmpty()) {
return ""; final String statsText = getTextFromObject(stats.getObject(0));
} if (statsText != null) {
return Long.parseLong(Utils.removeNonDigitCharacters(statsText));
}
}
@Nonnull return ITEM_COUNT_UNKNOWN;
@Override
public String getSubChannelAvatarUrl() {
return "";
} }
@Nonnull @Nonnull