Skip to content

Commit da52d03

Browse files
[SoundCloud] Refactor Soundcloud audio stream extraction code to separate building Hls and Progressive streams
Add HlsAudioStream to facilitate refreshing expired Hls playlists
1 parent cf1ae71 commit da52d03

File tree

5 files changed

+284
-51
lines changed

5 files changed

+284
-51
lines changed

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

Lines changed: 117 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,9 @@
1111

1212
import com.grack.nanojson.JsonArray;
1313
import com.grack.nanojson.JsonObject;
14-
import com.grack.nanojson.JsonParser;
15-
import com.grack.nanojson.JsonParserException;
1614

1715
import org.schabi.newpipe.extractor.Image;
1816
import org.schabi.newpipe.extractor.MediaFormat;
19-
import org.schabi.newpipe.extractor.NewPipe;
2017
import org.schabi.newpipe.extractor.StreamingService;
2118
import org.schabi.newpipe.extractor.downloader.Downloader;
2219
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
@@ -30,6 +27,8 @@
3027
import org.schabi.newpipe.extractor.stream.AudioStream;
3128
import org.schabi.newpipe.extractor.stream.DeliveryMethod;
3229
import org.schabi.newpipe.extractor.stream.Description;
30+
import org.schabi.newpipe.extractor.stream.HlsAudioStream;
31+
import org.schabi.newpipe.extractor.stream.SoundcloudHlsUtils;
3332
import org.schabi.newpipe.extractor.stream.Stream;
3433
import org.schabi.newpipe.extractor.stream.StreamExtractor;
3534
import org.schabi.newpipe.extractor.stream.StreamInfoItemsCollector;
@@ -189,25 +188,106 @@ public List<AudioStream> getAudioStreams() throws ExtractionException {
189188
return audioStreams;
190189
}
191190

