diff --git a/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/AudioSourceManagers.java b/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/AudioSourceManagers.java index ae2eea52..d4cca30d 100644 --- a/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/AudioSourceManagers.java +++ b/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/AudioSourceManagers.java @@ -7,6 +7,7 @@ import com.sedmelluq.discord.lavaplayer.source.getyarn.GetyarnAudioSourceManager; import com.sedmelluq.discord.lavaplayer.source.http.HttpAudioSourceManager; import com.sedmelluq.discord.lavaplayer.source.local.LocalAudioSourceManager; +import com.sedmelluq.discord.lavaplayer.source.nico.NicoAudioSourceManager; import com.sedmelluq.discord.lavaplayer.source.soundcloud.SoundCloudAudioSourceManager; import com.sedmelluq.discord.lavaplayer.source.twitch.TwitchStreamAudioSourceManager; import com.sedmelluq.discord.lavaplayer.source.vimeo.VimeoAudioSourceManager; @@ -40,6 +41,7 @@ public static void registerRemoteSources(AudioPlayerManager playerManager, Media playerManager.registerSourceManager(new TwitchStreamAudioSourceManager()); playerManager.registerSourceManager(new BeamAudioSourceManager()); playerManager.registerSourceManager(new GetyarnAudioSourceManager()); + playerManager.registerSourceManager(new NicoAudioSourceManager()); playerManager.registerSourceManager(new HttpAudioSourceManager(containerRegistry)); } diff --git a/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/nico/HeartbeatingHttpStream.java b/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/nico/HeartbeatingHttpStream.java new file mode 100644 index 00000000..246a1412 --- /dev/null +++ b/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/nico/HeartbeatingHttpStream.java @@ -0,0 +1,94 @@ +package com.sedmelluq.discord.lavaplayer.source.nico; + +import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; +import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools; +import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; +import com.sedmelluq.discord.lavaplayer.tools.io.PersistentHttpStream; +import org.apache.commons.io.IOUtils; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.Closeable; +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * An extension of PersistentHttpStream that allows for sending heartbeats to a secondary URL. + */ +public class HeartbeatingHttpStream extends PersistentHttpStream { + private static final Logger log = LoggerFactory.getLogger(HeartbeatingHttpStream.class); + private static final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + + private String heartbeatUrl; + private int heartbeatInterval; + private String heartbeatPayload; + + private ScheduledFuture heartbeatFuture; + + /** + * Creates a new heartbeating http stream. + * @param httpInterface The HTTP interface to use for requests. + * @param contentUrl The URL to play from. + * @param contentLength The length of the content. Null if unknown. + * @param heartbeatUrl The URL to send heartbeat requests to. + * @param heartbeatInterval The interval at which to heartbeat, in milliseconds. + * @param heartbeatPayload The initial heartbeat payload. + */ + public HeartbeatingHttpStream( + HttpInterface httpInterface, + URI contentUrl, + Long contentLength, + String heartbeatUrl, + int heartbeatInterval, + String heartbeatPayload + ) { + super(httpInterface, contentUrl, contentLength); + + this.heartbeatUrl = heartbeatUrl; + this.heartbeatInterval = heartbeatInterval; + this.heartbeatPayload = heartbeatPayload; + + setupHeartbeat(); + } + + protected void setupHeartbeat() { + log.debug("Heartbeat every {} milliseconds to URL: {}", heartbeatInterval, heartbeatUrl); + + heartbeatFuture = executor.scheduleAtFixedRate(() -> { + try { + sendHeartbeat(); + } catch (Throwable t) { + log.error("Heartbeat error!", t); + IOUtils.closeQuietly(this); + } + }, heartbeatInterval, heartbeatInterval, TimeUnit.MILLISECONDS); + } + + protected void sendHeartbeat() throws IOException { + HttpPost request = new HttpPost(heartbeatUrl); + request.addHeader("Host", "api.dmc.nico"); + request.addHeader("Connection", "keep-alive"); + request.addHeader("Content-Type", "application/json"); + request.addHeader("Origin", "https://www.nicovideo.jp"); + request.setEntity(new StringEntity(heartbeatPayload)); + + try (CloseableHttpResponse response = httpInterface.execute(request)) { + HttpClientTools.assertSuccessWithContent(response, "heartbeat page"); + + heartbeatPayload = JsonBrowser.parse(response.getEntity().getContent()).get("data").format(); + } + } + + @Override + public void close() throws IOException { + heartbeatFuture.cancel(false); + super.close(); + } +} diff --git a/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/nico/NicoAudioSourceManager.java b/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/nico/NicoAudioSourceManager.java index 8f9dc9ab..bb2d2fe6 100644 --- a/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/nico/NicoAudioSourceManager.java +++ b/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/nico/NicoAudioSourceManager.java @@ -47,20 +47,24 @@ public class NicoAudioSourceManager implements AudioSourceManager, HttpConfigura private static final Pattern trackUrlPattern = Pattern.compile(TRACK_URL_REGEX); - private final String email; - private final String password; private final HttpInterfaceManager httpInterfaceManager; private final AtomicBoolean loggedIn; + public NicoAudioSourceManager() { + this(null, null); + } + /** * @param email Site account email * @param password Site account password */ public NicoAudioSourceManager(String email, String password) { - this.email = email; - this.password = password; httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager(); loggedIn = new AtomicBoolean(); + // Log in at the start + if (!DataFormatTools.isNullOrEmpty(email) && !DataFormatTools.isNullOrEmpty(password)) { + logIn(email,password); + } } @Override @@ -80,8 +84,6 @@ public AudioItem loadItem(AudioPlayerManager manager, AudioReference reference) } private AudioTrack loadTrack(String videoId) { - checkLoggedIn(); - try (HttpInterface httpInterface = getHttpInterface()) { try (CloseableHttpResponse response = httpInterface.execute(new HttpGet("http://ext.nicovideo.jp/api/getthumbinfo/" + videoId))) { int statusCode = response.getStatusLine().getStatusCode(); @@ -99,10 +101,10 @@ private AudioTrack loadTrack(String videoId) { private AudioTrack extractTrackFromXml(String videoId, Document document) { for (Element element : document.select(":root > thumb")) { - String uploader = element.select("user_nickname").first().text(); - String title = element.select("title").first().text(); - String thumbnailUrl = element.select("thumbnail_url").first().text(); - long duration = DataFormatTools.durationTextToMillis(element.select("length").first().text()); + String uploader = element.selectFirst("user_nickname").text(); + String title = element.selectFirst("title").text(); + String thumbnailUrl = element.selectFirst("thumbnail_url").text(); + long duration = DataFormatTools.durationTextToMillis(element.selectFirst("length").text()); return new NicoAudioTrack(new AudioTrackInfo(title, uploader, @@ -155,7 +157,7 @@ public void configureBuilder(Consumer configurator) { httpInterfaceManager.configureBuilder(configurator); } - void checkLoggedIn() { + void logIn(String email, String password) { synchronized (loggedIn) { if (loggedIn.get()) { return; @@ -191,6 +193,6 @@ void checkLoggedIn() { } private static String getWatchUrl(String videoId) { - return "http://www.nicovideo.jp/watch/" + videoId; + return "https://www.nicovideo.jp/watch/" + videoId; } } diff --git a/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/nico/NicoAudioTrack.java b/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/nico/NicoAudioTrack.java index 80b523b0..a7b24f8e 100644 --- a/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/nico/NicoAudioTrack.java +++ b/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/nico/NicoAudioTrack.java @@ -2,26 +2,30 @@ import com.sedmelluq.discord.lavaplayer.container.mpeg.MpegAudioTrack; import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager; +import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools; +import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser; import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools; import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; -import com.sedmelluq.discord.lavaplayer.tools.io.PersistentHttpStream; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; import com.sedmelluq.discord.lavaplayer.track.DelegatedAudioTrack; import com.sedmelluq.discord.lavaplayer.track.playback.LocalAudioTrackExecutor; +import org.apache.http.HttpStatus; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.utils.URLEncodedUtils; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; import org.apache.http.util.EntityUtils; +import org.json.JSONArray; +import org.json.JSONObject; +import org.jsoup.parser.Parser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.util.Map; - -import static com.sedmelluq.discord.lavaplayer.tools.DataFormatTools.convertToMapLayout; +import java.util.List; +import java.util.stream.Collectors; /** * Audio track that handles processing NicoNico tracks. @@ -29,8 +33,14 @@ public class NicoAudioTrack extends DelegatedAudioTrack { private static final Logger log = LoggerFactory.getLogger(NicoAudioTrack.class); + private static String actionTrackId = "S1G2fKdzOl_1702504390263"; + private final NicoAudioSourceManager sourceManager; + private String heartbeatUrl; + private int heartbeatIntervalMs; + private String initialHeartbeatPayload; + /** * @param trackInfo Track info * @param sourceManager Source manager which was used to find this track @@ -43,49 +53,163 @@ public NicoAudioTrack(AudioTrackInfo trackInfo, NicoAudioSourceManager sourceMan @Override public void process(LocalAudioTrackExecutor localExecutor) throws Exception { - sourceManager.checkLoggedIn(); - try (HttpInterface httpInterface = sourceManager.getHttpInterface()) { - loadVideoMainPage(httpInterface); String playbackUrl = loadPlaybackUrl(httpInterface); log.debug("Starting NicoNico track from URL: {}", playbackUrl); - try (PersistentHttpStream stream = new PersistentHttpStream(httpInterface, new URI(playbackUrl), null)) { + try (HeartbeatingHttpStream stream = new HeartbeatingHttpStream( + httpInterface, + new URI(playbackUrl), + null, + heartbeatUrl, + heartbeatIntervalMs, + initialHeartbeatPayload + )) { processDelegate(new MpegAudioTrack(trackInfo, stream), localExecutor); } } } - private void loadVideoMainPage(HttpInterface httpInterface) throws IOException { - HttpGet request = new HttpGet("http://www.nicovideo.jp/watch/" + trackInfo.identifier); + private JsonBrowser loadVideoApi(HttpInterface httpInterface) throws IOException { + String apiUrl = "https://www.nicovideo.jp/api/watch/v3_guest/" + getIdentifier() + "?_frontendId=6&_frontendVersion=0&actionTrackId=" + actionTrackId + "&i18nLanguage=en-us"; - try (CloseableHttpResponse response = httpInterface.execute(request)) { - int statusCode = response.getStatusLine().getStatusCode(); - if (!HttpClientTools.isSuccessWithContent(statusCode)) { - throw new IOException("Unexpected status code from video main page: " + statusCode); - } + try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(apiUrl))) { + HttpClientTools.assertSuccessWithContent(response, "api response"); - EntityUtils.consume(response.getEntity()); + return JsonBrowser.parse(response.getEntity().getContent()).get("data"); + } + } + + private JsonBrowser loadVideoMainPage(HttpInterface httpInterface) throws IOException { + try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(trackInfo.uri))) { + HttpClientTools.assertSuccessWithContent(response, "video main page"); + + String urlEncodedData = DataFormatTools.extractBetween(EntityUtils.toString(response.getEntity()), "data-api-data=\"", "\""); + String watchData = Parser.unescapeEntities(urlEncodedData, false); + + return JsonBrowser.parse(watchData); } } private String loadPlaybackUrl(HttpInterface httpInterface) throws IOException { - HttpGet request = new HttpGet("http://flapi.nicovideo.jp/api/getflv/" + trackInfo.identifier); + JsonBrowser videoJson = loadVideoApi(httpInterface); + + if (videoJson.isNull()) { + log.warn("Couldn't retrieve NicoNico video details from API, falling back to HTML page..."); + videoJson = loadVideoMainPage(httpInterface); + } + + if (!videoJson.isNull()) { + // an "actionTrackId" is necessary to receive an API response. + // We make sure this is kept up to date to prevent any issues with tracking IDs becoming invalid. + String trackingId = videoJson.get("client").get("watchTrackId").text(); + + if (trackingId != null) { + actionTrackId = trackingId; + } + } + + JSONObject watchData = processJSON(videoJson.get("media").get("delivery").get("movie").get("session")); + + HttpPost request = new HttpPost("https://api.dmc.nico/api/sessions?_format=json"); + request.addHeader("Host", "api.dmc.nico"); + request.addHeader("Connection", "keep-alive"); + request.addHeader("Content-Type", "application/json"); + request.addHeader("Origin", "https://www.nicovideo.jp"); + request.setEntity(new StringEntity(watchData.toString())); try (CloseableHttpResponse response = httpInterface.execute(request)) { int statusCode = response.getStatusLine().getStatusCode(); - if (!HttpClientTools.isSuccessWithContent(statusCode)) { + + if (statusCode != HttpStatus.SC_CREATED) { throw new IOException("Unexpected status code from playback parameters page: " + statusCode); } - String text = EntityUtils.toString(response.getEntity()); - Map format = convertToMapLayout(URLEncodedUtils.parse(text, StandardCharsets.UTF_8)); + JsonBrowser info = JsonBrowser.parse(response.getEntity().getContent()).get("data"); + JsonBrowser session = info.get("session"); - return format.get("url"); + heartbeatUrl = "https://api.dmc.nico/api/sessions/" + session.get("id").text() + "?_format=json&_method=PUT"; + heartbeatIntervalMs = session.get("keep_method").get("heartbeat").get("lifetime").asInt(120000) - 5000; + initialHeartbeatPayload = info.format(); + + return session.get("content_uri").text(); } } + private JSONObject processJSON(JsonBrowser input) { + if (input.isNull()) { + throw new IllegalStateException("Invalid response received from NicoNico when loading video details"); + } + + JSONObject lifetime = new JSONObject().put("lifetime", input.get("heartbeatLifetime").asLong(120000)); + JSONObject heartbeat = new JSONObject().put("heartbeat", lifetime); + + List videos = input.get("videos").values().stream() + .map(JsonBrowser::text) + .collect(Collectors.toList()); + + List audios = input.get("audios").values().stream() + .map(JsonBrowser::text) + .collect(Collectors.toList()); + + JSONObject srcIds = new JSONObject() + .put("video_src_ids", videos) + .put("audio_src_ids", audios); + + JSONObject srcIdToMux = new JSONObject().put("src_id_to_mux", srcIds); + JSONArray array = new JSONArray().put(srcIdToMux); + JSONObject contentSrcIds = new JSONObject().put("content_src_ids", array); + JSONArray contentSrcIdSets = new JSONArray().put(contentSrcIds); + + JsonBrowser url = input.get("urls").index(0); + boolean useWellKnownPort = url.get("isWellKnownPort").asBoolean(false); + boolean useSsl = url.get("isSsl").asBoolean(false); + + JSONObject httpDownloadParameters = new JSONObject() + .put("use_well_known_port", useWellKnownPort ? "yes" : "no") + .put("use_ssl", useSsl ? "yes" : "no"); + + JSONObject innerParameters = new JSONObject() + .put("http_output_download_parameters", httpDownloadParameters); + + JSONObject httpParameters = new JSONObject().put("parameters", innerParameters); + JSONObject outerParameters = new JSONObject().put("http_parameters", httpParameters); + + JSONObject protocol = new JSONObject() + .put("name", "http") + .put("parameters", outerParameters); + + JSONObject sessionOperationAuthBySignature = new JSONObject() + .put("token", input.get("token").text()) + .put("signature", input.get("signature").text()); + + JSONObject sessionOperationAuth = new JSONObject() + .put("session_operation_auth_by_signature", sessionOperationAuthBySignature); + + JSONObject contentAuth = new JSONObject() + .put("auth_type", input.get("authTypes").get("http").text()) + .put("content_key_timeout", input.get("contentKeyTimeout").asLong(120000)) + .put("service_id", "nicovideo") + .put("service_user_id", input.get("serviceUserId").text()); + + JSONObject clientInfo = new JSONObject().put("player_id", input.get("playerId").text()); + + JSONObject session = new JSONObject() + .put("content_type", "movie") + .put("timing_constraint", "unlimited") + .put("recipe_id", input.get("recipeId").text()) + .put("content_id", input.get("contentId").text()) + .put("keep_method", heartbeat) + .put("content_src_id_sets", contentSrcIdSets) + .put("protocol", protocol) + .put("session_operation_auth", sessionOperationAuth) + .put("content_auth", contentAuth) + .put("client_info", clientInfo); + + return new JSONObject().put("session", session); + } + @Override protected AudioTrack makeShallowClone() { return new NicoAudioTrack(trackInfo, sourceManager); diff --git a/main/src/main/java/com/sedmelluq/discord/lavaplayer/tools/JsonBrowser.java b/main/src/main/java/com/sedmelluq/discord/lavaplayer/tools/JsonBrowser.java index a9b302e2..999eb4d2 100644 --- a/main/src/main/java/com/sedmelluq/discord/lavaplayer/tools/JsonBrowser.java +++ b/main/src/main/java/com/sedmelluq/discord/lavaplayer/tools/JsonBrowser.java @@ -246,6 +246,22 @@ public long asLong(long defaultValue) { return defaultValue; } + public int asInt(int defaultValue) { + if (node != null) { + if (node.isNumber()) { + return node.numberValue().intValue(); + } else if (node.isTextual()) { + try { + return Integer.parseInt(node.textValue()); + } catch (NumberFormatException ignored) { + // Fall through to default value. + } + } + } + + return defaultValue; + } + public String safeText() { String text = text(); return text != null ? text : ""; diff --git a/main/src/main/java/com/sedmelluq/discord/lavaplayer/tools/io/PersistentHttpStream.java b/main/src/main/java/com/sedmelluq/discord/lavaplayer/tools/io/PersistentHttpStream.java index 8f4e0915..b8fc7638 100644 --- a/main/src/main/java/com/sedmelluq/discord/lavaplayer/tools/io/PersistentHttpStream.java +++ b/main/src/main/java/com/sedmelluq/discord/lavaplayer/tools/io/PersistentHttpStream.java @@ -31,7 +31,7 @@ public class PersistentHttpStream extends SeekableInputStream implements AutoClo private static final long MAX_SKIP_DISTANCE = 512L * 1024L; - private final HttpInterface httpInterface; + protected final HttpInterface httpInterface; protected final URI contentUrl; private int lastStatusCode; private CloseableHttpResponse currentResponse;