Skip to content

Commit f196155

Browse files
committed
[SoundCloud] Add support for comment replies
1 parent 41c8dce commit f196155

6 files changed

Lines changed: 201 additions & 30 deletions

File tree

extractor/src/main/java/org/schabi/newpipe/extractor/Page.java

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
package org.schabi.newpipe.extractor;
22

3+
import javax.annotation.Nullable;
34
import java.io.Serializable;
45
import java.util.List;
56
import java.util.Map;
67

7-
import javax.annotation.Nullable;
8-
98
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
109

10+
/**
11+
* The {@link Page} class is used for storing information on future requests
12+
* for retrieving content.
13+
* <br>
14+
* A page has an {@link #id}, an {@link #url}, as well as information on possible {@link #cookies}.
15+
* In case the data behind the URL has already been retrieved,
16+
* it can be accessed by using @link #getBody()} and {@link #getContent()}.
17+
*/
1118
public class Page implements Serializable {
1219
private final String url;
1320
private final String id;
1421
private final List<String> ids;
1522
private final Map<String, String> cookies;
23+
private Serializable content;
1624

1725
@Nullable
1826
private final byte[] body;
@@ -78,4 +86,28 @@ public static boolean isValid(final Page page) {
7886
public byte[] getBody() {
7987
return body;
8088
}
89+
90+
public boolean hasContent() {
91+
return content != null;
92+
}
93+
94+
/**
95+
* Get the page's content if it has been set, returns {@code null} otherwise.
96+
* @return the page's content
97+
*/
98+
@Nullable
99+
public Serializable getContent() {
100+
return content;
101+
}
102+
103+
/**
104+
* Set the page's content.
105+
* The page's content can either be retrieved manually by requesting the resource
106+
* behind the page's URL (see {@link #url} and {@link #getUrl()})
107+
* or storing it in a {@link Page}s instance in case the content has already been downloaded.
108+
* @param content the page's content
109+
*/
110+
public void setContent(@Nullable final Serializable content) {
111+
this.content = content;
112+
}
81113
}

extractor/src/main/java/org/schabi/newpipe/extractor/services/soundcloud/SoundcloudParsingHelper.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,4 +323,17 @@ public static String getAvatarUrl(final JsonObject object) {
323323
public static String getUploaderName(final JsonObject object) {
324324
return object.getObject("user").getString("username", "");
325325
}
326+
327+
public static boolean isReplyTo(@Nonnull final JsonObject originalComment,
328+
@Nonnull final JsonObject otherComment) {
329+
final String mention = "@" + originalComment.getObject("user").getString("permalink");
330+
return otherComment.getString("body").startsWith(mention)
331+
&& originalComment.getInt("timestamp") == otherComment.getInt("timestamp");
332+
333+
}
334+
335+
public static boolean isReply(@Nonnull final JsonObject comment) {
336+
return comment.getString("body").startsWith("@");
337+
}
338+
326339
}

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

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
1717
import org.schabi.newpipe.extractor.exceptions.ParsingException;
1818
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
19+
import org.schabi.newpipe.extractor.services.soundcloud.SoundcloudParsingHelper;
1920

2021
import java.io.IOException;
2122

@@ -24,6 +25,8 @@
2425
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
2526

2627
public class SoundcloudCommentsExtractor extends CommentsExtractor {
28+
public static final String COLLECTION = "collection";
29+
2730
public SoundcloudCommentsExtractor(final StreamingService service,
2831
final ListLinkHandler uiHandler) {
2932
super(service, uiHandler);
@@ -46,7 +49,7 @@ public InfoItemsPage<CommentsInfoItem> getInitialPage() throws ExtractionExcepti
4649
final CommentsInfoItemsCollector collector = new CommentsInfoItemsCollector(
4750
getServiceId());
4851

49-
collectStreamsFrom(collector, json.getArray("collection"));
52+
collectStreamsFrom(collector, json);
5053

5154
return new InfoItemsPage<>(collector, new Page(json.getString("next_href")));
5255
}
@@ -57,21 +60,32 @@ public InfoItemsPage<CommentsInfoItem> getPage(final Page page) throws Extractio
5760
if (page == null || isNullOrEmpty(page.getUrl())) {
5861
throw new IllegalArgumentException("Page doesn't contain an URL");
5962
}
60-
61-
final Downloader downloader = NewPipe.getDownloader();
62-
final Response response = downloader.get(page.getUrl());
63-
6463
final JsonObject json;
65-
try {
66-
json = JsonParser.object().from(response.responseBody());
67-
} catch (final JsonParserException e) {
68-
throw new ParsingException("Could not parse json", e);
69-
}
70-
7164
final CommentsInfoItemsCollector collector = new CommentsInfoItemsCollector(
7265
getServiceId());
7366

74-
collectStreamsFrom(collector, json.getArray("collection"));
67+
if (page.hasContent()) {
68+
// This page contains the whole previously fetched comments.
69+
// We need to get the comments which are replies to the comment with the page's id.
70+
json = (JsonObject) page.getContent();
71+
try {
72+
final int commentId = Integer.parseInt(page.getId());
73+
collectRepliesFrom(collector, json, commentId, page.getUrl());
74+
} catch (final NumberFormatException e) {
75+
throw new ParsingException("Got invalid comment id", e);
76+
}
77+
} else {
78+
79+
final Downloader downloader = NewPipe.getDownloader();
80+
final Response response = downloader.get(page.getUrl());
81+
82+
try {
83+
json = JsonParser.object().from(response.responseBody());
84+
} catch (final JsonParserException e) {
85+
throw new ParsingException("Could not parse json", e);
86+
}
87+
collectStreamsFrom(collector, json);
88+
}
7589

7690
return new InfoItemsPage<>(collector, new Page(json.getString("next_href")));
7791
}
@@ -80,10 +94,39 @@ public InfoItemsPage<CommentsInfoItem> getPage(final Page page) throws Extractio
8094
public void onFetchPage(@Nonnull final Downloader downloader) { }
8195

8296
private void collectStreamsFrom(final CommentsInfoItemsCollector collector,
83-
final JsonArray entries) throws ParsingException {
97+
final JsonObject json) throws ParsingException {
8498
final String url = getUrl();
85-
for (final Object comment : entries) {
86-
collector.commit(new SoundcloudCommentsInfoItemExtractor((JsonObject) comment, url));
99+
final JsonArray entries = json.getArray(COLLECTION);
100+
for (int i = 0; i < entries.size(); i++) {
101+
final JsonObject entry = entries.getObject(i);
102+
if (i == 0
103+
|| (!SoundcloudParsingHelper.isReply(entry)
104+
&& !SoundcloudParsingHelper.isReplyTo(entries.getObject(i - 1), entry))) {
105+
collector.commit(new SoundcloudCommentsInfoItemExtractor(
106+
json, i, entries.getObject(i), url));
107+
}
87108
}
88109
}
110+
111+
private void collectRepliesFrom(final CommentsInfoItemsCollector collector,
112+
final JsonObject json,
113+
final int id,
114+
final String url) throws ParsingException {
115+
JsonObject originalComment = null;
116+
final JsonArray entries = json.getArray(COLLECTION);
117+
for (int i = 0; i < entries.size(); i++) {
118+
final JsonObject comment = entries.getObject(i);
119+
if (comment.getInt("id") == id) {
120+
originalComment = comment;
121+
continue;
122+
}
123+
if (originalComment != null
124+
&& SoundcloudParsingHelper.isReplyTo(originalComment, comment)) {
125+
collector.commit(new SoundcloudCommentsInfoItemExtractor(
126+
json, i, entries.getObject(i), url));
127+
128+
}
129+
}
130+
}
131+
89132
}
Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,79 @@
11
package org.schabi.newpipe.extractor.services.soundcloud.extractors;
22

3+
import com.grack.nanojson.JsonArray;
34
import com.grack.nanojson.JsonObject;
5+
import org.schabi.newpipe.extractor.Page;
6+
import org.schabi.newpipe.extractor.ServiceList;
7+
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
48
import org.schabi.newpipe.extractor.comments.CommentsInfoItemExtractor;
9+
import org.schabi.newpipe.extractor.comments.CommentsInfoItemsCollector;
510
import org.schabi.newpipe.extractor.exceptions.ParsingException;
611
import org.schabi.newpipe.extractor.localization.DateWrapper;
712
import org.schabi.newpipe.extractor.services.soundcloud.SoundcloudParsingHelper;
813
import org.schabi.newpipe.extractor.stream.Description;
914

1015
import javax.annotation.Nullable;
16+
import java.util.ArrayList;
17+
import java.util.List;
1118
import java.util.Objects;
1219

1320
public class SoundcloudCommentsInfoItemExtractor implements CommentsInfoItemExtractor {
21+
public static final String USER = "user";
22+
public static final String BODY = "body";
23+
1424
private final JsonObject json;
25+
private final int index;
26+
private final JsonObject item;
1527
private final String url;
1628

17-
public SoundcloudCommentsInfoItemExtractor(final JsonObject json, final String url) {
29+
private int replyCount = CommentsInfoItem.UNKNOWN_REPLY_COUNT;
30+
private Page repliesPage = null;
31+
32+
public SoundcloudCommentsInfoItemExtractor(final JsonObject json, final int index, final JsonObject item, final String url) {
1833
this.json = json;
34+
this.index = index;
35+
this.item = item;
1936
this.url = url;
2037
}
2138

2239
@Override
2340
public String getCommentId() {
24-
return Objects.toString(json.getLong("id"), null);
41+
return Objects.toString(item.getLong("id"), null);
2542
}
2643

2744
@Override
2845
public Description getCommentText() {
29-
return new Description(json.getString("body"), Description.PLAIN_TEXT);
46+
return new Description(item.getString(BODY), Description.PLAIN_TEXT);
3047
}
3148

3249
@Override
3350
public String getUploaderName() {
34-
return json.getObject("user").getString("username");
51+
return item.getObject(USER).getString("username");
3552
}
3653

3754
@Override
3855
public String getUploaderAvatarUrl() {
39-
return json.getObject("user").getString("avatar_url");
56+
return item.getObject(USER).getString("avatar_url");
4057
}
4158

4259
@Override
4360
public boolean isUploaderVerified() throws ParsingException {
44-
return json.getObject("user").getBoolean("verified");
61+
return item.getObject(USER).getBoolean("verified");
4562
}
4663

4764
@Override
4865
public int getStreamPosition() throws ParsingException {
49-
return json.getInt("timestamp") / 1000; // convert milliseconds to seconds
66+
return item.getInt("timestamp") / 1000; // convert milliseconds to seconds
5067
}
5168

5269
@Override
5370
public String getUploaderUrl() {
54-
return json.getObject("user").getString("permalink_url");
71+
return item.getObject(USER).getString("permalink_url");
5572
}
5673

5774
@Override
5875
public String getTextualUploadDate() {
59-
return json.getString("created_at");
76+
return item.getString("created_at");
6077
}
6178

6279
@Nullable
@@ -67,7 +84,7 @@ public DateWrapper getUploadDate() throws ParsingException {
6784

6885
@Override
6986
public String getName() throws ParsingException {
70-
return json.getObject("user").getString("permalink");
87+
return item.getObject(USER).getString("permalink");
7188
}
7289

7390
@Override
@@ -77,6 +94,52 @@ public String getUrl() {
7794

7895
@Override
7996
public String getThumbnailUrl() {
80-
return json.getObject("user").getString("avatar_url");
97+
return item.getObject(USER).getString("avatar_url");
98+
}
99+
100+
@Override
101+
public Page getReplies() {
102+
if (replyCount == CommentsInfoItem.UNKNOWN_REPLY_COUNT) {
103+
final List<JsonObject> replies = new ArrayList<>();
104+
final CommentsInfoItemsCollector collector = new CommentsInfoItemsCollector(
105+
ServiceList.SoundCloud.getServiceId());
106+
final JsonArray jsonArray = new JsonArray();
107+
// Replies start with the mention of the user who created the original comment.
108+
final String mention = "@" + item.getObject(USER).getString("permalink");
109+
// Loop through all comments which come after the original comment to find its replies.
110+
final JsonArray allItems = json.getArray(SoundcloudCommentsExtractor.COLLECTION);
111+
for (int i = index + 1; i < allItems.size(); i++) {
112+
final JsonObject comment = allItems.getObject(i);
113+
final String commentContent = comment.getString("body");
114+
if (commentContent.startsWith(mention)) {
115+
replies.add(comment);
116+
jsonArray.add(comment);
117+
collector.commit(new SoundcloudCommentsInfoItemExtractor(json, i, comment, url));
118+
} else if (!commentContent.startsWith("@") || replies.isEmpty()) {
119+
// Only the comments directly after the original comment
120+
// starting with the mention of the comment's creator
121+
// are replies to the original comment.
122+
// The first comment not starting with these letters
123+
// is the next top-level comment.
124+
break;
125+
}
126+
}
127+
replyCount = jsonArray.size();
128+
if (collector.getItems().isEmpty()) {
129+
return null;
130+
}
131+
repliesPage = new Page(getUrl(), getCommentId());
132+
repliesPage.setContent(json);
133+
}
134+
135+
return repliesPage;
136+
}
137+
138+
@Override
139+
public int getReplyCount() throws ParsingException {
140+
if (replyCount == CommentsInfoItem.UNKNOWN_REPLY_COUNT) {
141+
getReplies();
142+
}
143+
return replyCount;
81144
}
82145
}

extractor/src/main/java/org/schabi/newpipe/extractor/services/soundcloud/linkHandler/SoundcloudCommentsLinkHandlerFactory.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
44
import org.schabi.newpipe.extractor.exceptions.ParsingException;
55
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory;
6+
import org.schabi.newpipe.extractor.utils.Parser;
67

78
import java.io.IOException;
89
import java.util.List;
@@ -14,6 +15,8 @@ public final class SoundcloudCommentsLinkHandlerFactory extends ListLinkHandlerF
1415
private static final SoundcloudCommentsLinkHandlerFactory INSTANCE =
1516
new SoundcloudCommentsLinkHandlerFactory();
1617

18+
private static final String OFFSET_PATTERN = "https://api-v2.soundcloud.com/tracks/([0-9a-z]+)/comments?([0-9a-z/&])?offset=([0-9])+"
19+
1720
private SoundcloudCommentsLinkHandlerFactory() {
1821
}
1922

@@ -27,7 +30,7 @@ public String getUrl(final String id,
2730
final String sortFilter) throws ParsingException {
2831
try {
2932
return "https://api-v2.soundcloud.com/tracks/" + id + "/comments" + "?client_id="
30-
+ clientId() + "&threaded=0" + "&filter_replies=1";
33+
+ clientId() + "&threaded=1" + "&filter_replies=1";
3134
// Anything but 1 = sort by new
3235
// + "&limit=NUMBER_OF_ITEMS_PER_REQUEST". We let the API control (default = 10)
3336
// + "&offset=OFFSET". We let the API control (default = 0, then we use nextPageUrl)
@@ -36,12 +39,29 @@ public String getUrl(final String id,
3639
}
3740
}
3841

42+
public String getUrl(final String id,
43+
final List<String> contentFilter,
44+
final String sortFilter,
45+
final int offset) throws ParsingException {
46+
return getUrl(id, contentFilter, sortFilter) + "&offset=" + offset;
47+
}
48+
3949
@Override
4050
public String getId(final String url) throws ParsingException {
4151
// Delegation to avoid duplicate code, as we need the same id
4252
return SoundcloudStreamLinkHandlerFactory.getInstance().getId(url);
4353
}
4454

55+
public int getReplyOffset(final String url) throws ParsingException {
56+
try {
57+
return Integer.parseInt(Parser.matchGroup(OFFSET_PATTERN, url, 3));
58+
} catch (Parser.RegexException | NumberFormatException e) {
59+
throw new ParsingException("Could not get offset from URL: " + url, e);
60+
}
61+
}
62+
63+
64+
4565
@Override
4666
public boolean onAcceptUrl(final String url) {
4767
try {

0 commit comments

Comments
 (0)