192-
@Nonnull
193-
private String getTranscodingUrl(final String endpointUrl)
194-
throws IOException, ExtractionException {
195-
String apiStreamUrl = endpointUrl + "?client_id=" + clientId();
191+
// TODO: put this somewhere better
192+
/**
193+
* Constructs the API endpoint url for this track that will be called to get the url
194+
* for retrieving the actual byte data for playback
195+
* (e.g. the actual url could be an m3u8 playlist)
196+
* @param baseUrl The baseUrl needed to construct the full url
197+
* @return The full API endpoint url to call to get the actual playback url
198+
* @throws IOException If there is a problem getting clientId
199+
* @throws ExtractionException For the same reason
200+
*/
201+
private String getApiStreamUrl(final String baseUrl) throws ExtractionException, IOException {
202+
String apiStreamUrl = baseUrl + "?client_id=" + clientId();
196203

197204
final String trackAuthorization = track.getString("track_authorization");
198205
if (!isNullOrEmpty(trackAuthorization)) {
199206
apiStreamUrl += "&track_authorization=" + trackAuthorization;
200207
}
208+
return apiStreamUrl;
209+
}
201210

202-
final String response = NewPipe.getDownloader().get(apiStreamUrl).responseBody();
203-
final JsonObject urlObject;
204-
try {
205-
urlObject = JsonParser.object().from(response);
206-
} catch (final JsonParserException e) {
207-
throw new ParsingException("Could not parse streamable URL", e);
211+
public static final class StreamBuildResult {
212+
public final String contentUrl;
213+
public final MediaFormat mediaFormat;
214+
215+
public StreamBuildResult(final String contentUrl, final MediaFormat mediaFormat) {
216+
this.contentUrl = contentUrl;
217+
this.mediaFormat = mediaFormat;
218+
}
219+
220+
@Override
221+
public String toString() {
222+
return "StreamBuildResult{"
223+
+ "contentUrl='" + contentUrl + '\''
224+
+ ", mediaFormat=" + mediaFormat
225+
+ '}';
226+
}
227+
}
228+
229+
/**
230+
* Builds the common audio stream components for all SoundCloud audio streams<p>
231+
* Returns the stream content url if we support this type of transcoding, {@code null} otherwise
232+
* @param transcoding The SoundCloud JSON transcoding object for this stream
233+
* @param builder AudioStream builder to set the common values
234+
* @return the stream content url if this transcoding is supported and common values were built
235+
* {@code null} otherwise
236+
*/
237+
@Nullable
238+
private StreamBuildResult buildBaseAudioStream(final JsonObject transcoding,
239+
final AudioStream.Builder builder)
240+
throws ExtractionException, IOException {
241+
ExtractorLogger.d(TAG, getName() + " Building base audio stream info");
242+
final var preset = transcoding.getString("preset", ID_UNKNOWN);
243+
final MediaFormat mediaFormat;
244+
if (preset.contains("mp3")) {
245+
mediaFormat = MediaFormat.MP3;
246+
builder.setAverageBitrate(128);
247+
} else if (preset.contains("opus")) {
248+
mediaFormat = MediaFormat.OPUS;
249+
builder.setAverageBitrate(64);
250+
builder.setDeliveryMethod(DeliveryMethod.HLS);
251+
} else if (preset.contains("aac_160k")) {
252+
mediaFormat = MediaFormat.M4A;
253+
builder.setAverageBitrate(160);
254+
} else {
255+
// Unknown format, return null to skip to the next audio stream
256+
return null;
208257
}
209258

210-
return urlObject.getString("url");
259+
builder.setMediaFormat(mediaFormat);
260+
261+
builder.setId(preset);
262+
final var url = transcoding.getString("url");
263+
final var hlsPlaylistUrl = SoundcloudHlsUtils.getStreamContentUrl(getApiStreamUrl(url));
264+
builder.setContent(hlsPlaylistUrl, true);
265+
return new StreamBuildResult(hlsPlaylistUrl, mediaFormat);
266+
}
267+
268+
private HlsAudioStream buildHlsAudioStream(final JsonObject transcoding)
269+
throws ExtractionException, IOException {
270+
ExtractorLogger.d(TAG, getName() + "Extracting hls audio stream");
271+
final var builder = new HlsAudioStream.Builder();
272+
final StreamBuildResult buildResult = buildBaseAudioStream(transcoding, builder);
273+
if (buildResult == null) {
274+
return null;
275+
}
276+
277+
builder.setApiStreamUrl(getApiStreamUrl(transcoding.getString("url")));
278+
builder.setPlaylistId(SoundcloudHlsUtils.extractHlsPlaylistId(buildResult.contentUrl,
279+
buildResult.mediaFormat));
280+
281+
return builder.build();
282+
}
283+
284+
private AudioStream buildProgressiveAudioStream(final JsonObject transcoding)
285+
throws ExtractionException, IOException {
286+
ExtractorLogger.d(TAG, getName() + "Extracting progressive audio stream");
287+
final var builder = new AudioStream.Builder();
288+
final StreamBuildResult buildResult = buildBaseAudioStream(transcoding, builder);
289+
return buildResult == null ? null : builder.build();
290+
// TODO: anything else?
211291
}
212292

213293
private void extractAudioStreams(@Nonnull final JsonArray transcodings,
@@ -221,47 +301,35 @@ private void extractAudioStreams(@Nonnull final JsonArray transcodings,
221301
return;
222302
}
223303

224-
try {
225-
final String preset = transcoding.getString("preset", ID_UNKNOWN);
226-
final String protocol = transcoding.getObject("format")
227-
.getString("protocol");
228-
229-
if (protocol.contains("encrypted")) {
230-
// Skip DRM-protected streams, which have encrypted in their protocol
231-
// name
232-
return;
233-
}
234-
235-
final AudioStream.Builder builder = new AudioStream.Builder()
236-
.setId(preset);
304+
final String protocol = transcoding.getObject("format")
305+
.getString("protocol");
237306

238-
if (protocol.equals("hls")) {
239-
builder.setDeliveryMethod(DeliveryMethod.HLS);
240-
}
241-
242-
builder.setContent(getTranscodingUrl(url), true);
243-
244-
if (preset.contains("mp3")) {
245-
builder.setMediaFormat(MediaFormat.MP3);
246-
builder.setAverageBitrate(128);
247-
} else if (preset.contains("opus")) {
248-
builder.setMediaFormat(MediaFormat.OPUS);
249-
builder.setAverageBitrate(64);
250-
} else if (preset.contains("aac_160k")) {
251-
builder.setMediaFormat(MediaFormat.M4A);
252-
builder.setAverageBitrate(160);
253-
} else {
254-
// Unknown format, skip to the next audio stream
255-
return;
256-
}
307+
if (protocol.contains("encrypted")) {
308+
// Skip DRM-protected streams, which have encrypted in their protocol
309+
// name
310+
return;
311+
}
257312

258-
final AudioStream audioStream = builder.build();
259-
if (!Stream.containSimilarStream(audioStream, audioStreams)) {
313+
final AudioStream audioStream;
314+
try {
315+
// SoundCloud only has one progressive stream, the rest are HLS
316+
audioStream = protocol.equals("hls")
317+
? buildHlsAudioStream(transcoding)
318+
: buildProgressiveAudioStream(transcoding);
319+
if (audioStream != null
320+
&& !Stream.containSimilarStream(audioStream, audioStreams)) {
321+
ExtractorLogger.d(TAG, audioStream.getFormat().getName() + " "
322+
+ getName() + " " + audioStream.getContent());
260323
audioStreams.add(audioStream);
261324
}
262-
} catch (final ExtractionException | IOException ignored) {
325+
} catch (final ExtractionException | IOException e) {
263326
// Something went wrong when trying to get and add this audio stream,
264327
// skip to the next one
328+
final var preset = transcoding.getString("preset", "unknown");
329+
ExtractorLogger.e(TAG,
330+
getName() + " Failed to extract audio stream for transcoding "
331+
+ '[' + protocol + '/' + preset + "] " + url,
332+
e);
265333
}
266334
});
267335
}

extractor/src/main/java/org/schabi/newpipe/extractor/stream/AudioStream.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
import java.util.Locale;
2929
import java.util.Objects;
3030

31-
public final class AudioStream extends Stream {
31+
public class AudioStream extends Stream {
3232
public static final int UNKNOWN_BITRATE = -1;
3333

3434
private final int averageBitrate;
@@ -60,7 +60,7 @@ public final class AudioStream extends Stream {
6060
* Class to build {@link AudioStream} objects.
6161
*/
6262
@SuppressWarnings("checkstyle:hiddenField")
63-
public static final class Builder {
63+
public static class Builder {
6464
private String id;
6565
private String content;
6666
private boolean isUrl;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package org.schabi.newpipe.extractor.stream;
2+
3+
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
4+
5+
import java.io.IOException;
6+
7+
import javax.annotation.Nonnull;
8+
9+
public class HlsAudioStream extends AudioStream implements RefreshableStream {
10+
private final String apiStreamUrl;
11+
private final String playlistId;
12+
13+
HlsAudioStream(final Builder builder) {
14+
super(builder);
15+
apiStreamUrl = builder.apiStreamUrl;
16+
playlistId = builder.playlistId;
17+
}
18+
19+
@Nonnull
20+
public String fetchLatestUrl() throws IOException, ExtractionException {
21+
return SoundcloudHlsUtils.getStreamContentUrl(apiStreamUrl);
22+
}
23+
24+
@Nonnull
25+
public String initialUrl() {
26+
return getContent();
27+
}
28+
29+
@Override
30+
public String playlistId() {
31+
return playlistId;
32+
}
33+
34+
@SuppressWarnings({"checkstyle:HiddenField", "UnusedReturnValue"})
35+
public static class Builder extends AudioStream.Builder {
36+
private String apiStreamUrl;
37+
private String playlistId;
38+
39+
public Builder() {
40+
setDeliveryMethod(DeliveryMethod.HLS);
41+
}
42+
43+
@Override
44+
@Nonnull
45+
public HlsAudioStream build() {
46+
validateBuild();
47+
return new HlsAudioStream(this);
48+
}
49+
50+
public Builder setApiStreamUrl(@Nonnull final String apiStreamUrl) {
51+
this.apiStreamUrl = apiStreamUrl;
52+
return this;
53+
}
54+
55+
public Builder setPlaylistId(@Nonnull final String playlistId) {
56+
this.playlistId = playlistId;
57+
return this;
58+
}
59+
}
60+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package org.schabi.newpipe.extractor.stream;
2+
3+
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
4+
5+
import javax.annotation.Nonnull;
6+
import java.io.IOException;
7+
8+
@SuppressWarnings("checkstyle:LeftCurly")
9+
public interface RefreshableStream {
10+
@Nonnull
11+
String fetchLatestUrl() throws IOException, ExtractionException;
12+
String initialUrl();
13+
14+
String playlistId();
15+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package org.schabi.newpipe.extractor.stream;
2+
3+
import com.grack.nanojson.JsonObject;
4+
import com.grack.nanojson.JsonParser;
5+
import com.grack.nanojson.JsonParserException;
6+
7+
import org.schabi.newpipe.extractor.MediaFormat;
8+
import org.schabi.newpipe.extractor.NewPipe;
9+
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
10+
import org.schabi.newpipe.extractor.exceptions.ParsingException;
11+
import org.schabi.newpipe.extractor.utils.ExtractorLogger;
12+
import org.schabi.newpipe.extractor.utils.Parser;
13+
14+
import java.io.IOException;
15+
import java.util.regex.Pattern;
16+
17+
import javax.annotation.Nonnull;
18+
19+
public final class SoundcloudHlsUtils {
20+
private static final String TAG = HlsAudioStream.class.getSimpleName();
21+
private static final Pattern MP3_HLS_PATTERN =
22+
Pattern.compile("https://cf-hls-media\\.sndcdn.com/playlist/"
23+
+ "([a-zA-Z0-9]+)\\.128\\.mp3/playlist\\.m3u8");
24+
private static final Pattern AAC_HLS_PATTERN =
25+
Pattern.compile("https://playback\\.media-streaming\\.soundcloud\\.cloud/"
26+
+ "([a-zA-Z0-9]+)/aac_160k/[a-f0-9\\-]+/playlist\\.m3u8");
27+
private static final Pattern OPUS_HLS_PATTERN =
28+
Pattern.compile("https://cf-hls-opus-media\\.sndcdn\\.com/"
29+
+ "playlist/([a-zA-Z0-9]+)\\.64\\.opus/playlist\\.m3u8");
30+
31+
private SoundcloudHlsUtils() { }
32+
33+
/**
34+
* Calls the API endpoint url for this stream to get the url for retrieving the
35+
* actual byte data for playback (returns the m3u8 playlist url for HLS streams,
36+
* and the url to get the full binary track for progressives streams)<p>
37+
*
38+
* NOTE: this returns a different url every time! (for SoundCloud)
39+
* @param apiStreamUrl The url to call to get the actual stream data url
40+
* @return The url for playing the audio (e.g. playlist.m3u8)
41+
* @throws IOException If there's a problem calling the endpoint
42+
* @throws ExtractionException for the same reason
43+
*/
44+
public static String getStreamContentUrl(final String apiStreamUrl)
45+
throws IOException, ExtractionException {
46+
ExtractorLogger.d(TAG, "Fetching content url for " + apiStreamUrl);
47+
final String response = NewPipe.getDownloader()
48+
.get(apiStreamUrl)
49+
.validateResponseCode()
50+
.responseBody();
51+
final JsonObject urlObject;
52+
try {
53+
urlObject = JsonParser.object().from(response);
54+
} catch (final JsonParserException e) {
55+
// TODO: Improve error message.
56+
throw new ParsingException("Could not parse stream content from URL ("
57+
+ response + ")", e);
58+
}
59+
60+
return urlObject.getString("url");
61+
}
62+
63+
@Nonnull
64+
public static String extractHlsPlaylistId(final String hlsPlaylistUrl,
65+
final MediaFormat mediaFormat)
66+
throws ExtractionException {
67+
switch (mediaFormat) {
68+
case MP3: return extractHlsMp3PlaylistId(hlsPlaylistUrl);
69+
case M4A: return extractHlsAacPlaylistId(hlsPlaylistUrl);
70+
case OPUS: return extractHlsOpusPlaylistId(hlsPlaylistUrl);
71+
default:
72+
throw new IllegalArgumentException("Unsupported media format: " + mediaFormat);
73+
}
74+
}
75+
76+
private static String extractHlsMp3PlaylistId(final String hlsPlaylistUrl)
77+
throws ExtractionException {
78+
return Parser.matchGroup1(MP3_HLS_PATTERN, hlsPlaylistUrl);
79+
}
80+
81+
private static String extractHlsAacPlaylistId(final String hlsPlaylistUrl)
82+
throws ExtractionException {
83+
return Parser.matchGroup1(AAC_HLS_PATTERN, hlsPlaylistUrl);
84+
}
85+
86+
private static String extractHlsOpusPlaylistId(final String hlsPlaylistUrl)
87+
throws ExtractionException {
88+
return Parser.matchGroup1(OPUS_HLS_PATTERN, hlsPlaylistUrl);
89+
}
90+
}

0 commit comments

Comments
 (0)