Parse all the playlists of a channel
This commit is contained in:
parent
11565db17f
commit
e88c0abdeb
|
@ -4,6 +4,7 @@ import org.schabi.newpipe.extractor.ListExtractor;
|
||||||
import org.schabi.newpipe.extractor.StreamingService;
|
import org.schabi.newpipe.extractor.StreamingService;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
||||||
|
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -44,4 +45,8 @@ public abstract class ChannelExtractor extends ListExtractor<StreamInfoItem> {
|
||||||
public abstract String getParentChannelAvatarUrl() throws ParsingException;
|
public abstract String getParentChannelAvatarUrl() throws ParsingException;
|
||||||
public abstract boolean isVerified() throws ParsingException;
|
public abstract boolean isVerified() throws ParsingException;
|
||||||
|
|
||||||
|
public ListExtractor<PlaylistInfoItem> getPlaylists() throws ParsingException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,9 +34,11 @@ import com.grack.nanojson.JsonParserException;
|
||||||
import com.grack.nanojson.JsonWriter;
|
import com.grack.nanojson.JsonWriter;
|
||||||
import org.jsoup.nodes.Entities;
|
import org.jsoup.nodes.Entities;
|
||||||
import org.schabi.newpipe.extractor.MetaInfo;
|
import org.schabi.newpipe.extractor.MetaInfo;
|
||||||
|
import org.schabi.newpipe.extractor.Page;
|
||||||
import org.schabi.newpipe.extractor.downloader.Response;
|
import org.schabi.newpipe.extractor.downloader.Response;
|
||||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException;
|
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
||||||
|
@ -1177,6 +1179,61 @@ public final class YoutubeParsingHelper {
|
||||||
return responseBody;
|
return responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Optional<JsonObject> getTabByName(@Nonnull final JsonObject initialData,
|
||||||
|
@Nonnull final String tabName) {
|
||||||
|
final JsonArray tabs = initialData.getObject("contents")
|
||||||
|
.getObject("twoColumnBrowseResultsRenderer").getArray("tabs");
|
||||||
|
|
||||||
|
return tabs.stream().filter(Objects::nonNull).filter(JsonObject.class::isInstance)
|
||||||
|
.map(JsonObject.class::cast)
|
||||||
|
.filter(tab -> tab.has("tabRenderer")
|
||||||
|
&& tab.getObject("tabRenderer").getString("title", "").equals(tabName))
|
||||||
|
.findFirst().map(tab -> tab.getObject("tabRenderer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonObject getPlaylistsTab(@Nonnull final JsonObject initialData)
|
||||||
|
throws ContentNotSupportedException {
|
||||||
|
return getTabByName(initialData, "Playlists").orElseThrow(
|
||||||
|
() -> new ContentNotSupportedException("This channel has no Playlists tab"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a page, which contains the continuation of the current list - if the
|
||||||
|
* item has a 'continuationItemRenderer'.
|
||||||
|
*/
|
||||||
|
public static Page getNextPageFromItem(final JsonObject item, final Localization localization,
|
||||||
|
final ContentCountry contentCountry)
|
||||||
|
throws UnsupportedEncodingException, IOException, ExtractionException {
|
||||||
|
if (item.has("continuationItemRenderer")) {
|
||||||
|
return getNextPageFromContinuationItemRenderer(
|
||||||
|
item.getObject("continuationItemRenderer"), localization, contentCountry);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a page, which contains the continuation of the current list - if the
|
||||||
|
* item *is* a 'continuationItemRenderer', so it has 'continuationEndpoint'.
|
||||||
|
*/
|
||||||
|
public static Page getNextPageFromContinuationItemRenderer(final JsonObject item,
|
||||||
|
final Localization localization, final ContentCountry contentCountry)
|
||||||
|
throws UnsupportedEncodingException, IOException, ExtractionException {
|
||||||
|
final String token = item.getObject("continuationEndpoint").getObject("continuationCommand")
|
||||||
|
.getString("token");
|
||||||
|
if (token == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final byte[] body = JsonWriter
|
||||||
|
.string(prepareDesktopJsonBuilder(localization, contentCountry)
|
||||||
|
.value("continuation", token).done())
|
||||||
|
.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
return new Page(YOUTUBEI_V1_URL + "browse?key=" + getKey() + DISABLE_PRETTY_PRINT_PARAMETER,
|
||||||
|
body);
|
||||||
|
}
|
||||||
|
|
||||||
public static JsonObject getJsonPostResponse(final String endpoint,
|
public static JsonObject getJsonPostResponse(final String endpoint,
|
||||||
final byte[] body,
|
final byte[] body,
|
||||||
final Localization localization)
|
final Localization localization)
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
package org.schabi.newpipe.extractor.services.youtube.extractors;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||||
|
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItemExtractor;
|
||||||
|
import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper;
|
||||||
|
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubePlaylistLinkHandlerFactory;
|
||||||
|
|
||||||
|
import com.grack.nanojson.JsonObject;
|
||||||
|
|
||||||
|
public class GridPlaylistRendererExtractor implements PlaylistInfoItemExtractor {
|
||||||
|
|
||||||
|
private final JsonObject playlistInfoItem;
|
||||||
|
|
||||||
|
GridPlaylistRendererExtractor(final JsonObject playlistInfoItem) {
|
||||||
|
this.playlistInfoItem = playlistInfoItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() throws ParsingException {
|
||||||
|
return playlistInfoItem.getObject("title").getArray("runs").getObject(0).getString("text");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUrl() throws ParsingException {
|
||||||
|
try {
|
||||||
|
final String id = playlistInfoItem.getString("playlistId");
|
||||||
|
return YoutubePlaylistLinkHandlerFactory.getInstance().getUrl(id);
|
||||||
|
} catch (final Exception e) {
|
||||||
|
throw new ParsingException("Could not get url", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getThumbnailUrl() throws ParsingException {
|
||||||
|
return playlistInfoItem.getObject("thumbnailRenderer")
|
||||||
|
.getObject("playlistVideoThumbnailRenderer").getObject("thumbnail")
|
||||||
|
.getArray("thumbnails").getObject(0).getString("url");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUploaderName() throws ParsingException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUploaderUrl() throws ParsingException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUploaderVerified() throws ParsingException {
|
||||||
|
try {
|
||||||
|
return YoutubeParsingHelper.isVerified(playlistInfoItem.getArray("ownerBadges"));
|
||||||
|
} catch (final Exception e) {
|
||||||
|
throw new ParsingException("Could not get uploader verification info", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getStreamCount() throws ParsingException {
|
||||||
|
return Long.parseLong(
|
||||||
|
playlistInfoItem.getObject("videoCountShortText").getString("simpleText"));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -32,7 +32,6 @@ import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
@ -63,6 +62,7 @@ public class YoutubeChannelExtractor extends ChannelExtractor {
|
||||||
private Optional<JsonObject> channelHeader;
|
private Optional<JsonObject> channelHeader;
|
||||||
private boolean isCarouselHeader = false;
|
private boolean isCarouselHeader = false;
|
||||||
private JsonObject videoTab;
|
private JsonObject videoTab;
|
||||||
|
private JsonObject playlistsTab;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Some channels have response redirects and the only way to reliably get the id is by saving it
|
* Some channels have response redirects and the only way to reliably get the id is by saving it
|
||||||
|
@ -495,20 +495,7 @@ public class YoutubeChannelExtractor extends ChannelExtractor {
|
||||||
return videoTab;
|
return videoTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
final JsonArray tabs = initialData.getObject("contents")
|
final JsonObject foundVideoTab = YoutubeParsingHelper.getTabByName(initialData, "Videos")
|
||||||
.getObject("twoColumnBrowseResultsRenderer")
|
|
||||||
.getArray("tabs");
|
|
||||||
|
|
||||||
final JsonObject foundVideoTab = tabs.stream()
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.filter(JsonObject.class::isInstance)
|
|
||||||
.map(JsonObject.class::cast)
|
|
||||||
.filter(tab -> tab.has("tabRenderer")
|
|
||||||
&& tab.getObject("tabRenderer")
|
|
||||||
.getString("title", "")
|
|
||||||
.equals("Videos"))
|
|
||||||
.findFirst()
|
|
||||||
.map(tab -> tab.getObject("tabRenderer"))
|
|
||||||
.orElseThrow(
|
.orElseThrow(
|
||||||
() -> new ContentNotSupportedException("This channel has no Videos tab"));
|
() -> new ContentNotSupportedException("This channel has no Videos tab"));
|
||||||
|
|
||||||
|
@ -530,4 +517,26 @@ public class YoutubeChannelExtractor extends ChannelExtractor {
|
||||||
videoTab = foundVideoTab;
|
videoTab = foundVideoTab;
|
||||||
return foundVideoTab;
|
return foundVideoTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public YoutubeChannelPlaylistExtractor getPlaylists() throws ParsingException {
|
||||||
|
final JsonObject tab = getPlaylistsTab();
|
||||||
|
if (tab != null) {
|
||||||
|
return new YoutubeChannelPlaylistExtractor(getService(), getLinkHandler(),
|
||||||
|
tab.getObject("endpoint").getObject("browseEndpoint"));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private JsonObject getPlaylistsTab() throws ParsingException {
|
||||||
|
if (playlistsTab != null) {
|
||||||
|
return playlistsTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playlistsTab = YoutubeParsingHelper.getPlaylistsTab(initialData);
|
||||||
|
|
||||||
|
return playlistsTab;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,116 @@
|
||||||
|
package org.schabi.newpipe.extractor.services.youtube.extractors;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getJsonPostResponse;
|
||||||
|
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.prepareDesktopJsonBuilder;
|
||||||
|
import static org.schabi.newpipe.extractor.utils.Utils.UTF_8;
|
||||||
|
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.UnsupportedEncodingException;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.extractor.ListExtractor;
|
||||||
|
import org.schabi.newpipe.extractor.Page;
|
||||||
|
import org.schabi.newpipe.extractor.StreamingService;
|
||||||
|
import org.schabi.newpipe.extractor.downloader.Downloader;
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||||
|
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
||||||
|
import org.schabi.newpipe.extractor.localization.Localization;
|
||||||
|
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItemsCollector;
|
||||||
|
import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper;
|
||||||
|
|
||||||
|
import com.grack.nanojson.JsonArray;
|
||||||
|
import com.grack.nanojson.JsonObject;
|
||||||
|
import com.grack.nanojson.JsonWriter;
|
||||||
|
|
||||||
|
public class YoutubeChannelPlaylistExtractor extends ListExtractor<PlaylistInfoItem> {
|
||||||
|
|
||||||
|
private final String browseId;
|
||||||
|
private final String params;
|
||||||
|
private final String canonicalBaseUrl;
|
||||||
|
private JsonObject browseResponse;
|
||||||
|
private JsonObject playlistTab;
|
||||||
|
|
||||||
|
YoutubeChannelPlaylistExtractor(final StreamingService service,
|
||||||
|
final ListLinkHandler linkHandler, final JsonObject browseEndpoint) {
|
||||||
|
super(service, linkHandler);
|
||||||
|
this.browseId = browseEndpoint.getString("browseId");
|
||||||
|
this.params = browseEndpoint.getString("params");
|
||||||
|
this.canonicalBaseUrl = browseEndpoint.getString("canonicalBaseUrl");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InfoItemsPage<PlaylistInfoItem> getInitialPage()
|
||||||
|
throws IOException, ExtractionException {
|
||||||
|
final PlaylistInfoItemsCollector pic = new PlaylistInfoItemsCollector(getServiceId());
|
||||||
|
|
||||||
|
final JsonArray playlistItems = playlistTab.getObject("content")
|
||||||
|
.getObject("sectionListRenderer").getArray("contents").getObject(0)
|
||||||
|
.getObject("itemSectionRenderer").getArray("contents").getObject(0)
|
||||||
|
.getObject("gridRenderer").getArray("items");
|
||||||
|
final var continuation = collectPlaylistsFrom(playlistItems, pic);
|
||||||
|
return new InfoItemsPage<>(pic, continuation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Page collectPlaylistsFrom(final JsonArray playlistItems,
|
||||||
|
final PlaylistInfoItemsCollector collector)
|
||||||
|
throws UnsupportedEncodingException, IOException, ExtractionException {
|
||||||
|
Page continuation = null;
|
||||||
|
for (final var item : playlistItems) {
|
||||||
|
if (item instanceof JsonObject) {
|
||||||
|
final JsonObject jsonItem = (JsonObject) item;
|
||||||
|
if (jsonItem.has("gridPlaylistRenderer")) {
|
||||||
|
collector.commit(new GridPlaylistRendererExtractor(
|
||||||
|
jsonItem.getObject("gridPlaylistRenderer")));
|
||||||
|
} else if (jsonItem.has("continuationItemRenderer")) {
|
||||||
|
continuation = YoutubeParsingHelper.getNextPageFromItem(jsonItem,
|
||||||
|
getExtractorLocalization(), getExtractorContentCountry());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return continuation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InfoItemsPage<PlaylistInfoItem> getPage(final Page page)
|
||||||
|
throws IOException, ExtractionException {
|
||||||
|
if (page == null || isNullOrEmpty(page.getUrl())) {
|
||||||
|
throw new IllegalArgumentException("Page doesn't contain an URL");
|
||||||
|
}
|
||||||
|
final PlaylistInfoItemsCollector collector = new PlaylistInfoItemsCollector(getServiceId());
|
||||||
|
|
||||||
|
final JsonObject ajaxJson = getJsonPostResponse("browse", page.getBody(),
|
||||||
|
getExtractorLocalization());
|
||||||
|
|
||||||
|
final JsonArray continuation = ajaxJson.getArray("onResponseReceivedActions").getObject(0)
|
||||||
|
.getObject("appendContinuationItemsAction").getArray("continuationItems");
|
||||||
|
|
||||||
|
final var cont = collectPlaylistsFrom(continuation, collector);
|
||||||
|
|
||||||
|
return new InfoItemsPage<>(collector, cont);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFetchPage(final Downloader downloader) throws IOException, ExtractionException {
|
||||||
|
final Localization localization = getExtractorLocalization();
|
||||||
|
final byte[] body = JsonWriter
|
||||||
|
.string(prepareDesktopJsonBuilder(localization, getExtractorContentCountry())
|
||||||
|
.value("browseId", browseId)
|
||||||
|
.value("params", params)
|
||||||
|
.value("canonicalBaseUrl", canonicalBaseUrl).done())
|
||||||
|
.getBytes(UTF_8);
|
||||||
|
|
||||||
|
browseResponse = getJsonPostResponse("browse", body, localization);
|
||||||
|
playlistTab = YoutubeParsingHelper.getPlaylistsTab(browseResponse);
|
||||||
|
|
||||||
|
YoutubeParsingHelper.defaultAlertsCheck(browseResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() throws ParsingException {
|
||||||
|
return browseResponse.getObject("metadata").getObject("channelMetadataRenderer")
|
||||||
|
.getString("title");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,11 +1,8 @@
|
||||||
package org.schabi.newpipe.extractor.services.youtube.extractors;
|
package org.schabi.newpipe.extractor.services.youtube.extractors;
|
||||||
|
|
||||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.DISABLE_PRETTY_PRINT_PARAMETER;
|
|
||||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.YOUTUBEI_V1_URL;
|
|
||||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.extractPlaylistTypeFromPlaylistUrl;
|
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.extractPlaylistTypeFromPlaylistUrl;
|
||||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.fixThumbnailUrl;
|
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.fixThumbnailUrl;
|
||||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getJsonPostResponse;
|
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getJsonPostResponse;
|
||||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getKey;
|
|
||||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getTextFromObject;
|
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getTextFromObject;
|
||||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getUrlFromNavigationEndpoint;
|
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getUrlFromNavigationEndpoint;
|
||||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.prepareDesktopJsonBuilder;
|
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.prepareDesktopJsonBuilder;
|
||||||
|
@ -364,24 +361,8 @@ public class YoutubePlaylistExtractor extends PlaylistExtractor {
|
||||||
}
|
}
|
||||||
|
|
||||||
final JsonObject lastElement = contents.getObject(contents.size() - 1);
|
final JsonObject lastElement = contents.getObject(contents.size() - 1);
|
||||||
if (lastElement.has("continuationItemRenderer")) {
|
return YoutubeParsingHelper.getNextPageFromItem(lastElement, getExtractorLocalization(),
|
||||||
final String continuation = lastElement
|
getExtractorContentCountry());
|
||||||
.getObject("continuationItemRenderer")
|
|
||||||
.getObject("continuationEndpoint")
|
|
||||||
.getObject("continuationCommand")
|
|
||||||
.getString("token");
|
|
||||||
|
|
||||||
final byte[] body = JsonWriter.string(prepareDesktopJsonBuilder(
|
|
||||||
getExtractorLocalization(), getExtractorContentCountry())
|
|
||||||
.value("continuation", continuation)
|
|
||||||
.done())
|
|
||||||
.getBytes(StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
return new Page(YOUTUBEI_V1_URL + "browse?key=" + getKey()
|
|
||||||
+ DISABLE_PRETTY_PRINT_PARAMETER, body);
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void collectStreamsFrom(@Nonnull final StreamInfoItemsCollector collector,
|
private void collectStreamsFrom(@Nonnull final StreamInfoItemsCollector collector,
|
||||||
|
|
Loading…
Reference in New Issue