diff --git a/build.gradle.kts b/build.gradle.kts index ad4125e..745b9d9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,7 +25,7 @@ plugins { } project.group = "com.dunctebot" -project.version = "1.8.4" +project.version = "1.8.5" val archivesBaseName = "sourcemanagers" repositories { @@ -43,11 +43,11 @@ dependencies { // implementation(group = "com.github.duncte123", name = "lavaplayer", version = "be6e364") compileOnly(group = "com.github.walkyst", name = "lavaplayer-fork", version = "1.4.2") - implementation("commons-io:commons-io:2.6") - implementation(group = "org.jsoup", name = "jsoup", version = "1.12.1") + implementation("commons-io:commons-io:2.7") + implementation(group = "org.jsoup", name = "jsoup", version = "1.15.3") implementation(group = "com.google.code.findbugs", name = "jsr305", version = "3.0.2") - testImplementation(group = "com.github.walkyst", name = "lavaplayer-fork", version = "1.3.99.1") + testImplementation(group = "com.github.walkyst", name = "lavaplayer-fork", version = "1.4.2") } configure { diff --git a/src/main/java/com/dunctebot/sourcemanagers/mixcloud/MixcloudAudioSourceManager.java b/src/main/java/com/dunctebot/sourcemanagers/mixcloud/MixcloudAudioSourceManager.java index ead74f8..5f1e72e 100644 --- a/src/main/java/com/dunctebot/sourcemanagers/mixcloud/MixcloudAudioSourceManager.java +++ b/src/main/java/com/dunctebot/sourcemanagers/mixcloud/MixcloudAudioSourceManager.java @@ -29,6 +29,9 @@ import org.apache.commons.io.IOUtils; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; import java.io.DataInput; import java.io.DataOutput; @@ -41,16 +44,50 @@ import static com.dunctebot.sourcemanagers.Utils.urlEncode; public class MixcloudAudioSourceManager extends AbstractDuncteBotHttpSource { - private static final String REQUEST_STRUCTURE = "audioLength\n" + - " name\n" + + private static final String GRAPHQL_AUDIO_REQUEST = "query PlayerHeroQuery(\n" + + " $lookup: CloudcastLookup!\n" + + ") {\n" + + " cloudcast: cloudcastLookup(lookup: $lookup) {\n" + + " id\n" + + " name\n" + + " owner {\n" + + " ...AudioPageAvatar_user\n" + + " id\n" + + " }\n" + + " restrictedReason\n" + + " seekRestriction\n" + + " ...PlayButton_cloudcast\n" + + " }\n" + + "}\n" + + "\n" + + "fragment AudioPageAvatar_user on User {\n" + + " displayName\n" + + " username\n" + + "}\n" + + "\n" + + "fragment PlayButton_cloudcast on Cloudcast {\n" + + " restrictedReason\n" + " owner {\n" + - " username\n" + + " displayName\n" + + " country\n" + + " username\n" + + " isSubscribedTo\n" + + " isViewer\n" + + " id\n" + " }\n" + + " slug\n" + + " id\n" + + " isDraft\n" + + " isPlayable\n" + " streamInfo {\n" + - " dashUrl\n" + - " hlsUrl\n" + - " url\n" + - " }"; + " hlsUrl\n" + + " dashUrl\n" + + " url\n" + + " uuid\n" + + " }\n" + + " audioLength\n" + + " seekRestriction\n" + + "}\n"; private static final Pattern URL_REGEX = Pattern.compile("https?://(?:(?:www|beta|m)\\.)?mixcloud\\.com/([^/]+)/(?!stream|uploads|favorites|listens|playlists)([^/]+)/?"); @Override @@ -89,6 +126,16 @@ private AudioItem loadItemOnce(AudioReference reference, Matcher matcher) throws return AudioReference.NO_TRACK; } + final JsonBrowser restrictedReason = trackInfo.get("restrictedReason"); + + if (!restrictedReason.isNull()) { + throw new FriendlyException( + "Playback of this track is restricted.", + FriendlyException.Severity.COMMON, + new Exception(restrictedReason.text()) + ); + } + final String title = trackInfo.get("name").text(); final long duration = trackInfo.get("audioLength").as(Long.class) * 1000; final String uploader = trackInfo.get("owner").get("username").text(); // displayName @@ -107,17 +154,23 @@ private AudioItem loadItemOnce(AudioReference reference, Matcher matcher) throws } protected JsonBrowser extractTrackInfoGraphQl(String username, String slug) throws IOException { - final String slugFormatted = slug == null ? "" : String.format(", slug: \"%s\"", slug); - final String query = String.format( - "{\n cloudcastLookup(lookup: {username: \"%s\"%s}) {\n %s\n }\n}", - username, - slugFormatted, - REQUEST_STRUCTURE - ); - final String encodedQuery = urlEncode(query); - final HttpGet httpGet = new HttpGet("https://www.mixcloud.com/graphql?query=" + encodedQuery); + final var body = JsonBrowser.newMap(); + + body.put("query", GRAPHQL_AUDIO_REQUEST); + + final var variables = JsonBrowser.newMap(); + + variables.put("lookup", new MixcloudLookup( + slug, username + )); + + body.put("variables", variables); + + final HttpPost httpPost = new HttpPost("https://app.mixcloud.com/graphql"); + + httpPost.setEntity(new StringEntity(body.text(), ContentType.APPLICATION_JSON)); - try (final CloseableHttpResponse res = getHttpInterface().execute(httpGet)) { + try (final CloseableHttpResponse res = getHttpInterface().execute(httpPost)) { final int statusCode = res.getStatusLine().getStatusCode(); if (statusCode != 200) { @@ -129,7 +182,7 @@ protected JsonBrowser extractTrackInfoGraphQl(String username, String slug) thro } final String content = IOUtils.toString(res.getEntity().getContent(), StandardCharsets.UTF_8); - final JsonBrowser json = JsonBrowser.parse(content).get("data").get("cloudcastLookup"); + final JsonBrowser json = JsonBrowser.parse(content).get("data").get("cloudcast"); if (json.get("streamInfo").isNull()) { return null; diff --git a/src/main/java/com/dunctebot/sourcemanagers/mixcloud/MixcloudAudioTrack.java b/src/main/java/com/dunctebot/sourcemanagers/mixcloud/MixcloudAudioTrack.java index 582c87d..9383983 100644 --- a/src/main/java/com/dunctebot/sourcemanagers/mixcloud/MixcloudAudioTrack.java +++ b/src/main/java/com/dunctebot/sourcemanagers/mixcloud/MixcloudAudioTrack.java @@ -21,6 +21,7 @@ import com.sedmelluq.discord.lavaplayer.tools.ExceptionTools; import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; import com.sedmelluq.discord.lavaplayer.tools.Units; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; import java.io.IOException; @@ -62,6 +63,11 @@ public String getPlaybackUrl() { } } + @Override + protected AudioTrack makeShallowClone() { + return new MixcloudAudioTrack(trackInfo, getSourceManager()); + } + @Override public MixcloudAudioSourceManager getSourceManager() { return (MixcloudAudioSourceManager) super.getSourceManager(); diff --git a/src/main/java/com/dunctebot/sourcemanagers/mixcloud/MixcloudLookup.java b/src/main/java/com/dunctebot/sourcemanagers/mixcloud/MixcloudLookup.java new file mode 100644 index 0000000..4485af7 --- /dev/null +++ b/src/main/java/com/dunctebot/sourcemanagers/mixcloud/MixcloudLookup.java @@ -0,0 +1,43 @@ +/* + * Copyright 2023 Duncan "duncte123" Sterken + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.dunctebot.sourcemanagers.mixcloud; + +public class MixcloudLookup { + private String slug; + private String username; + + public MixcloudLookup(String slug, String username) { + this.slug = slug; + this.username = username; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } +} diff --git a/src/test/java/MixcloudTest.java b/src/test/java/MixcloudTest.java index 0643e69..28580b8 100644 --- a/src/test/java/MixcloudTest.java +++ b/src/test/java/MixcloudTest.java @@ -16,14 +16,24 @@ import com.dunctebot.sourcemanagers.mixcloud.MixcloudAudioSourceManager; import com.dunctebot.sourcemanagers.mixcloud.MixcloudAudioTrack; +import com.sedmelluq.discord.lavaplayer.track.AudioItem; import com.sedmelluq.discord.lavaplayer.track.AudioReference; public class MixcloudTest { public static void main(String[] args) { - final var url = "https://www.mixcloud.com/jordy-boesten2/the-egotripper-lets-walk-to-my-house-mix-259/"; +// System.out.println(MixcloudAudioSourceManager.GRAPHQL_AUDIO_REQUEST); + +// final var url = "https://www.mixcloud.com/jordy-boesten2/the-egotripper-lets-walk-to-my-house-mix-259/"; + final var url = "https://www.mixcloud.com/Hirockn/the-100-best-tracks-2020-hip-hop-rb-pops-etc-the-weeknd-dababy-dua-lipa-juice-wrld-etc/"; final var mnrg = new MixcloudAudioSourceManager(); - final var track = (MixcloudAudioTrack) mnrg.loadItem(null, new AudioReference(url, null)); - final var playbackUrl = track.getPlaybackUrl(); + final AudioItem track = mnrg.loadItem(null, new AudioReference(url, null)); + + if (track.equals(AudioReference.NO_TRACK)) { + return; + } + + final var mixcloudTrack = (MixcloudAudioTrack) track; + final var playbackUrl = mixcloudTrack.getPlaybackUrl(); System.out.println(playbackUrl); } diff --git a/src/test/resources/mixcloud.graphql b/src/test/resources/mixcloud.graphql new file mode 100644 index 0000000..dede46b --- /dev/null +++ b/src/test/resources/mixcloud.graphql @@ -0,0 +1,381 @@ +query PlayerHeroQuery( + $lookup: CloudcastLookup! +) { + cloudcast: cloudcastLookup(lookup: $lookup) { + id + name + picture { + isLight + primaryColor + darkPrimaryColor: primaryColor(darken: 60) + ...UGCImage_picture + } + ...AudioPageAvatar_cloudcast + owner { + ...AudioPageAvatar_user + id + } + restrictedReason + seekRestriction + ...HeaderActions_cloudcast + ...PlayButton_cloudcast + ...CloudcastBaseAutoPlayComponent_cloudcast + ...HeroWaveform_cloudcast + ...RepeatPlayUpsellBar_cloudcast + ...HeroAudioMeta_cloudcast + ...HeroChips_cloudcast + } + viewer { + restrictedPlayer: featureIsActive(switch: "restricted_player") + hasRepeatPlayFeature: featureIsActive(switch: "repeat_play") + ...HeroWaveform_viewer + ...HeroAudioMeta_viewer + ...HeaderActions_viewer + ...AudioPageAvatar_viewer + id + } +} + +fragment AddToButton_cloudcast on Cloudcast { + id + isUnlisted + isPublic +} + +fragment AudioPageAvatar_cloudcast on Cloudcast { + id + owner { + id + } + creatorAttributions(first: 2) { + totalCount + edges { + node { + displayName + followers { + totalCount + } + hasPremiumFeatures + hasProFeatures + isStaff + username + picture { + primaryColor + urlRoot + } + id + } + } + } +} + +fragment AudioPageAvatar_user on User { + displayName + followers { + totalCount + } + hasPremiumFeatures + hasProFeatures + isStaff + username + picture { + primaryColor + urlRoot + } + ...CTAButtons_user +} + +fragment AudioPageAvatar_viewer on Viewer { + ...CTAButtons_viewer +} + +fragment CTAButtons_user on User { + isSelect + ...ChannelSubscribeButton_user + ...ProfileFollowButton_user +} + +fragment CTAButtons_viewer on Viewer { + ...ChannelSubscribeButton_viewer + ...ProfileFollowButton_viewer +} + +fragment ChannelSubscribeButton_user on User { + username + displayName + isSelect + isViewer + isTippable + isSubscribedTo + isFollowing + selectUpsell { + valuePropsOffered + planInfo { + displayAmount + } + } +} + +fragment ChannelSubscribeButton_viewer on Viewer { + me { + hasProFeatures + id + } +} + +fragment CloudcastBaseAutoPlayComponent_cloudcast on Cloudcast { + id + streamInfo { + uuid + url + hlsUrl + dashUrl + } + audioLength + seekRestriction + currentPosition +} + +fragment FavoriteButton_cloudcast on Cloudcast { + id + isFavorited + isPublic + hiddenStats + favorites { + totalCount + } + slug + owner { + id + isFollowing + username + isSelect + displayName + isViewer + } +} + +fragment FavoriteButton_viewer on Viewer { + me { + id + } +} + +fragment HeaderActions_cloudcast on Cloudcast { + owner { + isViewer + id + } + ...FavoriteButton_cloudcast + ...AddToButton_cloudcast + ...RepostButton_cloudcast + ...MoreMenu_cloudcast + ...ShareButton_cloudcast +} + +fragment HeaderActions_viewer on Viewer { + ...FavoriteButton_viewer + ...RepostButton_viewer + ...MoreMenu_viewer +} + +fragment HeroAudioMeta_cloudcast on Cloudcast { + slug + plays + publishDate + qualityScore + listenerMinutes + owner { + username + id + } + hiddenStats +} + +fragment HeroAudioMeta_viewer on Viewer { + me { + isStaff + id + } +} + +fragment HeroChips_cloudcast on Cloudcast { + isUnlisted + audioType + isExclusive + audioQuality + owner { + isViewer + id + } + restrictedReason + isAwaitingAudio + isDisabledCopyright +} + +fragment HeroWaveform_cloudcast on Cloudcast { + id + audioType + waveformUrl + previewUrl + audioLength + isPlayable + streamInfo { + hlsUrl + dashUrl + url + uuid + } + restrictedReason + seekRestriction + currentPosition + ...SeekWarning_cloudcast +} + +fragment HeroWaveform_viewer on Viewer { + restrictedPlayer: featureIsActive(switch: "restricted_player") +} + +fragment MoreMenu_cloudcast on Cloudcast { + id + isSpam + owner { + isViewer + id + } +} + +fragment MoreMenu_viewer on Viewer { + me { + id + } +} + +fragment PlayButton_cloudcast on Cloudcast { + restrictedReason + owner { + displayName + country + username + isSubscribedTo + isViewer + id + } + slug + id + isAwaitingAudio + isDraft + isPlayable + streamInfo { + hlsUrl + dashUrl + url + uuid + } + audioLength + currentPosition + proportionListened + repeatPlayAmount + hasPlayCompleted + seekRestriction + previewUrl + isExclusivePreviewOnly + isExclusive +} + +fragment ProfileFollowButton_user on User { + id + isFollowing + followers { + totalCount + } + username + isViewer + ...ProfileFollowingButton_user +} + +fragment ProfileFollowButton_viewer on Viewer { + me { + id + } + ...ProfileFollowingButton_viewer +} + +fragment ProfileFollowingButton_user on User { + id + receivesUploadNotifications +} + +fragment ProfileFollowingButton_viewer on Viewer { + settings { + disableEmail + emailNotifications { + newUpload + } + } +} + +fragment RepeatPlayUpsellBar_cloudcast on Cloudcast { + audioType + owner { + username + displayName + isSelect + id + } +} + +fragment RepostButton_cloudcast on Cloudcast { + id + isReposted + isExclusive + isPublic + hiddenStats + reposts { + totalCount + } + owner { + isViewer + isSubscribedTo + id + } +} + +fragment RepostButton_viewer on Viewer { + me { + id + } +} + +fragment SeekWarning_cloudcast on Cloudcast { + owner { + displayName + isSelect + username + id + } + seekRestriction +} + +fragment ShareButton_cloudcast on Cloudcast { + id + isUnlisted + isPublic + slug + description + audioType + picture { + urlRoot + } + owner { + displayName + isViewer + username + id + } +} + +fragment UGCImage_picture on Picture { + urlRoot + primaryColor +} diff --git a/src/test/resources/mixcloud_condensed.graphql b/src/test/resources/mixcloud_condensed.graphql new file mode 100644 index 0000000..3314cb8 --- /dev/null +++ b/src/test/resources/mixcloud_condensed.graphql @@ -0,0 +1,44 @@ +query PlayerHeroQuery( + $lookup: CloudcastLookup! +) { + cloudcast: cloudcastLookup(lookup: $lookup) { + id + name + owner { + ...AudioPageAvatar_user + id + } + restrictedReason + seekRestriction + ...PlayButton_cloudcast + } +} + +fragment AudioPageAvatar_user on User { + displayName + username +} + +fragment PlayButton_cloudcast on Cloudcast { + restrictedReason + owner { + displayName + country + username + isSubscribedTo + isViewer + id + } + slug + id + isDraft + isPlayable + streamInfo { + hlsUrl + dashUrl + url + uuid + } + audioLength + seekRestriction +} diff --git a/src/test/resources/mixcloud_references.graphql b/src/test/resources/mixcloud_references.graphql new file mode 100644 index 0000000..31e5076 --- /dev/null +++ b/src/test/resources/mixcloud_references.graphql @@ -0,0 +1,4 @@ +type CloudcastLookup { + slug: String + username: String +}