From 1d825c0dd8c00c5261b045a24e989f0ffce39b4a Mon Sep 17 00:00:00 2001 From: devoxin Date: Tue, 17 Sep 2024 00:00:26 +0100 Subject: [PATCH] Implement fetching video streams over REST --- .../dev/lavalink/youtube/plugin/IOUtils.java | 15 ++ .../youtube/plugin/YoutubeRestHandler.java | 142 ++++++++++++++++-- 2 files changed, 147 insertions(+), 10 deletions(-) create mode 100644 plugin/src/main/java/dev/lavalink/youtube/plugin/IOUtils.java diff --git a/plugin/src/main/java/dev/lavalink/youtube/plugin/IOUtils.java b/plugin/src/main/java/dev/lavalink/youtube/plugin/IOUtils.java new file mode 100644 index 0000000..af6c8be --- /dev/null +++ b/plugin/src/main/java/dev/lavalink/youtube/plugin/IOUtils.java @@ -0,0 +1,15 @@ +package dev.lavalink.youtube.plugin; + +import java.io.Closeable; + +public class IOUtils { + public static void closeQuietly(Closeable... closeables) { + for (Closeable closeable : closeables) { + try { + closeable.close(); + } catch (Throwable ignored) { + + } + } + } +} diff --git a/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeRestHandler.java b/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeRestHandler.java index df3f602..58e2e47 100644 --- a/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeRestHandler.java +++ b/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeRestHandler.java @@ -1,18 +1,30 @@ package dev.lavalink.youtube.plugin; import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; -import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools; +import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; +import dev.lavalink.youtube.CannotBeLoaded; import dev.lavalink.youtube.YoutubeAudioSourceManager; import dev.lavalink.youtube.clients.Web; import dev.lavalink.youtube.clients.WebEmbedded; +import dev.lavalink.youtube.clients.skeleton.Client; import dev.lavalink.youtube.plugin.rest.MinimalConfigRequest; import dev.lavalink.youtube.plugin.rest.MinimalConfigResponse; +import dev.lavalink.youtube.track.YoutubePersistentHttpStream; +import dev.lavalink.youtube.track.format.StreamFormat; +import dev.lavalink.youtube.track.format.TrackFormats; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.IOException; +import java.net.URI; +import java.util.Arrays; @Service @RestController @@ -25,26 +37,136 @@ public YoutubeRestHandler(AudioPlayerManager playerManager) { this.playerManager = playerManager; } - @GetMapping("/youtube") - public MinimalConfigResponse getYoutubeConfig() { + private YoutubeAudioSourceManager getYoutubeSource() { YoutubeAudioSourceManager source = playerManager.source(YoutubeAudioSourceManager.class); if (source == null) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The YouTube source manager is not registered."); } - return MinimalConfigResponse.from(source); + return source; } - @PostMapping("/youtube") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void updateYoutubeConfig(@RequestBody MinimalConfigRequest config) { - YoutubeAudioSourceManager source = playerManager.source(YoutubeAudioSourceManager.class); + @GetMapping("/youtube/stream/{videoId}") + public ResponseEntity getYoutubeVideoStream(@PathVariable("videoId") String videoId, + @RequestParam(name = "itag", required = false) Integer itag, + @RequestParam(name = "withClient", required = false) String clientIdentifier) throws IOException { + YoutubeAudioSourceManager source = getYoutubeSource(); - if (source == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The YouTube source manager is not registered."); + if (Arrays.stream(source.getClients()).noneMatch(Client::supportsFormatLoading)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "None of the registered clients supports format loading."); } + boolean foundFormats = false; + + HttpInterface httpInterface = source.getInterface(); + + for (Client client : source.getClients()) { + log.debug("REST streaming {} attempting to use client {}", videoId, client.getIdentifier()); + + if (clientIdentifier != null && !client.getIdentifier().equalsIgnoreCase(clientIdentifier)) { + log.debug("Client identifier specified but does not match, trying next."); + continue; + } + + if (!client.supportsFormatLoading()) { + continue; + } + + log.debug("Loading formats for {} with client {}", videoId, client.getIdentifier()); + + TrackFormats formats; + + try { + formats = client.loadFormats(source, httpInterface, videoId); + } catch (CannotBeLoaded cbl) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This video cannot be loaded. Reason: " + cbl.getCause().getMessage()); + } + + if (formats == null || formats.getFormats().isEmpty()) { + log.debug("No formats found for {}", videoId); + continue; + } + + foundFormats = true; + StreamFormat selectedFormat; + + if (itag == null) { + selectedFormat = formats.getBestFormat(); + } else { + selectedFormat = formats.getFormats().stream().filter(fmt -> fmt.getItag() == itag).findFirst() + .orElse(null); + } + + if (selectedFormat == null) { + log.debug("No suitable formats found. (Matching: {})", itag); + continue; + } + + log.debug("Selected format {} for {}", selectedFormat.getItag(), videoId); + + URI resolved = source.getCipherManager().resolveFormatUrl(httpInterface, formats.getPlayerScriptUrl(), selectedFormat); + URI transformed = client.transformPlaybackUri(selectedFormat.getUrl(), resolved); + YoutubePersistentHttpStream httpStream = new YoutubePersistentHttpStream(httpInterface, transformed, selectedFormat.getContentLength()); + + boolean streamValidated = false; + + try { + int statusCode = httpStream.checkStatusCode(); + streamValidated = statusCode == 200; + + if (statusCode != 200) { + log.debug("REST streaming with {} for {} returned status code {} when opening video stream", client.getIdentifier(), videoId, statusCode); + } + } catch (Throwable t) { + if ("Not success status code: 403".equals(t.getMessage())) { + log.debug("REST streaming with {} for {} returned status code 403 when opening video stream", client.getIdentifier(), videoId); + } else { + IOUtils.closeQuietly(httpStream, httpInterface); + throw t; + } + } + + if (!streamValidated) { + IOUtils.closeQuietly(httpStream); + continue; + } + + StreamingResponseBody buffer = (os) -> { + int bytesRead; + byte[] copy = new byte[1024]; + + try (httpStream; httpInterface) { + while ((bytesRead = httpStream.read(copy, 0, copy.length)) != -1) { + os.write(copy, 0, bytesRead); + } + } + }; + + return ResponseEntity.ok() + .contentLength(selectedFormat.getContentLength()) + .contentType(MediaType.parseMediaType(selectedFormat.getType().getMimeType())) + .body(buffer); + } + + IOUtils.closeQuietly(httpInterface); + + if (foundFormats) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No formats found with the requested itag."); + } + + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not find formats for the requested videoId."); + } + + @GetMapping("/youtube") + public MinimalConfigResponse getYoutubeConfig() { + return MinimalConfigResponse.from(getYoutubeSource()); + } + + @PostMapping("/youtube") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void updateYoutubeConfig(@RequestBody MinimalConfigRequest config) { + YoutubeAudioSourceManager source = getYoutubeSource(); String refreshToken = config.getRefreshToken(); if (!"x".equals(refreshToken)) {