1010import org .schabi .newpipe .extractor .StreamingService ;
1111import org .schabi .newpipe .extractor .downloader .Downloader ;
1212import org .schabi .newpipe .extractor .exceptions .ContentNotAvailableException ;
13- import org .schabi .newpipe .extractor .exceptions .ContentNotSupportedException ;
1413import org .schabi .newpipe .extractor .exceptions .ExtractionException ;
1514import org .schabi .newpipe .extractor .exceptions .GeographicRestrictionException ;
1615import org .schabi .newpipe .extractor .exceptions .ParsingException ;
16+ import org .schabi .newpipe .extractor .exceptions .ReCaptchaException ;
1717import org .schabi .newpipe .extractor .exceptions .SoundCloudGoPlusContentException ;
1818import org .schabi .newpipe .extractor .linkhandler .LinkHandler ;
1919import org .schabi .newpipe .extractor .localization .DateWrapper ;
3434
3535public 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 }
0 commit comments