Skip to content

Commit f71cfd4

Browse files
authored
Merge pull request #526 from TiA4f8R/snd-hls-workaround
Support SoundCloud HLS-only tracks by using a workaround
2 parents def745b + 379d731 commit f71cfd4

2 files changed

Lines changed: 150 additions & 43 deletions

File tree

extractor/src/main/java/org/schabi/newpipe/extractor/services/soundcloud/extractors/SoundcloudStreamExtractor.java

Lines changed: 122 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
import org.schabi.newpipe.extractor.StreamingService;
1111
import org.schabi.newpipe.extractor.downloader.Downloader;
1212
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
13-
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
1413
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
1514
import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException;
1615
import org.schabi.newpipe.extractor.exceptions.ParsingException;
16+
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
1717
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
1818
import org.schabi.newpipe.extractor.linkhandler.LinkHandler;
1919
import org.schabi.newpipe.extractor.localization.DateWrapper;
@@ -34,6 +34,7 @@
3434

3535
public class SoundcloudStreamExtractor extends StreamExtractor {
3636
private JsonObject track;
37+
private boolean isAvailable = true;
3738

3839
public SoundcloudStreamExtractor(StreamingService service, LinkHandler linkHandler) {
3940
super(service, linkHandler);
@@ -43,8 +44,9 @@ public SoundcloudStreamExtractor(StreamingService service, LinkHandler linkHandl
4344
public void onFetchPage(@Nonnull Downloader downloader) throws IOException, ExtractionException {
4445
track = SoundcloudParsingHelper.resolveFor(downloader, getUrl());
4546

46-
String policy = track.getString("policy", EMPTY_STRING);
47+
final String policy = track.getString("policy", EMPTY_STRING);
4748
if (!policy.equals("ALLOW") && !policy.equals("MONETIZE")) {
49+
isAvailable = false;
4850
if (policy.equals("SNIP")) {
4951
throw new SoundCloudGoPlusContentException();
5052
}
@@ -181,62 +183,143 @@ public String getHlsUrl() {
181183
}
182184

183185
@Override
184-
public List<AudioStream> getAudioStreams() throws IOException, ExtractionException {
185-
List<AudioStream> audioStreams = new ArrayList<>();
186-
final Downloader dl = NewPipe.getDownloader();
186+
public List<AudioStream> getAudioStreams() throws ExtractionException {
187+
final List<AudioStream> audioStreams = new ArrayList<>();
187188

188189
// Streams can be streamable and downloadable - or explicitly not.
189190
// For playing the track, it is only necessary to have a streamable track.
190191
// If this is not the case, this track might not be published yet.
191-
if (!track.getBoolean("streamable")) return audioStreams;
192+
if (!track.getBoolean("streamable") || !isAvailable) return audioStreams;
192193

193194
try {
194195
final JsonArray transcodings = track.getObject("media").getArray("transcodings");
196+
if (transcodings != null) {
197+
// Get information about what stream formats are available
198+
extractAudioStreams(transcodings, checkMp3ProgressivePresence(transcodings),
199+
audioStreams);
200+
}
201+
} catch (final NullPointerException e) {
202+
throw new ExtractionException("Could not get SoundCloud's tracks audio URL", e);
203+
}
195204

196-
// get information about what stream formats are available
197-
for (Object transcoding : transcodings) {
198-
199-
final JsonObject t = (JsonObject) transcoding;
200-
String url = t.getString("url");
201-
202-
if (!isNullOrEmpty(url)) {
203-
204-
// We can only play the mp3 format, but not handle m3u playlists / streams.
205-
// what about Opus?
206-
if (t.getString("preset").contains("mp3")
207-
&& t.getObject("format").getString("protocol").equals("progressive")) {
208-
// This url points to the endpoint which generates a unique and short living url to the stream.
209-
// TODO: move this to a separate method to generate valid urls when needed (e.g. resuming a paused stream)
210-
url += "?client_id=" + SoundcloudParsingHelper.clientId();
211-
final String res = dl.get(url).responseBody();
212-
213-
try {
214-
JsonObject mp3UrlObject = JsonParser.object().from(res);
215-
// Links in this file are also only valid for a short period.
216-
audioStreams.add(new AudioStream(mp3UrlObject.getString("url"),
217-
MediaFormat.MP3, 128));
218-
} catch (JsonParserException e) {
219-
throw new ParsingException("Could not parse streamable url", e);
220-
}
221-
}
205+
return audioStreams;
206+
}
207+
208+
private static boolean checkMp3ProgressivePresence(final JsonArray transcodings) {
209+
boolean presence = false;
210+
for (final Object transcoding : transcodings) {
211+
final JsonObject transcodingJsonObject = (JsonObject) transcoding;
212+
if (transcodingJsonObject.getString("preset").contains("mp3") &&
213+
transcodingJsonObject.getObject("format").getString("protocol")
214+
.equals("progressive")) {
215+
presence = true;
216+
break;
217+
}
218+
}
219+
return presence;
220+
}
221+
222+
@Nonnull
223+
private static String getTranscodingUrl(final String endpointUrl, final String protocol) throws IOException, ExtractionException {
224+
final Downloader downloader = NewPipe.getDownloader();
225+
final String apiStreamUrl = endpointUrl + "?client_id=" + SoundcloudParsingHelper.clientId();
226+
final String response = downloader.get(apiStreamUrl).responseBody();
227+
final JsonObject urlObject;
228+
try {
229+
urlObject = JsonParser.object().from(response);
230+
} catch (final JsonParserException e) {
231+
throw new ParsingException("Could not parse streamable url", e);
232+
}
233+
final String urlString = urlObject.getString("url");
234+
235+
if (protocol.equals("progressive")) {
236+
return urlString;
237+
} else if (protocol.equals("hls")) {
238+
try {
239+
return getSingleUrlFromHlsManifest(urlString);
240+
} catch (final ParsingException ignored) {
241+
}
242+
}
243+
// else, unknown protocol
244+
return "";
245+
}
246+
247+
private static void extractAudioStreams(final JsonArray transcodings,
248+
final boolean mp3ProgressiveInStreams,
249+
final List<AudioStream> audioStreams) {
250+
for (final Object transcoding : transcodings) {
251+
final JsonObject transcodingJsonObject = (JsonObject) transcoding;
252+
final String url = transcodingJsonObject.getString("url");
253+
if (isNullOrEmpty(url)) {
254+
continue;
255+
}
256+
final String mediaUrl;
257+
final String preset = transcodingJsonObject.getString("preset");
258+
final String protocol = transcodingJsonObject.getObject("format").getString("protocol");
259+
MediaFormat mediaFormat = null;
260+
int bitrate = 0;
261+
if (preset.contains("mp3")) {
262+
// Don't add the MP3 HLS stream if there is a progressive stream present
263+
// because the two have the same bitrate
264+
if (mp3ProgressiveInStreams && protocol.equals("hls")) {
265+
continue;
222266
}
267+
mediaFormat = MediaFormat.MP3;
268+
bitrate = 128;
269+
} else if (preset.contains("opus")) {
270+
mediaFormat = MediaFormat.OPUS;
271+
bitrate = 64;
223272
}
224273

225-
} catch (NullPointerException e) {
226-
throw new ExtractionException("Could not get SoundCloud's track audio url", e);
274+
if (mediaFormat != null) {
275+
try {
276+
mediaUrl = getTranscodingUrl(url, protocol);
277+
if (!mediaUrl.isEmpty()) {
278+
audioStreams.add(new AudioStream(mediaUrl, mediaFormat, bitrate));
279+
}
280+
} catch (final Exception ignored) {
281+
// something went wrong when parsing this transcoding, don't add it to
282+
// audioStreams
283+
}
284+
}
227285
}
286+
}
287+
288+
/** Parses a SoundCloud HLS manifest to get a single URL of HLS streams.
289+
* <p>
290+
* This method downloads the provided manifest URL, find all web occurrences in the manifest,
291+
* get the last segment URL, changes its segment range to {@code 0/track-length} and return
292+
* this string.
293+
* @param hlsManifestUrl the URL of the manifest to be parsed
294+
* @return a single URL that contains a range equal to the length of the track
295+
*/
296+
private static String getSingleUrlFromHlsManifest(final String hlsManifestUrl) throws ParsingException {
297+
final Downloader dl = NewPipe.getDownloader();
298+
final String hlsManifestResponse;
228299

229-
if (audioStreams.isEmpty()) {
230-
throw new ContentNotSupportedException("HLS audio streams are not yet supported");
300+
try {
301+
hlsManifestResponse = dl.get(hlsManifestUrl).responseBody();
302+
} catch (final IOException | ReCaptchaException e) {
303+
throw new ParsingException("Could not get SoundCloud HLS manifest");
231304
}
232305

233-
return audioStreams;
306+
final String[] lines = hlsManifestResponse.split("\\r?\\n");
307+
for (int l = lines.length - 1; l >= 0; l--) {
308+
final String line = lines[l];
309+
// get the last URL from manifest, because it contains the range of the stream
310+
if (line.trim().length() != 0 && !line.startsWith("#") && line.startsWith("https")) {
311+
final String[] hlsLastRangeUrlArray = line.split("/");
312+
return HTTPS + hlsLastRangeUrlArray[2] + "/media/0/" + hlsLastRangeUrlArray[5] + "/"
313+
+ hlsLastRangeUrlArray[6];
314+
}
315+
}
316+
throw new ParsingException("Could not get any URL from HLS manifest");
234317
}
235318

236-
private static String urlEncode(String value) {
319+
private static String urlEncode(final String value) {
237320
try {
238321
return URLEncoder.encode(value, UTF_8);
239-
} catch (UnsupportedEncodingException e) {
322+
} catch (final UnsupportedEncodingException e) {
240323
throw new IllegalStateException(e);
241324
}
242325
}

extractor/src/test/java/org/schabi/newpipe/extractor/services/soundcloud/SoundcloudStreamExtractorTest.java

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
package org.schabi.newpipe.extractor.services.soundcloud;
22

33
import org.junit.BeforeClass;
4-
import org.junit.Ignore;
54
import org.junit.Test;
65
import org.schabi.newpipe.downloader.DownloaderTestImpl;
6+
import org.schabi.newpipe.extractor.MediaFormat;
77
import org.schabi.newpipe.extractor.NewPipe;
88
import org.schabi.newpipe.extractor.StreamingService;
9-
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
109
import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException;
1110
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
1211
import org.schabi.newpipe.extractor.services.DefaultStreamExtractorTest;
12+
import org.schabi.newpipe.extractor.stream.AudioStream;
1313
import org.schabi.newpipe.extractor.stream.StreamExtractor;
1414
import org.schabi.newpipe.extractor.stream.StreamType;
1515

@@ -19,12 +19,14 @@
1919

2020
import javax.annotation.Nullable;
2121

22+
import static junit.framework.TestCase.assertEquals;
23+
import static org.hamcrest.CoreMatchers.containsString;
24+
import static org.hamcrest.MatcherAssert.assertThat;
2225
import static org.schabi.newpipe.extractor.ServiceList.SoundCloud;
2326

2427
public class SoundcloudStreamExtractorTest {
2528
private static final String SOUNDCLOUD = "https://soundcloud.com/";
2629

27-
@Ignore("Ignore until #526 is merged. Throwing the ContentNotSupportedException is wrong and going to be fixed by that PR.")
2830
public static class SoundcloudGeoRestrictedTrack extends DefaultStreamExtractorTest {
2931
private static final String ID = "one-touch";
3032
private static final String UPLOADER = SOUNDCLOUD + "jessglynne";
@@ -59,6 +61,7 @@ public void geoRestrictedContent() throws Exception {
5961
@Nullable @Override public String expectedTextualUploadDate() { return "2019-05-16 16:28:45"; }
6062
@Override public long expectedLikeCountAtLeast() { return -1; }
6163
@Override public long expectedDislikeCountAtLeast() { return -1; }
64+
@Override public boolean expectedHasAudioStreams() { return false; }
6265
@Override public boolean expectedHasVideoStreams() { return false; }
6366
@Override public boolean expectedHasSubtitles() { return false; }
6467
@Override public boolean expectedHasFrames() { return false; }
@@ -100,7 +103,9 @@ public void goPlusContent() throws Exception {
100103
@Nullable @Override public String expectedTextualUploadDate() { return "2016-11-11 01:16:37"; }
101104
@Override public long expectedLikeCountAtLeast() { return -1; }
102105
@Override public long expectedDislikeCountAtLeast() { return -1; }
106+
@Override public boolean expectedHasAudioStreams() { return false; }
103107
@Override public boolean expectedHasVideoStreams() { return false; }
108+
@Override public boolean expectedHasRelatedStreams() { return false; }
104109
@Override public boolean expectedHasSubtitles() { return false; }
105110
@Override public boolean expectedHasFrames() { return false; }
106111
@Override public int expectedStreamSegmentsCount() { return 0; }
@@ -143,6 +148,25 @@ public static void setUp() throws Exception {
143148
@Override public boolean expectedHasSubtitles() { return false; }
144149
@Override public boolean expectedHasFrames() { return false; }
145150
@Override public int expectedStreamSegmentsCount() { return 0; }
146-
}
147151

152+
@Override
153+
@Test
154+
public void testAudioStreams() throws Exception {
155+
super.testAudioStreams();
156+
final List<AudioStream> audioStreams = extractor.getAudioStreams();
157+
assertEquals(2, audioStreams.size());
158+
for (final AudioStream audioStream : audioStreams) {
159+
final String mediaUrl = audioStream.getUrl();
160+
if (audioStream.getFormat() == MediaFormat.OPUS) {
161+
// assert that it's an OPUS 64 kbps media URL with a single range which comes from an HLS SoundCloud CDN
162+
assertThat(mediaUrl, containsString("-hls-opus-media.sndcdn.com"));
163+
assertThat(mediaUrl, containsString(".64.opus"));
164+
}
165+
if (audioStream.getFormat() == MediaFormat.MP3) {
166+
// assert that it's a MP3 128 kbps media URL which comes from a progressive SoundCloud CDN
167+
assertThat(mediaUrl, containsString("-media.sndcdn.com/bKOA7Pwbut93.128.mp3"));
168+
}
169+
}
170+
}
171+
}
148172
}

0 commit comments

Comments
 (0)