diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/Extractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/Extractor.java index e973b44167..cc75c8e7ec 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/Extractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/Extractor.java @@ -7,6 +7,7 @@ import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.localization.TimeAgoParser; +import org.schabi.newpipe.extractor.utils.ExtractorLogger; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -15,6 +16,8 @@ import java.util.Objects; public abstract class Extractor { + private final String TAG = getClass().getSimpleName() + "@" + hashCode(); + /** * {@link StreamingService} currently related to this extractor.
* Useful for getting other things from a service (like the url handlers for @@ -54,7 +57,9 @@ public LinkHandler getLinkHandler() { * @throws ExtractionException if the pages content is not understood */ public void fetchPage() throws IOException, ExtractionException { + ExtractorLogger.d(TAG, "base fetchPage called"); if (pageFetched) { + ExtractorLogger.d(TAG, "Page already fetched"); return; } onFetchPage(downloader); diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/Info.java b/extractor/src/main/java/org/schabi/newpipe/extractor/Info.java index 78a15553b1..0bcfeb559a 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/Info.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/Info.java @@ -2,6 +2,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.linkhandler.LinkHandler; +import org.schabi.newpipe.extractor.utils.ExtractorLogger; import java.io.Serializable; import java.util.ArrayList; @@ -10,6 +11,7 @@ public abstract class Info implements Serializable { + private static final String TAG = "Info"; private final int serviceId; /** * Id of this Info object
@@ -52,6 +54,7 @@ public Info(final int serviceId, this.url = url; this.originalUrl = originalUrl; this.name = name; + ExtractorLogger.d(TAG, "Base Created " + this); } public Info(final int serviceId, final LinkHandler linkHandler, final String name) { diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/NewPipe.java b/extractor/src/main/java/org/schabi/newpipe/extractor/NewPipe.java index 7dfa4c4cde..0fd06872b4 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/NewPipe.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/NewPipe.java @@ -24,6 +24,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.utils.ExtractorLogger; import java.util.List; @@ -34,6 +35,7 @@ * Provides access to streaming services supported by NewPipe. */ public final class NewPipe { + private static final String TAG = NewPipe.class.getSimpleName(); private static Downloader downloader; private static Localization preferredLocalization; private static ContentCountry preferredContentCountry; @@ -42,15 +44,19 @@ private NewPipe() { } public static void init(final Downloader d) { + ExtractorLogger.d(TAG, "Default init called"); init(d, Localization.DEFAULT); } public static void init(final Downloader d, final Localization l) { + ExtractorLogger.d(TAG, "Default init called with localization"); init(d, l, l.getCountryCode().isEmpty() ? ContentCountry.DEFAULT : new ContentCountry(l.getCountryCode())); } public static void init(final Downloader d, final Localization l, final ContentCountry c) { + ExtractorLogger.d(TAG, "Initializing with downloader: " + + d.getClass().getSimpleName() + ", " + l + ", " + c); downloader = d; preferredLocalization = l; preferredContentCountry = c; diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/StreamingServiceId.java b/extractor/src/main/java/org/schabi/newpipe/extractor/StreamingServiceId.java new file mode 100644 index 0000000000..5c40b5d5c0 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/StreamingServiceId.java @@ -0,0 +1,23 @@ +package org.schabi.newpipe.extractor; + +import java.util.Objects; + +public enum StreamingServiceId { + NO_SERVICE_ID, + YOUTUBE, + SOUNDCLOUD, + MEDIACCC, + PEERTUBE, + BANDCAMP; + + + private static final StreamingServiceId[] VALUES = values(); + + public static String nameFromId(final int serviceId) { + try { + return VALUES[Objects.checkIndex(serviceId + 1, VALUES.length)].name(); + } catch (final IndexOutOfBoundsException e) { + throw new IllegalArgumentException("Invalid serviceId: " + serviceId, e); + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/downloader/Response.java b/extractor/src/main/java/org/schabi/newpipe/extractor/downloader/Response.java index ac792dc756..87c3577ef4 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/downloader/Response.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/downloader/Response.java @@ -6,6 +6,9 @@ import java.util.List; import java.util.Map; +import org.schabi.newpipe.extractor.exceptions.HttpResponseException; +import org.schabi.newpipe.extractor.utils.HttpUtils; + /** * A Data class used to hold the results from requests made by the Downloader implementation. */ @@ -80,4 +83,21 @@ public String getHeader(final String name) { return null; } + // CHECKSTYLE:OFF + /** + * Helper function simply to make it easier to validate response code inline + * before getting the code/body/latestUrl/etc. + * Validates the response codes for the given {@link Response}, and throws a {@link HttpResponseException} if the code is invalid + * @see HttpUtils#validateResponseCode(Response, int...) + * @param validResponseCodes Expected valid response codes + * @return {@link this} response + * @throws HttpResponseException Thrown when the response code is not in {@code validResponseCodes}, + * or when {@code validResponseCodes} is empty and the code is a 4xx or 5xx error. + */ + // CHECKSTYLE:ON + public Response validateResponseCode(final int... validResponseCodes) + throws HttpResponseException { + HttpUtils.validateResponseCode(this, validResponseCodes); + return this; + } } diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/exceptions/HttpResponseException.java b/extractor/src/main/java/org/schabi/newpipe/extractor/exceptions/HttpResponseException.java new file mode 100644 index 0000000000..c07850a9d3 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/exceptions/HttpResponseException.java @@ -0,0 +1,15 @@ +package org.schabi.newpipe.extractor.exceptions; + +import java.io.IOException; +import org.schabi.newpipe.extractor.downloader.Response; + +public class HttpResponseException extends IOException { + public HttpResponseException(final Response response) { + this("Error in HTTP Response for " + response.latestUrl() + "\n\t" + + response.responseCode() + " - " + response.responseMessage()); + } + + public HttpResponseException(final String message) { + super(message); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/soundcloud/SoundcloudParsingHelper.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/soundcloud/SoundcloudParsingHelper.java index ae8fd77d6d..5a83fdecbc 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/soundcloud/SoundcloudParsingHelper.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/soundcloud/SoundcloudParsingHelper.java @@ -5,6 +5,7 @@ import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import static org.schabi.newpipe.extractor.utils.Utils.replaceHttpWithHttps; +import static org.schabi.newpipe.extractor.utils.HttpUtils.validateResponseCode; import com.grack.nanojson.JsonArray; import com.grack.nanojson.JsonObject; @@ -12,7 +13,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.MultiInfoItemsCollector; import org.schabi.newpipe.extractor.Image; @@ -28,6 +28,7 @@ import org.schabi.newpipe.extractor.services.soundcloud.extractors.SoundcloudLikesInfoItemExtractor; import org.schabi.newpipe.extractor.services.soundcloud.extractors.SoundcloudStreamInfoItemExtractor; import org.schabi.newpipe.extractor.stream.StreamInfoItemsCollector; +import org.schabi.newpipe.extractor.utils.ExtractorLogger; import org.schabi.newpipe.extractor.utils.ImageSuffix; import org.schabi.newpipe.extractor.utils.JsonUtils; import org.schabi.newpipe.extractor.utils.Parser; @@ -86,6 +87,7 @@ public final class SoundcloudParsingHelper { private static final List VISUALS_IMAGE_SUFFIXES = List.of(new ImageSuffix("t1240x260", 1240, 260, MEDIUM), new ImageSuffix("t2480x520", 2480, 520, MEDIUM)); + public static final String TAG = SoundcloudParsingHelper.class.getSimpleName(); private static String clientId; public static final String SOUNDCLOUD_API_V2_URL = "https://api-v2.soundcloud.com/"; @@ -99,13 +101,14 @@ private SoundcloudParsingHelper() { public static synchronized String clientId() throws ExtractionException, IOException { if (!isNullOrEmpty(clientId)) { + ExtractorLogger.d(TAG, "Returning clientId=" + clientId); return clientId; } final Downloader dl = NewPipe.getDownloader(); - final Response download = dl.get("https://soundcloud.com"); - final String responseBody = download.responseBody(); + final Response downloadResponse = dl.get("https://soundcloud.com").validateResponseCode(); + final String responseBody = downloadResponse.responseBody(); final String clientIdPattern = ",client_id:\"(.*?)\""; final Document doc = Jsoup.parse(responseBody); @@ -116,12 +119,15 @@ public static synchronized String clientId() throws ExtractionException, IOExcep final var headers = Map.of("Range", List.of("bytes=0-50000")); - for (final Element element : possibleScripts) { + for (final var element : possibleScripts) { final String srcUrl = element.attr("src"); if (!isNullOrEmpty(srcUrl)) { try { + ExtractorLogger.d(TAG, "Searching for clientId in " + srcUrl); clientId = Parser.matchGroup1(clientIdPattern, dl.get(srcUrl, headers) + .validateResponseCode() .responseBody()); + ExtractorLogger.d(TAG, "Found clientId=" + clientId); return clientId; } catch (final RegexException ignored) { // Ignore it and proceed to try searching other script @@ -148,13 +154,16 @@ public static OffsetDateTime parseDateFrom(final String textualUploadDate) } } + // CHECKSTYLE:OFF /** - * Call the endpoint "/resolve" of the API.

+ * Call the endpoint "/resolve" of the API. *

- * See https://developers.soundcloud.com/docs/api/reference#resolve + * See https://web.archive.org/web/20170804051146/https://developers.soundcloud.com/docs/api/reference#resolve */ + // CHECKSTYLE:ON public static JsonObject resolveFor(@Nonnull final Downloader downloader, final String url) throws IOException, ExtractionException { + ExtractorLogger.d(TAG, "resolveFor(" + url + ")"); final String apiUrl = SOUNDCLOUD_API_V2_URL + "resolve" + "?url=" + Utils.encodeUrlUtf8(url) + "&client_id=" + clientId(); @@ -177,10 +186,11 @@ public static JsonObject resolveFor(@Nonnull final Downloader downloader, final public static String resolveUrlWithEmbedPlayer(final String apiUrl) throws IOException, ReCaptchaException { - final String response = NewPipe.getDownloader().get("https://w.soundcloud.com/player/?url=" - + Utils.encodeUrlUtf8(apiUrl), SoundCloud.getLocalization()).responseBody(); - - return Jsoup.parse(response).select("link[rel=\"canonical\"]").first() + final var response = NewPipe.getDownloader().get("https://w.soundcloud.com/player/?url=" + + Utils.encodeUrlUtf8(apiUrl), SoundCloud.getLocalization()); + validateResponseCode(response); + final var responseBody = response.responseBody(); + return Jsoup.parse(responseBody).select("link[rel=\"canonical\"]").first() .attr("abs:href"); } @@ -189,6 +199,7 @@ public static String resolveUrlWithEmbedPlayer(final String apiUrl) throws IOExc * * @return the resolved id */ + // TODO: what makes this method different from the others? Don' they all return the same? public static String resolveIdWithWidgetApi(final String urlString) throws IOException, ParsingException { String fixedUrl = urlString; @@ -224,9 +235,12 @@ public static String resolveIdWithWidgetApi(final String urlString) throws IOExc final String widgetUrl = "https://api-widget.soundcloud.com/resolve?url=" + Utils.encodeUrlUtf8(url.toString()) + "&format=json&client_id=" + SoundcloudParsingHelper.clientId(); - final String response = NewPipe.getDownloader().get(widgetUrl, - SoundCloud.getLocalization()).responseBody(); - final JsonObject o = JsonParser.object().from(response); + + final var response = NewPipe.getDownloader().get(widgetUrl, + SoundCloud.getLocalization()); + + final var responseBody = response.validateResponseCode().responseBody(); + final JsonObject o = JsonParser.object().from(responseBody); return String.valueOf(JsonUtils.getValue(o, "id")); } catch (final JsonParserException e) { throw new ParsingException("Could not parse JSON response", e); 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 595862bde7..f03e777ef5 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 @@ -11,12 +11,9 @@ import com.grack.nanojson.JsonArray; import com.grack.nanojson.JsonObject; -import com.grack.nanojson.JsonParser; -import com.grack.nanojson.JsonParserException; import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.downloader.Downloader; import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; @@ -30,11 +27,14 @@ 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.HlsAudioStream; +import org.schabi.newpipe.extractor.stream.SoundcloudHlsUtils; 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; import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.extractor.utils.ExtractorLogger; import org.schabi.newpipe.extractor.utils.Utils; import java.io.IOException; @@ -46,6 +46,7 @@ import javax.annotation.Nullable; public class SoundcloudStreamExtractor extends StreamExtractor { + public static final String TAG = SoundcloudStreamExtractor.class.getSimpleName(); private JsonObject track; private boolean isAvailable = true; @@ -57,9 +58,12 @@ public SoundcloudStreamExtractor(final StreamingService service, @Override public void onFetchPage(@Nonnull final Downloader downloader) throws IOException, ExtractionException { - track = SoundcloudParsingHelper.resolveFor(downloader, getUrl()); + final var url = getUrl(); + ExtractorLogger.d(TAG, "onFetchPage(" + url + ")"); + track = SoundcloudParsingHelper.resolveFor(downloader, url); final String policy = track.getString("policy", ""); + ExtractorLogger.d(TAG, "policy is: " + policy); if (!policy.equals("ALLOW") && !policy.equals("MONETIZE")) { isAvailable = false; @@ -166,6 +170,7 @@ public List getAudioStreams() throws ExtractionException { // For playing the track, it is only necessary to have a streamable track. // If this is not the case, this track might not be published yet. if (!track.getBoolean("streamable") || !isAvailable) { + ExtractorLogger.d(TAG, "Not streamable track: " + getUrl()); return audioStreams; } @@ -174,6 +179,7 @@ public List getAudioStreams() throws ExtractionException { .getArray("transcodings"); if (!isNullOrEmpty(transcodings)) { // Get information about what stream formats are available + ExtractorLogger.d(TAG, "Extracting audio streams for " + getName()); extractAudioStreams(transcodings, audioStreams); } } catch (final NullPointerException e) { @@ -183,25 +189,106 @@ public List getAudioStreams() throws ExtractionException { return audioStreams; } - @Nonnull - private String getTranscodingUrl(final String endpointUrl) - throws IOException, ExtractionException { - String apiStreamUrl = endpointUrl + "?client_id=" + clientId(); + // TODO: put this somewhere better + /** + * Constructs the API endpoint url for this track that will be called to get the url + * for retrieving the actual byte data for playback + * (e.g. the actual url could be an m3u8 playlist) + * @param baseUrl The baseUrl needed to construct the full url + * @return The full API endpoint url to call to get the actual playback url + * @throws IOException If there is a problem getting clientId + * @throws ExtractionException For the same reason + */ + private String getApiStreamUrl(final String baseUrl) throws ExtractionException, IOException { + String apiStreamUrl = baseUrl + "?client_id=" + clientId(); final String trackAuthorization = track.getString("track_authorization"); if (!isNullOrEmpty(trackAuthorization)) { apiStreamUrl += "&track_authorization=" + trackAuthorization; } + return apiStreamUrl; + } - 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); + public static final class StreamBuildResult { + public final String contentUrl; + public final MediaFormat mediaFormat; + + public StreamBuildResult(final String contentUrl, final MediaFormat mediaFormat) { + this.contentUrl = contentUrl; + this.mediaFormat = mediaFormat; + } + + @Override + public String toString() { + return "StreamBuildResult{" + + "contentUrl='" + contentUrl + '\'' + + ", mediaFormat=" + mediaFormat + + '}'; + } + } + + /** + * Builds the common audio stream components for all SoundCloud audio streams

+ * Returns the stream content url if we support this type of transcoding, {@code null} otherwise + * @param transcoding The SoundCloud JSON transcoding object for this stream + * @param builder AudioStream builder to set the common values + * @return the stream content url if this transcoding is supported and common values were built + * {@code null} otherwise + */ + @Nullable + private StreamBuildResult buildBaseAudioStream(final JsonObject transcoding, + final AudioStream.Builder builder) + throws ExtractionException, IOException { + ExtractorLogger.d(TAG, getName() + " Building base audio stream info"); + final var preset = transcoding.getString("preset", ID_UNKNOWN); + final MediaFormat mediaFormat; + if (preset.contains("mp3")) { + mediaFormat = MediaFormat.MP3; + builder.setAverageBitrate(128); + } else if (preset.contains("opus")) { + mediaFormat = MediaFormat.OPUS; + builder.setAverageBitrate(64); + builder.setDeliveryMethod(DeliveryMethod.HLS); + } else if (preset.contains("aac_160k")) { + mediaFormat = MediaFormat.M4A; + builder.setAverageBitrate(160); + } else { + // Unknown format, return null to skip to the next audio stream + return null; } - return urlObject.getString("url"); + builder.setMediaFormat(mediaFormat); + + builder.setId(preset); + final var url = transcoding.getString("url"); + final var hlsPlaylistUrl = SoundcloudHlsUtils.getStreamContentUrl(getApiStreamUrl(url)); + builder.setContent(hlsPlaylistUrl, true); + return new StreamBuildResult(hlsPlaylistUrl, mediaFormat); + } + + private HlsAudioStream buildHlsAudioStream(final JsonObject transcoding) + throws ExtractionException, IOException { + ExtractorLogger.d(TAG, getName() + "Extracting hls audio stream"); + final var builder = new HlsAudioStream.Builder(); + final StreamBuildResult buildResult = buildBaseAudioStream(transcoding, builder); + if (buildResult == null) { + return null; + } + + builder.setApiStreamUrl(getApiStreamUrl(transcoding.getString("url"))); + builder.setPlaylistId(SoundcloudHlsUtils.extractHlsPlaylistId(buildResult.contentUrl, + buildResult.mediaFormat)); + + return builder.build(); + } + + private AudioStream buildProgressiveAudioStream(final JsonObject transcoding) + throws ExtractionException, IOException { + ExtractorLogger.d(TAG, getName() + "Extracting progressive audio stream"); + final var builder = new AudioStream.Builder(); + final StreamBuildResult buildResult = buildBaseAudioStream(transcoding, builder); + return buildResult == null ? null : builder.build(); + // TODO: anything else? } private void extractAudioStreams(@Nonnull final JsonArray transcodings, @@ -215,47 +302,35 @@ private void extractAudioStreams(@Nonnull final JsonArray transcodings, return; } - try { - final String preset = transcoding.getString("preset", ID_UNKNOWN); - final String protocol = transcoding.getObject("format") - .getString("protocol"); - - if (protocol.contains("encrypted")) { - // Skip DRM-protected streams, which have encrypted in their protocol - // name - return; - } - - final AudioStream.Builder builder = new AudioStream.Builder() - .setId(preset); + final String protocol = transcoding.getObject("format") + .getString("protocol"); - if (protocol.equals("hls")) { - builder.setDeliveryMethod(DeliveryMethod.HLS); - } - - builder.setContent(getTranscodingUrl(url), true); - - if (preset.contains("mp3")) { - builder.setMediaFormat(MediaFormat.MP3); - builder.setAverageBitrate(128); - } else if (preset.contains("opus")) { - builder.setMediaFormat(MediaFormat.OPUS); - builder.setAverageBitrate(64); - } else if (preset.contains("aac_160k")) { - builder.setMediaFormat(MediaFormat.M4A); - builder.setAverageBitrate(160); - } else { - // Unknown format, skip to the next audio stream - return; - } + if (protocol.contains("encrypted")) { + // Skip DRM-protected streams, which have encrypted in their protocol + // name + return; + } - final AudioStream audioStream = builder.build(); - if (!Stream.containSimilarStream(audioStream, audioStreams)) { + final AudioStream audioStream; + try { + // SoundCloud only has one progressive stream, the rest are HLS + audioStream = protocol.equals("hls") + ? buildHlsAudioStream(transcoding) + : buildProgressiveAudioStream(transcoding); + if (audioStream != null + && !Stream.containSimilarStream(audioStream, audioStreams)) { + ExtractorLogger.d(TAG, audioStream.getFormat().getName() + " " + + getName() + " " + audioStream.getContent()); audioStreams.add(audioStream); } - } catch (final ExtractionException | IOException ignored) { + } catch (final ExtractionException | IOException e) { // Something went wrong when trying to get and add this audio stream, // skip to the next one + final var preset = transcoding.getString("preset", "unknown"); + ExtractorLogger.e(TAG, + getName() + " Failed to extract audio stream for transcoding " + + '[' + protocol + '/' + preset + "] " + url, + e); } }); } 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 410a20592f..935ff1ff73 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 @@ -28,7 +28,7 @@ import java.util.Locale; import java.util.Objects; -public final class AudioStream extends Stream { +public class AudioStream extends Stream { public static final int UNKNOWN_BITRATE = -1; private final int averageBitrate; @@ -60,7 +60,7 @@ public final class AudioStream extends Stream { * Class to build {@link AudioStream} objects. */ @SuppressWarnings("checkstyle:hiddenField") - public static final class Builder { + public static class Builder { private String id; private String content; private boolean isUrl; diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/HlsAudioStream.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/HlsAudioStream.java new file mode 100644 index 0000000000..d935abb419 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/HlsAudioStream.java @@ -0,0 +1,60 @@ +package org.schabi.newpipe.extractor.stream; + +import org.schabi.newpipe.extractor.exceptions.ExtractionException; + +import java.io.IOException; + +import javax.annotation.Nonnull; + +public class HlsAudioStream extends AudioStream implements RefreshableStream { + private final String apiStreamUrl; + private final String playlistId; + + HlsAudioStream(final Builder builder) { + super(builder); + apiStreamUrl = builder.apiStreamUrl; + playlistId = builder.playlistId; + } + + @Nonnull + public String fetchLatestUrl() throws IOException, ExtractionException { + return SoundcloudHlsUtils.getStreamContentUrl(apiStreamUrl); + } + + @Nonnull + public String initialUrl() { + return getContent(); + } + + @Override + public String playlistId() { + return playlistId; + } + + @SuppressWarnings({"checkstyle:HiddenField", "UnusedReturnValue"}) + public static class Builder extends AudioStream.Builder { + private String apiStreamUrl; + private String playlistId; + + public Builder() { + setDeliveryMethod(DeliveryMethod.HLS); + } + + @Override + @Nonnull + public HlsAudioStream build() { + validateBuild(); + return new HlsAudioStream(this); + } + + public Builder setApiStreamUrl(@Nonnull final String apiStreamUrl) { + this.apiStreamUrl = apiStreamUrl; + return this; + } + + public Builder setPlaylistId(@Nonnull final String playlistId) { + this.playlistId = playlistId; + return this; + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/RefreshableStream.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/RefreshableStream.java new file mode 100644 index 0000000000..5d5a456adb --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/RefreshableStream.java @@ -0,0 +1,15 @@ +package org.schabi.newpipe.extractor.stream; + +import org.schabi.newpipe.extractor.exceptions.ExtractionException; + +import javax.annotation.Nonnull; +import java.io.IOException; + +@SuppressWarnings("checkstyle:LeftCurly") +public interface RefreshableStream { + @Nonnull + String fetchLatestUrl() throws IOException, ExtractionException; + String initialUrl(); + + String playlistId(); +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/stream/SoundcloudHlsUtils.java b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/SoundcloudHlsUtils.java new file mode 100644 index 0000000000..6dba187222 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/stream/SoundcloudHlsUtils.java @@ -0,0 +1,90 @@ +package org.schabi.newpipe.extractor.stream; + +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; +import com.grack.nanojson.JsonParserException; + +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.utils.ExtractorLogger; +import org.schabi.newpipe.extractor.utils.Parser; + +import java.io.IOException; +import java.util.regex.Pattern; + +import javax.annotation.Nonnull; + +public final class SoundcloudHlsUtils { + private static final String TAG = HlsAudioStream.class.getSimpleName(); + private static final Pattern MP3_HLS_PATTERN = + Pattern.compile("https://cf-hls-media\\.sndcdn.com/playlist/" + + "([a-zA-Z0-9]+)\\.128\\.mp3/playlist\\.m3u8"); + private static final Pattern AAC_HLS_PATTERN = + Pattern.compile("https://playback\\.media-streaming\\.soundcloud\\.cloud/" + + "([a-zA-Z0-9]+)/aac_160k/[a-f0-9\\-]+/playlist\\.m3u8"); + private static final Pattern OPUS_HLS_PATTERN = + Pattern.compile("https://cf-hls-opus-media\\.sndcdn\\.com/" + + "playlist/([a-zA-Z0-9]+)\\.64\\.opus/playlist\\.m3u8"); + + private SoundcloudHlsUtils() { } + + /** + * Calls the API endpoint url for this stream to get the url for retrieving the + * actual byte data for playback (returns the m3u8 playlist url for HLS streams, + * and the url to get the full binary track for progressives streams)

+ * + * NOTE: this returns a different url every time! (for SoundCloud) + * @param apiStreamUrl The url to call to get the actual stream data url + * @return The url for playing the audio (e.g. playlist.m3u8) + * @throws IOException If there's a problem calling the endpoint + * @throws ExtractionException for the same reason + */ + public static String getStreamContentUrl(final String apiStreamUrl) + throws IOException, ExtractionException { + ExtractorLogger.d(TAG, "Fetching content url for " + apiStreamUrl); + final String response = NewPipe.getDownloader() + .get(apiStreamUrl) + .validateResponseCode() + .responseBody(); + final JsonObject urlObject; + try { + urlObject = JsonParser.object().from(response); + } catch (final JsonParserException e) { + // TODO: Improve error message. + throw new ParsingException("Could not parse stream content from URL (" + + response + ")", e); + } + + return urlObject.getString("url"); + } + + @Nonnull + public static String extractHlsPlaylistId(final String hlsPlaylistUrl, + final MediaFormat mediaFormat) + throws ExtractionException { + switch (mediaFormat) { + case MP3: return extractHlsMp3PlaylistId(hlsPlaylistUrl); + case M4A: return extractHlsAacPlaylistId(hlsPlaylistUrl); + case OPUS: return extractHlsOpusPlaylistId(hlsPlaylistUrl); + default: + throw new IllegalArgumentException("Unsupported media format: " + mediaFormat); + } + } + + private static String extractHlsMp3PlaylistId(final String hlsPlaylistUrl) + throws ExtractionException { + return Parser.matchGroup1(MP3_HLS_PATTERN, hlsPlaylistUrl); + } + + private static String extractHlsAacPlaylistId(final String hlsPlaylistUrl) + throws ExtractionException { + return Parser.matchGroup1(AAC_HLS_PATTERN, hlsPlaylistUrl); + } + + private static String extractHlsOpusPlaylistId(final String hlsPlaylistUrl) + throws ExtractionException { + return Parser.matchGroup1(OPUS_HLS_PATTERN, hlsPlaylistUrl); + } +} 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 21d07cd94a..9339ae5a4f 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 @@ -31,6 +31,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.localization.DateWrapper; import org.schabi.newpipe.extractor.utils.ExtractorHelper; +import org.schabi.newpipe.extractor.utils.ExtractorLogger; import java.io.IOException; import java.util.List; @@ -44,7 +45,7 @@ * Info object for opened contents, i.e. the content ready to play. */ public class StreamInfo extends Info { - + private static final String TAG = StreamInfo.class.getSimpleName(); public static class StreamExtractException extends ExtractionException { StreamExtractException(final String message) { super(message); @@ -61,19 +62,37 @@ public StreamInfo(final int serviceId, super(serviceId, id, url, originalUrl, name); this.streamType = streamType; this.ageLimit = ageLimit; + ExtractorLogger.d(TAG, "Created " + this); + + } + + @Override + public String toString() { + return TAG + "[" + + "serviceId=" + getServiceId() + + ", url='" + getUrl() + '\'' + + ", originalUrl='" + getOriginalUrl() + '\'' + + ", id='" + getId() + '\'' + + ", name='" + getName() + '\'' + + ", streamType=" + streamType + + ", ageLimit=" + ageLimit + + ']'; } public static StreamInfo getInfo(final String url) throws IOException, ExtractionException { + ExtractorLogger.d(TAG, "getInfo(" + url + ")"); return getInfo(NewPipe.getServiceByUrl(url), url); } public static StreamInfo getInfo(@Nonnull final StreamingService service, final String url) throws IOException, ExtractionException { + ExtractorLogger.d(TAG, "getInfo(" + service.getClass().getSimpleName() + ", " + url + ")"); return getInfo(service.getStreamExtractor(url)); } public static StreamInfo getInfo(@Nonnull final StreamExtractor extractor) throws ExtractionException, IOException { + ExtractorLogger.d(TAG, "getInfo(" + extractor.getClass().getSimpleName() + ")"); extractor.fetchPage(); final StreamInfo streamInfo; try { @@ -168,6 +187,18 @@ private static void extractStreams(final StreamInfo streamInfo, // 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())) { + final var errors = streamInfo.getErrors(); + final var url = streamInfo.getOriginalUrl(); + final var name = streamInfo.getName(); + if (errors.isEmpty()) { + ExtractorLogger.e(TAG, "Error extracting " + name + " " + url + + "\nCould not get any stream and didn't catch any errors"); + } else { + errors.forEach(m -> ExtractorLogger.e(TAG, + "Error for " + streamInfo.getOriginalUrl(), + m)); + } + throw new StreamExtractException( "Could not get any stream. See error variable to get further details."); } diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/utils/ExtractorLogger.java b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/ExtractorLogger.java new file mode 100644 index 0000000000..4868faf762 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/ExtractorLogger.java @@ -0,0 +1,48 @@ +package org.schabi.newpipe.extractor.utils; + +public final class ExtractorLogger { + + private ExtractorLogger() { } + + private static Logger logger = new ConsoleLogger(); + + public static void setLogger(final Logger customLogger) { + logger = customLogger; + } + + public static void d(final String tag, final String msg) { + logger.debug(tag, msg); + } + + public static void w(final String tag, final String msg) { + logger.warn(tag, msg); + } + + public static void e(final String tag, final String msg) { + logger.error(tag, msg); + } + + public static void e(final String tag, final String msg, final Throwable t) { + logger.error(tag, msg, t); + } + + // default logger that prints to stdout + private static final class ConsoleLogger implements Logger { + public void debug(final String tag, final String msg) { + System.out.println("[DEBUG][" + tag + "] " + msg); + } + + public void warn(final String tag, final String msg) { + System.out.println("[WARN ][" + tag + "] " + msg); + } + + public void error(final String tag, final String msg) { + System.err.println("[ERROR][" + tag + "] " + msg); + } + + public void error(final String tag, final String msg, final Throwable t) { + System.err.println("[ERROR][" + tag + "] " + msg); + t.printStackTrace(System.err); + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/utils/HttpUtils.java b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/HttpUtils.java new file mode 100644 index 0000000000..31c937ea09 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/HttpUtils.java @@ -0,0 +1,36 @@ +package org.schabi.newpipe.extractor.utils; + +import java.util.Arrays; + +import org.schabi.newpipe.extractor.downloader.Response; +import org.schabi.newpipe.extractor.exceptions.HttpResponseException; + +public final class HttpUtils { + + private HttpUtils() { + // Utility class, no instances allowed + } + + // CHECKSTYLE:OFF + /** + * Validates the response codes for the given {@link Response}, and throws + * a {@link HttpResponseException} if the code is invalid + * @param response The response to validate + * @param validResponseCodes Expected valid response codes + * @throws HttpResponseException Thrown when the response code is not in {@code validResponseCodes}, + * or when {@code validResponseCodes} is empty and the code is a 4xx or 5xx error. + */ + // CHECKSTYLE:ON + public static void validateResponseCode(final Response response, + final int... validResponseCodes) + throws HttpResponseException { + final int code = response.responseCode(); + final var throwError = (validResponseCodes == null || validResponseCodes.length == 0) + ? code >= 400 && code <= 599 + : Arrays.stream(validResponseCodes).noneMatch(c -> c == code); + + if (throwError) { + throw new HttpResponseException(response); + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/utils/Logger.java b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/Logger.java new file mode 100644 index 0000000000..f6bba2a943 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/Logger.java @@ -0,0 +1,8 @@ +package org.schabi.newpipe.extractor.utils; + +public interface Logger { + void debug(String tag, String message); + void warn(String tag, String message); + void error(String tag, String message); + void error(String tag, String message, Throwable t); +}