diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/bandcamp/extractors/BandcampRadioStreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/bandcamp/extractors/BandcampRadioStreamExtractor.java
index 639d2abb8..5724d371d 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/bandcamp/extractors/BandcampRadioStreamExtractor.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/bandcamp/extractors/BandcampRadioStreamExtractor.java
@@ -30,9 +30,12 @@ import java.util.List;
import static org.schabi.newpipe.extractor.services.bandcamp.extractors.BandcampExtractorHelper.BASE_API_URL;
import static org.schabi.newpipe.extractor.services.bandcamp.extractors.BandcampExtractorHelper.BASE_URL;
import static org.schabi.newpipe.extractor.services.bandcamp.extractors.BandcampExtractorHelper.getImageUrl;
+import static org.schabi.newpipe.extractor.utils.Utils.EMPTY_STRING;
public class BandcampRadioStreamExtractor extends BandcampStreamExtractor {
+ private static final String OPUS_LO = "opus-lo";
+ private static final String MP3_128 = "mp3-128";
private JsonObject showInfo;
public BandcampRadioStreamExtractor(final StreamingService service,
@@ -116,23 +119,27 @@ public class BandcampRadioStreamExtractor extends BandcampStreamExtractor {
@Override
public List getAudioStreams() {
- final ArrayList list = new ArrayList<>();
+ final List audioStreams = new ArrayList<>();
final JsonObject streams = showInfo.getObject("audio_stream");
- if (streams.has("opus-lo")) {
- list.add(new AudioStream(
- streams.getString("opus-lo"),
- MediaFormat.OPUS, 100
- ));
- }
- if (streams.has("mp3-128")) {
- list.add(new AudioStream(
- streams.getString("mp3-128"),
- MediaFormat.MP3, 128
- ));
+ if (streams.has(MP3_128)) {
+ audioStreams.add(new AudioStream.Builder()
+ .setId(MP3_128)
+ .setContent(streams.getString(MP3_128), true)
+ .setMediaFormat(MediaFormat.MP3)
+ .setAverageBitrate(128)
+ .build());
}
- return list;
+ if (streams.has(OPUS_LO)) {
+ audioStreams.add(new AudioStream.Builder()
+ .setId(OPUS_LO)
+ .setContent(streams.getString(OPUS_LO), true)
+ .setMediaFormat(MediaFormat.OPUS)
+ .setAverageBitrate(100).build());
+ }
+
+ return audioStreams;
}
@Nonnull
@@ -156,14 +163,14 @@ public class BandcampRadioStreamExtractor extends BandcampStreamExtractor {
@Override
public String getLicence() {
// Contrary to other Bandcamp streams, radio streams don't have a license
- return "";
+ return EMPTY_STRING;
}
@Nonnull
@Override
public String getCategory() {
// Contrary to other Bandcamp streams, radio streams don't have categories
- return "";
+ return EMPTY_STRING;
}
@Nonnull
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/bandcamp/extractors/BandcampStreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/bandcamp/extractors/BandcampStreamExtractor.java
index 896644e96..4b5d9d12a 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/bandcamp/extractors/BandcampStreamExtractor.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/bandcamp/extractors/BandcampStreamExtractor.java
@@ -3,6 +3,8 @@
package org.schabi.newpipe.extractor.services.bandcamp.extractors;
import static org.schabi.newpipe.extractor.services.bandcamp.extractors.BandcampExtractorHelper.getImageUrl;
+import static org.schabi.newpipe.extractor.utils.Utils.EMPTY_STRING;
+import static org.schabi.newpipe.extractor.utils.Utils.HTTPS;
import com.grack.nanojson.JsonObject;
import com.grack.nanojson.JsonParserException;
@@ -10,7 +12,6 @@ import com.grack.nanojson.JsonParserException;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
-import org.jsoup.select.Elements;
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.downloader.Downloader;
@@ -27,16 +28,15 @@ import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.extractor.utils.JsonUtils;
import org.schabi.newpipe.extractor.utils.Utils;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
-
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
+import java.util.stream.Collectors;
public class BandcampStreamExtractor extends StreamExtractor {
-
private JsonObject albumJson;
private JsonObject current;
private Document document;
@@ -88,7 +88,7 @@ public class BandcampStreamExtractor extends StreamExtractor {
public String getUploaderUrl() throws ParsingException {
final String[] parts = getUrl().split("/");
// https: (/) (/) * .bandcamp.com (/) and leave out the rest
- return "https://" + parts[2] + "/";
+ return HTTPS + parts[2] + "/";
}
@Nonnull
@@ -119,10 +119,10 @@ public class BandcampStreamExtractor extends StreamExtractor {
@Override
public String getThumbnailUrl() throws ParsingException {
if (albumJson.isNull("art_id")) {
- return Utils.EMPTY_STRING;
- } else {
- return getImageUrl(albumJson.getLong("art_id"), true);
+ return EMPTY_STRING;
}
+
+ return getImageUrl(albumJson.getLong("art_id"), true);
}
@Nonnull
@@ -139,24 +139,26 @@ public class BandcampStreamExtractor extends StreamExtractor {
public Description getDescription() {
final String s = Utils.nonEmptyAndNullJoin(
"\n\n",
- new String[]{
+ new String[] {
current.getString("about"),
current.getString("lyrics"),
current.getString("credits")
- }
- );
+ });
return new Description(s, Description.PLAIN_TEXT);
}
@Override
public List getAudioStreams() {
final List audioStreams = new ArrayList<>();
-
- audioStreams.add(new AudioStream(
- albumJson.getArray("trackinfo").getObject(0)
- .getObject("file").getString("mp3-128"),
- MediaFormat.MP3, 128
- ));
+ audioStreams.add(new AudioStream.Builder()
+ .setId("mp3-128")
+ .setContent(albumJson.getArray("trackinfo")
+ .getObject(0)
+ .getObject("file")
+ .getString("mp3-128"), true)
+ .setMediaFormat(MediaFormat.MP3)
+ .setAverageBitrate(128)
+ .build());
return audioStreams;
}
@@ -184,11 +186,11 @@ public class BandcampStreamExtractor extends StreamExtractor {
@Override
public PlaylistInfoItemsCollector getRelatedItems() {
final PlaylistInfoItemsCollector collector = new PlaylistInfoItemsCollector(getServiceId());
- final Elements recommendedAlbums = document.getElementsByClass("recommended-album");
+ document.getElementsByClass("recommended-album")
+ .stream()
+ .map(BandcampRelatedPlaylistInfoItemExtractor::new)
+ .forEach(collector::commit);
- for (final Element album : recommendedAlbums) {
- collector.commit(new BandcampRelatedPlaylistInfoItemExtractor(album));
- }
return collector;
}
@@ -200,15 +202,17 @@ public class BandcampStreamExtractor extends StreamExtractor {
.flatMap(element -> element.getElementsByClass("tag").stream())
.map(Element::text)
.findFirst()
- .orElse("");
+ .orElse(EMPTY_STRING);
}
@Nonnull
@Override
public String getLicence() {
- /* Tests resulted in this mapping of ints to licence:
+ /*
+ Tests resulted in this mapping of ints to licence:
https://cloud.disroot.org/s/ZTWBxbQ9fKRmRWJ/preview (screenshot from a Bandcamp artist's
- account) */
+ account)
+ */
switch (current.getInt("license_type")) {
case 1:
@@ -233,14 +237,9 @@ public class BandcampStreamExtractor extends StreamExtractor {
@Nonnull
@Override
public List getTags() {
- final Elements tagElements = document.getElementsByAttributeValue("itemprop", "keywords");
-
- final List tags = new ArrayList<>();
-
- for (final Element e : tagElements) {
- tags.add(e.text());
- }
-
- return tags;
+ return document.getElementsByAttributeValue("itemprop", "keywords")
+ .stream()
+ .map(Element::text)
+ .collect(Collectors.toList());
}
}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/media_ccc/extractors/MediaCCCLiveStreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/media_ccc/extractors/MediaCCCLiveStreamExtractor.java
index 2a4eb45ed..c761b33a1 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/media_ccc/extractors/MediaCCCLiveStreamExtractor.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/media_ccc/extractors/MediaCCCLiveStreamExtractor.java
@@ -10,18 +10,30 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.linkhandler.LinkHandler;
import org.schabi.newpipe.extractor.stream.AudioStream;
+import org.schabi.newpipe.extractor.stream.DeliveryMethod;
import org.schabi.newpipe.extractor.stream.Description;
+import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.StreamExtractor;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.stream.VideoStream;
import java.io.IOException;
-import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Collectors;
import javax.annotation.Nonnull;
+import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE;
+import static org.schabi.newpipe.extractor.stream.Stream.ID_UNKNOWN;
+import static org.schabi.newpipe.extractor.utils.Utils.EMPTY_STRING;
+
public class MediaCCCLiveStreamExtractor extends StreamExtractor {
+ private static final String STREAMS = "streams";
+ private static final String URLS = "urls";
+ private static final String URL = "url";
+
private JsonObject conference = null;
private String group = "";
private JsonObject room = null;
@@ -34,19 +46,22 @@ public class MediaCCCLiveStreamExtractor extends StreamExtractor {
@Override
public void onFetchPage(@Nonnull final Downloader downloader)
throws IOException, ExtractionException {
- final JsonArray doc =
- MediaCCCParsingHelper.getLiveStreams(downloader, getExtractorLocalization());
- // find correct room
+ final JsonArray doc = MediaCCCParsingHelper.getLiveStreams(downloader,
+ getExtractorLocalization());
+ // Find the correct room
for (int c = 0; c < doc.size(); c++) {
- conference = doc.getObject(c);
- final JsonArray groups = conference.getArray("groups");
+ final JsonObject conferenceObject = doc.getObject(c);
+ final JsonArray groups = conferenceObject.getArray("groups");
for (int g = 0; g < groups.size(); g++) {
- group = groups.getObject(g).getString("group");
+ final String groupObject = groups.getObject(g).getString("group");
final JsonArray rooms = groups.getObject(g).getArray("rooms");
for (int r = 0; r < rooms.size(); r++) {
- room = rooms.getObject(r);
- if (getId().equals(
- conference.getString("slug") + "/" + room.getString("slug"))) {
+ final JsonObject roomObject = rooms.getObject(r);
+ if (getId().equals(conferenceObject.getString("slug") + "/"
+ + roomObject.getString("slug"))) {
+ conference = conferenceObject;
+ group = groupObject;
+ room = roomObject;
return;
}
}
@@ -91,69 +106,155 @@ public class MediaCCCLiveStreamExtractor extends StreamExtractor {
return conference.getString("conference");
}
+ /**
+ * Get the URL of the first DASH stream found.
+ *
+ *
+ * There can be several DASH streams, so the URL of the first one found is returned by this
+ * method.
+ *
+ *
+ *
+ * You can find the other DASH video streams by using {@link #getVideoStreams()}
+ *
+ */
+ @Nonnull
+ @Override
+ public String getDashMpdUrl() throws ParsingException {
+ return getManifestOfDeliveryMethodWanted("dash");
+ }
+
+ /**
+ * Get the URL of the first HLS stream found.
+ *
+ *
+ * There can be several HLS streams, so the URL of the first one found is returned by this
+ * method.
+ *
+ *
+ *
+ * You can find the other HLS video streams by using {@link #getVideoStreams()}
+ *
+ */
@Nonnull
@Override
public String getHlsUrl() {
- // TODO: There are multiple HLS streams.
- // Make getHlsUrl() and getDashMpdUrl() return lists of VideoStreams,
- // so the user can choose a resolution.
- for (int s = 0; s < room.getArray("streams").size(); s++) {
- final JsonObject stream = room.getArray("streams").getObject(s);
- if (stream.getString("type").equals("video")) {
- if (stream.has("hls")) {
- return stream.getObject("urls").getObject("hls").getString("url");
- }
- }
- }
- return "";
+ return getManifestOfDeliveryMethodWanted("hls");
+ }
+
+ @Nonnull
+ private String getManifestOfDeliveryMethodWanted(@Nonnull final String deliveryMethod) {
+ return room.getArray(STREAMS).stream()
+ .filter(JsonObject.class::isInstance)
+ .map(JsonObject.class::cast)
+ .map(streamObject -> streamObject.getObject(URLS))
+ .filter(urls -> urls.has(deliveryMethod))
+ .map(urls -> urls.getObject(deliveryMethod).getString(URL, EMPTY_STRING))
+ .findFirst()
+ .orElse(EMPTY_STRING);
}
@Override
public List getAudioStreams() throws IOException, ExtractionException {
- final List audioStreams = new ArrayList<>();
- for (int s = 0; s < room.getArray("streams").size(); s++) {
- final JsonObject stream = room.getArray("streams").getObject(s);
- if (stream.getString("type").equals("audio")) {
- for (final String type : stream.getObject("urls").keySet()) {
- final JsonObject url = stream.getObject("urls").getObject(type);
- audioStreams.add(new AudioStream(url.getString("url"),
- MediaFormat.getFromSuffix(type), -1));
- }
- }
- }
- return audioStreams;
+ return getStreams("audio",
+ dto -> {
+ final AudioStream.Builder builder = new AudioStream.Builder()
+ .setId(dto.urlValue.getString("tech", ID_UNKNOWN))
+ .setContent(dto.urlValue.getString(URL), true)
+ .setAverageBitrate(UNKNOWN_BITRATE);
+
+ if ("hls".equals(dto.urlKey)) {
+ // We don't know with the type string what media format will
+ // have HLS streams.
+ // However, the tech string may contain some information
+ // about the media format used.
+ return builder.setDeliveryMethod(DeliveryMethod.HLS)
+ .build();
+ }
+
+ return builder.setMediaFormat(MediaFormat.getFromSuffix(dto.urlKey))
+ .build();
+ });
}
@Override
public List getVideoStreams() throws IOException, ExtractionException {
- final List videoStreams = new ArrayList<>();
- for (int s = 0; s < room.getArray("streams").size(); s++) {
- final JsonObject stream = room.getArray("streams").getObject(s);
- if (stream.getString("type").equals("video")) {
- final String resolution = stream.getArray("videoSize").getInt(0) + "x"
- + stream.getArray("videoSize").getInt(1);
- for (final String type : stream.getObject("urls").keySet()) {
- if (!type.equals("hls")) {
- final JsonObject url = stream.getObject("urls").getObject(type);
- videoStreams.add(new VideoStream(
- url.getString("url"),
- MediaFormat.getFromSuffix(type),
- resolution));
+ return getStreams("video",
+ dto -> {
+ final JsonArray videoSize = dto.streamJsonObj.getArray("videoSize");
+
+ final VideoStream.Builder builder = new VideoStream.Builder()
+ .setId(dto.urlValue.getString("tech", ID_UNKNOWN))
+ .setContent(dto.urlValue.getString(URL), true)
+ .setIsVideoOnly(false)
+ .setResolution(videoSize.getInt(0) + "x" + videoSize.getInt(1));
+
+ if ("hls".equals(dto.urlKey)) {
+ // We don't know with the type string what media format will
+ // have HLS streams.
+ // However, the tech string may contain some information
+ // about the media format used.
+ return builder.setDeliveryMethod(DeliveryMethod.HLS)
+ .build();
}
- }
- }
+
+ return builder.setMediaFormat(MediaFormat.getFromSuffix(dto.urlKey))
+ .build();
+ });
+ }
+
+
+ /**
+ * This is just an internal class used in {@link #getStreams(String, Function)} to tie together
+ * the stream json object, its URL key and its URL value. An object of this class would be
+ * temporary and the three values it holds would be converted to a proper {@link Stream}
+ * object based on the wanted stream type.
+ */
+ private static final class MediaCCCLiveStreamMapperDTO {
+ final JsonObject streamJsonObj;
+ final String urlKey;
+ final JsonObject urlValue;
+
+ MediaCCCLiveStreamMapperDTO(final JsonObject streamJsonObj,
+ final String urlKey,
+ final JsonObject urlValue) {
+ this.streamJsonObj = streamJsonObj;
+ this.urlKey = urlKey;
+ this.urlValue = urlValue;
}
- return videoStreams;
+ }
+
+ private List getStreams(
+ @Nonnull final String streamType,
+ @Nonnull final Function converter) {
+ return room.getArray(STREAMS).stream()
+ // Ensure that we use only process JsonObjects
+ .filter(JsonObject.class::isInstance)
+ .map(JsonObject.class::cast)
+ // Only process streams of requested type
+ .filter(streamJsonObj -> streamType.equals(streamJsonObj.getString("type")))
+ // Flatmap Urls and ensure that we use only process JsonObjects
+ .flatMap(streamJsonObj -> streamJsonObj.getObject(URLS).entrySet().stream()
+ .filter(e -> e.getValue() instanceof JsonObject)
+ .map(e -> new MediaCCCLiveStreamMapperDTO(
+ streamJsonObj,
+ e.getKey(),
+ (JsonObject) e.getValue())))
+ // The DASH manifest will be extracted with getDashMpdUrl
+ .filter(dto -> !"dash".equals(dto.urlKey))
+ // Convert
+ .map(converter)
+ .collect(Collectors.toList());
}
@Override
public List getVideoOnlyStreams() {
- return null;
+ return Collections.emptyList();
}
@Override
public StreamType getStreamType() throws ParsingException {
- return StreamType.LIVE_STREAM; // TODO: video and audio only streams are both available
+ return StreamType.LIVE_STREAM;
}
@Nonnull
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/media_ccc/extractors/MediaCCCStreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/media_ccc/extractors/MediaCCCStreamExtractor.java
index 64a268971..0a086fcc6 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/media_ccc/extractors/MediaCCCStreamExtractor.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/media_ccc/extractors/MediaCCCStreamExtractor.java
@@ -1,5 +1,8 @@
package org.schabi.newpipe.extractor.services.media_ccc.extractors;
+import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE;
+import static org.schabi.newpipe.extractor.stream.Stream.ID_UNKNOWN;
+
import com.grack.nanojson.JsonArray;
import com.grack.nanojson.JsonObject;
import com.grack.nanojson.JsonParser;
@@ -99,7 +102,7 @@ public class MediaCCCStreamExtractor extends StreamExtractor {
final JsonObject recording = recordings.getObject(i);
final String mimeType = recording.getString("mime_type");
if (mimeType.startsWith("audio")) {
- //first we need to resolve the actual video data from CDN
+ // First we need to resolve the actual video data from the CDN
final MediaFormat mediaFormat;
if (mimeType.endsWith("opus")) {
mediaFormat = MediaFormat.OPUS;
@@ -108,11 +111,18 @@ public class MediaCCCStreamExtractor extends StreamExtractor {
} else if (mimeType.endsWith("ogg")) {
mediaFormat = MediaFormat.OGG;
} else {
- throw new ExtractionException("Unknown media format: " + mimeType);
+ mediaFormat = null;
}
- audioStreams.add(new AudioStream(recording.getString("recording_url"),
- mediaFormat, -1));
+ // Not checking containsSimilarStream here, since MediaCCC does not provide enough
+ // information to decide whether two streams are similar. Hence that method would
+ // always return false, e.g. even for different language variations.
+ audioStreams.add(new AudioStream.Builder()
+ .setId(recording.getString("filename", ID_UNKNOWN))
+ .setContent(recording.getString("recording_url"), true)
+ .setMediaFormat(mediaFormat)
+ .setAverageBitrate(UNKNOWN_BITRATE)
+ .build());
}
}
return audioStreams;
@@ -126,21 +136,29 @@ public class MediaCCCStreamExtractor extends StreamExtractor {
final JsonObject recording = recordings.getObject(i);
final String mimeType = recording.getString("mime_type");
if (mimeType.startsWith("video")) {
- //first we need to resolve the actual video data from CDN
-
+ // First we need to resolve the actual video data from the CDN
final MediaFormat mediaFormat;
if (mimeType.endsWith("webm")) {
mediaFormat = MediaFormat.WEBM;
} else if (mimeType.endsWith("mp4")) {
mediaFormat = MediaFormat.MPEG_4;
} else {
- throw new ExtractionException("Unknown media format: " + mimeType);
+ mediaFormat = null;
}
- videoStreams.add(new VideoStream(recording.getString("recording_url"),
- mediaFormat, recording.getInt("height") + "p"));
+ // Not checking containsSimilarStream here, since MediaCCC does not provide enough
+ // information to decide whether two streams are similar. Hence that method would
+ // always return false, e.g. even for different language variations.
+ videoStreams.add(new VideoStream.Builder()
+ .setId(recording.getString("filename", ID_UNKNOWN))
+ .setContent(recording.getString("recording_url"), true)
+ .setIsVideoOnly(false)
+ .setMediaFormat(mediaFormat)
+ .setResolution(recording.getInt("height") + "p")
+ .build());
}
}
+
return videoStreams;
}
@@ -163,7 +181,8 @@ public class MediaCCCStreamExtractor extends StreamExtractor {
conferenceData = JsonParser.object()
.from(downloader.get(data.getString("conference_url")).responseBody());
} catch (final JsonParserException jpe) {
- throw new ExtractionException("Could not parse json returned by url: " + videoUrl, jpe);
+ throw new ExtractionException("Could not parse json returned by URL: " + videoUrl,
+ jpe);
}
}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/peertube/extractors/PeertubeStreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/peertube/extractors/PeertubeStreamExtractor.java
index f80815d10..d42e23ede 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/peertube/extractors/PeertubeStreamExtractor.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/peertube/extractors/PeertubeStreamExtractor.java
@@ -22,6 +22,7 @@ import org.schabi.newpipe.extractor.services.peertube.PeertubeParsingHelper;
import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory;
import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeStreamLinkHandlerFactory;
import org.schabi.newpipe.extractor.stream.AudioStream;
+import org.schabi.newpipe.extractor.stream.DeliveryMethod;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.StreamExtractor;
@@ -39,14 +40,30 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
+import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
+import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE;
+import static org.schabi.newpipe.extractor.utils.Utils.EMPTY_STRING;
+
public class PeertubeStreamExtractor extends StreamExtractor {
+ private static final String ACCOUNT_HOST = "account.host";
+ private static final String ACCOUNT_NAME = "account.name";
+ private static final String FILES = "files";
+ private static final String FILE_DOWNLOAD_URL = "fileDownloadUrl";
+ private static final String FILE_URL = "fileUrl";
+ private static final String PLAYLIST_URL = "playlistUrl";
+ private static final String RESOLUTION_ID = "resolution.id";
+ private static final String STREAMING_PLAYLISTS = "streamingPlaylists";
+
private final String baseUrl;
private JsonObject json;
+
private final List subtitles = new ArrayList<>();
+ private final List audioStreams = new ArrayList<>();
+ private final List videoStreams = new ArrayList<>();
public PeertubeStreamExtractor(final StreamingService service, final LinkHandler linkHandler)
throws ParsingException {
@@ -85,9 +102,8 @@ public class PeertubeStreamExtractor extends StreamExtractor {
} catch (final ParsingException e) {
return Description.EMPTY_DESCRIPTION;
}
-
if (text.length() == 250 && text.substring(247).equals("...")) {
- //if description is shortened, get full description
+ // If description is shortened, get full description
final Downloader dl = NewPipe.getDownloader();
try {
final Response response = dl.get(baseUrl
@@ -95,8 +111,8 @@ public class PeertubeStreamExtractor extends StreamExtractor {
+ getId() + "/description");
final JsonObject jsonObject = JsonParser.object().from(response.responseBody());
text = JsonUtils.getString(jsonObject, "description");
- } catch (ReCaptchaException | IOException | JsonParserException e) {
- e.printStackTrace();
+ } catch (final IOException | ReCaptchaException | JsonParserException ignored) {
+ // Something went wrong when getting the full description, use the shortened one
}
}
return new Description(text, Description.MARKDOWN);
@@ -119,8 +135,8 @@ public class PeertubeStreamExtractor extends StreamExtractor {
@Override
public long getTimeStamp() throws ParsingException {
- final long timestamp =
- getTimestampSeconds("((#|&|\\?)start=\\d{0,3}h?\\d{0,3}m?\\d{1,3}s?)");
+ final long timestamp = getTimestampSeconds(
+ "((#|&|\\?)start=\\d{0,3}h?\\d{0,3}m?\\d{1,3}s?)");
if (timestamp == -2) {
// regex for timestamp was not found
@@ -148,10 +164,10 @@ public class PeertubeStreamExtractor extends StreamExtractor {
@Nonnull
@Override
public String getUploaderUrl() throws ParsingException {
- final String name = JsonUtils.getString(json, "account.name");
- final String host = JsonUtils.getString(json, "account.host");
- return getService().getChannelLHFactory()
- .fromId("accounts/" + name + "@" + host, baseUrl).getUrl();
+ final String name = JsonUtils.getString(json, ACCOUNT_NAME);
+ final String host = JsonUtils.getString(json, ACCOUNT_HOST);
+ return getService().getChannelLHFactory().fromId("accounts/" + name + "@" + host, baseUrl)
+ .getUrl();
}
@Nonnull
@@ -199,77 +215,51 @@ public class PeertubeStreamExtractor extends StreamExtractor {
@Nonnull
@Override
public String getHlsUrl() {
- return json.getArray("streamingPlaylists").getObject(0).getString("playlistUrl");
+ assertPageFetched();
+
+ if (getStreamType() == StreamType.VIDEO_STREAM
+ && !isNullOrEmpty(json.getObject(FILES))) {
+ return json.getObject(FILES).getString(PLAYLIST_URL, EMPTY_STRING);
+ }
+
+ return json.getArray(STREAMING_PLAYLISTS).getObject(0).getString(PLAYLIST_URL,
+ EMPTY_STRING);
}
@Override
- public List getAudioStreams() {
- return Collections.emptyList();
+ public List getAudioStreams() throws ParsingException {
+ assertPageFetched();
+
+ /*
+ Some videos have audio streams; others don't.
+ So an audio stream may be available if a video stream is available.
+ Audio streams are also not returned as separated streams for livestreams.
+ That's why the extraction of audio streams is only run when there are video streams
+ extracted and when the content is not a livestream.
+ */
+ if (audioStreams.isEmpty() && videoStreams.isEmpty()
+ && getStreamType() == StreamType.VIDEO_STREAM) {
+ getStreams();
+ }
+
+ return audioStreams;
}
@Override
public List getVideoStreams() throws ExtractionException {
assertPageFetched();
- final List videoStreams = new ArrayList<>();
- // mp4
- try {
- videoStreams.addAll(getVideoStreamsFromArray(json.getArray("files")));
- } catch (final Exception ignored) { }
-
- // HLS
- try {
- final JsonArray streamingPlaylists = json.getArray("streamingPlaylists");
- for (final Object p : streamingPlaylists) {
- if (!(p instanceof JsonObject)) {
- continue;
- }
- final JsonObject playlist = (JsonObject) p;
- videoStreams.addAll(getVideoStreamsFromArray(playlist.getArray("files")));
+ if (videoStreams.isEmpty()) {
+ if (getStreamType() == StreamType.VIDEO_STREAM) {
+ getStreams();
+ } else {
+ extractLiveVideoStreams();
}
- } catch (final Exception e) {
- throw new ParsingException("Could not get video streams", e);
- }
-
- if (getStreamType() == StreamType.LIVE_STREAM) {
- videoStreams.add(new VideoStream(getHlsUrl(), MediaFormat.MPEG_4, "720p"));
}
return videoStreams;
}
- private List getVideoStreamsFromArray(final JsonArray streams)
- throws ParsingException {
- try {
- final List videoStreams = new ArrayList<>();
- for (final Object s : streams) {
- if (!(s instanceof JsonObject)) {
- continue;
- }
- final JsonObject stream = (JsonObject) s;
- final String url;
- if (stream.has("fileDownloadUrl")) {
- url = JsonUtils.getString(stream, "fileDownloadUrl");
- } else {
- url = JsonUtils.getString(stream, "fileUrl");
- }
- final String torrentUrl = JsonUtils.getString(stream, "torrentUrl");
- final String resolution = JsonUtils.getString(stream, "resolution.label");
- final String extension = url.substring(url.lastIndexOf(".") + 1);
- final MediaFormat format = MediaFormat.getFromSuffix(extension);
- final VideoStream videoStream
- = new VideoStream(url, torrentUrl, format, resolution);
- if (!Stream.containSimilarStream(videoStream, videoStreams)) {
- videoStreams.add(videoStream);
- }
- }
- return videoStreams;
- } catch (final Exception e) {
- throw new ParsingException("Could not get video streams from array");
- }
-
- }
-
@Override
public List getVideoOnlyStreams() {
return Collections.emptyList();
@@ -284,13 +274,9 @@ public class PeertubeStreamExtractor extends StreamExtractor {
@Nonnull
@Override
public List getSubtitles(final MediaFormat format) {
- final List filteredSubs = new ArrayList<>();
- for (final SubtitlesStream sub : subtitles) {
- if (sub.getFormat() == format) {
- filteredSubs.add(sub);
- }
- }
- return filteredSubs;
+ return subtitles.stream()
+ .filter(sub -> sub.getFormat() == format)
+ .collect(Collectors.toList());
}
@Override
@@ -304,8 +290,8 @@ public class PeertubeStreamExtractor extends StreamExtractor {
final List tags = getTags();
final String apiUrl;
if (tags.isEmpty()) {
- apiUrl = baseUrl + "/api/v1/accounts/" + JsonUtils.getString(json, "account.name")
- + "@" + JsonUtils.getString(json, "account.host")
+ apiUrl = baseUrl + "/api/v1/accounts/" + JsonUtils.getString(json, ACCOUNT_NAME)
+ + "@" + JsonUtils.getString(json, ACCOUNT_HOST)
+ "/videos?start=0&count=8";
} else {
apiUrl = getRelatedItemsUrl(tags);
@@ -314,7 +300,8 @@ public class PeertubeStreamExtractor extends StreamExtractor {
if (Utils.isBlank(apiUrl)) {
return null;
} else {
- final StreamInfoItemsCollector collector = new StreamInfoItemsCollector(getServiceId());
+ final StreamInfoItemsCollector collector = new StreamInfoItemsCollector(
+ getServiceId());
getStreamsFromApi(collector, apiUrl);
return collector;
}
@@ -332,11 +319,13 @@ public class PeertubeStreamExtractor extends StreamExtractor {
try {
return JsonUtils.getString(json, "support");
} catch (final ParsingException e) {
- return "";
+ return EMPTY_STRING;
}
}
- private String getRelatedItemsUrl(final List tags) throws UnsupportedEncodingException {
+ @Nonnull
+ private String getRelatedItemsUrl(@Nonnull final List tags)
+ throws UnsupportedEncodingException {
final String url = baseUrl + PeertubeSearchQueryHandlerFactory.SEARCH_ENDPOINT;
final StringBuilder params = new StringBuilder();
params.append("start=0&count=8&sort=-createdAt");
@@ -348,7 +337,7 @@ public class PeertubeStreamExtractor extends StreamExtractor {
}
private void getStreamsFromApi(final StreamInfoItemsCollector collector, final String apiUrl)
- throws ReCaptchaException, IOException, ParsingException {
+ throws IOException, ReCaptchaException, ParsingException {
final Response response = getDownloader().get(apiUrl);
JsonObject relatedVideosJson = null;
if (response != null && !Utils.isBlank(response.responseBody())) {
@@ -365,21 +354,20 @@ public class PeertubeStreamExtractor extends StreamExtractor {
}
private void collectStreamsFrom(final StreamInfoItemsCollector collector,
- final JsonObject jsonObject)
- throws ParsingException {
+ final JsonObject jsonObject) throws ParsingException {
final JsonArray contents;
try {
contents = (JsonArray) JsonUtils.getValue(jsonObject, "data");
} catch (final Exception e) {
- throw new ParsingException("unable to extract related videos", e);
+ throw new ParsingException("Could not extract related videos", e);
}
for (final Object c : contents) {
if (c instanceof JsonObject) {
final JsonObject item = (JsonObject) c;
- final PeertubeStreamInfoItemExtractor extractor
- = new PeertubeStreamInfoItemExtractor(item, baseUrl);
- //do not add the same stream in related streams
+ final PeertubeStreamInfoItemExtractor extractor =
+ new PeertubeStreamInfoItemExtractor(item, baseUrl);
+ // Do not add the same stream in related streams
if (!extractor.getUrl().equals(getUrl())) {
collector.commit(extractor);
}
@@ -395,7 +383,7 @@ public class PeertubeStreamExtractor extends StreamExtractor {
if (response != null) {
setInitialData(response.responseBody());
} else {
- throw new ExtractionException("Unable to extract PeerTube channel data");
+ throw new ExtractionException("Could not extract PeerTube channel data");
}
loadSubtitles();
@@ -405,10 +393,10 @@ public class PeertubeStreamExtractor extends StreamExtractor {
try {
json = JsonParser.object().from(responseBody);
} catch (final JsonParserException e) {
- throw new ExtractionException("Unable to extract PeerTube stream data", e);
+ throw new ExtractionException("Could not extract PeerTube stream data", e);
}
if (json == null) {
- throw new ExtractionException("Unable to extract PeerTube stream data");
+ throw new ExtractionException("Could not extract PeerTube stream data");
}
PeertubeParsingHelper.validate(json);
}
@@ -429,16 +417,233 @@ public class PeertubeStreamExtractor extends StreamExtractor {
final String ext = url.substring(url.lastIndexOf(".") + 1);
final MediaFormat fmt = MediaFormat.getFromSuffix(ext);
if (fmt != null && !isNullOrEmpty(languageCode)) {
- subtitles.add(new SubtitlesStream(fmt, languageCode, url, false));
+ subtitles.add(new SubtitlesStream.Builder()
+ .setContent(url, true)
+ .setMediaFormat(fmt)
+ .setLanguageCode(languageCode)
+ .setAutoGenerated(false)
+ .build());
}
}
}
- } catch (final Exception e) {
- // ignore all exceptions
+ } catch (final Exception ignored) {
+ // Ignore all exceptions
}
}
}
+ private void extractLiveVideoStreams() throws ParsingException {
+ try {
+ final JsonArray streamingPlaylists = json.getArray(STREAMING_PLAYLISTS);
+ streamingPlaylists.stream()
+ .filter(JsonObject.class::isInstance)
+ .map(JsonObject.class::cast)
+ .map(stream -> new VideoStream.Builder()
+ .setId(String.valueOf(stream.getInt("id", -1)))
+ .setContent(stream.getString(PLAYLIST_URL, EMPTY_STRING), true)
+ .setIsVideoOnly(false)
+ .setResolution(EMPTY_STRING)
+ .setMediaFormat(MediaFormat.MPEG_4)
+ .setDeliveryMethod(DeliveryMethod.HLS)
+ .build())
+ // Don't use the containsSimilarStream method because it will always return
+ // false so if there are multiples HLS URLs returned, only the first will be
+ // extracted in this case.
+ .forEachOrdered(videoStreams::add);
+ } catch (final Exception e) {
+ throw new ParsingException("Could not get video streams", e);
+ }
+ }
+
+ private void getStreams() throws ParsingException {
+ // Progressive streams
+ getStreamsFromArray(json.getArray(FILES), EMPTY_STRING);
+
+ // HLS streams
+ try {
+ for (final JsonObject playlist : json.getArray(STREAMING_PLAYLISTS).stream()
+ .filter(JsonObject.class::isInstance)
+ .map(JsonObject.class::cast)
+ .collect(Collectors.toList())) {
+ getStreamsFromArray(playlist.getArray(FILES), playlist.getString(PLAYLIST_URL));
+ }
+ } catch (final Exception e) {
+ throw new ParsingException("Could not get streams", e);
+ }
+ }
+
+ private void getStreamsFromArray(@Nonnull final JsonArray streams,
+ final String playlistUrl) throws ParsingException {
+ try {
+ /*
+ Starting with version 3.4.0 of PeerTube, the HLS playlist of stream resolutions
+ contains the UUID of the streams, so we can't use the same method to get the URL of
+ the HLS playlist without fetching the master playlist.
+ These UUIDs are the same as the ones returned into the fileUrl and fileDownloadUrl
+ strings.
+ */
+ final boolean isInstanceUsingRandomUuidsForHlsStreams = !isNullOrEmpty(playlistUrl)
+ && playlistUrl.endsWith("-master.m3u8");
+
+ for (final JsonObject stream : streams.stream()
+ .filter(JsonObject.class::isInstance)
+ .map(JsonObject.class::cast)
+ .collect(Collectors.toList())) {
+
+ // Extract stream version of streams first
+ final String url = JsonUtils.getString(stream,
+ stream.has(FILE_URL) ? FILE_URL : FILE_DOWNLOAD_URL);
+ if (isNullOrEmpty(url)) {
+ // Not a valid stream URL
+ return;
+ }
+
+ final String resolution = JsonUtils.getString(stream, "resolution.label");
+ final String idSuffix = stream.has(FILE_URL) ? FILE_URL : FILE_DOWNLOAD_URL;
+
+ if (resolution.toLowerCase().contains("audio")) {
+ // An audio stream
+ addNewAudioStream(stream, isInstanceUsingRandomUuidsForHlsStreams, resolution,
+ idSuffix, url, playlistUrl);
+ } else {
+ // A video stream
+ addNewVideoStream(stream, isInstanceUsingRandomUuidsForHlsStreams, resolution,
+ idSuffix, url, playlistUrl);
+ }
+ }
+ } catch (final Exception e) {
+ throw new ParsingException("Could not get streams from array", e);
+ }
+ }
+
+ @Nonnull
+ private String getHlsPlaylistUrlFromFragmentedFileUrl(
+ @Nonnull final JsonObject streamJsonObject,
+ @Nonnull final String idSuffix,
+ @Nonnull final String format,
+ @Nonnull final String url) throws ParsingException {
+ final String streamUrl = FILE_DOWNLOAD_URL.equals(idSuffix)
+ ? JsonUtils.getString(streamJsonObject, FILE_URL)
+ : url;
+ return streamUrl.replace("-fragmented." + format, ".m3u8");
+ }
+
+ @Nonnull
+ private String getHlsPlaylistUrlFromMasterPlaylist(@Nonnull final JsonObject streamJsonObject,
+ @Nonnull final String playlistUrl)
+ throws ParsingException {
+ return playlistUrl.replace("master", JsonUtils.getNumber(streamJsonObject,
+ RESOLUTION_ID).toString());
+ }
+
+ private void addNewAudioStream(@Nonnull final JsonObject streamJsonObject,
+ final boolean isInstanceUsingRandomUuidsForHlsStreams,
+ @Nonnull final String resolution,
+ @Nonnull final String idSuffix,
+ @Nonnull final String url,
+ @Nullable final String playlistUrl) throws ParsingException {
+ final String extension = url.substring(url.lastIndexOf(".") + 1);
+ final MediaFormat format = MediaFormat.getFromSuffix(extension);
+ final String id = resolution + "-" + extension;
+
+ // Add progressive HTTP streams first
+ audioStreams.add(new AudioStream.Builder()
+ .setId(id + "-" + idSuffix + "-" + DeliveryMethod.PROGRESSIVE_HTTP)
+ .setContent(url, true)
+ .setMediaFormat(format)
+ .setAverageBitrate(UNKNOWN_BITRATE)
+ .build());
+
+ // Then add HLS streams
+ if (!isNullOrEmpty(playlistUrl)) {
+ final String hlsStreamUrl;
+ if (isInstanceUsingRandomUuidsForHlsStreams) {
+ hlsStreamUrl = getHlsPlaylistUrlFromFragmentedFileUrl(streamJsonObject, idSuffix,
+ extension, url);
+
+ } else {
+ hlsStreamUrl = getHlsPlaylistUrlFromMasterPlaylist(streamJsonObject, playlistUrl);
+ }
+ final AudioStream audioStream = new AudioStream.Builder()
+ .setId(id + "-" + DeliveryMethod.HLS)
+ .setContent(hlsStreamUrl, true)
+ .setDeliveryMethod(DeliveryMethod.HLS)
+ .setMediaFormat(format)
+ .setAverageBitrate(UNKNOWN_BITRATE)
+ .setManifestUrl(playlistUrl)
+ .build();
+ if (!Stream.containSimilarStream(audioStream, audioStreams)) {
+ audioStreams.add(audioStream);
+ }
+ }
+
+ // Finally, add torrent URLs
+ final String torrentUrl = JsonUtils.getString(streamJsonObject, "torrentUrl");
+ if (!isNullOrEmpty(torrentUrl)) {
+ audioStreams.add(new AudioStream.Builder()
+ .setId(id + "-" + idSuffix + "-" + DeliveryMethod.TORRENT)
+ .setContent(torrentUrl, true)
+ .setDeliveryMethod(DeliveryMethod.TORRENT)
+ .setMediaFormat(format)
+ .setAverageBitrate(UNKNOWN_BITRATE)
+ .build());
+ }
+ }
+
+ private void addNewVideoStream(@Nonnull final JsonObject streamJsonObject,
+ final boolean isInstanceUsingRandomUuidsForHlsStreams,
+ @Nonnull final String resolution,
+ @Nonnull final String idSuffix,
+ @Nonnull final String url,
+ @Nullable final String playlistUrl) throws ParsingException {
+ final String extension = url.substring(url.lastIndexOf(".") + 1);
+ final MediaFormat format = MediaFormat.getFromSuffix(extension);
+ final String id = resolution + "-" + extension;
+
+ // Add progressive HTTP streams first
+ videoStreams.add(new VideoStream.Builder()
+ .setId(id + "-" + idSuffix + "-" + DeliveryMethod.PROGRESSIVE_HTTP)
+ .setContent(url, true)
+ .setIsVideoOnly(false)
+ .setResolution(resolution)
+ .setMediaFormat(format)
+ .build());
+
+ // Then add HLS streams
+ if (!isNullOrEmpty(playlistUrl)) {
+ final String hlsStreamUrl = isInstanceUsingRandomUuidsForHlsStreams
+ ? getHlsPlaylistUrlFromFragmentedFileUrl(streamJsonObject, idSuffix, extension,
+ url)
+ : getHlsPlaylistUrlFromMasterPlaylist(streamJsonObject, playlistUrl);
+
+ final VideoStream videoStream = new VideoStream.Builder()
+ .setId(id + "-" + DeliveryMethod.HLS)
+ .setContent(hlsStreamUrl, true)
+ .setIsVideoOnly(false)
+ .setDeliveryMethod(DeliveryMethod.HLS)
+ .setResolution(resolution)
+ .setMediaFormat(format)
+ .setManifestUrl(playlistUrl)
+ .build();
+ if (!Stream.containSimilarStream(videoStream, videoStreams)) {
+ videoStreams.add(videoStream);
+ }
+ }
+
+ // Add finally torrent URLs
+ final String torrentUrl = JsonUtils.getString(streamJsonObject, "torrentUrl");
+ if (!isNullOrEmpty(torrentUrl)) {
+ videoStreams.add(new VideoStream.Builder()
+ .setId(id + "-" + idSuffix + "-" + DeliveryMethod.TORRENT)
+ .setContent(torrentUrl, true)
+ .setIsVideoOnly(false)
+ .setDeliveryMethod(DeliveryMethod.TORRENT)
+ .setResolution(resolution)
+ .setMediaFormat(format)
+ .build());
+ }
+ }
+
@Nonnull
@Override
public String getName() throws ParsingException {
@@ -448,7 +653,7 @@ public class PeertubeStreamExtractor extends StreamExtractor {
@Nonnull
@Override
public String getHost() throws ParsingException {
- return JsonUtils.getString(json, "account.host");
+ return JsonUtils.getString(json, ACCOUNT_HOST);
}
@Nonnull
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/soundcloud/extractors/SoundcloudStreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/soundcloud/extractors/SoundcloudStreamExtractor.java
index 24bb6ec5b..42a832cde 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/soundcloud/extractors/SoundcloudStreamExtractor.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/soundcloud/extractors/SoundcloudStreamExtractor.java
@@ -1,6 +1,9 @@
package org.schabi.newpipe.extractor.services.soundcloud.extractors;
import static org.schabi.newpipe.extractor.services.soundcloud.SoundcloudParsingHelper.SOUNDCLOUD_API_V2_URL;
+import static org.schabi.newpipe.extractor.services.soundcloud.SoundcloudParsingHelper.clientId;
+import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE;
+import static org.schabi.newpipe.extractor.stream.Stream.ID_UNKNOWN;
import static org.schabi.newpipe.extractor.utils.Utils.EMPTY_STRING;
import static org.schabi.newpipe.extractor.utils.Utils.HTTPS;
import static org.schabi.newpipe.extractor.utils.Utils.UTF_8;
@@ -25,7 +28,9 @@ import org.schabi.newpipe.extractor.linkhandler.LinkHandler;
import org.schabi.newpipe.extractor.localization.DateWrapper;
import org.schabi.newpipe.extractor.services.soundcloud.SoundcloudParsingHelper;
import org.schabi.newpipe.extractor.stream.AudioStream;
+import org.schabi.newpipe.extractor.stream.DeliveryMethod;
import org.schabi.newpipe.extractor.stream.Description;
+import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.StreamExtractor;
import org.schabi.newpipe.extractor.stream.StreamInfoItemsCollector;
import org.schabi.newpipe.extractor.stream.StreamType;
@@ -58,13 +63,16 @@ public class SoundcloudStreamExtractor extends StreamExtractor {
final String policy = track.getString("policy", EMPTY_STRING);
if (!policy.equals("ALLOW") && !policy.equals("MONETIZE")) {
isAvailable = false;
+
if (policy.equals("SNIP")) {
throw new SoundCloudGoPlusContentException();
}
+
if (policy.equals("BLOCK")) {
throw new GeographicRestrictionException(
"This track is not available in user's country");
}
+
throw new ContentNotAvailableException("Content not available: policy " + policy);
}
}
@@ -72,7 +80,7 @@ public class SoundcloudStreamExtractor extends StreamExtractor {
@Nonnull
@Override
public String getId() {
- return track.getInt("id") + EMPTY_STRING;
+ return String.valueOf(track.getInt("id"));
}
@Nonnull
@@ -168,118 +176,205 @@ public class SoundcloudStreamExtractor extends StreamExtractor {
try {
final JsonArray transcodings = track.getObject("media").getArray("transcodings");
- if (transcodings != null) {
+ if (!isNullOrEmpty(transcodings)) {
// Get information about what stream formats are available
extractAudioStreams(transcodings, checkMp3ProgressivePresence(transcodings),
audioStreams);
}
+
+ extractDownloadableFileIfAvailable(audioStreams);
} catch (final NullPointerException e) {
- throw new ExtractionException("Could not get SoundCloud's tracks audio URL", e);
+ throw new ExtractionException("Could not get audio streams", e);
}
return audioStreams;
}
- private static boolean checkMp3ProgressivePresence(final JsonArray transcodings) {
- boolean presence = false;
- for (final Object transcoding : transcodings) {
- final JsonObject transcodingJsonObject = (JsonObject) transcoding;
- if (transcodingJsonObject.getString("preset").contains("mp3")
- && transcodingJsonObject.getObject("format").getString("protocol")
- .equals("progressive")) {
- presence = true;
- break;
- }
- }
- return presence;
+ private static boolean checkMp3ProgressivePresence(@Nonnull final JsonArray transcodings) {
+ return transcodings.stream()
+ .filter(JsonObject.class::isInstance)
+ .map(JsonObject.class::cast)
+ .anyMatch(transcodingJsonObject -> transcodingJsonObject.getString("preset")
+ .contains("mp3") && transcodingJsonObject.getObject("format")
+ .getString("protocol").equals("progressive"));
}
@Nonnull
- private static String getTranscodingUrl(final String endpointUrl,
- final String protocol)
+ private String getTranscodingUrl(final String endpointUrl)
throws IOException, ExtractionException {
- final Downloader downloader = NewPipe.getDownloader();
- final String apiStreamUrl = endpointUrl + "?client_id="
- + SoundcloudParsingHelper.clientId();
- final String response = downloader.get(apiStreamUrl).responseBody();
+ final String apiStreamUrl = endpointUrl + "?client_id=" + clientId();
+ final String response = NewPipe.getDownloader().get(apiStreamUrl).responseBody();
final JsonObject urlObject;
try {
urlObject = JsonParser.object().from(response);
} catch (final JsonParserException e) {
- throw new ParsingException("Could not parse streamable url", e);
+ throw new ParsingException("Could not parse streamable URL", e);
}
- final String urlString = urlObject.getString("url");
- if (protocol.equals("progressive")) {
- return urlString;
- } else if (protocol.equals("hls")) {
- try {
- return getSingleUrlFromHlsManifest(urlString);
- } catch (final ParsingException ignored) {
- }
- }
- // else, unknown protocol
- return "";
+ return urlObject.getString("url");
}
- private static void extractAudioStreams(final JsonArray transcodings,
- final boolean mp3ProgressiveInStreams,
- final List audioStreams) {
- for (final Object transcoding : transcodings) {
- final JsonObject transcodingJsonObject = (JsonObject) transcoding;
- final String url = transcodingJsonObject.getString("url");
- if (isNullOrEmpty(url)) {
- continue;
- }
- final String mediaUrl;
- final String preset = transcodingJsonObject.getString("preset");
- final String protocol = transcodingJsonObject.getObject("format")
- .getString("protocol");
- MediaFormat mediaFormat = null;
- int bitrate = 0;
- if (preset.contains("mp3")) {
- // Don't add the MP3 HLS stream if there is a progressive stream present
- // because the two have the same bitrate
- if (mp3ProgressiveInStreams && protocol.equals("hls")) {
- continue;
- }
- mediaFormat = MediaFormat.MP3;
- bitrate = 128;
- } else if (preset.contains("opus")) {
- mediaFormat = MediaFormat.OPUS;
- bitrate = 64;
- }
+ @Nullable
+ private String getDownloadUrl(@Nonnull final String trackId)
+ throws IOException, ExtractionException {
+ final String response = NewPipe.getDownloader().get(SOUNDCLOUD_API_V2_URL + "tracks/"
+ + trackId + "/download" + "?client_id=" + clientId()).responseBody();
- if (mediaFormat != null) {
- try {
- mediaUrl = getTranscodingUrl(url, protocol);
- if (!mediaUrl.isEmpty()) {
- audioStreams.add(new AudioStream(mediaUrl, mediaFormat, bitrate));
+ final JsonObject downloadJsonObject;
+ try {
+ downloadJsonObject = JsonParser.object().from(response);
+ } catch (final JsonParserException e) {
+ throw new ParsingException("Could not parse download URL", e);
+ }
+ final String redirectUri = downloadJsonObject.getString("redirectUri");
+ if (!isNullOrEmpty(redirectUri)) {
+ return redirectUri;
+ }
+ return null;
+ }
+
+ private void extractAudioStreams(@Nonnull final JsonArray transcodings,
+ final boolean mp3ProgressiveInStreams,
+ final List audioStreams) {
+ transcodings.stream()
+ .filter(JsonObject.class::isInstance)
+ .map(JsonObject.class::cast)
+ .forEachOrdered(transcoding -> {
+ final String url = transcoding.getString("url");
+ if (isNullOrEmpty(url)) {
+ return;
}
- } catch (final Exception ignored) {
- // something went wrong when parsing this transcoding, don't add it to
- // audioStreams
+
+ final String preset = transcoding.getString("preset", ID_UNKNOWN);
+ final String protocol = transcoding.getObject("format").getString("protocol");
+ final AudioStream.Builder builder = new AudioStream.Builder()
+ .setId(preset);
+
+ try {
+ // streamUrl can be either the MP3 progressive stream URL or the
+ // manifest URL of the HLS MP3 stream (if there is no MP3 progressive
+ // stream, see above)
+ final String streamUrl = getTranscodingUrl(url);
+
+ if (preset.contains("mp3")) {
+ // Don't add the MP3 HLS stream if there is a progressive stream
+ // present because the two have the same bitrate
+ final boolean isHls = protocol.equals("hls");
+ if (mp3ProgressiveInStreams && isHls) {
+ return;
+ }
+
+ builder.setMediaFormat(MediaFormat.MP3);
+ builder.setAverageBitrate(128);
+
+ if (isHls) {
+ builder.setDeliveryMethod(DeliveryMethod.HLS);
+ builder.setContent(streamUrl, true);
+
+ final AudioStream hlsStream = builder.build();
+ if (!Stream.containSimilarStream(hlsStream, audioStreams)) {
+ audioStreams.add(hlsStream);
+ }
+
+ final String progressiveHlsUrl =
+ getSingleUrlFromHlsManifest(streamUrl);
+ builder.setDeliveryMethod(DeliveryMethod.PROGRESSIVE_HTTP);
+ builder.setContent(progressiveHlsUrl, true);
+
+ final AudioStream progressiveHlsStream = builder.build();
+ if (!Stream.containSimilarStream(
+ progressiveHlsStream, audioStreams)) {
+ audioStreams.add(progressiveHlsStream);
+ }
+
+ // The MP3 HLS stream has been added in both versions (HLS and
+ // progressive with the manifest parsing trick), so we need to
+ // continue (otherwise the code would try to add again the stream,
+ // which would be not added because the containsSimilarStream
+ // method would return false and an audio stream object would be
+ // created for nothing)
+ return;
+ } else {
+ builder.setContent(streamUrl, true);
+ }
+ } else if (preset.contains("opus")) {
+ // The HLS manifest trick doesn't work for opus streams
+ builder.setContent(streamUrl, true);
+ builder.setMediaFormat(MediaFormat.OPUS);
+ builder.setAverageBitrate(64);
+ builder.setDeliveryMethod(DeliveryMethod.HLS);
+ } else {
+ // Unknown format, skip to the next audio stream
+ return;
+ }
+
+ final AudioStream audioStream = builder.build();
+ if (!Stream.containSimilarStream(audioStream, audioStreams)) {
+ audioStreams.add(audioStream);
+ }
+ } catch (final ExtractionException | IOException ignored) {
+ // Something went wrong when trying to get and add this audio stream,
+ // skip to the next one
+ }
+ });
+ }
+
+ /**
+ * Add the downloadable format if it is available.
+ *
+ *
+ * A track can have the {@code downloadable} boolean set to {@code true}, but it doesn't mean
+ * we can download it.
+ *
+ *
+ *
+ * If the value of the {@code has_download_left} boolean is {@code true}, the track can be
+ * downloaded, and not otherwise.
+ *
+ *
+ * @param audioStreams the audio streams to which the downloadable file is added
+ */
+ public void extractDownloadableFileIfAvailable(final List audioStreams) {
+ if (track.getBoolean("downloadable") && track.getBoolean("has_downloads_left")) {
+ try {
+ final String downloadUrl = getDownloadUrl(getId());
+ if (!isNullOrEmpty(downloadUrl)) {
+ audioStreams.add(new AudioStream.Builder()
+ .setId("original-format")
+ .setContent(downloadUrl, true)
+ .setAverageBitrate(UNKNOWN_BITRATE)
+ .build());
}
+ } catch (final Exception ignored) {
+ // If something went wrong when trying to get the download URL, ignore the
+ // exception throw because this "stream" is not necessary to play the track
}
}
}
/**
- * Parses a SoundCloud HLS manifest to get a single URL of HLS streams.
+ * Parses a SoundCloud HLS MP3 manifest to get a single URL of HLS streams.
+ *
*
- * This method downloads the provided manifest URL, find all web occurrences in the manifest,
- * get the last segment URL, changes its segment range to {@code 0/track-length} and return
- * this string.
+ * This method downloads the provided manifest URL, finds all web occurrences in the manifest,
+ * gets the last segment URL, changes its segment range to {@code 0/track-length}, and return
+ * this as a string.
+ *
+ *
+ *
+ * This was working before for Opus streams, but has been broken by SoundCloud.
+ *
+ *
* @param hlsManifestUrl the URL of the manifest to be parsed
* @return a single URL that contains a range equal to the length of the track
*/
- private static String getSingleUrlFromHlsManifest(final String hlsManifestUrl)
+ @Nonnull
+ private static String getSingleUrlFromHlsManifest(@Nonnull final String hlsManifestUrl)
throws ParsingException {
- final Downloader dl = NewPipe.getDownloader();
final String hlsManifestResponse;
try {
- hlsManifestResponse = dl.get(hlsManifestUrl).responseBody();
+ hlsManifestResponse = NewPipe.getDownloader().get(hlsManifestUrl).responseBody();
} catch (final IOException | ReCaptchaException e) {
throw new ParsingException("Could not get SoundCloud HLS manifest");
}
@@ -288,12 +383,13 @@ public class SoundcloudStreamExtractor extends StreamExtractor {
for (int l = lines.length - 1; l >= 0; l--) {
final String line = lines[l];
// Get the last URL from manifest, because it contains the range of the stream
- if (line.trim().length() != 0 && !line.startsWith("#") && line.startsWith("https")) {
+ if (line.trim().length() != 0 && !line.startsWith("#") && line.startsWith(HTTPS)) {
final String[] hlsLastRangeUrlArray = line.split("/");
return HTTPS + hlsLastRangeUrlArray[2] + "/media/0/" + hlsLastRangeUrlArray[5]
+ "/" + hlsLastRangeUrlArray[6];
}
}
+
throw new ParsingException("Could not get any URL from HLS manifest");
}
@@ -326,7 +422,7 @@ public class SoundcloudStreamExtractor extends StreamExtractor {
final StreamInfoItemsCollector collector = new StreamInfoItemsCollector(getServiceId());
final String apiUrl = SOUNDCLOUD_API_V2_URL + "tracks/" + urlEncode(getId())
- + "/related?client_id=" + urlEncode(SoundcloudParsingHelper.clientId());
+ + "/related?client_id=" + urlEncode(clientId());
SoundcloudParsingHelper.getStreamsFromApi(collector, apiUrl);
return collector;
@@ -355,19 +451,19 @@ public class SoundcloudStreamExtractor extends StreamExtractor {
// Tags are separated by spaces, but they can be multiple words escaped by quotes "
final String[] tagList = track.getString("tag_list").split(" ");
final List tags = new ArrayList<>();
- String escapedTag = "";
+ final StringBuilder escapedTag = new StringBuilder();
boolean isEscaped = false;
for (final String tag : tagList) {
if (tag.startsWith("\"")) {
- escapedTag += tag.replace("\"", "");
+ escapedTag.append(tag.replace("\"", ""));
isEscaped = true;
} else if (isEscaped) {
if (tag.endsWith("\"")) {
- escapedTag += " " + tag.replace("\"", "");
+ escapedTag.append(" ").append(tag.replace("\"", ""));
isEscaped = false;
- tags.add(escapedTag);
+ tags.add(escapedTag.toString());
} else {
- escapedTag += " " + tag;
+ escapedTag.append(" ").append(tag);
}
} else if (!tag.isEmpty()) {
tags.add(tag);
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/DeliveryType.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/DeliveryType.java
new file mode 100644
index 000000000..17833dc5f
--- /dev/null
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/DeliveryType.java
@@ -0,0 +1,55 @@
+package org.schabi.newpipe.extractor.services.youtube;
+
+/**
+ * Streaming format types used by YouTube in their streams.
+ *
+ *
+ * It is different from {@link org.schabi.newpipe.extractor.stream.DeliveryMethod delivery methods}!
+ *
+ */
+public enum DeliveryType {
+
+ /**
+ * YouTube's progressive delivery method, which works with HTTP range headers.
+ * (Note that official clients use the corresponding parameter instead.)
+ *
+ *
+ * Initialization and index ranges are available to get metadata (the corresponding values
+ * are returned in the player response).
+ *
+ */
+ PROGRESSIVE,
+
+ /**
+ * YouTube's OTF delivery method which uses a sequence parameter to get segments of
+ * streams.
+ *
+ *
+ * The first sequence (which can be fetched with the {@code &sq=0} parameter) contains all the
+ * metadata needed to build the stream source (sidx boxes, segment length, segment count,
+ * duration, ...).
+ *
+ *
+ *
+ * Only used for videos; mostly those with a small amount of views, or ended livestreams
+ * which have just been re-encoded as normal videos.
+ *
+ */
+ OTF,
+
+ /**
+ * YouTube's delivery method for livestreams which uses a sequence parameter to get
+ * segments of streams.
+ *
+ *
+ * Each sequence (which can be fetched with the {@code &sq=0} parameter) contains its own
+ * metadata (sidx boxes, segment length, ...), which make no need of an initialization
+ * segment.
+ *
+ *
+ *
+ * Only used for livestreams (ended or running).
+ *
+ */
+ LIVE
+}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/ItagItem.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/ItagItem.java
index 55d3082a2..e0ff09a6f 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/ItagItem.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/ItagItem.java
@@ -14,16 +14,20 @@ import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import java.io.Serializable;
+
+public class ItagItem implements Serializable {
-public class ItagItem {
/**
- * List can be found here
- * https://github.com/ytdl-org/youtube-dl/blob/9fc5eaf/youtube_dl/extractor/youtube.py#L1071
+ * List can be found here:
+ * https://github.com/ytdl-org/youtube-dl/blob/e988fa4/youtube_dl/extractor/youtube.py#L1195
*/
private static final ItagItem[] ITAG_LIST = {
/////////////////////////////////////////////////////
- // VIDEO ID Type Format Resolution FPS ///
- ///////////////////////////////////////////////////
+ // VIDEO ID Type Format Resolution FPS ////
+ /////////////////////////////////////////////////////
new ItagItem(17, VIDEO, v3GPP, "144p"),
new ItagItem(36, VIDEO, v3GPP, "240p"),
@@ -41,8 +45,8 @@ public class ItagItem {
new ItagItem(45, VIDEO, WEBM, "720p"),
new ItagItem(46, VIDEO, WEBM, "1080p"),
- ////////////////////////////////////////////////////////////////////
- // AUDIO ID ItagType Format Bitrate ///
+ //////////////////////////////////////////////////////////////////
+ // AUDIO ID ItagType Format Bitrate //
//////////////////////////////////////////////////////////////////
new ItagItem(171, AUDIO, WEBMA, 128),
new ItagItem(172, AUDIO, WEBMA, 256),
@@ -54,8 +58,8 @@ public class ItagItem {
new ItagItem(251, AUDIO, WEBMA_OPUS, 160),
/// VIDEO ONLY ////////////////////////////////////////////
- // ID Type Format Resolution FPS ///
- /////////////////////////////////////////////////////////
+ // ID Type Format Resolution FPS ////
+ ///////////////////////////////////////////////////////////
new ItagItem(160, VIDEO_ONLY, MPEG_4, "144p"),
new ItagItem(133, VIDEO_ONLY, MPEG_4, "240p"),
new ItagItem(134, VIDEO_ONLY, MPEG_4, "360p"),
@@ -102,14 +106,26 @@ public class ItagItem {
public static ItagItem getItag(final int itagId) throws ParsingException {
for (final ItagItem item : ITAG_LIST) {
if (itagId == item.id) {
- return item;
+ return new ItagItem(item);
}
}
- throw new ParsingException("itag=" + itagId + " not supported");
+ throw new ParsingException("itag " + itagId + " is not supported");
}
/*//////////////////////////////////////////////////////////////////////////
- // Contructors and misc
+ // Static constants
+ //////////////////////////////////////////////////////////////////////////*/
+
+ public static final int AVERAGE_BITRATE_UNKNOWN = -1;
+ public static final int SAMPLE_RATE_UNKNOWN = -1;
+ public static final int FPS_NOT_APPLICABLE_OR_UNKNOWN = -1;
+ public static final int TARGET_DURATION_SEC_UNKNOWN = -1;
+ public static final int AUDIO_CHANNELS_NOT_APPLICABLE_OR_UNKNOWN = -1;
+ public static final long CONTENT_LENGTH_UNKNOWN = -1;
+ public static final long APPROX_DURATION_MS_UNKNOWN = -1;
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Constructors and misc
//////////////////////////////////////////////////////////////////////////*/
public enum ItagType {
@@ -134,8 +150,6 @@ public class ItagItem {
/**
* Constructor for videos.
- *
- * @param resolution string that will be used in the frontend
*/
public ItagItem(final int id,
final ItagType type,
@@ -159,22 +173,58 @@ public class ItagItem {
this.avgBitrate = avgBitrate;
}
- private final MediaFormat mediaFormat;
-
+ /**
+ * Copy constructor of the {@link ItagItem} class.
+ *
+ * @param itagItem the {@link ItagItem} to copy its properties into a new {@link ItagItem}
+ */
+ public ItagItem(@Nonnull final ItagItem itagItem) {
+ this.mediaFormat = itagItem.mediaFormat;
+ this.id = itagItem.id;
+ this.itagType = itagItem.itagType;
+ this.avgBitrate = itagItem.avgBitrate;
+ this.sampleRate = itagItem.sampleRate;
+ this.audioChannels = itagItem.audioChannels;
+ this.resolutionString = itagItem.resolutionString;
+ this.fps = itagItem.fps;
+ this.bitrate = itagItem.bitrate;
+ this.width = itagItem.width;
+ this.height = itagItem.height;
+ this.initStart = itagItem.initStart;
+ this.initEnd = itagItem.initEnd;
+ this.indexStart = itagItem.indexStart;
+ this.indexEnd = itagItem.indexEnd;
+ this.quality = itagItem.quality;
+ this.codec = itagItem.codec;
+ this.targetDurationSec = itagItem.targetDurationSec;
+ this.approxDurationMs = itagItem.approxDurationMs;
+ this.contentLength = itagItem.contentLength;
+ }
public MediaFormat getMediaFormat() {
return mediaFormat;
}
+ private final MediaFormat mediaFormat;
+
public final int id;
public final ItagType itagType;
// Audio fields
- public int avgBitrate = -1;
+ /** @deprecated Use {@link #getAverageBitrate()} instead. */
+ @Deprecated
+ public int avgBitrate = AVERAGE_BITRATE_UNKNOWN;
+ private int sampleRate = SAMPLE_RATE_UNKNOWN;
+ private int audioChannels = AUDIO_CHANNELS_NOT_APPLICABLE_OR_UNKNOWN;
// Video fields
+ /** @deprecated Use {@link #getResolutionString()} instead. */
+ @Deprecated
public String resolutionString;
- public int fps = -1;
+
+ /** @deprecated Use {@link #getFps()} and {@link #setFps(int)} instead. */
+ @Deprecated
+ public int fps = FPS_NOT_APPLICABLE_OR_UNKNOWN;
// Fields for Dash
private int bitrate;
@@ -186,6 +236,9 @@ public class ItagItem {
private int indexEnd;
private String quality;
private String codec;
+ private int targetDurationSec = TARGET_DURATION_SEC_UNKNOWN;
+ private long approxDurationMs = APPROX_DURATION_MS_UNKNOWN;
+ private long contentLength = CONTENT_LENGTH_UNKNOWN;
public int getBitrate() {
return bitrate;
@@ -211,6 +264,43 @@ public class ItagItem {
this.height = height;
}
+ /**
+ * Get the frame rate.
+ *
+ *
+ * It is set to the {@code fps} value returned in the corresponding itag in the YouTube player
+ * response.
+ *
+ *
+ *
+ * It defaults to the standard value associated with this itag.
+ *
+ *
+ *
+ * Note that this value is only known for video itags, so {@link
+ * #FPS_NOT_APPLICABLE_OR_UNKNOWN} is returned for non video itags.
+ *
+ *
+ * @return the frame rate or {@link #FPS_NOT_APPLICABLE_OR_UNKNOWN}
+ */
+ public int getFps() {
+ return fps;
+ }
+
+ /**
+ * Set the frame rate.
+ *
+ *
+ * It is only known for video itags, so {@link #FPS_NOT_APPLICABLE_OR_UNKNOWN} is set/used for
+ * non video itags or if the sample rate value is less than or equal to 0.
+ *
+ *
+ * @param fps the frame rate
+ */
+ public void setFps(final int fps) {
+ this.fps = fps > 0 ? fps : FPS_NOT_APPLICABLE_OR_UNKNOWN;
+ }
+
public int getInitStart() {
return initStart;
}
@@ -251,6 +341,21 @@ public class ItagItem {
this.quality = quality;
}
+ /**
+ * Get the resolution string associated with this {@code ItagItem}.
+ *
+ *
+ * It is only known for video itags.
+ *
+ *
+ * @return the resolution string associated with this {@code ItagItem} or
+ * {@code null}.
+ */
+ @Nullable
+ public String getResolutionString() {
+ return resolutionString;
+ }
+
public String getCodec() {
return codec;
}
@@ -258,4 +363,180 @@ public class ItagItem {
public void setCodec(final String codec) {
this.codec = codec;
}
+
+ /**
+ * Get the average bitrate.
+ *
+ *
+ * It is only known for audio itags, so {@link #AVERAGE_BITRATE_UNKNOWN} is always returned for
+ * other itag types.
+ *
+ *
+ *
+ * Bitrate of video itags and precise bitrate of audio itags can be known using
+ * {@link #getBitrate()}.
+ *
+ *
+ * @return the average bitrate or {@link #AVERAGE_BITRATE_UNKNOWN}
+ * @see #getBitrate()
+ */
+ public int getAverageBitrate() {
+ return avgBitrate;
+ }
+
+ /**
+ * Get the sample rate.
+ *
+ *
+ * It is only known for audio itags, so {@link #SAMPLE_RATE_UNKNOWN} is returned for non audio
+ * itags, or if the sample rate is unknown.
+ *
+ *
+ * @return the sample rate or {@link #SAMPLE_RATE_UNKNOWN}
+ */
+ public int getSampleRate() {
+ return sampleRate;
+ }
+
+ /**
+ * Set the sample rate.
+ *
+ *
+ * It is only known for audio itags, so {@link #SAMPLE_RATE_UNKNOWN} is set/used for non audio
+ * itags, or if the sample rate value is less than or equal to 0.
+ *
+ *
+ * @param sampleRate the sample rate of an audio itag
+ */
+ public void setSampleRate(final int sampleRate) {
+ this.sampleRate = sampleRate > 0 ? sampleRate : SAMPLE_RATE_UNKNOWN;
+ }
+
+ /**
+ * Get the number of audio channels.
+ *
+ *
+ * It is only known for audio itags, so {@link #AUDIO_CHANNELS_NOT_APPLICABLE_OR_UNKNOWN} is
+ * returned for non audio itags, or if it is unknown.
+ *
+ *
+ * @return the number of audio channels or {@link #AUDIO_CHANNELS_NOT_APPLICABLE_OR_UNKNOWN}
+ */
+ public int getAudioChannels() {
+ return audioChannels;
+ }
+
+ /**
+ * Set the number of audio channels.
+ *
+ *
+ * It is only known for audio itags, so {@link #AUDIO_CHANNELS_NOT_APPLICABLE_OR_UNKNOWN} is
+ * set/used for non audio itags, or if the {@code audioChannels} value is less than or equal to
+ * 0.
+ *
+ *
+ * @param audioChannels the number of audio channels of an audio itag
+ */
+ public void setAudioChannels(final int audioChannels) {
+ this.audioChannels = audioChannels > 0
+ ? audioChannels
+ : AUDIO_CHANNELS_NOT_APPLICABLE_OR_UNKNOWN;
+ }
+
+ /**
+ * Get the {@code targetDurationSec} value.
+ *
+ *
+ * This value is the average time in seconds of the duration of sequences of livestreams and
+ * ended livestreams. It is only returned by YouTube for these stream types, and makes no sense
+ * for videos, so {@link #TARGET_DURATION_SEC_UNKNOWN} is returned for those.
+ *
+ *
+ * @return the {@code targetDurationSec} value or {@link #TARGET_DURATION_SEC_UNKNOWN}
+ */
+ public int getTargetDurationSec() {
+ return targetDurationSec;
+ }
+
+ /**
+ * Set the {@code targetDurationSec} value.
+ *
+ *
+ * This value is the average time in seconds of the duration of sequences of livestreams and
+ * ended livestreams.
+ *
+ *
+ *
+ * It is only returned for these stream types by YouTube and makes no sense for videos, so
+ * {@link #TARGET_DURATION_SEC_UNKNOWN} will be set/used for video streams or if this value is
+ * less than or equal to 0.
+ *
+ *
+ * @param targetDurationSec the target duration of a segment of streams which are using the
+ * live delivery method type
+ */
+ public void setTargetDurationSec(final int targetDurationSec) {
+ this.targetDurationSec = targetDurationSec > 0
+ ? targetDurationSec
+ : TARGET_DURATION_SEC_UNKNOWN;
+ }
+
+ /**
+ * Get the {@code approxDurationMs} value.
+ *
+ *
+ * It is only known for DASH progressive streams, so {@link #APPROX_DURATION_MS_UNKNOWN} is
+ * returned for other stream types or if this value is less than or equal to 0.
+ *
+ *
+ * @return the {@code approxDurationMs} value or {@link #APPROX_DURATION_MS_UNKNOWN}
+ */
+ public long getApproxDurationMs() {
+ return approxDurationMs;
+ }
+
+ /**
+ * Set the {@code approxDurationMs} value.
+ *
+ *
+ * It is only known for DASH progressive streams, so {@link #APPROX_DURATION_MS_UNKNOWN} is
+ * set/used for other stream types or if this value is less than or equal to 0.
+ *
+ *
+ * @param approxDurationMs the approximate duration of a DASH progressive stream, in
+ * milliseconds
+ */
+ public void setApproxDurationMs(final long approxDurationMs) {
+ this.approxDurationMs = approxDurationMs > 0
+ ? approxDurationMs
+ : APPROX_DURATION_MS_UNKNOWN;
+ }
+
+ /**
+ * Get the {@code contentLength} value.
+ *
+ *
+ * It is only known for DASH progressive streams, so {@link #CONTENT_LENGTH_UNKNOWN} is
+ * returned for other stream types or if this value is less than or equal to 0.
+ *
+ *
+ * @return the {@code contentLength} value or {@link #CONTENT_LENGTH_UNKNOWN}
+ */
+ public long getContentLength() {
+ return contentLength;
+ }
+
+ /**
+ * Set the content length of stream.
+ *
+ *
+ * It is only known for DASH progressive streams, so {@link #CONTENT_LENGTH_UNKNOWN} is
+ * set/used for other stream types or if this value is less than or equal to 0.
+ *
+ *
+ * @param contentLength the content length of a DASH progressive stream
+ */
+ public void setContentLength(final long contentLength) {
+ this.contentLength = contentLength > 0 ? contentLength : CONTENT_LENGTH_UNKNOWN;
+ }
}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java
index eddb69cde..566da5217 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java
@@ -71,6 +71,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Random;
+import java.util.regex.Pattern;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -246,6 +247,11 @@ public final class YoutubeParsingHelper {
private static final String FEED_BASE_CHANNEL_ID =
"https://www.youtube.com/feeds/videos.xml?channel_id=";
private static final String FEED_BASE_USER = "https://www.youtube.com/feeds/videos.xml?user=";
+ private static final Pattern C_WEB_PATTERN = Pattern.compile("&c=WEB");
+ private static final Pattern C_TVHTML5_SIMPLY_EMBEDDED_PLAYER_PATTERN =
+ Pattern.compile("&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER");
+ private static final Pattern C_ANDROID_PATTERN = Pattern.compile("&c=ANDROID");
+ private static final Pattern C_IOS_PATTERN = Pattern.compile("&c=IOS");
private static boolean isGoogleURL(final String url) {
final String cachedUrl = extractCachedUrlIfNeeded(url);
@@ -1190,7 +1196,7 @@ public final class YoutubeParsingHelper {
@Nonnull final Localization localization,
@Nonnull final ContentCountry contentCountry,
@Nonnull final String videoId) {
- // @formatter:off
+ // @formatter:off
return JsonObject.builder()
.object("context")
.object("client")
@@ -1258,8 +1264,7 @@ public final class YoutubeParsingHelper {
// Spoofing an Android 12 device with the hardcoded version of the Android app
return "com.google.android.youtube/" + MOBILE_YOUTUBE_CLIENT_VERSION
+ " (Linux; U; Android 12; "
- + (localization != null ? localization.getCountryCode()
- : Localization.DEFAULT.getCountryCode())
+ + (localization != null ? localization : Localization.DEFAULT).getCountryCode()
+ ") gzip";
}
@@ -1278,10 +1283,8 @@ public final class YoutubeParsingHelper {
public static String getIosUserAgent(@Nullable final Localization localization) {
// Spoofing an iPhone running iOS 15.4 with the hardcoded mobile client version
return "com.google.ios.youtube/" + MOBILE_YOUTUBE_CLIENT_VERSION
- + "(" + IOS_DEVICE_MODEL
- + "; U; CPU iOS 15_4 like Mac OS X; "
- + (localization != null ? localization.getCountryCode()
- : Localization.DEFAULT.getCountryCode())
+ + "(" + IOS_DEVICE_MODEL + "; U; CPU iOS 15_4 like Mac OS X; "
+ + (localization != null ? localization : Localization.DEFAULT).getCountryCode()
+ ")";
}
@@ -1588,4 +1591,46 @@ public final class YoutubeParsingHelper {
return RandomStringFromAlphabetGenerator.generate(
CONTENT_PLAYBACK_NONCE_ALPHABET, 12, numberGenerator);
}
+
+ /**
+ * Check if the streaming URL is from the YouTube {@code WEB} client.
+ *
+ * @param url the streaming URL to be checked.
+ * @return true if it's a {@code WEB} streaming URL, false otherwise
+ */
+ public static boolean isWebStreamingUrl(@Nonnull final String url) {
+ return Parser.isMatch(C_WEB_PATTERN, url);
+ }
+
+ /**
+ * Check if the streaming URL is a URL from the YouTube {@code TVHTML5_SIMPLY_EMBEDDED_PLAYER}
+ * client.
+ *
+ * @param url the streaming URL on which check if it's a {@code TVHTML5_SIMPLY_EMBEDDED_PLAYER}
+ * streaming URL.
+ * @return true if it's a {@code TVHTML5_SIMPLY_EMBEDDED_PLAYER} streaming URL, false otherwise
+ */
+ public static boolean isTvHtml5SimplyEmbeddedPlayerStreamingUrl(@Nonnull final String url) {
+ return Parser.isMatch(C_TVHTML5_SIMPLY_EMBEDDED_PLAYER_PATTERN, url);
+ }
+
+ /**
+ * Check if the streaming URL is a URL from the YouTube {@code ANDROID} client.
+ *
+ * @param url the streaming URL to be checked.
+ * @return true if it's a {@code ANDROID} streaming URL, false otherwise
+ */
+ public static boolean isAndroidStreamingUrl(@Nonnull final String url) {
+ return Parser.isMatch(C_ANDROID_PATTERN, url);
+ }
+
+ /**
+ * Check if the streaming URL is a URL from the YouTube {@code IOS} client.
+ *
+ * @param url the streaming URL on which check if it's a {@code IOS} streaming URL.
+ * @return true if it's a {@code IOS} streaming URL, false otherwise
+ */
+ public static boolean isIosStreamingUrl(@Nonnull final String url) {
+ return Parser.isMatch(C_IOS_PATTERN, url);
+ }
}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/CreationException.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/CreationException.java
new file mode 100644
index 000000000..46f32664b
--- /dev/null
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/CreationException.java
@@ -0,0 +1,63 @@
+package org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Exception that is thrown when a YouTube DASH manifest creator encounters a problem
+ * while creating a manifest.
+ */
+public final class CreationException extends RuntimeException {
+
+ /**
+ * Create a new {@link CreationException} with a detail message.
+ *
+ * @param message the detail message to add in the exception
+ */
+ public CreationException(final String message) {
+ super(message);
+ }
+
+ /**
+ * Create a new {@link CreationException} with a detail message and a cause.
+ * @param message the detail message to add in the exception
+ * @param cause the exception cause of this {@link CreationException}
+ */
+ public CreationException(final String message, final Exception cause) {
+ super(message, cause);
+ }
+
+ // Methods to create exceptions easily without having to use big exception messages and to
+ // reduce duplication
+
+ /**
+ * Create a new {@link CreationException} with a cause and the following detail message format:
+ *
+ * {@code "Could not add " + element + " element", cause}, where {@code element} is an element
+ * of a DASH manifest.
+ *
+ * @param element the element which was not added to the DASH document
+ * @param cause the exception which prevented addition of the element to the DASH document
+ * @return a new {@link CreationException}
+ */
+ @Nonnull
+ public static CreationException couldNotAddElement(final String element,
+ final Exception cause) {
+ return new CreationException("Could not add " + element + " element", cause);
+ }
+
+ /**
+ * Create a new {@link CreationException} with a cause and the following detail message format:
+ *
+ * {@code "Could not add " + element + " element: " + reason}, where {@code element} is an
+ * element of a DASH manifest and {@code reason} the reason why this element cannot be added to
+ * the DASH document.
+ *
+ * @param element the element which was not added to the DASH document
+ * @param reason the reason message of why the element has been not added to the DASH document
+ * @return a new {@link CreationException}
+ */
+ @Nonnull
+ public static CreationException couldNotAddElement(final String element, final String reason) {
+ return new CreationException("Could not add " + element + " element: " + reason);
+ }
+}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubeDashManifestCreatorsUtils.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubeDashManifestCreatorsUtils.java
new file mode 100644
index 000000000..5c45f65df
--- /dev/null
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubeDashManifestCreatorsUtils.java
@@ -0,0 +1,757 @@
+package org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators;
+
+import org.schabi.newpipe.extractor.MediaFormat;
+import org.schabi.newpipe.extractor.NewPipe;
+import org.schabi.newpipe.extractor.downloader.Downloader;
+import org.schabi.newpipe.extractor.downloader.Response;
+import org.schabi.newpipe.extractor.exceptions.ExtractionException;
+import org.schabi.newpipe.extractor.services.youtube.DeliveryType;
+import org.schabi.newpipe.extractor.services.youtube.ItagItem;
+import org.schabi.newpipe.extractor.utils.ManifestCreatorCache;
+import org.w3c.dom.Attr;
+import org.w3c.dom.DOMException;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import javax.annotation.Nonnull;
+import javax.xml.XMLConstants;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerException;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.addClientInfoHeaders;
+import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent;
+import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent;
+import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl;
+import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl;
+import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5SimplyEmbeddedPlayerStreamingUrl;
+import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl;
+import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
+
+/**
+ * Utilities and constants for YouTube DASH manifest creators.
+ *
+ *
+ * This class includes common methods of manifest creators and useful constants.
+ *
+ *
+ *
+ * Generation of DASH documents and their conversion as a string is done using external classes
+ * from {@link org.w3c.dom} and {@link javax.xml} packages.
+ *
+ */
+public final class YoutubeDashManifestCreatorsUtils {
+
+ private YoutubeDashManifestCreatorsUtils() {
+ }
+
+ /**
+ * The redirect count limit that this class uses, which is the same limit as OkHttp.
+ */
+ public static final int MAXIMUM_REDIRECT_COUNT = 20;
+
+ /**
+ * URL parameter of the first sequence for live, post-live-DVR and OTF streams.
+ */
+ public static final String SQ_0 = "&sq=0";
+
+ /**
+ * URL parameter of the first stream request made by official clients.
+ */
+ public static final String RN_0 = "&rn=0";
+
+ /**
+ * URL parameter specific to web clients. When this param is added, if a redirection occurs,
+ * the server will not redirect clients to the redirect URL. Instead, it will provide this URL
+ * as the response body.
+ */
+ public static final String ALR_YES = "&alr=yes";
+
+ // XML elements of DASH MPD manifests
+ // see https://www.brendanlong.com/the-structure-of-an-mpeg-dash-mpd.html
+ public static final String MPD = "MPD";
+ public static final String PERIOD = "Period";
+ public static final String ADAPTATION_SET = "AdaptationSet";
+ public static final String ROLE = "Role";
+ public static final String REPRESENTATION = "Representation";
+ public static final String AUDIO_CHANNEL_CONFIGURATION = "AudioChannelConfiguration";
+ public static final String SEGMENT_TEMPLATE = "SegmentTemplate";
+ public static final String SEGMENT_TIMELINE = "SegmentTimeline";
+ public static final String BASE_URL = "BaseURL";
+ public static final String SEGMENT_BASE = "SegmentBase";
+ public static final String INITIALIZATION = "Initialization";
+
+ /**
+ * Create an attribute with {@link Document#createAttribute(String)}, assign to it the provided
+ * name and value, then add it to the provided element using {@link
+ * Element#setAttributeNode(Attr)}.
+ *
+ * @param element element to which to add the created node
+ * @param doc document to use to create the attribute
+ * @param name name of the attribute
+ * @param value value of the attribute, will be set using {@link Attr#setValue(String)}
+ */
+ public static void setAttribute(final Element element,
+ final Document doc,
+ final String name,
+ final String value) {
+ final Attr attr = doc.createAttribute(name);
+ attr.setValue(value);
+ element.setAttributeNode(attr);
+ }
+
+ /**
+ * Generate a {@link Document} with common manifest creator elements added to it.
+ *
+ *
+ * Those are:
+ *
+ * - {@code MPD} (using {@link #generateDocumentAndMpdElement(long)});
+ * - {@code Period} (using {@link #generatePeriodElement(Document)});
+ * - {@code AdaptationSet} (using {@link #generateAdaptationSetElement(Document,
+ * ItagItem)});
+ * - {@code Role} (using {@link #generateRoleElement(Document)});
+ * - {@code Representation} (using {@link #generateRepresentationElement(Document,
+ * ItagItem)});
+ * - and, for audio streams, {@code AudioChannelConfiguration} (using
+ * {@link #generateAudioChannelConfigurationElement(Document, ItagItem)}).
+ *
+ *
+ *
+ * @param itagItem the {@link ItagItem} associated to the stream, which must not be null
+ * @param streamDuration the duration of the stream, in milliseconds
+ * @return a {@link Document} with the common elements added in it
+ */
+ @Nonnull
+ public static Document generateDocumentAndDoCommonElementsGeneration(
+ @Nonnull final ItagItem itagItem,
+ final long streamDuration) throws CreationException {
+ final Document doc = generateDocumentAndMpdElement(streamDuration);
+
+ generatePeriodElement(doc);
+ generateAdaptationSetElement(doc, itagItem);
+ generateRoleElement(doc);
+ generateRepresentationElement(doc, itagItem);
+ if (itagItem.itagType == ItagItem.ItagType.AUDIO) {
+ generateAudioChannelConfigurationElement(doc, itagItem);
+ }
+
+ return doc;
+ }
+
+ /**
+ * Create a {@link Document} instance and generate the {@code } element of the manifest.
+ *
+ *
+ * The generated {@code } element looks like the manifest returned into the player
+ * response of videos:
+ *
+ *
+ *
+ * {@code }
+ * (where {@code $duration$} represents the duration in seconds (a number with 3 digits after
+ * the decimal point)).
+ *
+ *
+ * @param duration the duration of the stream, in milliseconds
+ * @return a {@link Document} instance which contains a {@code } element
+ */
+ @Nonnull
+ public static Document generateDocumentAndMpdElement(final long duration)
+ throws CreationException {
+ try {
+ final Document doc = newDocument();
+
+ final Element mpdElement = doc.createElement(MPD);
+ doc.appendChild(mpdElement);
+
+ setAttribute(mpdElement, doc, "xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
+ setAttribute(mpdElement, doc, "xmlns", "urn:mpeg:DASH:schema:MPD:2011");
+ setAttribute(mpdElement, doc, "xsi:schemaLocation",
+ "urn:mpeg:DASH:schema:MPD:2011 DASH-MPD.xsd");
+ setAttribute(mpdElement, doc, "minBufferTime", "PT1.500S");
+ setAttribute(mpdElement, doc, "profiles", "urn:mpeg:dash:profile:full:2011");
+ setAttribute(mpdElement, doc, "type", "static");
+ setAttribute(mpdElement, doc, "mediaPresentationDuration",
+ String.format(Locale.ENGLISH, "PT%.3fS", duration / 1000.0));
+
+ return doc;
+ } catch (final Exception e) {
+ throw new CreationException(
+ "Could not generate the DASH manifest or append the MPD doc to it", e);
+ }
+ }
+
+ /**
+ * Generate the {@code } element, appended as a child of the {@code } element.
+ *
+ *
+ * The {@code } element needs to be generated before this element with
+ * {@link #generateDocumentAndMpdElement(long)}.
+ *
+ *
+ * @param doc the {@link Document} on which the the {@code } element will be appended
+ */
+ public static void generatePeriodElement(@Nonnull final Document doc)
+ throws CreationException {
+ try {
+ final Element mpdElement = (Element) doc.getElementsByTagName(MPD).item(0);
+ final Element periodElement = doc.createElement(PERIOD);
+ mpdElement.appendChild(periodElement);
+ } catch (final DOMException e) {
+ throw CreationException.couldNotAddElement(PERIOD, e);
+ }
+ }
+
+ /**
+ * Generate the {@code } element, appended as a child of the {@code }
+ * element.
+ *
+ *
+ * The {@code } element needs to be generated before this element with
+ * {@link #generatePeriodElement(Document)}.
+ *
+ *
+ * @param doc the {@link Document} on which the {@code } element will be appended
+ * @param itagItem the {@link ItagItem} corresponding to the stream, which must not be null
+ */
+ public static void generateAdaptationSetElement(@Nonnull final Document doc,
+ @Nonnull final ItagItem itagItem)
+ throws CreationException {
+ try {
+ final Element periodElement = (Element) doc.getElementsByTagName(PERIOD)
+ .item(0);
+ final Element adaptationSetElement = doc.createElement(ADAPTATION_SET);
+
+ setAttribute(adaptationSetElement, doc, "id", "0");
+
+ final MediaFormat mediaFormat = itagItem.getMediaFormat();
+ if (mediaFormat == null || isNullOrEmpty(mediaFormat.getMimeType())) {
+ throw CreationException.couldNotAddElement(ADAPTATION_SET,
+ "the MediaFormat or its mime type is null or empty");
+ }
+
+ setAttribute(adaptationSetElement, doc, "mimeType", mediaFormat.getMimeType());
+ setAttribute(adaptationSetElement, doc, "subsegmentAlignment", "true");
+
+ periodElement.appendChild(adaptationSetElement);
+ } catch (final DOMException e) {
+ throw CreationException.couldNotAddElement(ADAPTATION_SET, e);
+ }
+ }
+
+ /**
+ * Generate the {@code } element, appended as a child of the {@code }
+ * element.
+ *
+ *
+ * This element, with its attributes and values, is:
+ *
+ *
+ *
+ * {@code }
+ *
+ *
+ *
+ * The {@code } element needs to be generated before this element with
+ * {@link #generateAdaptationSetElement(Document, ItagItem)}).
+ *
+ *
+ * @param doc the {@link Document} on which the the {@code } element will be appended
+ */
+ public static void generateRoleElement(@Nonnull final Document doc)
+ throws CreationException {
+ try {
+ final Element adaptationSetElement = (Element) doc.getElementsByTagName(
+ ADAPTATION_SET).item(0);
+ final Element roleElement = doc.createElement(ROLE);
+
+ setAttribute(roleElement, doc, "schemeIdUri", "urn:mpeg:DASH:role:2011");
+ setAttribute(roleElement, doc, "value", "main");
+
+ adaptationSetElement.appendChild(roleElement);
+ } catch (final DOMException e) {
+ throw CreationException.couldNotAddElement(ROLE, e);
+ }
+ }
+
+ /**
+ * Generate the {@code } element, appended as a child of the
+ * {@code } element.
+ *
+ *
+ * The {@code } element needs to be generated before this element with
+ * {@link #generateAdaptationSetElement(Document, ItagItem)}).
+ *
+ *
+ * @param doc the {@link Document} on which the the {@code } element will be
+ * appended
+ * @param itagItem the {@link ItagItem} to use, which must not be null
+ */
+ public static void generateRepresentationElement(@Nonnull final Document doc,
+ @Nonnull final ItagItem itagItem)
+ throws CreationException {
+ try {
+ final Element adaptationSetElement = (Element) doc.getElementsByTagName(
+ ADAPTATION_SET).item(0);
+ final Element representationElement = doc.createElement(REPRESENTATION);
+
+ final int id = itagItem.id;
+ if (id <= 0) {
+ throw CreationException.couldNotAddElement(REPRESENTATION,
+ "the id of the ItagItem is <= 0");
+ }
+ setAttribute(representationElement, doc, "id", String.valueOf(id));
+
+ final String codec = itagItem.getCodec();
+ if (isNullOrEmpty(codec)) {
+ throw CreationException.couldNotAddElement(ADAPTATION_SET,
+ "the codec value of the ItagItem is null or empty");
+ }
+ setAttribute(representationElement, doc, "codecs", codec);
+ setAttribute(representationElement, doc, "startWithSAP", "1");
+ setAttribute(representationElement, doc, "maxPlayoutRate", "1");
+
+ final int bitrate = itagItem.getBitrate();
+ if (bitrate <= 0) {
+ throw CreationException.couldNotAddElement(REPRESENTATION,
+ "the bitrate of the ItagItem is <= 0");
+ }
+ setAttribute(representationElement, doc, "bandwidth", String.valueOf(bitrate));
+
+ if (itagItem.itagType == ItagItem.ItagType.VIDEO
+ || itagItem.itagType == ItagItem.ItagType.VIDEO_ONLY) {
+ final int height = itagItem.getHeight();
+ final int width = itagItem.getWidth();
+ if (height <= 0 && width <= 0) {
+ throw CreationException.couldNotAddElement(REPRESENTATION,
+ "both width and height of the ItagItem are <= 0");
+ }
+
+ if (width > 0) {
+ setAttribute(representationElement, doc, "width", String.valueOf(width));
+ }
+ setAttribute(representationElement, doc, "height",
+ String.valueOf(itagItem.getHeight()));
+
+ final int fps = itagItem.getFps();
+ if (fps > 0) {
+ setAttribute(representationElement, doc, "frameRate", String.valueOf(fps));
+ }
+ }
+
+ if (itagItem.itagType == ItagItem.ItagType.AUDIO && itagItem.getSampleRate() > 0) {
+ final Attr audioSamplingRateAttribute = doc.createAttribute(
+ "audioSamplingRate");
+ audioSamplingRateAttribute.setValue(String.valueOf(itagItem.getSampleRate()));
+ }
+
+ adaptationSetElement.appendChild(representationElement);
+ } catch (final DOMException e) {
+ throw CreationException.couldNotAddElement(REPRESENTATION, e);
+ }
+ }
+
+ /**
+ * Generate the {@code } element, appended as a child of the
+ * {@code } element.
+ *
+ *
+ * This method is only used when generating DASH manifests of audio streams.
+ *
+ *
+ *
+ * It will produce the following element:
+ *
+ * {@code
+ * (where {@code audioChannelsValue} is get from the {@link ItagItem} passed as the second
+ * parameter of this method)
+ *
+ *
+ *
+ * The {@code } element needs to be generated before this element with
+ * {@link #generateRepresentationElement(Document, ItagItem)}).
+ *
+ *
+ * @param doc the {@link Document} on which the {@code } element will
+ * be appended
+ * @param itagItem the {@link ItagItem} to use, which must not be null
+ */
+ public static void generateAudioChannelConfigurationElement(
+ @Nonnull final Document doc,
+ @Nonnull final ItagItem itagItem) throws CreationException {
+ try {
+ final Element representationElement = (Element) doc.getElementsByTagName(
+ REPRESENTATION).item(0);
+ final Element audioChannelConfigurationElement = doc.createElement(
+ AUDIO_CHANNEL_CONFIGURATION);
+
+ setAttribute(audioChannelConfigurationElement, doc, "schemeIdUri",
+ "urn:mpeg:dash:23003:3:audio_channel_configuration:2011");
+
+ if (itagItem.getAudioChannels() <= 0) {
+ throw new CreationException("the number of audioChannels in the ItagItem is <= 0: "
+ + itagItem.getAudioChannels());
+ }
+ setAttribute(audioChannelConfigurationElement, doc, "value",
+ String.valueOf(itagItem.getAudioChannels()));
+
+ representationElement.appendChild(audioChannelConfigurationElement);
+ } catch (final DOMException e) {
+ throw CreationException.couldNotAddElement(AUDIO_CHANNEL_CONFIGURATION, e);
+ }
+ }
+
+ /**
+ * Convert a DASH manifest {@link Document doc} to a string and cache it.
+ *
+ * @param originalBaseStreamingUrl the original base URL of the stream
+ * @param doc the doc to be converted
+ * @param manifestCreatorCache the {@link ManifestCreatorCache} on which store the string
+ * generated
+ * @return the DASH manifest {@link Document doc} converted to a string
+ */
+ public static String buildAndCacheResult(
+ @Nonnull final String originalBaseStreamingUrl,
+ @Nonnull final Document doc,
+ @Nonnull final ManifestCreatorCache manifestCreatorCache)
+ throws CreationException {
+
+ try {
+ final String documentXml = documentToXml(doc);
+ manifestCreatorCache.put(originalBaseStreamingUrl, documentXml);
+ return documentXml;
+ } catch (final Exception e) {
+ throw new CreationException(
+ "Could not convert the DASH manifest generated to a string", e);
+ }
+ }
+
+ /**
+ * Generate the {@code } element, appended as a child of the
+ * {@code } element.
+ *
+ *
+ * This method is only used when generating DASH manifests from OTF and post-live-DVR streams.
+ *
+ *
+ *
+ * It will produce a {@code } element with the following attributes:
+ *
+ * - {@code startNumber}, which takes the value {@code 0} for post-live-DVR streams and
+ * {@code 1} for OTF streams;
+ * - {@code timescale}, which is always {@code 1000};
+ * - {@code media}, which is the base URL of the stream on which is appended
+ * {@code &sq=$Number$};
+ * - {@code initialization} (only for OTF streams), which is the base URL of the stream
+ * on which is appended {@link #SQ_0}.
+ *
+ *
+ *
+ *
+ * The {@code } element needs to be generated before this element with
+ * {@link #generateRepresentationElement(Document, ItagItem)}).
+ *
+ *
+ * @param doc the {@link Document} on which the {@code } element will
+ * be appended
+ * @param baseUrl the base URL of the OTF/post-live-DVR stream
+ * @param deliveryType the stream {@link DeliveryType delivery type}, which must be either
+ * {@link DeliveryType#OTF OTF} or {@link DeliveryType#LIVE LIVE}
+ */
+ public static void generateSegmentTemplateElement(@Nonnull final Document doc,
+ @Nonnull final String baseUrl,
+ final DeliveryType deliveryType)
+ throws CreationException {
+ if (deliveryType != DeliveryType.OTF && deliveryType != DeliveryType.LIVE) {
+ throw CreationException.couldNotAddElement(SEGMENT_TEMPLATE, "invalid delivery type: "
+ + deliveryType);
+ }
+
+ try {
+ final Element representationElement = (Element) doc.getElementsByTagName(
+ REPRESENTATION).item(0);
+ final Element segmentTemplateElement = doc.createElement(SEGMENT_TEMPLATE);
+
+ // The first sequence of post DVR streams is the beginning of the video stream and not
+ // an initialization segment
+ setAttribute(segmentTemplateElement, doc, "startNumber",
+ deliveryType == DeliveryType.LIVE ? "0" : "1");
+ setAttribute(segmentTemplateElement, doc, "timescale", "1000");
+
+ // Post-live-DVR/ended livestreams streams don't require an initialization sequence
+ if (deliveryType != DeliveryType.LIVE) {
+ setAttribute(segmentTemplateElement, doc, "initialization", baseUrl + SQ_0);
+ }
+
+ setAttribute(segmentTemplateElement, doc, "media", baseUrl + "&sq=$Number$");
+
+ representationElement.appendChild(segmentTemplateElement);
+ } catch (final DOMException e) {
+ throw CreationException.couldNotAddElement(SEGMENT_TEMPLATE, e);
+ }
+ }
+
+ /**
+ * Generate the {@code } element, appended as a child of the
+ * {@code } element.
+ *
+ *
+ * The {@code } element needs to be generated before this element with
+ * {@link #generateSegmentTemplateElement(Document, String, DeliveryType)}.
+ *
+ *
+ * @param doc the {@link Document} on which the the {@code } element will be
+ * appended
+ */
+ public static void generateSegmentTimelineElement(@Nonnull final Document doc)
+ throws CreationException {
+ try {
+ final Element segmentTemplateElement = (Element) doc.getElementsByTagName(
+ SEGMENT_TEMPLATE).item(0);
+ final Element segmentTimelineElement = doc.createElement(SEGMENT_TIMELINE);
+
+ segmentTemplateElement.appendChild(segmentTimelineElement);
+ } catch (final DOMException e) {
+ throw CreationException.couldNotAddElement(SEGMENT_TIMELINE, e);
+ }
+ }
+
+ /**
+ * Get the "initialization" {@link Response response} of a stream.
+ *
+ * This method fetches, for OTF streams and for post-live-DVR streams:
+ *
+ * - the base URL of the stream, to which are appended {@link #SQ_0} and
+ * {@link #RN_0} parameters, with a {@code GET} request for streaming URLs from HTML5
+ * clients and a {@code POST} request for the ones from the {@code ANDROID} and the
+ * {@code IOS} clients;
+ * - for streaming URLs from HTML5 clients, the {@link #ALR_YES} param is also added.
+ *
+ *
+ *
+ *
+ * @param baseStreamingUrl the base URL of the stream, which must not be null
+ * @param itagItem the {@link ItagItem} of stream, which must not be null
+ * @param deliveryType the {@link DeliveryType} of the stream
+ * @return the "initialization" response, without redirections on the network on which the
+ * request(s) is/are made
+ */
+ @SuppressWarnings("checkstyle:FinalParameters")
+ @Nonnull
+ public static Response getInitializationResponse(@Nonnull String baseStreamingUrl,
+ @Nonnull final ItagItem itagItem,
+ final DeliveryType deliveryType)
+ throws CreationException {
+ final boolean isHtml5StreamingUrl = isWebStreamingUrl(baseStreamingUrl)
+ || isTvHtml5SimplyEmbeddedPlayerStreamingUrl(baseStreamingUrl);
+ final boolean isAndroidStreamingUrl = isAndroidStreamingUrl(baseStreamingUrl);
+ final boolean isIosStreamingUrl = isIosStreamingUrl(baseStreamingUrl);
+ if (isHtml5StreamingUrl) {
+ baseStreamingUrl += ALR_YES;
+ }
+ baseStreamingUrl = appendRnSqParamsIfNeeded(baseStreamingUrl, deliveryType);
+
+ final Downloader downloader = NewPipe.getDownloader();
+ if (isHtml5StreamingUrl) {
+ final String mimeTypeExpected = itagItem.getMediaFormat().getMimeType();
+ if (!isNullOrEmpty(mimeTypeExpected)) {
+ return getStreamingWebUrlWithoutRedirects(downloader, baseStreamingUrl,
+ mimeTypeExpected);
+ }
+ } else if (isAndroidStreamingUrl || isIosStreamingUrl) {
+ try {
+ final Map> headers = new HashMap<>();
+ headers.put("User-Agent", Collections.singletonList(
+ isAndroidStreamingUrl ? getAndroidUserAgent(null)
+ : getIosUserAgent(null)));
+ final byte[] emptyBody = "".getBytes(StandardCharsets.UTF_8);
+ return downloader.post(baseStreamingUrl, headers, emptyBody);
+ } catch (final IOException | ExtractionException e) {
+ throw new CreationException("Could not get the "
+ + (isIosStreamingUrl ? "ANDROID" : "IOS") + " streaming URL response", e);
+ }
+ }
+
+ try {
+ return downloader.get(baseStreamingUrl);
+ } catch (final IOException | ExtractionException e) {
+ throw new CreationException("Could not get the streaming URL response", e);
+ }
+ }
+
+ /**
+ * Generate a new {@link DocumentBuilder} secured from XXE attacks, on platforms which
+ * support setting {@link XMLConstants#ACCESS_EXTERNAL_DTD} and
+ * {@link XMLConstants#ACCESS_EXTERNAL_SCHEMA} in {@link DocumentBuilderFactory} instances.
+ *
+ * @return an instance of {@link Document} secured against XXE attacks on supported platforms,
+ * that should then be convertible to an XML string without security problems
+ */
+ private static Document newDocument() throws ParserConfigurationException {
+ final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
+ try {
+ documentBuilderFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
+ documentBuilderFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
+ } catch (final Exception ignored) {
+ // Ignore exceptions as setting these attributes to secure XML generation is not
+ // supported by all platforms (like the Android implementation)
+ }
+
+ return documentBuilderFactory.newDocumentBuilder().newDocument();
+ }
+
+ /**
+ * Generate a new {@link TransformerFactory} secured from XXE attacks, on platforms which
+ * support setting {@link XMLConstants#ACCESS_EXTERNAL_DTD} and
+ * {@link XMLConstants#ACCESS_EXTERNAL_SCHEMA} in {@link TransformerFactory} instances.
+ *
+ * @param doc the doc to convert, which must have been created using {@link #newDocument()} to
+ * properly prevent XXE attacks
+ * @return the doc converted to an XML string, making sure there can't be XXE attacks
+ */
+ // Sonar warning is suppressed because it is still shown even if we apply its solution
+ @SuppressWarnings("squid:S2755")
+ private static String documentToXml(@Nonnull final Document doc)
+ throws TransformerException {
+
+ final TransformerFactory transformerFactory = TransformerFactory.newInstance();
+ try {
+ transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
+ transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
+ } catch (final Exception ignored) {
+ // Ignore exceptions as setting these attributes to secure XML generation is not
+ // supported by all platforms (like the Android implementation)
+ }
+
+ final Transformer transformer = transformerFactory.newTransformer();
+ transformer.setOutputProperty(OutputKeys.VERSION, "1.0");
+ transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
+ transformer.setOutputProperty(OutputKeys.STANDALONE, "no");
+
+ final StringWriter result = new StringWriter();
+ transformer.transform(new DOMSource(doc), new StreamResult(result));
+
+ return result.toString();
+ }
+
+ /**
+ * Append {@link #SQ_0} for post-live-DVR and OTF streams and {@link #RN_0} to all streams.
+ *
+ * @param baseStreamingUrl the base streaming URL to which the parameter(s) are being appended
+ * @param deliveryType the {@link DeliveryType} of the stream
+ * @return the base streaming URL to which the param(s) are appended, depending on the
+ * {@link DeliveryType} of the stream
+ */
+ @Nonnull
+ private static String appendRnSqParamsIfNeeded(@Nonnull final String baseStreamingUrl,
+ @Nonnull final DeliveryType deliveryType) {
+ return baseStreamingUrl + (deliveryType == DeliveryType.PROGRESSIVE ? "" : SQ_0) + RN_0;
+ }
+
+ /**
+ * Get a URL on which no redirection between playback hosts should be present on the network
+ * and/or IP used to fetch the streaming URL, for HTML5 clients.
+ *
+ * This method will follow redirects which works in the following way:
+ *
+ * - the {@link #ALR_YES} param is appended to all streaming URLs
+ * - if no redirection occurs, the video server will return the streaming data;
+ * - if a redirection occurs, the server will respond with HTTP status code 200 and a
+ * {@code text/plain} mime type. The redirection URL is the response body;
+ * - the redirection URL is requested and the steps above from step 2 are repeated,
+ * until too many redirects are reached of course (the maximum number of redirects is
+ * {@link #MAXIMUM_REDIRECT_COUNT the same as OkHttp}).
+ *
+ *
+ *
+ *
+ * For non-HTML5 clients, redirections are managed in the standard way in
+ * {@link #getInitializationResponse(String, ItagItem, DeliveryType)}.
+ *
+ *
+ * @param downloader the {@link Downloader} instance to be used
+ * @param streamingUrl the streaming URL which we are trying to get a streaming URL
+ * without any redirection on the network and/or IP used
+ * @param responseMimeTypeExpected the response mime type expected from Google video servers
+ * @return the {@link Response} of the stream, which should have no redirections
+ */
+ @SuppressWarnings("checkstyle:FinalParameters")
+ @Nonnull
+ private static Response getStreamingWebUrlWithoutRedirects(
+ @Nonnull final Downloader downloader,
+ @Nonnull String streamingUrl,
+ @Nonnull final String responseMimeTypeExpected)
+ throws CreationException {
+ try {
+ final Map> headers = new HashMap<>();
+ addClientInfoHeaders(headers);
+
+ String responseMimeType = "";
+
+ int redirectsCount = 0;
+ while (!responseMimeType.equals(responseMimeTypeExpected)
+ && redirectsCount < MAXIMUM_REDIRECT_COUNT) {
+ final Response response = downloader.get(streamingUrl, headers);
+
+ final int responseCode = response.responseCode();
+ if (responseCode != 200) {
+ throw new CreationException(
+ "Could not get the initialization URL: HTTP response code "
+ + responseCode);
+ }
+
+ // A valid HTTP 1.0+ response should include a Content-Type header, so we can
+ // require that the response from video servers has this header.
+ responseMimeType = Objects.requireNonNull(response.getHeader("Content-Type"),
+ "Could not get the Content-Type header from the response headers");
+
+ // The response body is the redirection URL
+ if (responseMimeType.equals("text/plain")) {
+ streamingUrl = response.responseBody();
+ redirectsCount++;
+ } else {
+ return response;
+ }
+ }
+
+ if (redirectsCount >= MAXIMUM_REDIRECT_COUNT) {
+ throw new CreationException(
+ "Too many redirects when trying to get the the streaming URL response of a "
+ + "HTML5 client");
+ }
+
+ // This should never be reached, but is required because we don't want to return null
+ // here
+ throw new CreationException(
+ "Could not get the streaming URL response of a HTML5 client: unreachable code "
+ + "reached!");
+ } catch (final IOException | ExtractionException e) {
+ throw new CreationException(
+ "Could not get the streaming URL response of a HTML5 client", e);
+ }
+ }
+}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubeOtfDashManifestCreator.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubeOtfDashManifestCreator.java
new file mode 100644
index 000000000..8161b5263
--- /dev/null
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubeOtfDashManifestCreator.java
@@ -0,0 +1,265 @@
+package org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators;
+
+import org.schabi.newpipe.extractor.downloader.Response;
+import org.schabi.newpipe.extractor.services.youtube.DeliveryType;
+import org.schabi.newpipe.extractor.services.youtube.ItagItem;
+import org.schabi.newpipe.extractor.utils.ManifestCreatorCache;
+import org.schabi.newpipe.extractor.utils.Utils;
+import org.w3c.dom.DOMException;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import javax.annotation.Nonnull;
+import java.util.Arrays;
+import java.util.Objects;
+
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.ALR_YES;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.RN_0;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.SEGMENT_TIMELINE;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.SQ_0;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.buildAndCacheResult;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.generateDocumentAndDoCommonElementsGeneration;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.generateSegmentTemplateElement;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.generateSegmentTimelineElement;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.getInitializationResponse;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.setAttribute;
+import static org.schabi.newpipe.extractor.utils.Utils.EMPTY_STRING;
+import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
+
+/**
+ * Class which generates DASH manifests of YouTube {@link DeliveryType#OTF OTF streams}.
+ */
+public final class YoutubeOtfDashManifestCreator {
+
+ /**
+ * Cache of DASH manifests generated for OTF streams.
+ */
+ private static final ManifestCreatorCache OTF_STREAMS_CACHE
+ = new ManifestCreatorCache<>();
+
+ private YoutubeOtfDashManifestCreator() {
+ }
+
+ /**
+ * Create DASH manifests from a YouTube OTF stream.
+ *
+ *
+ * OTF streams are YouTube-DASH specific streams which work with sequences and without the need
+ * to get a manifest (even if one is provided, it is not used by official clients).
+ *
+ *
+ *
+ * They can be found only on videos; mostly those with a small amount of views, or ended
+ * livestreams which have just been re-encoded as normal videos.
+ *
+ *
+ * This method needs:
+ *
+ * - the base URL of the stream (which, if you try to access to it, returns HTTP
+ * status code 404 after redirects, and if the URL is valid);
+ * - an {@link ItagItem}, which needs to contain the following information:
+ *
+ * - its type (see {@link ItagItem.ItagType}), to identify if the content is
+ * an audio or a video stream;
+ * - its bitrate;
+ * - its mime type;
+ * - its codec(s);
+ * - for an audio stream: its audio channels;
+ * - for a video stream: its width and height.
+ *
+ *
+ * - the duration of the video, which will be used if the duration could not be
+ * parsed from the first sequence of the stream.
+ *
+ *
+ *
+ * In order to generate the DASH manifest, this method will:
+ *
+ * - request the first sequence of the stream (the base URL on which the first
+ * sequence parameter is appended (see {@link YoutubeDashManifestCreatorsUtils#SQ_0}))
+ * with a {@code POST} or {@code GET} request (depending of the client on which the
+ * streaming URL comes from is a mobile one ({@code POST}) or not ({@code GET}));
+ * - follow its redirection(s), if any;
+ * - save the last URL, remove the first sequence parameter;
+ * - use the information provided in the {@link ItagItem} to generate all
+ * elements of the DASH manifest.
+ *
+ *
+ *
+ *
+ * If the duration cannot be extracted, the {@code durationSecondsFallback} value will be used
+ * as the stream duration.
+ *
+ *
+ * @param otfBaseStreamingUrl the base URL of the OTF stream, which must not be null
+ * @param itagItem the {@link ItagItem} corresponding to the stream, which
+ * must not be null
+ * @param durationSecondsFallback the duration of the video, which will be used if the duration
+ * could not be extracted from the first sequence
+ * @return the manifest generated into a string
+ */
+ @Nonnull
+ public static String fromOtfStreamingUrl(
+ @Nonnull final String otfBaseStreamingUrl,
+ @Nonnull final ItagItem itagItem,
+ final long durationSecondsFallback) throws CreationException {
+ if (OTF_STREAMS_CACHE.containsKey(otfBaseStreamingUrl)) {
+ return Objects.requireNonNull(OTF_STREAMS_CACHE.get(otfBaseStreamingUrl)).getSecond();
+ }
+
+ String realOtfBaseStreamingUrl = otfBaseStreamingUrl;
+ // Try to avoid redirects when streaming the content by saving the last URL we get
+ // from video servers.
+ final Response response = getInitializationResponse(realOtfBaseStreamingUrl,
+ itagItem, DeliveryType.OTF);
+ realOtfBaseStreamingUrl = response.latestUrl().replace(SQ_0, EMPTY_STRING)
+ .replace(RN_0, EMPTY_STRING).replace(ALR_YES, EMPTY_STRING);
+
+ final int responseCode = response.responseCode();
+ if (responseCode != 200) {
+ throw new CreationException("Could not get the initialization URL: response code "
+ + responseCode);
+ }
+
+ final String[] segmentDuration;
+
+ try {
+ final String[] segmentsAndDurationsResponseSplit = response.responseBody()
+ // Get the lines with the durations and the following
+ .split("Segment-Durations-Ms: ")[1]
+ // Remove the other lines
+ .split("\n")[0]
+ // Get all durations and repetitions which are separated by a comma
+ .split(",");
+ final int lastIndex = segmentsAndDurationsResponseSplit.length - 1;
+ if (isBlank(segmentsAndDurationsResponseSplit[lastIndex])) {
+ segmentDuration = Arrays.copyOf(segmentsAndDurationsResponseSplit, lastIndex);
+ } else {
+ segmentDuration = segmentsAndDurationsResponseSplit;
+ }
+ } catch (final Exception e) {
+ throw new CreationException("Could not get segment durations", e);
+ }
+
+ long streamDuration;
+ try {
+ streamDuration = getStreamDuration(segmentDuration);
+ } catch (final CreationException e) {
+ streamDuration = durationSecondsFallback * 1000;
+ }
+
+ final Document doc = generateDocumentAndDoCommonElementsGeneration(itagItem,
+ streamDuration);
+
+ generateSegmentTemplateElement(doc, realOtfBaseStreamingUrl, DeliveryType.OTF);
+ generateSegmentTimelineElement(doc);
+ generateSegmentElementsForOtfStreams(segmentDuration, doc);
+
+ return buildAndCacheResult(otfBaseStreamingUrl, doc, OTF_STREAMS_CACHE);
+ }
+
+ /**
+ * @return the cache of DASH manifests generated for OTF streams
+ */
+ @Nonnull
+ public static ManifestCreatorCache getCache() {
+ return OTF_STREAMS_CACHE;
+ }
+
+ /**
+ * Generate segment elements for OTF streams.
+ *
+ *
+ * By parsing by the first media sequence, we know how many durations and repetitions there are
+ * so we just have to loop into segment durations to generate the following elements for each
+ * duration repeated X times:
+ *
+ *
+ *
+ * {@code }
+ *
+ *
+ *
+ * If there is no repetition of the duration between two segments, the {@code r} attribute is
+ * not added to the {@code S} element, as it is not needed.
+ *
+ *
+ *
+ * These elements will be appended as children of the {@code } element, which
+ * needs to be generated before these elements with
+ * {@link YoutubeDashManifestCreatorsUtils#generateSegmentTimelineElement(Document)}.
+ *
+ *
+ * @param segmentDurations the sequences "length" or "length(r=repeat_count" extracted with the
+ * regular expressions
+ * @param doc the {@link Document} on which the {@code } elements will be
+ * appended
+ */
+ private static void generateSegmentElementsForOtfStreams(
+ @Nonnull final String[] segmentDurations,
+ @Nonnull final Document doc) throws CreationException {
+ try {
+ final Element segmentTimelineElement = (Element) doc.getElementsByTagName(
+ SEGMENT_TIMELINE).item(0);
+
+ for (final String segmentDuration : segmentDurations) {
+ final Element sElement = doc.createElement("S");
+
+ final String[] segmentLengthRepeat = segmentDuration.split("\\(r=");
+ // make sure segmentLengthRepeat[0], which is the length, is convertible to int
+ Integer.parseInt(segmentLengthRepeat[0]);
+
+ // There are repetitions of a segment duration in other segments
+ if (segmentLengthRepeat.length > 1) {
+ final int segmentRepeatCount = Integer.parseInt(
+ Utils.removeNonDigitCharacters(segmentLengthRepeat[1]));
+ setAttribute(sElement, doc, "r", String.valueOf(segmentRepeatCount));
+ }
+ setAttribute(sElement, doc, "d", segmentLengthRepeat[0]);
+
+ segmentTimelineElement.appendChild(sElement);
+ }
+
+ } catch (final DOMException | IllegalStateException | IndexOutOfBoundsException
+ | NumberFormatException e) {
+ throw CreationException.couldNotAddElement("segment (S)", e);
+ }
+ }
+
+ /**
+ * Get the duration of an OTF stream.
+ *
+ *
+ * The duration of OTF streams is not returned into the player response and needs to be
+ * calculated by adding the duration of each segment.
+ *
+ *
+ * @param segmentDuration the segment duration object extracted from the initialization
+ * sequence of the stream
+ * @return the duration of the OTF stream, in milliseconds
+ */
+ private static long getStreamDuration(@Nonnull final String[] segmentDuration)
+ throws CreationException {
+ try {
+ long streamLengthMs = 0;
+
+ for (final String segDuration : segmentDuration) {
+ final String[] segmentLengthRepeat = segDuration.split("\\(r=");
+ long segmentRepeatCount = 0;
+
+ // There are repetitions of a segment duration in other segments
+ if (segmentLengthRepeat.length > 1) {
+ segmentRepeatCount = Long.parseLong(Utils.removeNonDigitCharacters(
+ segmentLengthRepeat[1]));
+ }
+
+ final long segmentLength = Integer.parseInt(segmentLengthRepeat[0]);
+ streamLengthMs += segmentLength + segmentRepeatCount * segmentLength;
+ }
+
+ return streamLengthMs;
+ } catch (final NumberFormatException e) {
+ throw new CreationException("Could not get stream length from sequences list", e);
+ }
+ }
+}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubePostLiveStreamDvrDashManifestCreator.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubePostLiveStreamDvrDashManifestCreator.java
new file mode 100644
index 000000000..43d7e41e5
--- /dev/null
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubePostLiveStreamDvrDashManifestCreator.java
@@ -0,0 +1,217 @@
+package org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators;
+
+import org.schabi.newpipe.extractor.downloader.Response;
+import org.schabi.newpipe.extractor.services.youtube.DeliveryType;
+import org.schabi.newpipe.extractor.services.youtube.ItagItem;
+import org.schabi.newpipe.extractor.utils.ManifestCreatorCache;
+import org.w3c.dom.DOMException;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import javax.annotation.Nonnull;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.ALR_YES;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.RN_0;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.SEGMENT_TIMELINE;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.SQ_0;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.buildAndCacheResult;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.generateDocumentAndDoCommonElementsGeneration;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.generateSegmentTemplateElement;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.generateSegmentTimelineElement;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.getInitializationResponse;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.setAttribute;
+import static org.schabi.newpipe.extractor.utils.Utils.EMPTY_STRING;
+import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
+
+/**
+ * Class which generates DASH manifests of YouTube post-live DVR streams (which use the
+ * {@link DeliveryType#LIVE LIVE delivery type}).
+ */
+public final class YoutubePostLiveStreamDvrDashManifestCreator {
+
+ /**
+ * Cache of DASH manifests generated for post-live-DVR streams.
+ */
+ private static final ManifestCreatorCache POST_LIVE_DVR_STREAMS_CACHE
+ = new ManifestCreatorCache<>();
+
+ private YoutubePostLiveStreamDvrDashManifestCreator() {
+ }
+
+ /**
+ * Create DASH manifests from a YouTube post-live-DVR stream/ended livestream.
+ *
+ *
+ * Post-live-DVR streams/ended livestreams are one of the YouTube DASH specific streams which
+ * works with sequences and without the need to get a manifest (even if one is provided but not
+ * used by main clients (and is not complete for big ended livestreams because it doesn't
+ * return the full stream)).
+ *
+ *
+ *
+ * They can be found only on livestreams which have ended very recently (a few hours, most of
+ * the time)
+ *
+ *
+ * This method needs:
+ *
+ * - the base URL of the stream (which, if you try to access to it, returns HTTP
+ * status code 404 after redirects, and if the URL is valid);
+ * - an {@link ItagItem}, which needs to contain the following information:
+ *
+ * - its type (see {@link ItagItem.ItagType}), to identify if the content is
+ * an audio or a video stream;
+ * - its bitrate;
+ * - its mime type;
+ * - its codec(s);
+ * - for an audio stream: its audio channels;
+ * - for a video stream: its width and height.
+ *
+ *
+ * - the duration of the video, which will be used if the duration could not be
+ * parsed from the first sequence of the stream.
+ *
+ *
+ *
+ * In order to generate the DASH manifest, this method will:
+ *
+ * - request the first sequence of the stream (the base URL on which the first
+ * sequence parameter is appended (see {@link YoutubeDashManifestCreatorsUtils#SQ_0}))
+ * with a {@code POST} or {@code GET} request (depending of the client on which the
+ * streaming URL comes from is a mobile one ({@code POST}) or not ({@code GET}));
+ * - follow its redirection(s), if any;
+ * - save the last URL, remove the first sequence parameters;
+ * - use the information provided in the {@link ItagItem} to generate all elements
+ * of the DASH manifest.
+ *
+ *
+ *
+ *
+ * If the duration cannot be extracted, the {@code durationSecondsFallback} value will be used
+ * as the stream duration.
+ *
+ *
+ * @param postLiveStreamDvrStreamingUrl the base URL of the post-live-DVR stream/ended
+ * livestream, which must not be null
+ * @param itagItem the {@link ItagItem} corresponding to the stream, which
+ * must not be null
+ * @param targetDurationSec the target duration of each sequence, in seconds (this
+ * value is returned with the {@code targetDurationSec}
+ * field for each stream in YouTube's player response)
+ * @param durationSecondsFallback the duration of the ended livestream, which will be
+ * used if the duration could not be extracted from the
+ * first sequence
+ * @return the manifest generated into a string
+ */
+ @Nonnull
+ public static String fromPostLiveStreamDvrStreamingUrl(
+ @Nonnull final String postLiveStreamDvrStreamingUrl,
+ @Nonnull final ItagItem itagItem,
+ final int targetDurationSec,
+ final long durationSecondsFallback) throws CreationException {
+ if (POST_LIVE_DVR_STREAMS_CACHE.containsKey(postLiveStreamDvrStreamingUrl)) {
+ return Objects.requireNonNull(
+ POST_LIVE_DVR_STREAMS_CACHE.get(postLiveStreamDvrStreamingUrl)).getSecond();
+ }
+
+ String realPostLiveStreamDvrStreamingUrl = postLiveStreamDvrStreamingUrl;
+ final String streamDurationString;
+ final String segmentCount;
+
+ if (targetDurationSec <= 0) {
+ throw new CreationException("targetDurationSec value is <= 0: " + targetDurationSec);
+ }
+
+ try {
+ // Try to avoid redirects when streaming the content by saving the latest URL we get
+ // from video servers.
+ final Response response = getInitializationResponse(realPostLiveStreamDvrStreamingUrl,
+ itagItem, DeliveryType.LIVE);
+ realPostLiveStreamDvrStreamingUrl = response.latestUrl().replace(SQ_0, EMPTY_STRING)
+ .replace(RN_0, EMPTY_STRING).replace(ALR_YES, EMPTY_STRING);
+
+ final int responseCode = response.responseCode();
+ if (responseCode != 200) {
+ throw new CreationException(
+ "Could not get the initialization sequence: response code " + responseCode);
+ }
+
+ final Map> responseHeaders = response.responseHeaders();
+ streamDurationString = responseHeaders.get("X-Head-Time-Millis").get(0);
+ segmentCount = responseHeaders.get("X-Head-Seqnum").get(0);
+ } catch (final IndexOutOfBoundsException e) {
+ throw new CreationException(
+ "Could not get the value of the X-Head-Time-Millis or the X-Head-Seqnum header",
+ e);
+ }
+
+ if (isNullOrEmpty(segmentCount)) {
+ throw new CreationException("Could not get the number of segments");
+ }
+
+ long streamDuration;
+ try {
+ streamDuration = Long.parseLong(streamDurationString);
+ } catch (final NumberFormatException e) {
+ streamDuration = durationSecondsFallback;
+ }
+
+ final Document doc = generateDocumentAndDoCommonElementsGeneration(itagItem,
+ streamDuration);
+
+ generateSegmentTemplateElement(doc, realPostLiveStreamDvrStreamingUrl,
+ DeliveryType.LIVE);
+ generateSegmentTimelineElement(doc);
+ generateSegmentElementForPostLiveDvrStreams(doc, targetDurationSec, segmentCount);
+
+ return buildAndCacheResult(postLiveStreamDvrStreamingUrl, doc,
+ POST_LIVE_DVR_STREAMS_CACHE);
+ }
+
+ /**
+ * @return the cache of DASH manifests generated for post-live-DVR streams
+ */
+ @Nonnull
+ public static ManifestCreatorCache getCache() {
+ return POST_LIVE_DVR_STREAMS_CACHE;
+ }
+
+ /**
+ * Generate the segment ({@code }) element.
+ *
+ *
+ * We don't know the exact duration of segments for post-live-DVR streams but an
+ * average instead (which is the {@code targetDurationSec} value), so we can use the following
+ * structure to generate the segment timeline for DASH manifests of ended livestreams:
+ *
+ * {@code }
+ *
+ *
+ * @param doc the {@link Document} on which the {@code } element will
+ * be appended
+ * @param targetDurationSeconds the {@code targetDurationSec} value from YouTube player
+ * response's stream
+ * @param segmentCount the number of segments, extracted by {@link
+ * #fromPostLiveStreamDvrStreamingUrl(String, ItagItem, int, long)}
+ */
+ private static void generateSegmentElementForPostLiveDvrStreams(
+ @Nonnull final Document doc,
+ final int targetDurationSeconds,
+ @Nonnull final String segmentCount) throws CreationException {
+ try {
+ final Element segmentTimelineElement = (Element) doc.getElementsByTagName(
+ SEGMENT_TIMELINE).item(0);
+ final Element sElement = doc.createElement("S");
+
+ setAttribute(sElement, doc, "d", String.valueOf(targetDurationSeconds * 1000));
+ setAttribute(sElement, doc, "r", segmentCount);
+
+ segmentTimelineElement.appendChild(sElement);
+ } catch (final DOMException e) {
+ throw CreationException.couldNotAddElement("segment (S)", e);
+ }
+ }
+}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubeProgressiveDashManifestCreator.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubeProgressiveDashManifestCreator.java
new file mode 100644
index 000000000..0f69895bb
--- /dev/null
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubeProgressiveDashManifestCreator.java
@@ -0,0 +1,235 @@
+package org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators;
+
+import org.schabi.newpipe.extractor.services.youtube.DeliveryType;
+import org.schabi.newpipe.extractor.services.youtube.ItagItem;
+import org.schabi.newpipe.extractor.utils.ManifestCreatorCache;
+import org.w3c.dom.DOMException;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import javax.annotation.Nonnull;
+import java.util.Objects;
+
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.BASE_URL;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.INITIALIZATION;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.MPD;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.REPRESENTATION;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.SEGMENT_BASE;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.buildAndCacheResult;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.generateDocumentAndDoCommonElementsGeneration;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.setAttribute;
+
+/**
+ * Class which generates DASH manifests of {@link DeliveryType#PROGRESSIVE YouTube progressive}
+ * streams.
+ */
+public final class YoutubeProgressiveDashManifestCreator {
+
+ /**
+ * Cache of DASH manifests generated for progressive streams.
+ */
+ private static final ManifestCreatorCache PROGRESSIVE_STREAMS_CACHE
+ = new ManifestCreatorCache<>();
+
+ private YoutubeProgressiveDashManifestCreator() {
+ }
+
+ /**
+ * Create DASH manifests from a YouTube progressive stream.
+ *
+ *
+ * Progressive streams are YouTube DASH streams which work with range requests and without the
+ * need to get a manifest.
+ *
+ *
+ *
+ * They can be found on all videos, and for all streams for most of videos which come from a
+ * YouTube partner, and on videos with a large number of views.
+ *
+ *
+ * This method needs:
+ *
+ * - the base URL of the stream (which, if you try to access to it, returns the whole
+ * stream, after redirects, and if the URL is valid);
+ * - an {@link ItagItem}, which needs to contain the following information:
+ *
+ * - its type (see {@link ItagItem.ItagType}), to identify if the content is
+ * an audio or a video stream;
+ * - its bitrate;
+ * - its mime type;
+ * - its codec(s);
+ * - for an audio stream: its audio channels;
+ * - for a video stream: its width and height.
+ *
+ *
+ * - the duration of the video (parameter {@code durationSecondsFallback}), which
+ * will be used as the stream duration if the duration could not be parsed from the
+ * {@link ItagItem}.
+ *
+ *
+ *
+ * @param progressiveStreamingBaseUrl the base URL of the progressive stream, which must not be
+ * null
+ * @param itagItem the {@link ItagItem} corresponding to the stream, which
+ * must not be null
+ * @param durationSecondsFallback the duration of the progressive stream which will be used
+ * if the duration could not be extracted from the
+ * {@link ItagItem}
+ * @return the manifest generated into a string
+ */
+ @Nonnull
+ public static String fromProgressiveStreamingUrl(
+ @Nonnull final String progressiveStreamingBaseUrl,
+ @Nonnull final ItagItem itagItem,
+ final long durationSecondsFallback) throws CreationException {
+ if (PROGRESSIVE_STREAMS_CACHE.containsKey(progressiveStreamingBaseUrl)) {
+ return Objects.requireNonNull(
+ PROGRESSIVE_STREAMS_CACHE.get(progressiveStreamingBaseUrl)).getSecond();
+ }
+
+ final long itagItemDuration = itagItem.getApproxDurationMs();
+ final long streamDuration;
+ if (itagItemDuration != -1) {
+ streamDuration = itagItemDuration;
+ } else {
+ if (durationSecondsFallback > 0) {
+ streamDuration = durationSecondsFallback * 1000;
+ } else {
+ throw CreationException.couldNotAddElement(MPD, "the duration of the stream "
+ + "could not be determined and durationSecondsFallback is <= 0");
+ }
+ }
+
+ final Document doc = generateDocumentAndDoCommonElementsGeneration(itagItem,
+ streamDuration);
+
+ generateBaseUrlElement(doc, progressiveStreamingBaseUrl);
+ generateSegmentBaseElement(doc, itagItem);
+ generateInitializationElement(doc, itagItem);
+
+ return buildAndCacheResult(progressiveStreamingBaseUrl, doc,
+ PROGRESSIVE_STREAMS_CACHE);
+ }
+
+ /**
+ * @return the cache of DASH manifests generated for progressive streams
+ */
+ @Nonnull
+ public static ManifestCreatorCache getCache() {
+ return PROGRESSIVE_STREAMS_CACHE;
+ }
+
+ /**
+ * Generate the {@code } element, appended as a child of the
+ * {@code } element.
+ *
+ *
+ * The {@code } element needs to be generated before this element with
+ * {@link YoutubeDashManifestCreatorsUtils#generateRepresentationElement(Document, ItagItem)}).
+ *
+ *
+ * @param doc the {@link Document} on which the {@code } element will be appended
+ * @param baseUrl the base URL of the stream, which must not be null and will be set as the
+ * content of the {@code } element
+ */
+ private static void generateBaseUrlElement(@Nonnull final Document doc,
+ @Nonnull final String baseUrl)
+ throws CreationException {
+ try {
+ final Element representationElement = (Element) doc.getElementsByTagName(
+ REPRESENTATION).item(0);
+ final Element baseURLElement = doc.createElement(BASE_URL);
+ baseURLElement.setTextContent(baseUrl);
+ representationElement.appendChild(baseURLElement);
+ } catch (final DOMException e) {
+ throw CreationException.couldNotAddElement(BASE_URL, e);
+ }
+ }
+
+ /**
+ * Generate the {@code } element, appended as a child of the
+ * {@code } element.
+ *
+ *
+ * It generates the following element:
+ *
+ * {@code }
+ *
+ * (where {@code indexStart} and {@code indexEnd} are gotten from the {@link ItagItem} passed
+ * as the second parameter)
+ *
+ *
+ *
+ * The {@code } element needs to be generated before this element with
+ * {@link YoutubeDashManifestCreatorsUtils#generateRepresentationElement(Document, ItagItem)}),
+ * and the {@code BaseURL} element with {@link #generateBaseUrlElement(Document, String)}
+ * should be generated too.
+ *
+ *
+ * @param doc the {@link Document} on which the {@code } element will be appended
+ * @param itagItem the {@link ItagItem} to use, which must not be null
+ */
+ private static void generateSegmentBaseElement(@Nonnull final Document doc,
+ @Nonnull final ItagItem itagItem)
+ throws CreationException {
+ try {
+ final Element representationElement = (Element) doc.getElementsByTagName(
+ REPRESENTATION).item(0);
+ final Element segmentBaseElement = doc.createElement(SEGMENT_BASE);
+
+ final String range = itagItem.getIndexStart() + "-" + itagItem.getIndexEnd();
+ if (itagItem.getIndexStart() < 0 || itagItem.getIndexEnd() < 0) {
+ throw CreationException.couldNotAddElement(SEGMENT_BASE,
+ "ItagItem's indexStart or " + "indexEnd are < 0: " + range);
+ }
+ setAttribute(segmentBaseElement, doc, "indexRange", range);
+
+ representationElement.appendChild(segmentBaseElement);
+ } catch (final DOMException e) {
+ throw CreationException.couldNotAddElement(SEGMENT_BASE, e);
+ }
+ }
+
+ /**
+ * Generate the {@code } element, appended as a child of the
+ * {@code } element.
+ *
+ *
+ * It generates the following element:
+ *
+ * {@code }
+ *
+ * (where {@code indexStart} and {@code indexEnd} are gotten from the {@link ItagItem} passed
+ * as the second parameter)
+ *
+ *
+ *
+ * The {@code } element needs to be generated before this element with
+ * {@link #generateSegmentBaseElement(Document, ItagItem)}).
+ *
+ *
+ * @param doc the {@link Document} on which the {@code } element will be
+ * appended
+ * @param itagItem the {@link ItagItem} to use, which must not be null
+ */
+ private static void generateInitializationElement(@Nonnull final Document doc,
+ @Nonnull final ItagItem itagItem)
+ throws CreationException {
+ try {
+ final Element segmentBaseElement = (Element) doc.getElementsByTagName(
+ SEGMENT_BASE).item(0);
+ final Element initializationElement = doc.createElement(INITIALIZATION);
+
+ final String range = itagItem.getInitStart() + "-" + itagItem.getInitEnd();
+ if (itagItem.getInitStart() < 0 || itagItem.getInitEnd() < 0) {
+ throw CreationException.couldNotAddElement(INITIALIZATION,
+ "ItagItem's initStart and/or " + "initEnd are/is < 0: " + range);
+ }
+ setAttribute(initializationElement, doc, "range", range);
+
+ segmentBaseElement.appendChild(initializationElement);
+ } catch (final DOMException e) {
+ throw CreationException.couldNotAddElement(INITIALIZATION, e);
+ }
+ }
+}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/ItagInfo.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/ItagInfo.java
new file mode 100644
index 000000000..c1ac4f5f6
--- /dev/null
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/ItagInfo.java
@@ -0,0 +1,80 @@
+package org.schabi.newpipe.extractor.services.youtube.extractors;
+
+import org.schabi.newpipe.extractor.services.youtube.ItagItem;
+
+import javax.annotation.Nonnull;
+import java.io.Serializable;
+
+/**
+ * Class to build easier {@link org.schabi.newpipe.extractor.stream.Stream}s for
+ * {@link YoutubeStreamExtractor}.
+ *
+ *
+ * It stores, per stream:
+ *
+ * - its content (the URL/the base URL of streams);
+ * - whether its content is the URL the content itself or the base URL;
+ * - its associated {@link ItagItem}.
+ *
+ *
+ */
+final class ItagInfo implements Serializable {
+ @Nonnull
+ private final String content;
+ @Nonnull
+ private final ItagItem itagItem;
+ private boolean isUrl;
+
+ /**
+ * Creates a new {@code ItagInfo} instance.
+ *
+ * @param content the content of the stream, which must be not null
+ * @param itagItem the {@link ItagItem} associated with the stream, which must be not null
+ */
+ ItagInfo(@Nonnull final String content,
+ @Nonnull final ItagItem itagItem) {
+ this.content = content;
+ this.itagItem = itagItem;
+ }
+
+ /**
+ * Sets whether the stream is a URL.
+ *
+ * @param isUrl whether the content is a URL
+ */
+ void setIsUrl(final boolean isUrl) {
+ this.isUrl = isUrl;
+ }
+
+ /**
+ * Gets the content stored in this {@code ItagInfo} instance, which is either the URL to the
+ * content itself or the base URL.
+ *
+ * @return the content stored in this {@code ItagInfo} instance
+ */
+ @Nonnull
+ String getContent() {
+ return content;
+ }
+
+ /**
+ * Gets the {@link ItagItem} associated with this {@code ItagInfo} instance.
+ *
+ * @return the {@link ItagItem} associated with this {@code ItagInfo} instance, which is not
+ * null
+ */
+ @Nonnull
+ ItagItem getItagItem() {
+ return itagItem;
+ }
+
+ /**
+ * Gets whether the content stored is the URL to the content itself or the base URL of it.
+ *
+ * @return whether the content stored is the URL to the content itself or the base URL of it
+ * @see #getContent() for more details
+ */
+ boolean getIsUrl() {
+ return isUrl;
+ }
+}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java
index 46676ff3c..a41325f3d 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java
@@ -1,10 +1,32 @@
+/*
+ * Created by Christian Schabesberger on 06.08.15.
+ *
+ * Copyright (C) Christian Schabesberger 2019
+ * YoutubeStreamExtractor.java is part of NewPipe Extractor.
+ *
+ * NewPipe Extractor is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * NewPipe Extractor is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with NewPipe Extractor. If not, see .
+ */
+
package org.schabi.newpipe.extractor.services.youtube.extractors;
+import static org.schabi.newpipe.extractor.services.youtube.ItagItem.APPROX_DURATION_MS_UNKNOWN;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.CONTENT_CHECK_OK;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.CPN;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.RACY_CHECK_OK;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.VIDEO_ID;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.createDesktopPlayerBody;
+import static org.schabi.newpipe.extractor.services.youtube.ItagItem.CONTENT_LENGTH_UNKNOWN;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.fixThumbnailUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.generateContentPlaybackNonce;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.generateTParameter;
@@ -50,6 +72,7 @@ import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper;
import org.schabi.newpipe.extractor.services.youtube.YoutubeThrottlingDecrypter;
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeChannelLinkHandlerFactory;
import org.schabi.newpipe.extractor.stream.AudioStream;
+import org.schabi.newpipe.extractor.stream.DeliveryMethod;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.stream.Frameset;
import org.schabi.newpipe.extractor.stream.Stream;
@@ -64,7 +87,6 @@ import org.schabi.newpipe.extractor.utils.Parser;
import org.schabi.newpipe.extractor.utils.Utils;
import java.io.IOException;
-import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.OffsetDateTime;
@@ -72,7 +94,6 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
-import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -82,26 +103,6 @@ import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
-/*
- * Created by Christian Schabesberger on 06.08.15.
- *
- * Copyright (C) Christian Schabesberger 2019
- * YoutubeStreamExtractor.java is part of NewPipe.
- *
- * NewPipe is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * NewPipe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with NewPipe. If not, see .
- */
-
public class YoutubeStreamExtractor extends StreamExtractor {
/*//////////////////////////////////////////////////////////////////////////
// Exceptions
@@ -113,7 +114,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
}
}
- /*//////////////////////////////////////////////////////////////////////////*/
+ /*////////////////////////////////////////////////////////////////////////*/
@Nullable
private static String cachedDeobfuscationCode = null;
@@ -140,8 +141,6 @@ public class YoutubeStreamExtractor extends StreamExtractor {
private JsonObject playerMicroFormatRenderer;
private int ageLimit = -1;
private StreamType streamType;
- @Nullable
- private List subtitles = null;
// We need to store the contentPlaybackNonces because we need to append them to videoplayback
// URLs (with the cpn parameter).
@@ -580,73 +579,25 @@ public class YoutubeStreamExtractor extends StreamExtractor {
.orElse(EMPTY_STRING);
}
- @FunctionalInterface
- interface StreamTypeStreamBuilderHelper {
- T buildStream(String url, ItagItem itagItem);
- }
-
- /**
- * Abstract method for
- * {@link #getAudioStreams()}, {@link #getVideoOnlyStreams()} and {@link #getVideoStreams()}.
- *
- * @param itags A map of Urls + ItagItems
- * @param streamBuilder Builds the stream from the provided data
- * @param exMsgStreamType Stream type inside the exception message e.g. "video streams"
- * @param Type of the stream
- * @return
- * @throws ExtractionException
- */
- private List getStreamsByType(
- final Map itags,
- final StreamTypeStreamBuilderHelper streamBuilder,
- final String exMsgStreamType
- ) throws ExtractionException {
- final List streams = new ArrayList<>();
-
- try {
- for (final Map.Entry entry : itags.entrySet()) {
- final String url = tryDecryptUrl(entry.getKey(), getId());
-
- final T stream = streamBuilder.buildStream(url, entry.getValue());
- if (!Stream.containSimilarStream(stream, streams)) {
- streams.add(stream);
- }
- }
- } catch (final Exception e) {
- throw new ParsingException("Could not get " + exMsgStreamType, e);
- }
-
- return streams;
- }
-
@Override
public List getAudioStreams() throws ExtractionException {
assertPageFetched();
- return getStreamsByType(
- getItags(ADAPTIVE_FORMATS, ItagItem.ItagType.AUDIO),
- AudioStream::new,
- "audio streams"
- );
+ return getItags(ADAPTIVE_FORMATS, ItagItem.ItagType.AUDIO,
+ getAudioStreamBuilderHelper(), "audio");
}
@Override
public List getVideoStreams() throws ExtractionException {
assertPageFetched();
- return getStreamsByType(
- getItags(FORMATS, ItagItem.ItagType.VIDEO),
- (url, itag) -> new VideoStream(url, false, itag),
- "video streams"
- );
+ return getItags(FORMATS, ItagItem.ItagType.VIDEO,
+ getVideoStreamBuilderHelper(false), "video");
}
@Override
public List getVideoOnlyStreams() throws ExtractionException {
assertPageFetched();
- return getStreamsByType(
- getItags(ADAPTIVE_FORMATS, ItagItem.ItagType.VIDEO_ONLY),
- (url, itag) -> new VideoStream(url, true, itag),
- "video only streams"
- );
+ return getItags(ADAPTIVE_FORMATS, ItagItem.ItagType.VIDEO_ONLY,
+ getVideoStreamBuilderHelper(true), "video-only");
}
/**
@@ -672,18 +623,15 @@ public class YoutubeStreamExtractor extends StreamExtractor {
@Nonnull
public List getSubtitles(final MediaFormat format) throws ParsingException {
assertPageFetched();
- if (subtitles != null) {
- // Already calculated
- return subtitles;
- }
+ // We cannot store the subtitles list because the media format may change
+ final List subtitlesToReturn = new ArrayList<>();
final JsonObject renderer = playerResponse.getObject("captions")
.getObject("playerCaptionsTracklistRenderer");
final JsonArray captionsArray = renderer.getArray("captionTracks");
// TODO: use this to apply auto translation to different language from a source language
// final JsonArray autoCaptionsArray = renderer.getArray("translationLanguages");
- subtitles = new ArrayList<>();
for (int i = 0; i < captionsArray.size(); i++) {
final String languageCode = captionsArray.getObject(i).getString("languageCode");
final String baseUrl = captionsArray.getObject(i).getString("baseUrl");
@@ -692,15 +640,21 @@ public class YoutubeStreamExtractor extends StreamExtractor {
if (languageCode != null && baseUrl != null && vssId != null) {
final boolean isAutoGenerated = vssId.startsWith("a.");
final String cleanUrl = baseUrl
- .replaceAll("&fmt=[^&]*", "") // Remove preexisting format if exists
- .replaceAll("&tlang=[^&]*", ""); // Remove translation language
+ // Remove preexisting format if exists
+ .replaceAll("&fmt=[^&]*", "")
+ // Remove translation language
+ .replaceAll("&tlang=[^&]*", "");
- subtitles.add(new SubtitlesStream(format, languageCode,
- cleanUrl + "&fmt=" + format.getSuffix(), isAutoGenerated));
+ subtitlesToReturn.add(new SubtitlesStream.Builder()
+ .setContent(cleanUrl + "&fmt=" + format.getSuffix(), true)
+ .setMediaFormat(format)
+ .setLanguageCode(languageCode)
+ .setAutoGenerated(isAutoGenerated)
+ .build());
}
}
- return subtitles;
+ return subtitlesToReturn;
}
@Override
@@ -711,9 +665,10 @@ public class YoutubeStreamExtractor extends StreamExtractor {
}
private void setStreamType() {
- if (playerResponse.getObject("playabilityStatus").has("liveStreamability")
- || playerResponse.getObject("videoDetails").getBoolean("isPostLiveDvr", false)) {
+ if (playerResponse.getObject("playabilityStatus").has("liveStreamability")) {
streamType = StreamType.LIVE_STREAM;
+ } else if (playerResponse.getObject("videoDetails").getBoolean("isPostLiveDvr", false)) {
+ streamType = StreamType.POST_LIVE_STREAM;
} else {
streamType = StreamType.VIDEO_STREAM;
}
@@ -788,6 +743,8 @@ public class YoutubeStreamExtractor extends StreamExtractor {
private static final String STREAMING_DATA = "streamingData";
private static final String PLAYER = "player";
private static final String NEXT = "next";
+ private static final String SIGNATURE_CIPHER = "signatureCipher";
+ private static final String CIPHER = "cipher";
private static final String[] REGEXES = {
"(?:\\b|[^a-zA-Z0-9$])([a-zA-Z0-9$]{2,})\\s*=\\s*function\\(\\s*a\\s*\\)"
@@ -827,7 +784,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
final JsonObject playabilityStatus = playerResponse.getObject("playabilityStatus");
- final boolean ageRestricted = playabilityStatus.getString("reason", EMPTY_STRING)
+ final boolean isAgeRestricted = playabilityStatus.getString("reason", EMPTY_STRING)
.contains("age");
setStreamType();
@@ -837,12 +794,12 @@ public class YoutubeStreamExtractor extends StreamExtractor {
fetchTvHtml5EmbedJsonPlayer(contentCountry, localization, videoId);
} catch (final Exception ignored) {
}
-
- // Refresh the stream type because the stream type may be not properly known for
- // age-restricted videos
- setStreamType();
}
+ // Refresh the stream type because the stream type may be not properly known for
+ // age-restricted videos
+ setStreamType();
+
if (html5StreamingData == null && playerResponse.has(STREAMING_DATA)) {
html5StreamingData = playerResponse.getObject(STREAMING_DATA);
}
@@ -866,7 +823,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
.getBytes(StandardCharsets.UTF_8);
nextResponse = getJsonPostResponse(NEXT, body, localization);
- if ((!ageRestricted && streamType == StreamType.VIDEO_STREAM)
+ if ((!isAgeRestricted && streamType == StreamType.VIDEO_STREAM)
|| isAndroidClientFetchForced) {
try {
fetchAndroidMobileJsonPlayer(contentCountry, localization, videoId);
@@ -874,7 +831,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
}
}
- if ((!ageRestricted && streamType == StreamType.LIVE_STREAM)
+ if ((!isAgeRestricted && streamType == StreamType.LIVE_STREAM)
|| isIosClientFetchForced) {
try {
fetchIosMobileJsonPlayer(contentCountry, localization, videoId);
@@ -1184,103 +1141,254 @@ public class YoutubeStreamExtractor extends StreamExtractor {
}
@Nonnull
- private Map getItags(@Nonnull final String streamingDataKey,
- @Nonnull final ItagItem.ItagType itagTypeWanted) {
- final Map urlAndItags = new LinkedHashMap<>();
- if (html5StreamingData == null && androidStreamingData == null
- && iosStreamingData == null) {
- return urlAndItags;
+ private List getItags(
+ final String streamingDataKey,
+ final ItagItem.ItagType itagTypeWanted,
+ final java.util.function.Function streamBuilderHelper,
+ final String streamTypeExceptionMessage) throws ParsingException {
+ try {
+ final String videoId = getId();
+ final List streamList = new ArrayList<>();
+
+ java.util.stream.Stream.of(
+ // Use the androidStreamingData object first because there is no n param and no
+ // signatureCiphers in streaming URLs of the Android client
+ new Pair<>(androidStreamingData, androidCpn),
+ new Pair<>(html5StreamingData, html5Cpn),
+ // Use the iosStreamingData object in the last position because most of the
+ // available streams can be extracted with the Android and web clients and also
+ // because the iOS client is only enabled by default on livestreams
+ new Pair<>(iosStreamingData, iosCpn)
+ )
+ .flatMap(pair -> getStreamsFromStreamingDataKey(videoId, pair.getFirst(),
+ streamingDataKey, itagTypeWanted, pair.getSecond()))
+ .map(streamBuilderHelper)
+ .forEachOrdered(stream -> {
+ if (!Stream.containSimilarStream(stream, streamList)) {
+ streamList.add(stream);
+ }
+ });
+
+ return streamList;
+ } catch (final Exception e) {
+ throw new ParsingException(
+ "Could not get " + streamTypeExceptionMessage + " streams", e);
}
+ }
- final List> streamingDataAndCpnLoopList = new ArrayList<>();
- // Use the androidStreamingData object first because there is no n param and no
- // signatureCiphers in streaming URLs of the Android client
- streamingDataAndCpnLoopList.add(new Pair<>(androidStreamingData, androidCpn));
- streamingDataAndCpnLoopList.add(new Pair<>(html5StreamingData, html5Cpn));
- // Use the iosStreamingData object in the last position because most of the available
- // streams can be extracted with the Android and web clients and also because the iOS
- // client is only enabled by default on livestreams
- streamingDataAndCpnLoopList.add(new Pair<>(iosStreamingData, iosCpn));
+ /**
+ * Get the stream builder helper which will be used to build {@link AudioStream}s in
+ * {@link #getItags(String, ItagItem.ItagType, java.util.function.Function, String)}
+ *
+ *
+ * The {@code StreamBuilderHelper} will set the following attributes in the
+ * {@link AudioStream}s built:
+ *
+ * - the {@link ItagItem}'s id of the stream as its id;
+ * - {@link ItagInfo#getContent()} and {@link ItagInfo#getIsUrl()} as its content and
+ * and as the value of {@code isUrl};
+ * - the media format returned by the {@link ItagItem} as its media format;
+ * - its average bitrate with the value returned by {@link
+ * ItagItem#getAverageBitrate()};
+ * - the {@link ItagItem};
+ * - the {@link DeliveryMethod#DASH DASH delivery method}, for OTF streams, live streams
+ * and ended streams.
+ *
+ *
+ *
+ *
+ * Note that the {@link ItagItem} comes from an {@link ItagInfo} instance.
+ *
+ *
+ * @return a stream builder helper to build {@link AudioStream}s
+ */
+ @Nonnull
+ private java.util.function.Function getAudioStreamBuilderHelper() {
+ return (itagInfo) -> {
+ final ItagItem itagItem = itagInfo.getItagItem();
+ final AudioStream.Builder builder = new AudioStream.Builder()
+ .setId(String.valueOf(itagItem.id))
+ .setContent(itagInfo.getContent(), itagInfo.getIsUrl())
+ .setMediaFormat(itagItem.getMediaFormat())
+ .setAverageBitrate(itagItem.getAverageBitrate())
+ .setItagItem(itagItem);
- for (final Pair pair : streamingDataAndCpnLoopList) {
- urlAndItags.putAll(getStreamsFromStreamingDataKey(pair.getFirst(), streamingDataKey,
- itagTypeWanted, pair.getSecond()));
- }
+ if (streamType == StreamType.LIVE_STREAM
+ || streamType == StreamType.POST_LIVE_STREAM
+ || !itagInfo.getIsUrl()) {
+ // For YouTube videos on OTF streams and for all streams of post-live streams
+ // and live streams, only the DASH delivery method can be used.
+ builder.setDeliveryMethod(DeliveryMethod.DASH);
+ }
- return urlAndItags;
+ return builder.build();
+ };
+ }
+
+ /**
+ * Get the stream builder helper which will be used to build {@link VideoStream}s in
+ * {@link #getItags(String, ItagItem.ItagType, java.util.function.Function, String)}
+ *
+ *
+ * The {@code StreamBuilderHelper} will set the following attributes in the
+ * {@link VideoStream}s built:
+ *
+ * - the {@link ItagItem}'s id of the stream as its id;
+ * - {@link ItagInfo#getContent()} and {@link ItagInfo#getIsUrl()} as its content and
+ * and as the value of {@code isUrl};
+ * - the media format returned by the {@link ItagItem} as its media format;
+ * - whether it is video-only with the {@code areStreamsVideoOnly} parameter
+ * - the {@link ItagItem};
+ * - the resolution, by trying to use, in this order:
+ *
+ * - the height returned by the {@link ItagItem} + {@code p} + the frame rate if
+ * it is more than 30;
+ * - the default resolution string from the {@link ItagItem};
+ * - an {@link Utils#EMPTY_STRING empty string}.
+ *
+ *
+ * - the {@link DeliveryMethod#DASH DASH delivery method}, for OTF streams, live streams
+ * and ended streams.
+ *
+ *
+ *
+ * Note that the {@link ItagItem} comes from an {@link ItagInfo} instance.
+ *
+ *
+ * @param areStreamsVideoOnly whether the stream builder helper will set the video
+ * streams as video-only streams
+ * @return a stream builder helper to build {@link VideoStream}s
+ */
+ @Nonnull
+ private java.util.function.Function getVideoStreamBuilderHelper(
+ final boolean areStreamsVideoOnly) {
+ return (itagInfo) -> {
+ final ItagItem itagItem = itagInfo.getItagItem();
+ final VideoStream.Builder builder = new VideoStream.Builder()
+ .setId(String.valueOf(itagItem.id))
+ .setContent(itagInfo.getContent(), itagInfo.getIsUrl())
+ .setMediaFormat(itagItem.getMediaFormat())
+ .setIsVideoOnly(areStreamsVideoOnly)
+ .setItagItem(itagItem);
+
+ final String resolutionString = itagItem.getResolutionString();
+ builder.setResolution(resolutionString != null ? resolutionString
+ : EMPTY_STRING);
+
+ if (streamType != StreamType.VIDEO_STREAM || !itagInfo.getIsUrl()) {
+ // For YouTube videos on OTF streams and for all streams of post-live streams
+ // and live streams, only the DASH delivery method can be used.
+ builder.setDeliveryMethod(DeliveryMethod.DASH);
+ }
+
+ return builder.build();
+ };
}
@Nonnull
- private Map getStreamsFromStreamingDataKey(
+ private java.util.stream.Stream getStreamsFromStreamingDataKey(
+ final String videoId,
final JsonObject streamingData,
- @Nonnull final String streamingDataKey,
+ final String streamingDataKey,
@Nonnull final ItagItem.ItagType itagTypeWanted,
@Nonnull final String contentPlaybackNonce) {
if (streamingData == null || !streamingData.has(streamingDataKey)) {
- return Collections.emptyMap();
+ return java.util.stream.Stream.empty();
}
- final Map urlAndItagsFromStreamingDataObject = new LinkedHashMap<>();
- final JsonArray formats = streamingData.getArray(streamingDataKey);
- for (int i = 0; i < formats.size(); i++) {
- final JsonObject formatData = formats.getObject(i);
- final int itag = formatData.getInt("itag");
-
- if (!ItagItem.isSupported(itag)) {
- continue;
- }
-
- try {
- final ItagItem itagItem = ItagItem.getItag(itag);
- if (itagItem.itagType != itagTypeWanted) {
- continue;
- }
-
- // Ignore streams that are delivered using YouTube's OTF format,
- // as those only work with DASH and not with progressive HTTP.
- if ("FORMAT_STREAM_TYPE_OTF".equalsIgnoreCase(formatData.getString("type"))) {
- continue;
- }
-
- final String streamUrl;
- if (formatData.has("url")) {
- streamUrl = formatData.getString("url");
- } else {
- // This url has an obfuscated signature
- final String cipherString = formatData.has("cipher")
- ? formatData.getString("cipher")
- : formatData.getString("signatureCipher");
- final Map cipher = Parser.compatParseMap(
- cipherString);
- streamUrl = cipher.get("url") + "&" + cipher.get("sp") + "="
- + deobfuscateSignature(cipher.get("s"));
- }
-
- final JsonObject initRange = formatData.getObject("initRange");
- final JsonObject indexRange = formatData.getObject("indexRange");
- final String mimeType = formatData.getString("mimeType", EMPTY_STRING);
- final String codec = mimeType.contains("codecs")
- ? mimeType.split("\"")[1]
- : EMPTY_STRING;
-
- itagItem.setBitrate(formatData.getInt("bitrate"));
- itagItem.setWidth(formatData.getInt("width"));
- itagItem.setHeight(formatData.getInt("height"));
- itagItem.setInitStart(Integer.parseInt(initRange.getString("start", "-1")));
- itagItem.setInitEnd(Integer.parseInt(initRange.getString("end", "-1")));
- itagItem.setIndexStart(Integer.parseInt(indexRange.getString("start", "-1")));
- itagItem.setIndexEnd(Integer.parseInt(indexRange.getString("end", "-1")));
- itagItem.fps = formatData.getInt("fps");
- itagItem.setQuality(formatData.getString("quality"));
- itagItem.setCodec(codec);
-
- urlAndItagsFromStreamingDataObject.put(streamUrl, itagItem);
- } catch (final UnsupportedEncodingException | ParsingException ignored) {
- }
- }
- return urlAndItagsFromStreamingDataObject;
+ return streamingData.getArray(streamingDataKey).stream()
+ .filter(JsonObject.class::isInstance)
+ .map(JsonObject.class::cast)
+ .map(formatData -> {
+ try {
+ final ItagItem itagItem = ItagItem.getItag(formatData.getInt("itag"));
+ if (itagItem.itagType == itagTypeWanted) {
+ return buildAndAddItagInfoToList(videoId, formatData, itagItem,
+ itagItem.itagType, contentPlaybackNonce);
+ }
+ } catch (final IOException | ExtractionException ignored) {
+ // if the itag is not supported and getItag fails, we end up here
+ }
+ return null;
+ })
+ .filter(Objects::nonNull);
}
+ private ItagInfo buildAndAddItagInfoToList(
+ @Nonnull final String videoId,
+ @Nonnull final JsonObject formatData,
+ @Nonnull final ItagItem itagItem,
+ @Nonnull final ItagItem.ItagType itagType,
+ @Nonnull final String contentPlaybackNonce) throws IOException, ExtractionException {
+ String streamUrl;
+ if (formatData.has("url")) {
+ streamUrl = formatData.getString("url");
+ } else {
+ // This url has an obfuscated signature
+ final String cipherString = formatData.has(CIPHER)
+ ? formatData.getString(CIPHER)
+ : formatData.getString(SIGNATURE_CIPHER);
+ final Map cipher = Parser.compatParseMap(
+ cipherString);
+ streamUrl = cipher.get("url") + "&" + cipher.get("sp") + "="
+ + deobfuscateSignature(cipher.get("s"));
+ }
+
+ // Add the content playback nonce to the stream URL
+ streamUrl += "&" + CPN + "=" + contentPlaybackNonce;
+
+ // Decrypt the n parameter if it is present
+ streamUrl = tryDecryptUrl(streamUrl, videoId);
+
+ final JsonObject initRange = formatData.getObject("initRange");
+ final JsonObject indexRange = formatData.getObject("indexRange");
+ final String mimeType = formatData.getString("mimeType", EMPTY_STRING);
+ final String codec = mimeType.contains("codecs")
+ ? mimeType.split("\"")[1] : EMPTY_STRING;
+
+ itagItem.setBitrate(formatData.getInt("bitrate"));
+ itagItem.setWidth(formatData.getInt("width"));
+ itagItem.setHeight(formatData.getInt("height"));
+ itagItem.setInitStart(Integer.parseInt(initRange.getString("start", "-1")));
+ itagItem.setInitEnd(Integer.parseInt(initRange.getString("end", "-1")));
+ itagItem.setIndexStart(Integer.parseInt(indexRange.getString("start", "-1")));
+ itagItem.setIndexEnd(Integer.parseInt(indexRange.getString("end", "-1")));
+ itagItem.setQuality(formatData.getString("quality"));
+ itagItem.setCodec(codec);
+
+ if (streamType == StreamType.LIVE_STREAM || streamType == StreamType.POST_LIVE_STREAM) {
+ itagItem.setTargetDurationSec(formatData.getInt("targetDurationSec"));
+ } else if (itagType == ItagItem.ItagType.VIDEO
+ || itagType == ItagItem.ItagType.VIDEO_ONLY) {
+ itagItem.setFps(formatData.getInt("fps"));
+ } else if (itagType == ItagItem.ItagType.AUDIO) {
+ // YouTube return the audio sample rate as a string
+ itagItem.setSampleRate(Integer.parseInt(formatData.getString("audioSampleRate")));
+ itagItem.setAudioChannels(formatData.getInt("audioChannels"));
+ }
+
+ // YouTube return the content length and the approximate duration as strings
+ itagItem.setContentLength(Long.parseLong(formatData.getString("contentLength",
+ String.valueOf(CONTENT_LENGTH_UNKNOWN))));
+ itagItem.setApproxDurationMs(Long.parseLong(formatData.getString("approxDurationMs",
+ String.valueOf(APPROX_DURATION_MS_UNKNOWN))));
+
+ final ItagInfo itagInfo = new ItagInfo(streamUrl, itagItem);
+
+ if (streamType == StreamType.VIDEO_STREAM) {
+ itagInfo.setIsUrl(!formatData.getString("type", EMPTY_STRING)
+ .equalsIgnoreCase("FORMAT_STREAM_TYPE_OTF"));
+ } else {
+ // We are currently not able to generate DASH manifests for running
+ // livestreams, so because of the requirements of StreamInfo
+ // objects, return these streams as DASH URL streams (even if they
+ // are not playable).
+ // Ended livestreams are returned as non URL streams
+ itagInfo.setIsUrl(streamType != StreamType.POST_LIVE_STREAM);
+ }
+
+ return itagInfo;
+ }
@Nonnull
@Override
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/AudioStream.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/AudioStream.java
index c1cf2e0e1..59cf9a323 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/AudioStream.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/AudioStream.java
@@ -4,30 +4,35 @@ package org.schabi.newpipe.extractor.stream;
* Created by Christian Schabesberger on 04.03.16.
*
* Copyright (C) Christian Schabesberger 2016
- * AudioStream.java is part of NewPipe.
+ * AudioStream.java is part of NewPipe Extractor.
*
- * NewPipe is free software: you can redistribute it and/or modify
+ * NewPipe Extractor is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
- * NewPipe is distributed in the hope that it will be useful,
+ * NewPipe Extractor is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with NewPipe. If not, see .
+ * along with NewPipe Extractor. If not, see .
*/
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.services.youtube.ItagItem;
-public class AudioStream extends Stream {
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+public final class AudioStream extends Stream {
+ public static final int UNKNOWN_BITRATE = -1;
+
private final int averageBitrate;
- // Fields for Dash
- private int itag;
+ // Fields for DASH
+ private int itag = ITAG_NOT_AVAILABLE_OR_NOT_APPLICABLE;
private int bitrate;
private int initStart;
private int initEnd;
@@ -35,37 +40,241 @@ public class AudioStream extends Stream {
private int indexEnd;
private String quality;
private String codec;
+ @Nullable
+ private ItagItem itagItem;
/**
- * Create a new audio stream
- * @param url the url
- * @param format the format
- * @param averageBitrate the average bitrate
+ * Class to build {@link AudioStream} objects.
*/
- public AudioStream(final String url,
- final MediaFormat format,
- final int averageBitrate) {
- super(url, format);
+ @SuppressWarnings("checkstyle:hiddenField")
+ public static final class Builder {
+ private String id;
+ private String content;
+ private boolean isUrl;
+ private DeliveryMethod deliveryMethod = DeliveryMethod.PROGRESSIVE_HTTP;
+ @Nullable
+ private MediaFormat mediaFormat;
+ @Nullable
+ private String manifestUrl;
+ private int averageBitrate = UNKNOWN_BITRATE;
+ @Nullable
+ private ItagItem itagItem;
+
+ /**
+ * Create a new {@link Builder} instance with its default values.
+ */
+ public Builder() {
+ }
+
+ /**
+ * Set the identifier of the {@link AudioStream}.
+ *
+ *
+ * It must not be null and should be non empty.
+ *
+ *
+ *
+ * If you are not able to get an identifier, use the static constant {@link
+ * Stream#ID_UNKNOWN ID_UNKNOWN} of the {@link Stream} class.
+ *
+ *
+ * @param id the identifier of the {@link AudioStream}, which must not be null
+ * @return this {@link Builder} instance
+ */
+ public Builder setId(@Nonnull final String id) {
+ this.id = id;
+ return this;
+ }
+
+ /**
+ * Set the content of the {@link AudioStream}.
+ *
+ *
+ * It must not be null, and should be non empty.
+ *
+ *
+ * @param content the content of the {@link AudioStream}
+ * @param isUrl whether the content is a URL
+ * @return this {@link Builder} instance
+ */
+ public Builder setContent(@Nonnull final String content,
+ final boolean isUrl) {
+ this.content = content;
+ this.isUrl = isUrl;
+ return this;
+ }
+
+ /**
+ * Set the {@link MediaFormat} used by the {@link AudioStream}.
+ *
+ *
+ * It should be one of the audio {@link MediaFormat}s ({@link MediaFormat#M4A M4A},
+ * {@link MediaFormat#WEBMA WEBMA}, {@link MediaFormat#MP3 MP3}, {@link MediaFormat#OPUS
+ * OPUS}, {@link MediaFormat#OGG OGG}, or {@link MediaFormat#WEBMA_OPUS WEBMA_OPUS}) but
+ * can be {@code null} if the media format could not be determined.
+ *
+ *
+ *
+ * The default value is {@code null}.
+ *
+ *
+ * @param mediaFormat the {@link MediaFormat} of the {@link AudioStream}, which can be null
+ * @return this {@link Builder} instance
+ */
+ public Builder setMediaFormat(@Nullable final MediaFormat mediaFormat) {
+ this.mediaFormat = mediaFormat;
+ return this;
+ }
+
+ /**
+ * Set the {@link DeliveryMethod} of the {@link AudioStream}.
+ *
+ *
+ * It must not be null.
+ *
+ *
+ *
+ * The default delivery method is {@link DeliveryMethod#PROGRESSIVE_HTTP}.
+ *
+ *
+ * @param deliveryMethod the {@link DeliveryMethod} of the {@link AudioStream}, which must
+ * not be null
+ * @return this {@link Builder} instance
+ */
+ public Builder setDeliveryMethod(@Nonnull final DeliveryMethod deliveryMethod) {
+ this.deliveryMethod = deliveryMethod;
+ return this;
+ }
+
+ /**
+ * Sets the URL of the manifest this stream comes from (if applicable, otherwise null).
+ *
+ * @param manifestUrl the URL of the manifest this stream comes from or {@code null}
+ * @return this {@link Builder} instance
+ */
+ public Builder setManifestUrl(@Nullable final String manifestUrl) {
+ this.manifestUrl = manifestUrl;
+ return this;
+ }
+
+ /**
+ * Set the average bitrate of the {@link AudioStream}.
+ *
+ *
+ * The default value is {@link #UNKNOWN_BITRATE}.
+ *
+ *
+ * @param averageBitrate the average bitrate of the {@link AudioStream}, which should
+ * positive
+ * @return this {@link Builder} instance
+ */
+ public Builder setAverageBitrate(final int averageBitrate) {
+ this.averageBitrate = averageBitrate;
+ return this;
+ }
+
+ /**
+ * Set the {@link ItagItem} corresponding to the {@link AudioStream}.
+ *
+ *
+ * {@link ItagItem}s are YouTube specific objects, so they are only known for this service
+ * and can be null.
+ *
+ *
+ *
+ * The default value is {@code null}.
+ *
+ *
+ * @param itagItem the {@link ItagItem} of the {@link AudioStream}, which can be null
+ * @return this {@link Builder} instance
+ */
+ public Builder setItagItem(@Nullable final ItagItem itagItem) {
+ this.itagItem = itagItem;
+ return this;
+ }
+
+ /**
+ * Build an {@link AudioStream} using the builder's current values.
+ *
+ *
+ * The identifier and the content (and so the {@code isUrl} boolean) properties must have
+ * been set.
+ *
+ *
+ * @return a new {@link AudioStream} using the builder's current values
+ * @throws IllegalStateException if {@code id}, {@code content} (and so {@code isUrl}) or
+ * {@code deliveryMethod} have been not set, or have been set as {@code null}
+ */
+ @Nonnull
+ public AudioStream build() {
+ if (id == null) {
+ throw new IllegalStateException(
+ "The identifier of the audio stream has been not set or is null. If you "
+ + "are not able to get an identifier, use the static constant "
+ + "ID_UNKNOWN of the Stream class.");
+ }
+
+ if (content == null) {
+ throw new IllegalStateException("The content of the audio stream has been not set "
+ + "or is null. Please specify a non-null one with setContent.");
+ }
+
+ if (deliveryMethod == null) {
+ throw new IllegalStateException(
+ "The delivery method of the audio stream has been set as null, which is "
+ + "not allowed. Pass a valid one instead with setDeliveryMethod.");
+ }
+
+ return new AudioStream(id, content, isUrl, mediaFormat, deliveryMethod, averageBitrate,
+ manifestUrl, itagItem);
+ }
+ }
+
+
+ /**
+ * Create a new audio stream.
+ *
+ * @param id the identifier which uniquely identifies the stream, e.g. for YouTube
+ * this would be the itag
+ * @param content the content or the URL of the stream, depending on whether isUrl is
+ * true
+ * @param isUrl whether content is the URL or the actual content of e.g. a DASH
+ * manifest
+ * @param format the {@link MediaFormat} used by the stream, which can be null
+ * @param deliveryMethod the {@link DeliveryMethod} of the stream
+ * @param averageBitrate the average bitrate of the stream (which can be unknown, see
+ * {@link #UNKNOWN_BITRATE})
+ * @param itagItem the {@link ItagItem} corresponding to the stream, which cannot be null
+ * @param manifestUrl the URL of the manifest this stream comes from (if applicable,
+ * otherwise null)
+ */
+ @SuppressWarnings("checkstyle:ParameterNumber")
+ private AudioStream(@Nonnull final String id,
+ @Nonnull final String content,
+ final boolean isUrl,
+ @Nullable final MediaFormat format,
+ @Nonnull final DeliveryMethod deliveryMethod,
+ final int averageBitrate,
+ @Nullable final String manifestUrl,
+ @Nullable final ItagItem itagItem) {
+ super(id, content, isUrl, format, deliveryMethod, manifestUrl);
+ if (itagItem != null) {
+ this.itagItem = itagItem;
+ this.itag = itagItem.id;
+ this.quality = itagItem.getQuality();
+ this.bitrate = itagItem.getBitrate();
+ this.initStart = itagItem.getInitStart();
+ this.initEnd = itagItem.getInitEnd();
+ this.indexStart = itagItem.getIndexStart();
+ this.indexEnd = itagItem.getIndexEnd();
+ this.codec = itagItem.getCodec();
+ }
this.averageBitrate = averageBitrate;
}
/**
- * Create a new audio stream
- * @param url the url
- * @param itag the ItagItem of the Stream
+ * {@inheritDoc}
*/
- public AudioStream(final String url, final ItagItem itag) {
- this(url, itag.getMediaFormat(), itag.avgBitrate);
- this.itag = itag.id;
- this.quality = itag.getQuality();
- this.bitrate = itag.getBitrate();
- this.initStart = itag.getInitStart();
- this.initEnd = itag.getInitEnd();
- this.indexStart = itag.getIndexStart();
- this.indexEnd = itag.getIndexEnd();
- this.codec = itag.getCodec();
- }
-
@Override
public boolean equalStats(final Stream cmp) {
return super.equalStats(cmp) && cmp instanceof AudioStream
@@ -73,42 +282,102 @@ public class AudioStream extends Stream {
}
/**
- * Get the average bitrate
- * @return the average bitrate or -1
+ * Get the average bitrate of the stream.
+ *
+ * @return the average bitrate or {@link #UNKNOWN_BITRATE} if it is unknown
*/
public int getAverageBitrate() {
return averageBitrate;
}
+ /**
+ * Get the itag identifier of the stream.
+ *
+ *
+ * Always equals to {@link #ITAG_NOT_AVAILABLE_OR_NOT_APPLICABLE} for other streams than the
+ * ones of the YouTube service.
+ *
+ *
+ * @return the number of the {@link ItagItem} passed in the constructor of the audio stream.
+ */
public int getItag() {
return itag;
}
+ /**
+ * Get the bitrate of the stream.
+ *
+ * @return the bitrate set from the {@link ItagItem} passed in the constructor of the stream.
+ */
public int getBitrate() {
return bitrate;
}
+ /**
+ * Get the initialization start of the stream.
+ *
+ * @return the initialization start value set from the {@link ItagItem} passed in the
+ * constructor of the stream.
+ */
public int getInitStart() {
return initStart;
}
+ /**
+ * Get the initialization end of the stream.
+ *
+ * @return the initialization end value set from the {@link ItagItem} passed in the constructor
+ * of the stream.
+ */
public int getInitEnd() {
return initEnd;
}
+ /**
+ * Get the index start of the stream.
+ *
+ * @return the index start value set from the {@link ItagItem} passed in the constructor of the
+ * stream.
+ */
public int getIndexStart() {
return indexStart;
}
+ /**
+ * Get the index end of the stream.
+ *
+ * @return the index end value set from the {@link ItagItem} passed in the constructor of the
+ * stream.
+ */
public int getIndexEnd() {
return indexEnd;
}
+ /**
+ * Get the quality of the stream.
+ *
+ * @return the quality label set from the {@link ItagItem} passed in the constructor of the
+ * stream.
+ */
public String getQuality() {
return quality;
}
+ /**
+ * Get the codec of the stream.
+ *
+ * @return the codec set from the {@link ItagItem} passed in the constructor of the stream.
+ */
public String getCodec() {
return codec;
}
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ @Nullable
+ public ItagItem getItagItem() {
+ return itagItem;
+ }
}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/DeliveryMethod.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/DeliveryMethod.java
new file mode 100644
index 000000000..ed9893572
--- /dev/null
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/DeliveryMethod.java
@@ -0,0 +1,53 @@
+package org.schabi.newpipe.extractor.stream;
+
+/**
+ * An enum to represent the different delivery methods of {@link Stream streams} which are returned
+ * by the extractor.
+ */
+public enum DeliveryMethod {
+
+ /**
+ * Used for {@link Stream}s served using the progressive HTTP streaming method.
+ */
+ PROGRESSIVE_HTTP,
+
+ /**
+ * Used for {@link Stream}s served using the DASH (Dynamic Adaptive Streaming over HTTP)
+ * adaptive streaming method.
+ *
+ * @see the
+ * Dynamic Adaptive Streaming over HTTP Wikipedia page and
+ * DASH Industry Forum's website for more information about the DASH delivery method
+ */
+ DASH,
+
+ /**
+ * Used for {@link Stream}s served using the HLS (HTTP Live Streaming) adaptive streaming
+ * method.
+ *
+ * @see the HTTP Live Streaming
+ * page and Apple's developers website page
+ * about HTTP Live Streaming for more information about the HLS delivery method
+ */
+ HLS,
+
+ /**
+ * Used for {@link Stream}s served using the SmoothStreaming adaptive streaming method.
+ *
+ * @see Wikipedia's page about adaptive bitrate streaming,
+ * section Microsoft Smooth Streaming (MSS) for more information about the
+ * SmoothStreaming delivery method
+ */
+ SS,
+
+ /**
+ * Used for {@link Stream}s served via a torrent file.
+ *
+ * @see Wikipedia's BitTorrent's page,
+ * Wikipedia's page about torrent files
+ * and Bitorrent's website for more information
+ * about the BitTorrent protocol
+ */
+ TORRENT
+}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/Stream.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/Stream.java
index 5b827c159..04d2b3fac 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/Stream.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/Stream.java
@@ -1,68 +1,72 @@
package org.schabi.newpipe.extractor.stream;
-import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
-
import org.schabi.newpipe.extractor.MediaFormat;
+import org.schabi.newpipe.extractor.services.youtube.ItagItem;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import java.io.Serializable;
import java.util.List;
+import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
+
/**
- * Creates a stream object from url, format and optional torrent url
+ * Abstract class which represents streams in the extractor.
*/
public abstract class Stream implements Serializable {
- private final MediaFormat mediaFormat;
- private final String url;
- private final String torrentUrl;
+ public static final int FORMAT_ID_UNKNOWN = -1;
+ public static final String ID_UNKNOWN = " ";
/**
- * @deprecated Use {@link #getFormat()} or {@link #getFormatId()}
- */
- @Deprecated
- public final int format;
-
- /**
- * Instantiates a new stream object.
+ * An integer to represent that the itag ID returned is not available (only for YouTube; this
+ * should never happen) or not applicable (for other services than YouTube).
*
- * @param url the url
- * @param format the format
+ *
+ * An itag should not have a negative value, so {@code -1} is used for this constant.
+ *
*/
- public Stream(final String url, final MediaFormat format) {
- this(url, null, format);
- }
+ public static final int ITAG_NOT_AVAILABLE_OR_NOT_APPLICABLE = -1;
+
+ private final String id;
+ @Nullable private final MediaFormat mediaFormat;
+ private final String content;
+ private final boolean isUrl;
+ private final DeliveryMethod deliveryMethod;
+ @Nullable private final String manifestUrl;
/**
- * Instantiates a new stream object.
+ * Instantiates a new {@code Stream} object.
*
- * @param url the url
- * @param torrentUrl the url to torrent file, example
- * https://webtorrent.io/torrents/big-buck-bunny.torrent
- * @param format the format
+ * @param id the identifier which uniquely identifies the file, e.g. for YouTube
+ * this would be the itag
+ * @param content the content or URL, depending on whether isUrl is true
+ * @param isUrl whether content is the URL or the actual content of e.g. a DASH
+ * manifest
+ * @param format the {@link MediaFormat}, which can be null
+ * @param deliveryMethod the delivery method of the stream
+ * @param manifestUrl the URL of the manifest this stream comes from (if applicable,
+ * otherwise null)
*/
- public Stream(final String url, final String torrentUrl, final MediaFormat format) {
- this.url = url;
- this.torrentUrl = torrentUrl;
- //noinspection deprecation
- this.format = format.id;
+ public Stream(final String id,
+ final String content,
+ final boolean isUrl,
+ @Nullable final MediaFormat format,
+ final DeliveryMethod deliveryMethod,
+ @Nullable final String manifestUrl) {
+ this.id = id;
+ this.content = content;
+ this.isUrl = isUrl;
this.mediaFormat = format;
+ this.deliveryMethod = deliveryMethod;
+ this.manifestUrl = manifestUrl;
}
/**
- * Reveals whether two streams have the same stats (format and bitrate, for example)
- */
- public boolean equalStats(final Stream cmp) {
- return cmp != null && getFormatId() == cmp.getFormatId();
- }
-
- /**
- * Reveals whether two Streams are equal
- */
- public boolean equals(final Stream cmp) {
- return equalStats(cmp) && url.equals(cmp.url);
- }
-
- /**
- * Check if the list already contains one stream with equals stats
+ * Checks if the list already contains a stream with the same statistics.
+ *
+ * @param stream the stream to be compared against the streams in the stream list
+ * @param streamList the list of {@link Stream}s which will be compared
+ * @return whether the list already contains one stream with equals stats
*/
public static boolean containSimilarStream(final Stream stream,
final List extends Stream> streamList) {
@@ -78,38 +82,126 @@ public abstract class Stream implements Serializable {
}
/**
- * Gets the url.
+ * Reveals whether two streams have the same statistics ({@link MediaFormat media format} and
+ * {@link DeliveryMethod delivery method}).
*
- * @return the url
+ *
+ * If the {@link MediaFormat media format} of the stream is unknown, the streams are compared
+ * by using only the {@link DeliveryMethod delivery method} and their ID.
+ *
+ *
+ *
+ * Note: This method always returns false if the stream passed is null.
+ *
+ *
+ * @param other the stream object to be compared to this stream object
+ * @return whether the stream have the same stats or not, based on the criteria above
*/
+ public boolean equalStats(@Nullable final Stream other) {
+ if (other == null || mediaFormat == null || other.mediaFormat == null) {
+ return false;
+ }
+ return mediaFormat.id == other.mediaFormat.id && deliveryMethod == other.deliveryMethod
+ && isUrl == other.isUrl;
+ }
+
+ /**
+ * Gets the identifier of this stream, e.g. the itag for YouTube.
+ *
+ *
+ * It should normally be unique, but {@link #ID_UNKNOWN} may be returned as the identifier if
+ * the one used by the stream extractor cannot be extracted, which could happen if the
+ * extractor uses a value from a streaming service.
+ *
+ *
+ * @return the identifier (which may be {@link #ID_UNKNOWN})
+ */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * Gets the URL of this stream if the content is a URL, or {@code null} otherwise.
+ *
+ * @return the URL if the content is a URL, {@code null} otherwise
+ * @deprecated Use {@link #getContent()} instead.
+ */
+ @Deprecated
+ @Nullable
public String getUrl() {
- return url;
+ return isUrl ? content : null;
}
/**
- * Gets the torrent url.
+ * Gets the content or URL.
*
- * @return the torrent url, example https://webtorrent.io/torrents/big-buck-bunny.torrent
+ * @return the content or URL
*/
- public String getTorrentUrl() {
- return torrentUrl;
+ public String getContent() {
+ return content;
}
/**
- * Gets the format.
+ * Returns whether the content is a URL or not.
+ *
+ * @return {@code true} if the content of this stream is a URL, {@code false} if it's the
+ * actual content
+ */
+ public boolean isUrl() {
+ return isUrl;
+ }
+
+ /**
+ * Gets the {@link MediaFormat}, which can be null.
*
* @return the format
*/
+ @Nullable
public MediaFormat getFormat() {
return mediaFormat;
}
/**
- * Gets the format id.
+ * Gets the format ID, which can be unknown.
*
- * @return the format id
+ * @return the format ID or {@link #FORMAT_ID_UNKNOWN}
*/
public int getFormatId() {
- return mediaFormat.id;
+ if (mediaFormat != null) {
+ return mediaFormat.id;
+ }
+ return FORMAT_ID_UNKNOWN;
}
+
+ /**
+ * Gets the {@link DeliveryMethod}.
+ *
+ * @return the delivery method
+ */
+ @Nonnull
+ public DeliveryMethod getDeliveryMethod() {
+ return deliveryMethod;
+ }
+
+ /**
+ * Gets the URL of the manifest this stream comes from (if applicable, otherwise null).
+ *
+ * @return the URL of the manifest this stream comes from or {@code null}
+ */
+ @Nullable
+ public String getManifestUrl() {
+ return manifestUrl;
+ }
+
+ /**
+ * Gets the {@link ItagItem} of a stream.
+ *
+ *
+ * If the stream is not from YouTube, this value will always be null.
+ *
+ *
+ * @return the {@link ItagItem} of the stream or {@code null}
+ */
+ @Nullable
+ public abstract ItagItem getItagItem();
}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamInfo.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamInfo.java
index fd5a55d75..8d3f2c522 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamInfo.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamInfo.java
@@ -9,7 +9,6 @@ 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.localization.DateWrapper;
-import org.schabi.newpipe.extractor.utils.DashMpdParser;
import org.schabi.newpipe.extractor.utils.ExtractorHelper;
import java.io.IOException;
@@ -26,24 +25,24 @@ import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
* Created by Christian Schabesberger on 26.08.15.
*
* Copyright (C) Christian Schabesberger 2016
- * StreamInfo.java is part of NewPipe.
+ * StreamInfo.java is part of NewPipe Extractor.
*
- * NewPipe is free software: you can redistribute it and/or modify
+ * NewPipe Extractor is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
- * NewPipe is distributed in the hope that it will be useful,
+ * NewPipe Extractor is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with NewPipe. If not, see .
+ * along with NewPipe Extractor. If not, see .
*/
/**
- * Info object for opened videos, ie the video ready to play.
+ * Info object for opened contents, i.e. the content ready to play.
*/
public class StreamInfo extends Info {
@@ -69,27 +68,26 @@ public class StreamInfo extends Info {
return getInfo(NewPipe.getServiceByUrl(url), url);
}
- public static StreamInfo getInfo(final StreamingService service,
+ public static StreamInfo getInfo(@Nonnull final StreamingService service,
final String url) throws IOException, ExtractionException {
return getInfo(service.getStreamExtractor(url));
}
- public static StreamInfo getInfo(final StreamExtractor extractor)
+ public static StreamInfo getInfo(@Nonnull final StreamExtractor extractor)
throws ExtractionException, IOException {
extractor.fetchPage();
+ final StreamInfo streamInfo;
try {
- final StreamInfo streamInfo = extractImportantData(extractor);
+ streamInfo = extractImportantData(extractor);
extractStreams(streamInfo, extractor);
extractOptionalData(streamInfo, extractor);
return streamInfo;
} catch (final ExtractionException e) {
- // Currently YouTube does not distinguish between age restricted videos and
- // videos blocked
- // by country. This means that during the initialisation of the extractor, the
- // extractor
- // will assume that a video is age restricted while in reality it it blocked by
- // country.
+ // Currently, YouTube does not distinguish between age restricted videos and videos
+ // blocked by country. This means that during the initialisation of the extractor, the
+ // extractor will assume that a video is age restricted while in reality it is blocked
+ // by country.
//
// We will now detect whether the video is blocked by country or not.
@@ -102,22 +100,27 @@ public class StreamInfo extends Info {
}
}
- private static StreamInfo extractImportantData(final StreamExtractor extractor)
+ @Nonnull
+ private static StreamInfo extractImportantData(@Nonnull final StreamExtractor extractor)
throws ExtractionException {
- /* ---- important data, without the video can't be displayed goes here: ---- */
- // if one of these is not available an exception is meant to be thrown directly
- // into the frontend.
+ // Important data, without it the content can't be displayed.
+ // If one of these is not available, the frontend will receive an exception directly.
+ final int serviceId = extractor.getServiceId();
final String url = extractor.getUrl();
+ final String originalUrl = extractor.getOriginalUrl();
final StreamType streamType = extractor.getStreamType();
final String id = extractor.getId();
final String name = extractor.getName();
final int ageLimit = extractor.getAgeLimit();
- // suppress always-non-null warning as here we double-check it really is not null
+ // Suppress always-non-null warning as here we double-check it really is not null
//noinspection ConstantConditions
- if (streamType == StreamType.NONE || isNullOrEmpty(url) || isNullOrEmpty(id)
- || name == null /* but it can be empty of course */ || ageLimit == -1) {
+ if (streamType == StreamType.NONE
+ || isNullOrEmpty(url)
+ || isNullOrEmpty(id)
+ || name == null /* but it can be empty of course */
+ || ageLimit == -1) {
throw new ExtractionException("Some important stream information was not given.");
}
@@ -125,16 +128,18 @@ public class StreamInfo extends Info {
streamType, id, name, ageLimit);
}
- private static void extractStreams(final StreamInfo streamInfo, final StreamExtractor extractor)
+
+ private static void extractStreams(final StreamInfo streamInfo,
+ final StreamExtractor extractor)
throws ExtractionException {
- /* ---- stream extraction goes here ---- */
- // At least one type of stream has to be available,
- // otherwise an exception will be thrown directly into the frontend.
+ /* ---- Stream extraction goes here ---- */
+ // At least one type of stream has to be available, otherwise an exception will be thrown
+ // directly into the frontend.
try {
streamInfo.setDashMpdUrl(extractor.getDashMpdUrl());
} catch (final Exception e) {
- streamInfo.addError(new ExtractionException("Couldn't get Dash manifest", e));
+ streamInfo.addError(new ExtractionException("Couldn't get DASH manifest", e));
}
try {
@@ -151,12 +156,14 @@ public class StreamInfo extends Info {
} catch (final Exception e) {
streamInfo.addError(new ExtractionException("Couldn't get audio streams", e));
}
+
/* Extract video stream url */
try {
streamInfo.setVideoStreams(extractor.getVideoStreams());
} catch (final Exception e) {
streamInfo.addError(new ExtractionException("Couldn't get video streams", e));
}
+
/* Extract video only stream url */
try {
streamInfo.setVideoOnlyStreams(extractor.getVideoOnlyStreams());
@@ -164,7 +171,7 @@ public class StreamInfo extends Info {
streamInfo.addError(new ExtractionException("Couldn't get video only streams", e));
}
- // Lists can be null if a exception was thrown during extraction
+ // Lists can be null if an exception was thrown during extraction
if (streamInfo.getVideoStreams() == null) {
streamInfo.setVideoStreams(Collections.emptyList());
}
@@ -175,37 +182,9 @@ public class StreamInfo extends Info {
streamInfo.setAudioStreams(Collections.emptyList());
}
- Exception dashMpdError = null;
- if (!isNullOrEmpty(streamInfo.getDashMpdUrl())) {
- try {
- final DashMpdParser.ParserResult result = DashMpdParser.getStreams(streamInfo);
- streamInfo.getVideoOnlyStreams().addAll(result.getVideoOnlyStreams());
- streamInfo.getAudioStreams().addAll(result.getAudioStreams());
- streamInfo.getVideoStreams().addAll(result.getVideoStreams());
- streamInfo.segmentedVideoOnlyStreams = result.getSegmentedVideoOnlyStreams();
- streamInfo.segmentedAudioStreams = result.getSegmentedAudioStreams();
- streamInfo.segmentedVideoStreams = result.getSegmentedVideoStreams();
- } catch (final Exception e) {
- // Sometimes we receive 403 (forbidden) error when trying to download the
- // manifest (similar to what happens with youtube-dl),
- // just skip the exception (but store it somewhere), as we later check if we
- // have streams anyway.
- dashMpdError = e;
- }
- }
-
- // Either audio or video has to be available, otherwise we didn't get a stream
- // (since videoOnly are optional, they don't count).
+ // Either audio or video has to be available, otherwise we didn't get a stream (since
+ // videoOnly are optional, they don't count).
if ((streamInfo.videoStreams.isEmpty()) && (streamInfo.audioStreams.isEmpty())) {
-
- if (dashMpdError != null) {
- // If we don't have any video or audio and the dashMpd 'errored', add it to the
- // error list
- // (it's optional and it don't get added automatically, but it's good to have
- // some additional error context)
- streamInfo.addError(dashMpdError);
- }
-
throw new StreamExtractException(
"Could not get any stream. See error variable to get further details.");
}
@@ -214,11 +193,9 @@ public class StreamInfo extends Info {
@SuppressWarnings("MethodLength")
private static void extractOptionalData(final StreamInfo streamInfo,
final StreamExtractor extractor) {
- /* ---- optional data goes here: ---- */
- // If one of these fails, the frontend needs to handle that they are not
- // available.
- // Exceptions are therefore not thrown into the frontend, but stored into the
- // error List,
+ /* ---- Optional data goes here: ---- */
+ // If one of these fails, the frontend needs to handle that they are not available.
+ // Exceptions are therefore not thrown into the frontend, but stored into the error list,
// so the frontend can afterwards check where errors happened.
try {
@@ -314,7 +291,7 @@ public class StreamInfo extends Info {
streamInfo.addError(e);
}
- //additional info
+ // Additional info
try {
streamInfo.setHost(extractor.getHost());
} catch (final Exception e) {
@@ -360,15 +337,14 @@ public class StreamInfo extends Info {
} catch (final Exception e) {
streamInfo.addError(e);
}
-
try {
streamInfo.setPreviewFrames(extractor.getFrames());
} catch (final Exception e) {
streamInfo.addError(e);
}
- streamInfo
- .setRelatedItems(ExtractorHelper.getRelatedItemsOrLogError(streamInfo, extractor));
+ streamInfo.setRelatedItems(ExtractorHelper.getRelatedItemsOrLogError(streamInfo,
+ extractor));
}
private StreamType streamType;
@@ -398,11 +374,6 @@ public class StreamInfo extends Info {
private List videoOnlyStreams = new ArrayList<>();
private String dashMpdUrl = "";
- private List segmentedVideoStreams = new ArrayList<>();
- private List segmentedAudioStreams = new ArrayList<>();
- private List segmentedVideoOnlyStreams = new ArrayList<>();
-
-
private String hlsUrl = "";
private List relatedItems = new ArrayList<>();
@@ -625,30 +596,6 @@ public class StreamInfo extends Info {
this.dashMpdUrl = dashMpdUrl;
}
- public List getSegmentedVideoStreams() {
- return segmentedVideoStreams;
- }
-
- public void setSegmentedVideoStreams(final List segmentedVideoStreams) {
- this.segmentedVideoStreams = segmentedVideoStreams;
- }
-
- public List getSegmentedAudioStreams() {
- return segmentedAudioStreams;
- }
-
- public void setSegmentedAudioStreams(final List segmentedAudioStreams) {
- this.segmentedAudioStreams = segmentedAudioStreams;
- }
-
- public List getSegmentedVideoOnlyStreams() {
- return segmentedVideoOnlyStreams;
- }
-
- public void setSegmentedVideoOnlyStreams(final List segmentedVideoOnlyStreams) {
- this.segmentedVideoOnlyStreams = segmentedVideoOnlyStreams;
- }
-
public String getHlsUrl() {
return hlsUrl;
}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamType.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamType.java
index 2d6b9a571..7e668cbd4 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamType.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/StreamType.java
@@ -1,10 +1,74 @@
package org.schabi.newpipe.extractor.stream;
+/**
+ * An enum representing the stream type of a {@link StreamInfo} extracted by a {@link
+ * StreamExtractor}.
+ */
public enum StreamType {
- NONE, // placeholder to check if stream type was checked or not
+
+ /**
+ * Placeholder to check if the stream type was checked or not. It doesn't make sense to use this
+ * enum constant outside of the extractor as it will never be returned by an {@link
+ * org.schabi.newpipe.extractor.Extractor} and is only used internally.
+ */
+ NONE,
+
+ /**
+ * A normal video stream, usually with audio. Note that the {@link StreamInfo} can also
+ * provide audio-only {@link AudioStream}s in addition to video or video-only {@link
+ * VideoStream}s.
+ */
VIDEO_STREAM,
+
+ /**
+ * An audio-only stream. There should be no {@link VideoStream}s available! In order to prevent
+ * unexpected behaviors, when {@link StreamExtractor}s return this stream type, they should
+ * ensure that no video stream is returned in {@link StreamExtractor#getVideoStreams()} and
+ * {@link StreamExtractor#getVideoOnlyStreams()}.
+ */
AUDIO_STREAM,
+
+ /**
+ * A video live stream, usually with audio. Note that the {@link StreamInfo} can also
+ * provide audio-only {@link AudioStream}s in addition to video or video-only {@link
+ * VideoStream}s.
+ */
LIVE_STREAM,
+
+ /**
+ * An audio-only live stream. There should be no {@link VideoStream}s available! In order to
+ * prevent unexpected behaviors, when {@link StreamExtractor}s return this stream type, they
+ * should ensure that no video stream is returned in {@link StreamExtractor#getVideoStreams()}
+ * and {@link StreamExtractor#getVideoOnlyStreams()}.
+ */
AUDIO_LIVE_STREAM,
- FILE
+
+ /**
+ * A video live stream that has just ended but has not yet been encoded into a normal video
+ * stream. Note that the {@link StreamInfo} can also provide audio-only {@link
+ * AudioStream}s in addition to video or video-only {@link VideoStream}s.
+ *
+ *
+ * Note that most of the content of an ended live video (or audio) may be extracted as {@link
+ * #VIDEO_STREAM regular video contents} (or {@link #AUDIO_STREAM regular audio contents})
+ * later, because the service may encode them again later as normal video/audio streams. That's
+ * the case on YouTube, for example.
+ *
+ */
+ POST_LIVE_STREAM,
+
+ /**
+ * An audio live stream that has just ended but has not yet been encoded into a normal audio
+ * stream. There should be no {@link VideoStream}s available! In order to prevent unexpected
+ * behaviors, when {@link StreamExtractor}s return this stream type, they should ensure that no
+ * video stream is returned in {@link StreamExtractor#getVideoStreams()} and
+ * {@link StreamExtractor#getVideoOnlyStreams()}.
+ *
+ *
+ * Note that most of ended live audio streams extracted with this value are processed as
+ * {@link #AUDIO_STREAM regular audio streams} later, because the service may encode them
+ * again later.
+ *
+ */
+ POST_LIVE_AUDIO_STREAM
}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/SubtitlesStream.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/SubtitlesStream.java
index 0ac01a89c..796264d41 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/SubtitlesStream.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/SubtitlesStream.java
@@ -1,53 +1,286 @@
package org.schabi.newpipe.extractor.stream;
import org.schabi.newpipe.extractor.MediaFormat;
+import org.schabi.newpipe.extractor.services.youtube.ItagItem;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
-import java.io.Serializable;
import java.util.Locale;
-public class SubtitlesStream extends Stream implements Serializable {
+import static org.schabi.newpipe.extractor.utils.Utils.EMPTY_STRING;
+
+public final class SubtitlesStream extends Stream {
private final MediaFormat format;
private final Locale locale;
private final boolean autoGenerated;
private final String code;
- public SubtitlesStream(final MediaFormat format,
- final String languageCode,
- final String url,
- final boolean autoGenerated) {
- super(url, format);
+ /**
+ * Class to build {@link SubtitlesStream} objects.
+ */
+ @SuppressWarnings("checkstyle:HiddenField")
+ public static final class Builder {
+ private String id;
+ private String content;
+ private boolean isUrl;
+ private DeliveryMethod deliveryMethod = DeliveryMethod.PROGRESSIVE_HTTP;
+ @Nullable
+ private MediaFormat mediaFormat;
+ @Nullable
+ private String manifestUrl;
+ private String languageCode;
+ // Use of the Boolean class instead of the primitive type needed for setter call check
+ private Boolean autoGenerated;
+
+ /**
+ * Create a new {@link Builder} instance with default values.
+ */
+ public Builder() {
+ }
+
+ /**
+ * Set the identifier of the {@link SubtitlesStream}.
+ *
+ * @param id the identifier of the {@link SubtitlesStream}, which should not be null
+ * (otherwise the fallback to create the identifier will be used when building
+ * the builder)
+ * @return this {@link Builder} instance
+ */
+ public Builder setId(@Nonnull final String id) {
+ this.id = id;
+ return this;
+ }
+
+ /**
+ * Set the content of the {@link SubtitlesStream}.
+ *
+ *
+ * It must not be null, and should be non empty.
+ *
+ *
+ * @param content the content of the {@link SubtitlesStream}, which must not be null
+ * @param isUrl whether the content is a URL
+ * @return this {@link Builder} instance
+ */
+ public Builder setContent(@Nonnull final String content,
+ final boolean isUrl) {
+ this.content = content;
+ this.isUrl = isUrl;
+ return this;
+ }
+
+ /**
+ * Set the {@link MediaFormat} used by the {@link SubtitlesStream}.
+ *
+ *
+ * It should be one of the subtitles {@link MediaFormat}s ({@link MediaFormat#SRT SRT},
+ * {@link MediaFormat#TRANSCRIPT1 TRANSCRIPT1}, {@link MediaFormat#TRANSCRIPT2
+ * TRANSCRIPT2}, {@link MediaFormat#TRANSCRIPT3 TRANSCRIPT3}, {@link MediaFormat#TTML
+ * TTML}, or {@link MediaFormat#VTT VTT}) but can be {@code null} if the media format could
+ * not be determined.
+ *
+ *
+ *
+ * The default value is {@code null}.
+ *
+ *
+ * @param mediaFormat the {@link MediaFormat} of the {@link SubtitlesStream}, which can be
+ * null
+ * @return this {@link Builder} instance
+ */
+ public Builder setMediaFormat(@Nullable final MediaFormat mediaFormat) {
+ this.mediaFormat = mediaFormat;
+ return this;
+ }
+
+ /**
+ * Set the {@link DeliveryMethod} of the {@link SubtitlesStream}.
+ *
+ *
+ * It must not be null.
+ *
+ *
+ *
+ * The default delivery method is {@link DeliveryMethod#PROGRESSIVE_HTTP}.
+ *
+ *
+ * @param deliveryMethod the {@link DeliveryMethod} of the {@link SubtitlesStream}, which
+ * must not be null
+ * @return this {@link Builder} instance
+ */
+ public Builder setDeliveryMethod(@Nonnull final DeliveryMethod deliveryMethod) {
+ this.deliveryMethod = deliveryMethod;
+ return this;
+ }
+
+ /**
+ * Sets the URL of the manifest this stream comes from (if applicable, otherwise null).
+ *
+ * @param manifestUrl the URL of the manifest this stream comes from or {@code null}
+ * @return this {@link Builder} instance
+ */
+ public Builder setManifestUrl(@Nullable final String manifestUrl) {
+ this.manifestUrl = manifestUrl;
+ return this;
+ }
+
+ /**
+ * Set the language code of the {@link SubtitlesStream}.
+ *
+ *
+ * It must not be null and should not be an empty string.
+ *
+ *
+ * @param languageCode the language code of the {@link SubtitlesStream}
+ * @return this {@link Builder} instance
+ */
+ public Builder setLanguageCode(@Nonnull final String languageCode) {
+ this.languageCode = languageCode;
+ return this;
+ }
+
+ /**
+ * Set whether the subtitles have been auto-generated by the streaming service.
+ *
+ * @param autoGenerated whether the subtitles have been generated by the streaming
+ * service
+ * @return this {@link Builder} instance
+ */
+ public Builder setAutoGenerated(final boolean autoGenerated) {
+ this.autoGenerated = autoGenerated;
+ return this;
+ }
+
+ /**
+ * Build a {@link SubtitlesStream} using the builder's current values.
+ *
+ *
+ * The content (and so the {@code isUrl} boolean), the language code and the {@code
+ * isAutoGenerated} properties must have been set.
+ *
+ *
+ *
+ * If no identifier has been set, an identifier will be generated using the language code
+ * and the media format suffix, if the media format is known.
+ *
+ *
+ * @return a new {@link SubtitlesStream} using the builder's current values
+ * @throws IllegalStateException if {@code id}, {@code content} (and so {@code isUrl}),
+ * {@code deliveryMethod}, {@code languageCode} or the {@code isAutogenerated} have been
+ * not set, or have been set as {@code null}
+ */
+ @Nonnull
+ public SubtitlesStream build() {
+ if (content == null) {
+ throw new IllegalStateException("No valid content was specified. Please specify a "
+ + "valid one with setContent.");
+ }
+
+ if (deliveryMethod == null) {
+ throw new IllegalStateException(
+ "The delivery method of the subtitles stream has been set as null, which "
+ + "is not allowed. Pass a valid one instead with"
+ + "setDeliveryMethod.");
+ }
+
+ if (languageCode == null) {
+ throw new IllegalStateException("The language code of the subtitles stream has "
+ + "been not set or is null. Make sure you specified an non null language "
+ + "code with setLanguageCode.");
+ }
+
+ if (autoGenerated == null) {
+ throw new IllegalStateException("The subtitles stream has been not set as an "
+ + "autogenerated subtitles stream or not. Please specify this information "
+ + "with setIsAutoGenerated.");
+ }
+
+ if (id == null) {
+ id = languageCode + (mediaFormat != null ? "." + mediaFormat.suffix
+ : EMPTY_STRING);
+ }
+
+ return new SubtitlesStream(id, content, isUrl, mediaFormat, deliveryMethod,
+ languageCode, autoGenerated, manifestUrl);
+ }
+ }
+
+ /**
+ * Create a new subtitles stream.
+ *
+ * @param id the identifier which uniquely identifies the stream, e.g. for YouTube
+ * this would be the itag
+ * @param content the content or the URL of the stream, depending on whether isUrl is
+ * true
+ * @param isUrl whether content is the URL or the actual content of e.g. a DASH
+ * manifest
+ * @param mediaFormat the {@link MediaFormat} used by the stream
+ * @param deliveryMethod the {@link DeliveryMethod} of the stream
+ * @param languageCode the language code of the stream
+ * @param autoGenerated whether the subtitles are auto-generated by the streaming service
+ * @param manifestUrl the URL of the manifest this stream comes from (if applicable,
+ * otherwise null)
+ */
+ @SuppressWarnings("checkstyle:ParameterNumber")
+ private SubtitlesStream(@Nonnull final String id,
+ @Nonnull final String content,
+ final boolean isUrl,
+ @Nullable final MediaFormat mediaFormat,
+ @Nonnull final DeliveryMethod deliveryMethod,
+ @Nonnull final String languageCode,
+ final boolean autoGenerated,
+ @Nullable final String manifestUrl) {
+ super(id, content, isUrl, mediaFormat, deliveryMethod, manifestUrl);
/*
- * Locale.forLanguageTag only for API >= 21
- * Locale.Builder only for API >= 21
- * Country codes doesn't work well without
- */
+ * Locale.forLanguageTag only for Android API >= 21
+ * Locale.Builder only for Android API >= 21
+ * Country codes doesn't work well without
+ */
final String[] splits = languageCode.split("-");
switch (splits.length) {
- default:
- this.locale = new Locale(splits[0]);
- break;
- case 3:
- // complex variants doesn't work!
- this.locale = new Locale(splits[0], splits[1], splits[2]);
- break;
case 2:
this.locale = new Locale(splits[0], splits[1]);
break;
+ case 3:
+ // Complex variants don't work!
+ this.locale = new Locale(splits[0], splits[1], splits[2]);
+ break;
+ default:
+ this.locale = new Locale(splits[0]);
+ break;
}
+
this.code = languageCode;
- this.format = format;
+ this.format = mediaFormat;
this.autoGenerated = autoGenerated;
}
+ /**
+ * Get the extension of the subtitles.
+ *
+ * @return the extension of the subtitles
+ */
public String getExtension() {
return format.suffix;
}
+ /**
+ * Return whether if the subtitles are auto-generated.
+ *
+ * Some streaming services can generate subtitles for their contents, like YouTube.
+ *
+ *
+ * @return {@code true} if the subtitles are auto-generated, {@code false} otherwise
+ */
public boolean isAutoGenerated() {
return autoGenerated;
}
+ /**
+ * {@inheritDoc}
+ */
@Override
public boolean equalStats(final Stream cmp) {
return super.equalStats(cmp)
@@ -56,16 +289,42 @@ public class SubtitlesStream extends Stream implements Serializable {
&& autoGenerated == ((SubtitlesStream) cmp).autoGenerated;
}
+ /**
+ * Get the display language name of the subtitles.
+ *
+ * @return the display language name of the subtitles
+ */
public String getDisplayLanguageName() {
return locale.getDisplayName(locale);
}
+ /**
+ * Get the language tag of the subtitles.
+ *
+ * @return the language tag of the subtitles
+ */
public String getLanguageTag() {
return code;
}
+ /**
+ * Get the {@link Locale locale} of the subtitles.
+ *
+ * @return the {@link Locale locale} of the subtitles
+ */
public Locale getLocale() {
return locale;
}
+ /**
+ * No subtitles which are currently extracted use an {@link ItagItem}, so {@code null} is
+ * returned by this method.
+ *
+ * @return {@code null}
+ */
+ @Nullable
+ @Override
+ public ItagItem getItagItem() {
+ return null;
+ }
}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/VideoStream.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/VideoStream.java
index 9e6b4eb2b..14952ebd1 100644
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/VideoStream.java
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/VideoStream.java
@@ -4,31 +4,41 @@ package org.schabi.newpipe.extractor.stream;
* Created by Christian Schabesberger on 04.03.16.
*
* Copyright (C) Christian Schabesberger 2016
- * VideoStream.java is part of NewPipe.
+ * VideoStream.java is part of NewPipe Extractor.
*
- * NewPipe is free software: you can redistribute it and/or modify
+ * NewPipe Extractor is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
- * NewPipe is distributed in the hope that it will be useful,
+ * NewPipe Extractor is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
- * along with NewPipe. If not, see .
+ * along with NewPipe Extractor. If not, see .
*/
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.services.youtube.ItagItem;
-public class VideoStream extends Stream {
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+public final class VideoStream extends Stream {
+ public static final String RESOLUTION_UNKNOWN = "";
+
+ /** @deprecated Use {@link #getResolution()} instead. */
+ @Deprecated
public final String resolution;
+
+ /** @deprecated Use {@link #isVideoOnly()} instead. */
+ @Deprecated
public final boolean isVideoOnly;
- // Fields for Dash
- private int itag;
+ // Fields for DASH
+ private int itag = ITAG_NOT_AVAILABLE_OR_NOT_APPLICABLE;
private int bitrate;
private int initStart;
private int initEnd;
@@ -39,118 +49,437 @@ public class VideoStream extends Stream {
private int fps;
private String quality;
private String codec;
+ @Nullable private ItagItem itagItem;
- public VideoStream(final String url, final MediaFormat format, final String resolution) {
- this(url, format, resolution, false);
+ /**
+ * Class to build {@link VideoStream} objects.
+ */
+ @SuppressWarnings("checkstyle:hiddenField")
+ public static final class Builder {
+ private String id;
+ private String content;
+ private boolean isUrl;
+ private DeliveryMethod deliveryMethod = DeliveryMethod.PROGRESSIVE_HTTP;
+ @Nullable
+ private MediaFormat mediaFormat;
+ @Nullable
+ private String manifestUrl;
+ // Use of the Boolean class instead of the primitive type needed for setter call check
+ private Boolean isVideoOnly;
+ private String resolution;
+ @Nullable
+ private ItagItem itagItem;
+
+ /**
+ * Create a new {@link Builder} instance with its default values.
+ */
+ public Builder() {
+ }
+
+ /**
+ * Set the identifier of the {@link VideoStream}.
+ *
+ *
+ * It must not be null, and should be non empty.
+ *
+ *
+ *
+ * If you are not able to get an identifier, use the static constant {@link
+ * Stream#ID_UNKNOWN ID_UNKNOWN} of the {@link Stream} class.
+ *
+ *
+ * @param id the identifier of the {@link VideoStream}, which must not be null
+ * @return this {@link Builder} instance
+ */
+ public Builder setId(@Nonnull final String id) {
+ this.id = id;
+ return this;
+ }
+
+ /**
+ * Set the content of the {@link VideoStream}.
+ *
+ *
+ * It must not be null, and should be non empty.
+ *
+ *
+ * @param content the content of the {@link VideoStream}
+ * @param isUrl whether the content is a URL
+ * @return this {@link Builder} instance
+ */
+ public Builder setContent(@Nonnull final String content,
+ final boolean isUrl) {
+ this.content = content;
+ this.isUrl = isUrl;
+ return this;
+ }
+
+ /**
+ * Set the {@link MediaFormat} used by the {@link VideoStream}.
+ *
+ *
+ * It should be one of the video {@link MediaFormat}s ({@link MediaFormat#MPEG_4 MPEG_4},
+ * {@link MediaFormat#v3GPP v3GPP}, or {@link MediaFormat#WEBM WEBM}) but can be {@code
+ * null} if the media format could not be determined.
+ *
+ *
+ *
+ * The default value is {@code null}.
+ *
+ *
+ * @param mediaFormat the {@link MediaFormat} of the {@link VideoStream}, which can be null
+ * @return this {@link Builder} instance
+ */
+ public Builder setMediaFormat(@Nullable final MediaFormat mediaFormat) {
+ this.mediaFormat = mediaFormat;
+ return this;
+ }
+
+ /**
+ * Set the {@link DeliveryMethod} of the {@link VideoStream}.
+ *
+ *
+ * It must not be null.
+ *
+ *
+ *
+ * The default delivery method is {@link DeliveryMethod#PROGRESSIVE_HTTP}.
+ *
+ *
+ * @param deliveryMethod the {@link DeliveryMethod} of the {@link VideoStream}, which must
+ * not be null
+ * @return this {@link Builder} instance
+ */
+ public Builder setDeliveryMethod(@Nonnull final DeliveryMethod deliveryMethod) {
+ this.deliveryMethod = deliveryMethod;
+ return this;
+ }
+
+ /**
+ * Sets the URL of the manifest this stream comes from (if applicable, otherwise null).
+ *
+ * @param manifestUrl the URL of the manifest this stream comes from or {@code null}
+ * @return this {@link Builder} instance
+ */
+ public Builder setManifestUrl(@Nullable final String manifestUrl) {
+ this.manifestUrl = manifestUrl;
+ return this;
+ }
+
+ /**
+ * Set whether the {@link VideoStream} is video-only.
+ *
+ *
+ * This property must be set before building the {@link VideoStream}.
+ *
+ *
+ * @param isVideoOnly whether the {@link VideoStream} is video-only
+ * @return this {@link Builder} instance
+ */
+ public Builder setIsVideoOnly(final boolean isVideoOnly) {
+ this.isVideoOnly = isVideoOnly;
+ return this;
+ }
+
+ /**
+ * Set the resolution of the {@link VideoStream}.
+ *
+ *
+ * This resolution can be used by clients to know the quality of the video stream.
+ *
+ *
+ *
+ * If you are not able to know the resolution, you should use {@link #RESOLUTION_UNKNOWN}
+ * as the resolution of the video stream.
+ *
+ *
+ *
+ * It must be set before building the builder and not null.
+ *
+ *
+ * @param resolution the resolution of the {@link VideoStream}
+ * @return this {@link Builder} instance
+ */
+ public Builder setResolution(@Nonnull final String resolution) {
+ this.resolution = resolution;
+ return this;
+ }
+
+ /**
+ * Set the {@link ItagItem} corresponding to the {@link VideoStream}.
+ *
+ *
+ * {@link ItagItem}s are YouTube specific objects, so they are only known for this service
+ * and can be null.
+ *
+ *
+ *
+ * The default value is {@code null}.
+ *
+ *
+ * @param itagItem the {@link ItagItem} of the {@link VideoStream}, which can be null
+ * @return this {@link Builder} instance
+ */
+ public Builder setItagItem(@Nullable final ItagItem itagItem) {
+ this.itagItem = itagItem;
+ return this;
+ }
+
+ /**
+ * Build a {@link VideoStream} using the builder's current values.
+ *
+ *
+ * The identifier, the content (and so the {@code isUrl} boolean), the {@code isVideoOnly}
+ * and the {@code resolution} properties must have been set.
+ *
+ *
+ * @return a new {@link VideoStream} using the builder's current values
+ * @throws IllegalStateException if {@code id}, {@code content} (and so {@code isUrl}),
+ * {@code deliveryMethod}, {@code isVideoOnly} or {@code resolution} have been not set, or
+ * have been set as {@code null}
+ */
+ @Nonnull
+ public VideoStream build() {
+ if (id == null) {
+ throw new IllegalStateException(
+ "The identifier of the video stream has been not set or is null. If you "
+ + "are not able to get an identifier, use the static constant "
+ + "ID_UNKNOWN of the Stream class.");
+ }
+
+ if (content == null) {
+ throw new IllegalStateException("The content of the video stream has been not set "
+ + "or is null. Please specify a non-null one with setContent.");
+ }
+
+ if (deliveryMethod == null) {
+ throw new IllegalStateException(
+ "The delivery method of the video stream has been set as null, which is "
+ + "not allowed. Pass a valid one instead with setDeliveryMethod.");
+ }
+
+ if (isVideoOnly == null) {
+ throw new IllegalStateException("The video stream has been not set as a "
+ + "video-only stream or as a video stream with embedded audio. Please "
+ + "specify this information with setIsVideoOnly.");
+ }
+
+ if (resolution == null) {
+ throw new IllegalStateException(
+ "The resolution of the video stream has been not set. Please specify it "
+ + "with setResolution (use an empty string if you are not able to "
+ + "get it).");
+ }
+
+ return new VideoStream(id, content, isUrl, mediaFormat, deliveryMethod, resolution,
+ isVideoOnly, manifestUrl, itagItem);
+ }
}
- public VideoStream(final String url,
- final MediaFormat format,
- final String resolution,
- final boolean isVideoOnly) {
- this(url, null, format, resolution, isVideoOnly);
- }
-
- public VideoStream(final String url, final boolean isVideoOnly, final ItagItem itag) {
- this(url, itag.getMediaFormat(), itag.resolutionString, isVideoOnly);
- this.itag = itag.id;
- this.bitrate = itag.getBitrate();
- this.initStart = itag.getInitStart();
- this.initEnd = itag.getInitEnd();
- this.indexStart = itag.getIndexStart();
- this.indexEnd = itag.getIndexEnd();
- this.codec = itag.getCodec();
- this.height = itag.getHeight();
- this.width = itag.getWidth();
- this.quality = itag.getQuality();
- this.fps = itag.fps;
- }
-
- public VideoStream(final String url,
- final String torrentUrl,
- final MediaFormat format,
- final String resolution) {
- this(url, torrentUrl, format, resolution, false);
- }
-
- public VideoStream(final String url,
- final String torrentUrl,
- final MediaFormat format,
- final String resolution,
- final boolean isVideoOnly) {
- super(url, torrentUrl, format);
+ /**
+ * Create a new video stream.
+ *
+ * @param id the identifier which uniquely identifies the stream, e.g. for YouTube
+ * this would be the itag
+ * @param content the content or the URL of the stream, depending on whether isUrl is
+ * true
+ * @param isUrl whether content is the URL or the actual content of e.g. a DASH
+ * manifest
+ * @param format the {@link MediaFormat} used by the stream, which can be null
+ * @param deliveryMethod the {@link DeliveryMethod} of the stream
+ * @param resolution the resolution of the stream
+ * @param isVideoOnly whether the stream is video-only
+ * @param itagItem the {@link ItagItem} corresponding to the stream, which cannot be null
+ * @param manifestUrl the URL of the manifest this stream comes from (if applicable,
+ * otherwise null)
+ */
+ @SuppressWarnings("checkstyle:ParameterNumber")
+ private VideoStream(@Nonnull final String id,
+ @Nonnull final String content,
+ final boolean isUrl,
+ @Nullable final MediaFormat format,
+ @Nonnull final DeliveryMethod deliveryMethod,
+ @Nonnull final String resolution,
+ final boolean isVideoOnly,
+ @Nullable final String manifestUrl,
+ @Nullable final ItagItem itagItem) {
+ super(id, content, isUrl, format, deliveryMethod, manifestUrl);
+ if (itagItem != null) {
+ this.itagItem = itagItem;
+ this.itag = itagItem.id;
+ this.bitrate = itagItem.getBitrate();
+ this.initStart = itagItem.getInitStart();
+ this.initEnd = itagItem.getInitEnd();
+ this.indexStart = itagItem.getIndexStart();
+ this.indexEnd = itagItem.getIndexEnd();
+ this.codec = itagItem.getCodec();
+ this.height = itagItem.getHeight();
+ this.width = itagItem.getWidth();
+ this.quality = itagItem.getQuality();
+ this.fps = itagItem.getFps();
+ }
this.resolution = resolution;
this.isVideoOnly = isVideoOnly;
}
+ /**
+ * {@inheritDoc}
+ */
@Override
public boolean equalStats(final Stream cmp) {
- return super.equalStats(cmp) && cmp instanceof VideoStream
+ return super.equalStats(cmp)
+ && cmp instanceof VideoStream
&& resolution.equals(((VideoStream) cmp).resolution)
&& isVideoOnly == ((VideoStream) cmp).isVideoOnly;
}
/**
- * Get the video resolution
+ * Get the video resolution.
*
- * @return the video resolution
+ *
+ * It can be unknown for some streams, like for HLS master playlists. In this case,
+ * {@link #RESOLUTION_UNKNOWN} is returned by this method.
+ *
+ *
+ * @return the video resolution or {@link #RESOLUTION_UNKNOWN}
*/
+ @Nonnull
public String getResolution() {
return resolution;
}
/**
- * Check if the video is video only.
- *
- * Video only streams have no audio
+ * Return whether the stream is video-only.
*
- * @return {@code true} if this stream is vid
+ *
+ * Video-only streams have no audio.
+ *
+ *
+ * @return {@code true} if this stream is video-only, {@code false} otherwise
*/
public boolean isVideoOnly() {
return isVideoOnly;
}
+ /**
+ * Get the itag identifier of the stream.
+ *
+ *
+ * Always equals to {@link #ITAG_NOT_AVAILABLE_OR_NOT_APPLICABLE} for other streams than the
+ * ones of the YouTube service.
+ *
+ *
+ * @return the number of the {@link ItagItem} passed in the constructor of the video stream.
+ */
public int getItag() {
return itag;
}
+ /**
+ * Get the bitrate of the stream.
+ *
+ * @return the bitrate set from the {@link ItagItem} passed in the constructor of the stream.
+ */
public int getBitrate() {
return bitrate;
}
+ /**
+ * Get the initialization start of the stream.
+ *
+ * @return the initialization start value set from the {@link ItagItem} passed in the
+ * constructor of the
+ * stream.
+ */
public int getInitStart() {
return initStart;
}
+ /**
+ * Get the initialization end of the stream.
+ *
+ * @return the initialization end value set from the {@link ItagItem} passed in the constructor
+ * of the stream.
+ */
public int getInitEnd() {
return initEnd;
}
+ /**
+ * Get the index start of the stream.
+ *
+ * @return the index start value set from the {@link ItagItem} passed in the constructor of the
+ * stream.
+ */
public int getIndexStart() {
return indexStart;
}
+ /**
+ * Get the index end of the stream.
+ *
+ * @return the index end value set from the {@link ItagItem} passed in the constructor of the
+ * stream.
+ */
public int getIndexEnd() {
return indexEnd;
}
+ /**
+ * Get the width of the video stream.
+ *
+ * @return the width set from the {@link ItagItem} passed in the constructor of the
+ * stream.
+ */
public int getWidth() {
return width;
}
+ /**
+ * Get the height of the video stream.
+ *
+ * @return the height set from the {@link ItagItem} passed in the constructor of the
+ * stream.
+ */
public int getHeight() {
return height;
}
+ /**
+ * Get the frames per second of the video stream.
+ *
+ * @return the frames per second set from the {@link ItagItem} passed in the constructor of the
+ * stream.
+ */
public int getFps() {
return fps;
}
+ /**
+ * Get the quality of the stream.
+ *
+ * @return the quality label set from the {@link ItagItem} passed in the constructor of the
+ * stream.
+ */
public String getQuality() {
return quality;
}
+ /**
+ * Get the codec of the stream.
+ *
+ * @return the codec set from the {@link ItagItem} passed in the constructor of the stream.
+ */
public String getCodec() {
return codec;
}
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ @Nullable
+ public ItagItem getItagItem() {
+ return itagItem;
+ }
}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/utils/DashMpdParser.java b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/DashMpdParser.java
deleted file mode 100644
index b1acabc75..000000000
--- a/extractor/src/main/java/org/schabi/newpipe/extractor/utils/DashMpdParser.java
+++ /dev/null
@@ -1,225 +0,0 @@
-package org.schabi.newpipe.extractor.utils;
-
-import org.schabi.newpipe.extractor.MediaFormat;
-import org.schabi.newpipe.extractor.NewPipe;
-import org.schabi.newpipe.extractor.downloader.Downloader;
-import org.schabi.newpipe.extractor.exceptions.ParsingException;
-import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
-import org.schabi.newpipe.extractor.services.youtube.ItagItem;
-import org.schabi.newpipe.extractor.stream.AudioStream;
-import org.schabi.newpipe.extractor.stream.Stream;
-import org.schabi.newpipe.extractor.stream.StreamInfo;
-import org.schabi.newpipe.extractor.stream.VideoStream;
-import org.w3c.dom.Document;
-import org.w3c.dom.Element;
-import org.w3c.dom.Node;
-import org.w3c.dom.NodeList;
-
-import javax.xml.parsers.DocumentBuilder;
-import javax.xml.parsers.DocumentBuilderFactory;
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.ArrayList;
-import java.util.List;
-
-/*
- * Created by Christian Schabesberger on 02.02.16.
- *
- * Copyright (C) Christian Schabesberger 2016
- * DashMpdParser.java is part of NewPipe.
- *
- * NewPipe is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * NewPipe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with NewPipe. If not, see .
- */
-
-public final class DashMpdParser {
-
- private DashMpdParser() {
- }
-
- public static class DashMpdParsingException extends ParsingException {
- DashMpdParsingException(final String message, final Exception e) {
- super(message, e);
- }
- }
-
- public static class ParserResult {
- private final List videoStreams;
- private final List audioStreams;
- private final List videoOnlyStreams;
-
- private final List segmentedVideoStreams;
- private final List segmentedAudioStreams;
- private final List segmentedVideoOnlyStreams;
-
-
- public ParserResult(final List videoStreams,
- final List audioStreams,
- final List videoOnlyStreams,
- final List segmentedVideoStreams,
- final List segmentedAudioStreams,
- final List segmentedVideoOnlyStreams) {
- this.videoStreams = videoStreams;
- this.audioStreams = audioStreams;
- this.videoOnlyStreams = videoOnlyStreams;
- this.segmentedVideoStreams = segmentedVideoStreams;
- this.segmentedAudioStreams = segmentedAudioStreams;
- this.segmentedVideoOnlyStreams = segmentedVideoOnlyStreams;
- }
-
- public List getVideoStreams() {
- return videoStreams;
- }
-
- public List getAudioStreams() {
- return audioStreams;
- }
-
- public List getVideoOnlyStreams() {
- return videoOnlyStreams;
- }
-
- public List getSegmentedVideoStreams() {
- return segmentedVideoStreams;
- }
-
- public List getSegmentedAudioStreams() {
- return segmentedAudioStreams;
- }
-
- public List getSegmentedVideoOnlyStreams() {
- return segmentedVideoOnlyStreams;
- }
- }
-
- /**
- * Will try to download (using {@link StreamInfo#getDashMpdUrl()}) and parse the dash manifest,
- * then it will search for any stream that the ItagItem has (by the id).
- *
- * It has video, video only and audio streams and will only add to the list if it don't
- * find a similar stream in the respective lists (calling {@link Stream#equalStats}).
- *
- * Info about dash MPD can be found
- * here.
- *
- * @param streamInfo where the parsed streams will be added
- */
- public static ParserResult getStreams(final StreamInfo streamInfo)
- throws DashMpdParsingException, ReCaptchaException {
- final String dashDoc;
- final Downloader downloader = NewPipe.getDownloader();
- try {
- dashDoc = downloader.get(streamInfo.getDashMpdUrl()).responseBody();
- } catch (final IOException ioe) {
- throw new DashMpdParsingException(
- "Could not get dash mpd: " + streamInfo.getDashMpdUrl(), ioe);
- }
-
- try {
- final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
- final DocumentBuilder builder = factory.newDocumentBuilder();
- final InputStream stream = new ByteArrayInputStream(dashDoc.getBytes());
-
- final Document doc = builder.parse(stream);
- final NodeList representationList = doc.getElementsByTagName("Representation");
-
- final List videoStreams = new ArrayList<>();
- final List audioStreams = new ArrayList<>();
- final List videoOnlyStreams = new ArrayList<>();
-
- final List segmentedVideoStreams = new ArrayList<>();
- final List segmentedAudioStreams = new ArrayList<>();
- final List segmentedVideoOnlyStreams = new ArrayList<>();
-
- for (int i = 0; i < representationList.getLength(); i++) {
- final Element representation = (Element) representationList.item(i);
- try {
- final String mimeType
- = ((Element) representation.getParentNode()).getAttribute("mimeType");
- final String id = representation.getAttribute("id");
- final String url = representation
- .getElementsByTagName("BaseURL").item(0).getTextContent();
- final ItagItem itag = ItagItem.getItag(Integer.parseInt(id));
- final Node segmentationList
- = representation.getElementsByTagName("SegmentList").item(0);
-
- // If SegmentList is not null this means that BaseUrl is not representing the
- // url to the stream. Instead we need to add the "media=" value from the
- // tags inside the tag in order to get a full
- // working url. However each of these is just pointing to a part of the video,
- // so we can not return a URL with a working stream here. Instead of putting
- // those streams into the list of regular stream urls we put them in a for
- // example "segmentedVideoStreams" list.
-
- final MediaFormat mediaFormat = MediaFormat.getFromMimeType(mimeType);
-
- if (itag.itagType.equals(ItagItem.ItagType.AUDIO)) {
- if (segmentationList == null) {
- final AudioStream audioStream
- = new AudioStream(url, mediaFormat, itag.avgBitrate);
- if (!Stream.containSimilarStream(audioStream,
- streamInfo.getAudioStreams())) {
- audioStreams.add(audioStream);
- }
- } else {
- segmentedAudioStreams.add(
- new AudioStream(id, mediaFormat, itag.avgBitrate));
- }
- } else {
- final boolean isVideoOnly
- = itag.itagType.equals(ItagItem.ItagType.VIDEO_ONLY);
-
- if (segmentationList == null) {
- final VideoStream videoStream = new VideoStream(url,
- mediaFormat,
- itag.resolutionString,
- isVideoOnly);
-
- if (isVideoOnly) {
- if (!Stream.containSimilarStream(videoStream,
- streamInfo.getVideoOnlyStreams())) {
- videoOnlyStreams.add(videoStream);
- }
- } else if (!Stream.containSimilarStream(videoStream,
- streamInfo.getVideoStreams())) {
- videoStreams.add(videoStream);
- }
- } else {
- final VideoStream videoStream = new VideoStream(id,
- mediaFormat,
- itag.resolutionString,
- isVideoOnly);
-
- if (isVideoOnly) {
- segmentedVideoOnlyStreams.add(videoStream);
- } else {
- segmentedVideoStreams.add(videoStream);
- }
- }
- }
- } catch (final Exception ignored) {
- }
- }
- return new ParserResult(
- videoStreams,
- audioStreams,
- videoOnlyStreams,
- segmentedVideoStreams,
- segmentedAudioStreams,
- segmentedVideoOnlyStreams);
- } catch (final Exception e) {
- throw new DashMpdParsingException("Could not parse Dash mpd", e);
- }
- }
-}
diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/utils/ManifestCreatorCache.java b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/ManifestCreatorCache.java
new file mode 100644
index 000000000..ac12f83f9
--- /dev/null
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/ManifestCreatorCache.java
@@ -0,0 +1,255 @@
+package org.schabi.newpipe.extractor.utils;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * A {@link Serializable serializable} cache class used by the extractor to cache manifests
+ * generated with extractor's manifests generators.
+ *
+ *
+ * It relies internally on a {@link ConcurrentHashMap} to allow concurrent access to the cache.
+ *
+ *
+ * @param the type of cache keys, which must be {@link Serializable serializable}
+ * @param the type of the second element of {@link Pair pairs} used as values of the cache,
+ * which must be {@link Serializable serializable}
+ */
+public final class ManifestCreatorCache
+ implements Serializable {
+
+ /**
+ * The default maximum size of a manifest cache.
+ */
+ public static final int DEFAULT_MAXIMUM_SIZE = Integer.MAX_VALUE;
+
+ /**
+ * The default clear factor of a manifest cache.
+ */
+ public static final double DEFAULT_CLEAR_FACTOR = 0.75;
+
+ /**
+ * The {@link ConcurrentHashMap} used internally as the cache of manifests.
+ */
+ private final ConcurrentHashMap> concurrentHashMap;
+
+ /**
+ * The maximum size of the cache.
+ *
+ *
+ * The default value is {@link #DEFAULT_MAXIMUM_SIZE}.
+ *
+ */
+ private int maximumSize = DEFAULT_MAXIMUM_SIZE;
+
+ /**
+ * The clear factor of the cache, which is a double between {@code 0} and {@code 1} excluded.
+ *
+ *
+ * The default value is {@link #DEFAULT_CLEAR_FACTOR}.
+ *
+ */
+ private double clearFactor = DEFAULT_CLEAR_FACTOR;
+
+ /**
+ * Creates a new {@link ManifestCreatorCache}.
+ */
+ public ManifestCreatorCache() {
+ concurrentHashMap = new ConcurrentHashMap<>();
+ }
+
+ /**
+ * Tests if the specified key is in the cache.
+ *
+ * @param key the key to test its presence in the cache
+ * @return {@code true} if the key is in the cache, {@code false} otherwise.
+ */
+ public boolean containsKey(final K key) {
+ return concurrentHashMap.containsKey(key);
+ }
+
+ /**
+ * Returns the value to which the specified key is mapped, or {@code null} if the cache
+ * contains no mapping for the key.
+ *
+ * @param key the key to which getting its value
+ * @return the value to which the specified key is mapped, or {@code null}
+ */
+ @Nullable
+ public Pair get(final K key) {
+ return concurrentHashMap.get(key);
+ }
+
+ /**
+ * Adds a new element to the cache.
+ *
+ *
+ * If the cache limit is reached, oldest elements will be cleared first using the load factor
+ * and the maximum size.
+ *
+ *
+ * @param key the key to put
+ * @param value the value to associate to the key
+ *
+ * @return the previous value associated with the key, or {@code null} if there was no mapping
+ * for the key (note that a null return can also indicate that the cache previously associated
+ * {@code null} with the key).
+ */
+ @Nullable
+ public V put(final K key, final V value) {
+ if (!concurrentHashMap.containsKey(key) && concurrentHashMap.size() == maximumSize) {
+ final int newCacheSize = (int) Math.round(maximumSize * clearFactor);
+ keepNewestEntries(newCacheSize != 0 ? newCacheSize : 1);
+ }
+
+ final Pair returnValue = concurrentHashMap.put(key,
+ new Pair<>(concurrentHashMap.size(), value));
+ return returnValue == null ? null : returnValue.getSecond();
+ }
+
+ /**
+ * Clears the cached manifests.
+ *
+ *
+ * The cache will be empty after this method is called.
+ *
+ */
+ public void clear() {
+ concurrentHashMap.clear();
+ }
+
+ /**
+ * Resets the cache.
+ *
+ *
+ * The cache will be empty and the clear factor and the maximum size will be reset to their
+ * default values.
+ *
+ *
+ * @see #clear()
+ * @see #resetClearFactor()
+ * @see #resetMaximumSize()
+ */
+ public void reset() {
+ clear();
+ resetClearFactor();
+ resetMaximumSize();
+ }
+
+ /**
+ * @return the number of cached manifests in the cache
+ */
+ public int size() {
+ return concurrentHashMap.size();
+ }
+
+ /**
+ * @return the maximum size of the cache
+ */
+ public long getMaximumSize() {
+ return maximumSize;
+ }
+
+ /**
+ * Sets the maximum size of the cache.
+ *
+ * If the current cache size is more than the new maximum size, the percentage of one less the
+ * clear factor of the maximum new size of manifests in the cache will be removed.
+ *
+ * @param maximumSize the new maximum size of the cache
+ * @throws IllegalArgumentException if {@code maximumSize} is less than or equal to 0
+ */
+ public void setMaximumSize(final int maximumSize) {
+ if (maximumSize <= 0) {
+ throw new IllegalArgumentException("Invalid maximum size");
+ }
+
+ if (maximumSize < this.maximumSize && !concurrentHashMap.isEmpty()) {
+ final int newCacheSize = (int) Math.round(maximumSize * clearFactor);
+ keepNewestEntries(newCacheSize != 0 ? newCacheSize : 1);
+ }
+
+ this.maximumSize = maximumSize;
+ }
+
+ /**
+ * Resets the maximum size of the cache to its {@link #DEFAULT_MAXIMUM_SIZE default value}.
+ */
+ public void resetMaximumSize() {
+ this.maximumSize = DEFAULT_MAXIMUM_SIZE;
+ }
+
+ /**
+ * @return the current clear factor of the cache, used when the cache limit size is reached
+ */
+ public double getClearFactor() {
+ return clearFactor;
+ }
+
+ /**
+ * Sets the clear factor of the cache, used when the cache limit size is reached.
+ *
+ *
+ * The clear factor must be a double between {@code 0} excluded and {@code 1} excluded.
+ *
+ *
+ *
+ * Note that it will be only used the next time the cache size limit is reached.
+ *
+ *
+ * @param clearFactor the new clear factor of the cache
+ * @throws IllegalArgumentException if the clear factor passed a parameter is invalid
+ */
+ public void setClearFactor(final double clearFactor) {
+ if (clearFactor <= 0 || clearFactor >= 1) {
+ throw new IllegalArgumentException("Invalid clear factor");
+ }
+
+ this.clearFactor = clearFactor;
+ }
+
+ /**
+ * Resets the clear factor to its {@link #DEFAULT_CLEAR_FACTOR default value}.
+ */
+ public void resetClearFactor() {
+ this.clearFactor = DEFAULT_CLEAR_FACTOR;
+ }
+
+ @Nonnull
+ @Override
+ public String toString() {
+ return "ManifestCreatorCache[clearFactor=" + clearFactor + ", maximumSize=" + maximumSize
+ + ", concurrentHashMap=" + concurrentHashMap + "]";
+ }
+
+ /**
+ * Keeps only the newest entries in a cache.
+ *
+ *
+ * This method will first collect the entries to remove by looping through the concurrent hash
+ * map
+ *
+ *
+ * @param newLimit the new limit of the cache
+ */
+ private void keepNewestEntries(final int newLimit) {
+ final int difference = concurrentHashMap.size() - newLimit;
+ final ArrayList>> entriesToRemove = new ArrayList<>();
+
+ concurrentHashMap.entrySet().forEach(entry -> {
+ final Pair value = entry.getValue();
+ if (value.getFirst() < difference) {
+ entriesToRemove.add(entry);
+ } else {
+ value.setFirst(value.getFirst() - difference);
+ }
+ });
+
+ entriesToRemove.forEach(entry -> concurrentHashMap.remove(entry.getKey(),
+ entry.getValue()));
+ }
+}
diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/ExtractorAsserts.java b/extractor/src/test/java/org/schabi/newpipe/extractor/ExtractorAsserts.java
index bdbd59530..124d998d0 100644
--- a/extractor/src/test/java/org/schabi/newpipe/extractor/ExtractorAsserts.java
+++ b/extractor/src/test/java/org/schabi/newpipe/extractor/ExtractorAsserts.java
@@ -15,6 +15,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import org.schabi.newpipe.extractor.utils.Utils;
+
public class ExtractorAsserts {
public static void assertEmptyErrors(String message, List errors) {
if (!errors.isEmpty()) {
@@ -64,6 +66,14 @@ public class ExtractorAsserts {
}
}
+ public static void assertNotBlank(String stringToCheck) {
+ assertNotBlank(stringToCheck, null);
+ }
+
+ public static void assertNotBlank(String stringToCheck, @Nullable String message) {
+ assertFalse(Utils.isBlank(stringToCheck), message);
+ }
+
public static void assertGreater(final long expected, final long actual) {
assertGreater(expected, actual, actual + " is not > " + expected);
}
diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/DefaultStreamExtractorTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/DefaultStreamExtractorTest.java
index f8fb6e935..d9b4e6cde 100644
--- a/extractor/src/test/java/org/schabi/newpipe/extractor/services/DefaultStreamExtractorTest.java
+++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/DefaultStreamExtractorTest.java
@@ -271,13 +271,20 @@ public abstract class DefaultStreamExtractorTest extends DefaultExtractorTest audioStreams = extractor.getAudioStreams();
assertEquals(2, audioStreams.size());
- for (final AudioStream audioStream : audioStreams) {
- final String mediaUrl = audioStream.getUrl();
+ audioStreams.forEach(audioStream -> {
+ final DeliveryMethod deliveryMethod = audioStream.getDeliveryMethod();
+ final String mediaUrl = audioStream.getContent();
if (audioStream.getFormat() == MediaFormat.OPUS) {
- // assert that it's an OPUS 64 kbps media URL with a single range which comes from an HLS SoundCloud CDN
+ // Assert that it's an OPUS 64 kbps media URL with a single range which comes
+ // from an HLS SoundCloud CDN
ExtractorAsserts.assertContains("-hls-opus-media.sndcdn.com", mediaUrl);
ExtractorAsserts.assertContains(".64.opus", mediaUrl);
+ assertSame(DeliveryMethod.HLS, deliveryMethod,
+ "Wrong delivery method for stream " + audioStream.getId() + ": "
+ + deliveryMethod);
+ } else if (audioStream.getFormat() == MediaFormat.MP3) {
+ // Assert that it's a MP3 128 kbps media URL which comes from a progressive
+ // SoundCloud CDN
+ ExtractorAsserts.assertContains("-media.sndcdn.com/bKOA7Pwbut93.128.mp3",
+ mediaUrl);
+ assertSame(DeliveryMethod.PROGRESSIVE_HTTP, deliveryMethod,
+ "Wrong delivery method for stream " + audioStream.getId() + ": "
+ + deliveryMethod);
}
- if (audioStream.getFormat() == MediaFormat.MP3) {
- // assert that it's a MP3 128 kbps media URL which comes from a progressive SoundCloud CDN
- ExtractorAsserts.assertContains("-media.sndcdn.com/bKOA7Pwbut93.128.mp3", mediaUrl);
- }
- }
+ });
}
}
}
diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeDashManifestCreatorsTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeDashManifestCreatorsTest.java
new file mode 100644
index 000000000..0d276f901
--- /dev/null
+++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeDashManifestCreatorsTest.java
@@ -0,0 +1,363 @@
+package org.schabi.newpipe.extractor.services.youtube;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.schabi.newpipe.downloader.DownloaderTestImpl;
+import org.schabi.newpipe.extractor.NewPipe;
+import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.CreationException;
+import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator;
+import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator;
+import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
+import org.schabi.newpipe.extractor.stream.DeliveryMethod;
+import org.schabi.newpipe.extractor.stream.Stream;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+import org.xml.sax.InputSource;
+
+import javax.annotation.Nonnull;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import java.io.StringReader;
+import java.util.List;
+import java.util.Random;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.schabi.newpipe.extractor.ExtractorAsserts.assertGreater;
+import static org.schabi.newpipe.extractor.ExtractorAsserts.assertGreaterOrEqual;
+import static org.schabi.newpipe.extractor.ExtractorAsserts.assertIsValidUrl;
+import static org.schabi.newpipe.extractor.ExtractorAsserts.assertNotBlank;
+import static org.schabi.newpipe.extractor.ServiceList.YouTube;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.ADAPTATION_SET;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.AUDIO_CHANNEL_CONFIGURATION;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.BASE_URL;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.INITIALIZATION;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.MPD;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.PERIOD;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.REPRESENTATION;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.ROLE;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.SEGMENT_BASE;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.SEGMENT_TEMPLATE;
+import static org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeDashManifestCreatorsUtils.SEGMENT_TIMELINE;
+import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
+
+/**
+ * Test for YouTube DASH manifest creators.
+ *
+ *
+ * Tests the generation of OTF and progressive manifests.
+ *
+ *
+ *
+ * We cannot test the generation of DASH manifests for ended livestreams because these videos will
+ * be re-encoded as normal videos later, so we can't use a specific video.
+ *
+ *
+ *
+ * The generation of DASH manifests for OTF streams, which can be tested, uses a video licenced
+ * under the Creative Commons Attribution licence (reuse allowed): {@code A New Era of Open?
+ * COVID-19 and the Pursuit for Equitable Solutions} (https://www.youtube.com/watch?v=DJ8GQUNUXGM)
+ *
+ *
+ *
+ * We couldn't use mocks for these tests because the streaming URLs needs to fetched and the IP
+ * address used to get these URLs is required (used as a param in the URLs; without it, video
+ * servers return 403/Forbidden HTTP response code).
+ *
+ *
+ *
+ * So the real downloader will be used everytime on this test class.
+ *
+ */
+class YoutubeDashManifestCreatorsTest {
+ // Setting a higher number may let Google video servers return 403s
+ private static final int MAX_STREAMS_TO_TEST_PER_METHOD = 3;
+ private static final String url = "https://www.youtube.com/watch?v=DJ8GQUNUXGM";
+ private static YoutubeStreamExtractor extractor;
+ private static long videoLength;
+
+ @BeforeAll
+ public static void setUp() throws Exception {
+ YoutubeParsingHelper.resetClientVersionAndKey();
+ YoutubeParsingHelper.setNumberGenerator(new Random(1));
+ NewPipe.init(DownloaderTestImpl.getInstance());
+
+ extractor = (YoutubeStreamExtractor) YouTube.getStreamExtractor(url);
+ extractor.fetchPage();
+ videoLength = extractor.getLength();
+ }
+
+ @Test
+ void testOtfStreams() throws Exception {
+ assertDashStreams(extractor.getVideoOnlyStreams());
+ assertDashStreams(extractor.getAudioStreams());
+
+ // no video stream with audio uses the DASH delivery method (YouTube OTF stream type)
+ assertEquals(0, assertFilterStreams(extractor.getVideoStreams(),
+ DeliveryMethod.DASH).size());
+ }
+
+ @Test
+ void testProgressiveStreams() throws Exception {
+ assertProgressiveStreams(extractor.getVideoOnlyStreams());
+ assertProgressiveStreams(extractor.getAudioStreams());
+
+ // we are not able to generate DASH manifests of video formats with audio
+ assertThrows(CreationException.class,
+ () -> assertProgressiveStreams(extractor.getVideoStreams()));
+ }
+
+ private void assertDashStreams(final List extends Stream> streams) throws Exception {
+
+ for (final Stream stream : assertFilterStreams(streams, DeliveryMethod.DASH)) {
+ //noinspection ConstantConditions
+ final String manifest = YoutubeOtfDashManifestCreator.fromOtfStreamingUrl(
+ stream.getContent(), stream.getItagItem(), videoLength);
+ assertNotBlank(manifest);
+
+ assertManifestGenerated(
+ manifest,
+ stream.getItagItem(),
+ document -> assertAll(
+ () -> assertSegmentTemplateElement(document),
+ () -> assertSegmentTimelineAndSElements(document)
+ )
+ );
+ }
+ }
+
+ private void assertProgressiveStreams(final List extends Stream> streams) throws Exception {
+
+ for (final Stream stream : assertFilterStreams(streams, DeliveryMethod.PROGRESSIVE_HTTP)) {
+ //noinspection ConstantConditions
+ final String manifest =
+ YoutubeProgressiveDashManifestCreator.fromProgressiveStreamingUrl(
+ stream.getContent(), stream.getItagItem(), videoLength);
+ assertNotBlank(manifest);
+
+ assertManifestGenerated(
+ manifest,
+ stream.getItagItem(),
+ document -> assertAll(
+ () -> assertBaseUrlElement(document),
+ () -> assertSegmentBaseElement(document, stream.getItagItem()),
+ () -> assertInitializationElement(document, stream.getItagItem())
+ )
+ );
+ }
+ }
+
+ @Nonnull
+ private List extends Stream> assertFilterStreams(
+ @Nonnull final List extends Stream> streams,
+ final DeliveryMethod deliveryMethod) {
+
+ final List extends Stream> filteredStreams = streams.stream()
+ .filter(stream -> stream.getDeliveryMethod() == deliveryMethod)
+ .limit(MAX_STREAMS_TO_TEST_PER_METHOD)
+ .collect(Collectors.toList());
+
+ assertAll(filteredStreams.stream()
+ .flatMap(stream -> java.util.stream.Stream.of(
+ () -> assertNotBlank(stream.getContent()),
+ () -> assertNotNull(stream.getItagItem())
+ ))
+ );
+
+ return filteredStreams;
+ }
+
+ private void assertManifestGenerated(final String dashManifest,
+ final ItagItem itagItem,
+ final Consumer additionalAsserts)
+ throws Exception {
+
+ final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory
+ .newInstance();
+ final DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
+ final Document document = documentBuilder.parse(new InputSource(
+ new StringReader(dashManifest)));
+
+ assertAll(
+ () -> assertMpdElement(document),
+ () -> assertPeriodElement(document),
+ () -> assertAdaptationSetElement(document, itagItem),
+ () -> assertRoleElement(document),
+ () -> assertRepresentationElement(document, itagItem),
+ () -> {
+ if (itagItem.itagType.equals(ItagItem.ItagType.AUDIO)) {
+ assertAudioChannelConfigurationElement(document, itagItem);
+ }
+ },
+ () -> additionalAsserts.accept(document)
+ );
+ }
+
+ private void assertMpdElement(@Nonnull final Document document) {
+ final Element element = (Element) document.getElementsByTagName(MPD).item(0);
+ assertNotNull(element);
+ assertNull(element.getParentNode().getNodeValue());
+
+ final String mediaPresentationDuration = element.getAttribute("mediaPresentationDuration");
+ assertNotNull(mediaPresentationDuration);
+ assertTrue(mediaPresentationDuration.startsWith("PT"));
+ }
+
+ private void assertPeriodElement(@Nonnull final Document document) {
+ assertGetElement(document, PERIOD, MPD);
+ }
+
+ private void assertAdaptationSetElement(@Nonnull final Document document,
+ @Nonnull final ItagItem itagItem) {
+ final Element element = assertGetElement(document, ADAPTATION_SET, PERIOD);
+ assertAttrEquals(itagItem.getMediaFormat().getMimeType(), element, "mimeType");
+ }
+
+ private void assertRoleElement(@Nonnull final Document document) {
+ assertGetElement(document, ROLE, ADAPTATION_SET);
+ }
+
+ private void assertRepresentationElement(@Nonnull final Document document,
+ @Nonnull final ItagItem itagItem) {
+ final Element element = assertGetElement(document, REPRESENTATION, ADAPTATION_SET);
+
+ assertAttrEquals(itagItem.getBitrate(), element, "bandwidth");
+ assertAttrEquals(itagItem.getCodec(), element, "codecs");
+
+ if (itagItem.itagType == ItagItem.ItagType.VIDEO_ONLY
+ || itagItem.itagType == ItagItem.ItagType.VIDEO) {
+ assertAttrEquals(itagItem.getFps(), element, "frameRate");
+ assertAttrEquals(itagItem.getHeight(), element, "height");
+ assertAttrEquals(itagItem.getWidth(), element, "width");
+ }
+
+ assertAttrEquals(itagItem.id, element, "id");
+ }
+
+ private void assertAudioChannelConfigurationElement(@Nonnull final Document document,
+ @Nonnull final ItagItem itagItem) {
+ final Element element = assertGetElement(document, AUDIO_CHANNEL_CONFIGURATION,
+ REPRESENTATION);
+ assertAttrEquals(itagItem.getAudioChannels(), element, "value");
+ }
+
+ private void assertSegmentTemplateElement(@Nonnull final Document document) {
+ final Element element = assertGetElement(document, SEGMENT_TEMPLATE, REPRESENTATION);
+
+ final String initializationValue = element.getAttribute("initialization");
+ assertIsValidUrl(initializationValue);
+ assertTrue(initializationValue.endsWith("&sq=0"));
+
+ final String mediaValue = element.getAttribute("media");
+ assertIsValidUrl(mediaValue);
+ assertTrue(mediaValue.endsWith("&sq=$Number$"));
+
+ assertEquals("1", element.getAttribute("startNumber"));
+ }
+
+ private void assertSegmentTimelineAndSElements(@Nonnull final Document document) {
+ final Element element = assertGetElement(document, SEGMENT_TIMELINE, SEGMENT_TEMPLATE);
+ final NodeList childNodes = element.getChildNodes();
+ assertGreater(0, childNodes.getLength());
+
+ assertAll(IntStream.range(0, childNodes.getLength())
+ .mapToObj(childNodes::item)
+ .map(Element.class::cast)
+ .flatMap(sElement -> java.util.stream.Stream.of(
+ () -> assertEquals("S", sElement.getTagName()),
+ () -> assertGreater(0, Integer.parseInt(sElement.getAttribute("d"))),
+ () -> {
+ final String rValue = sElement.getAttribute("r");
+ // A segment duration can or can't be repeated, so test the next segment
+ // if there is no r attribute
+ if (!isBlank(rValue)) {
+ assertGreater(0, Integer.parseInt(rValue));
+ }
+ }
+ )
+ )
+ );
+ }
+
+ private void assertBaseUrlElement(@Nonnull final Document document) {
+ final Element element = assertGetElement(document, BASE_URL, REPRESENTATION);
+ assertIsValidUrl(element.getTextContent());
+ }
+
+ private void assertSegmentBaseElement(@Nonnull final Document document,
+ @Nonnull final ItagItem itagItem) {
+ final Element element = assertGetElement(document, SEGMENT_BASE, REPRESENTATION);
+ assertRangeEquals(itagItem.getIndexStart(), itagItem.getIndexEnd(), element, "indexRange");
+ }
+
+ private void assertInitializationElement(@Nonnull final Document document,
+ @Nonnull final ItagItem itagItem) {
+ final Element element = assertGetElement(document, INITIALIZATION, SEGMENT_BASE);
+ assertRangeEquals(itagItem.getInitStart(), itagItem.getInitEnd(), element, "range");
+ }
+
+
+ private void assertAttrEquals(final int expected,
+ @Nonnull final Element element,
+ final String attribute) {
+
+ final int actual = Integer.parseInt(element.getAttribute(attribute));
+ assertAll(
+ () -> assertGreater(0, actual),
+ () -> assertEquals(expected, actual)
+ );
+ }
+
+ private void assertAttrEquals(final String expected,
+ @Nonnull final Element element,
+ final String attribute) {
+ final String actual = element.getAttribute(attribute);
+ assertAll(
+ () -> assertNotBlank(actual),
+ () -> assertEquals(expected, actual)
+ );
+ }
+
+ private void assertRangeEquals(final int expectedStart,
+ final int expectedEnd,
+ @Nonnull final Element element,
+ final String attribute) {
+ final String range = element.getAttribute(attribute);
+ assertNotBlank(range);
+ final String[] rangeParts = range.split("-");
+ assertEquals(2, rangeParts.length);
+
+ final int actualStart = Integer.parseInt(rangeParts[0]);
+ final int actualEnd = Integer.parseInt(rangeParts[1]);
+
+ assertAll(
+ () -> assertGreaterOrEqual(0, actualStart),
+ () -> assertEquals(expectedStart, actualStart),
+ () -> assertGreater(0, actualEnd),
+ () -> assertEquals(expectedEnd, actualEnd)
+ );
+ }
+
+ @Nonnull
+ private Element assertGetElement(@Nonnull final Document document,
+ final String tagName,
+ final String expectedParentTagName) {
+
+ final Element element = (Element) document.getElementsByTagName(tagName).item(0);
+ assertNotNull(element);
+ assertTrue(element.getParentNode().isEqualNode(
+ document.getElementsByTagName(expectedParentTagName).item(0)),
+ "Element with tag name \"" + tagName + "\" does not have a parent node"
+ + " with tag name \"" + expectedParentTagName + "\"");
+ return element;
+ }
+}
diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/utils/ManifestCreatorCacheTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/utils/ManifestCreatorCacheTest.java
new file mode 100644
index 000000000..83c5c1dfb
--- /dev/null
+++ b/extractor/src/test/java/org/schabi/newpipe/extractor/utils/ManifestCreatorCacheTest.java
@@ -0,0 +1,74 @@
+package org.schabi.newpipe.extractor.utils;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class ManifestCreatorCacheTest {
+ @Test
+ void basicMaximumSizeAndResetTest() {
+ final ManifestCreatorCache cache = new ManifestCreatorCache<>();
+
+ // 30 elements set -> cache resized to 23 -> 5 new elements set to the cache -> 28
+ cache.setMaximumSize(30);
+ setCacheContent(cache);
+ assertEquals(28, cache.size(),
+ "Wrong cache size with default clear factor and 30 as the maximum size");
+ cache.reset();
+
+ assertEquals(0, cache.size(),
+ "The cache has been not cleared after a reset call (wrong cache size)");
+ assertEquals(ManifestCreatorCache.DEFAULT_MAXIMUM_SIZE, cache.getMaximumSize(),
+ "Wrong maximum size after cache reset");
+ assertEquals(ManifestCreatorCache.DEFAULT_CLEAR_FACTOR, cache.getClearFactor(),
+ "Wrong clear factor after cache reset");
+ }
+
+ @Test
+ void maximumSizeAndClearFactorSettersAndResettersTest() {
+ final ManifestCreatorCache cache = new ManifestCreatorCache<>();
+ cache.setMaximumSize(20);
+ cache.setClearFactor(0.5);
+
+ setCacheContent(cache);
+ // 30 elements set -> cache resized to 10 -> 5 new elements set to the cache -> 15
+ assertEquals(15, cache.size(),
+ "Wrong cache size with 0.5 as the clear factor and 20 as the maximum size");
+
+ // Clear factor and maximum size getters tests
+ assertEquals(0.5, cache.getClearFactor(),
+ "Wrong clear factor gotten from clear factor getter");
+ assertEquals(20, cache.getMaximumSize(),
+ "Wrong maximum cache size gotten from maximum size getter");
+
+ // Resetters tests
+ cache.resetMaximumSize();
+ assertEquals(ManifestCreatorCache.DEFAULT_MAXIMUM_SIZE, cache.getMaximumSize(),
+ "Wrong maximum cache size gotten from maximum size getter after maximum size "
+ + "resetter call");
+
+ cache.resetClearFactor();
+ assertEquals(ManifestCreatorCache.DEFAULT_CLEAR_FACTOR, cache.getClearFactor(),
+ "Wrong clear factor gotten from clear factor getter after clear factor resetter "
+ + "call");
+ }
+
+ /**
+ * Adds sample strings to the provided manifest creator cache, in order to test clear factor and
+ * maximum size.
+ * @param cache the cache to fill with some data
+ */
+ private static void setCacheContent(final ManifestCreatorCache cache) {
+ int i = 0;
+ while (i < 26) {
+ cache.put(String.valueOf((char) ('a' + i)), "V");
+ ++i;
+ }
+
+ i = 0;
+ while (i < 9) {
+ cache.put("a" + (char) ('a' + i), "V");
+ ++i;
+ }
+ }
+}
diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/utils/UtilsTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/utils/UtilsTest.java
index 6e6b2a8e0..2dd787b88 100644
--- a/extractor/src/test/java/org/schabi/newpipe/extractor/utils/UtilsTest.java
+++ b/extractor/src/test/java/org/schabi/newpipe/extractor/utils/UtilsTest.java
@@ -3,13 +3,14 @@ package org.schabi.newpipe.extractor.utils;
import org.junit.jupiter.api.Test;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
+import javax.annotation.Nonnull;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertEquals;
-public class UtilsTest {
+class UtilsTest {
@Test
- public void testMixedNumberWordToLong() throws ParsingException {
+ void testMixedNumberWordToLong() throws ParsingException {
assertEquals(10, Utils.mixedNumberWordToLong("10"));
assertEquals(10.5e3, Utils.mixedNumberWordToLong("10.5K"), 0.0);
assertEquals(10.5e6, Utils.mixedNumberWordToLong("10.5M"), 0.0);
@@ -18,13 +19,13 @@ public class UtilsTest {
}
@Test
- public void testJoin() {
+ void testJoin() {
assertEquals("some,random,stuff", Utils.join(",", Arrays.asList("some", "random", "stuff")));
assertEquals("some,random,not-null,stuff", Utils.nonEmptyAndNullJoin(",", new String[]{"some", "null", "random", "", "not-null", null, "stuff"}));
}
@Test
- public void testGetBaseUrl() throws ParsingException {
+ void testGetBaseUrl() throws ParsingException {
assertEquals("https://www.youtube.com", Utils.getBaseUrl("https://www.youtube.com/watch?v=Hu80uDzh8RY"));
assertEquals("vnd.youtube", Utils.getBaseUrl("vnd.youtube://www.youtube.com/watch?v=jZViOEv90dI"));
assertEquals("vnd.youtube", Utils.getBaseUrl("vnd.youtube:jZViOEv90dI"));
@@ -33,7 +34,7 @@ public class UtilsTest {
}
@Test
- public void testFollowGoogleRedirect() {
+ void testFollowGoogleRedirect() {
assertEquals("https://www.youtube.com/watch?v=Hu80uDzh8RY",
Utils.followGoogleRedirectIfNeeded("https://www.google.it/url?sa=t&rct=j&q=&esrc=s&cd=&cad=rja&uact=8&url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DHu80uDzh8RY&source=video"));
assertEquals("https://www.youtube.com/watch?v=0b6cFWG45kA",