Skip to content

Commit 209a00e

Browse files
authored
(plugin only) Support retrieving video streams via REST requests (#50)
* Implement fetching video streams over REST * Update README.md * Update README.md * forgot some words
1 parent 200ec45 commit 209a00e

File tree

3 files changed

+164
-10
lines changed

3 files changed

+164
-10
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,23 @@ Otherwise:
341341
}
342342
```
343343

344+
### `GET` `/youtube/stream/{videoId}`
345+
346+
Query parameters:
347+
348+
| Key | Value Type | Required | Notes |
349+
|--------------|------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
350+
| itag | integer | No | The [itag](https://gist.github.com/AgentOak/34d47c65b1d28829bb17c24c04a0096f) of the desired format. If unspecified, youtube-source's default format selector will be used. |
351+
| withClient | string | No | The identifier of the client to use for streaming. Uses all clients if unspecified. |
352+
353+
Response:
354+
355+
If `videoId` could not be found or loaded, or the `itag` does not exist, or if no client supports format loading:
356+
`400 - Bad Request`
357+
358+
Otherwise:
359+
`200 - OK` accompanied by the selected format stream (audio or video). `Content-Type` header will be set appropriately.
360+
344361
## Migration from Lavaplayer's built-in YouTube source
345362

346363
This client is intended as a direct replacement for Lavaplayer's built-in `YoutubeAudioSourceManager`,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package dev.lavalink.youtube.plugin;
2+
3+
import java.io.Closeable;
4+
5+
public class IOUtils {
6+
public static void closeQuietly(Closeable... closeables) {
7+
for (Closeable closeable : closeables) {
8+
try {
9+
closeable.close();
10+
} catch (Throwable ignored) {
11+
12+
}
13+
}
14+
}
15+
}

plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeRestHandler.java

Lines changed: 132 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,30 @@
11
package dev.lavalink.youtube.plugin;
22

33
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
4-
import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools;
4+
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface;
5+
import dev.lavalink.youtube.CannotBeLoaded;
56
import dev.lavalink.youtube.YoutubeAudioSourceManager;
67
import dev.lavalink.youtube.clients.Web;
78
import dev.lavalink.youtube.clients.WebEmbedded;
9+
import dev.lavalink.youtube.clients.skeleton.Client;
810
import dev.lavalink.youtube.plugin.rest.MinimalConfigRequest;
911
import dev.lavalink.youtube.plugin.rest.MinimalConfigResponse;
12+
import dev.lavalink.youtube.track.YoutubePersistentHttpStream;
13+
import dev.lavalink.youtube.track.format.StreamFormat;
14+
import dev.lavalink.youtube.track.format.TrackFormats;
1015
import org.slf4j.Logger;
1116
import org.slf4j.LoggerFactory;
1217
import org.springframework.http.HttpStatus;
18+
import org.springframework.http.MediaType;
19+
import org.springframework.http.ResponseEntity;
1320
import org.springframework.stereotype.Service;
1421
import org.springframework.web.bind.annotation.*;
1522
import org.springframework.web.server.ResponseStatusException;
23+
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
24+
25+
import java.io.IOException;
26+
import java.net.URI;
27+
import java.util.Arrays;
1628

1729
@Service
1830
@RestController
@@ -25,26 +37,136 @@ public YoutubeRestHandler(AudioPlayerManager playerManager) {
2537
this.playerManager = playerManager;
2638
}
2739

28-
@GetMapping("/youtube")
29-
public MinimalConfigResponse getYoutubeConfig() {
40+
private YoutubeAudioSourceManager getYoutubeSource() {
3041
YoutubeAudioSourceManager source = playerManager.source(YoutubeAudioSourceManager.class);
3142

3243
if (source == null) {
3344
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The YouTube source manager is not registered.");
3445
}
3546

36-
return MinimalConfigResponse.from(source);
47+
return source;
3748
}
3849

39-
@PostMapping("/youtube")
40-
@ResponseStatus(HttpStatus.NO_CONTENT)
41-
public void updateYoutubeConfig(@RequestBody MinimalConfigRequest config) {
42-
YoutubeAudioSourceManager source = playerManager.source(YoutubeAudioSourceManager.class);
50+
@GetMapping("/youtube/stream/{videoId}")
51+
public ResponseEntity<StreamingResponseBody> getYoutubeVideoStream(@PathVariable("videoId") String videoId,
52+
@RequestParam(name = "itag", required = false) Integer itag,
53+
@RequestParam(name = "withClient", required = false) String clientIdentifier) throws IOException {
54+
YoutubeAudioSourceManager source = getYoutubeSource();
4355

44-
if (source == null) {
45-
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The YouTube source manager is not registered.");
56+
if (Arrays.stream(source.getClients()).noneMatch(Client::supportsFormatLoading)) {
57+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "None of the registered clients supports format loading.");
4658
}
4759

60+
boolean foundFormats = false;
61+
62+
HttpInterface httpInterface = source.getInterface();
63+
64+
for (Client client : source.getClients()) {
65+
log.debug("REST streaming {} attempting to use client {}", videoId, client.getIdentifier());
66+
67+
if (clientIdentifier != null && !client.getIdentifier().equalsIgnoreCase(clientIdentifier)) {
68+
log.debug("Client identifier specified but does not match, trying next.");
69+
continue;
70+
}
71+
72+
if (!client.supportsFormatLoading()) {
73+
continue;
74+
}
75+
76+
log.debug("Loading formats for {} with client {}", videoId, client.getIdentifier());
77+
78+
TrackFormats formats;
79+
80+
try {
81+
formats = client.loadFormats(source, httpInterface, videoId);
82+
} catch (CannotBeLoaded cbl) {
83+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This video cannot be loaded. Reason: " + cbl.getCause().getMessage());
84+
}
85+
86+
if (formats == null || formats.getFormats().isEmpty()) {
87+
log.debug("No formats found for {}", videoId);
88+
continue;
89+
}
90+
91+
foundFormats = true;
92+
StreamFormat selectedFormat;
93+
94+
if (itag == null) {
95+
selectedFormat = formats.getBestFormat();
96+
} else {
97+
selectedFormat = formats.getFormats().stream().filter(fmt -> fmt.getItag() == itag).findFirst()
98+
.orElse(null);
99+
}
100+
101+
if (selectedFormat == null) {
102+
log.debug("No suitable formats found. (Matching: {})", itag);
103+
continue;
104+
}
105+
106+
log.debug("Selected format {} for {}", selectedFormat.getItag(), videoId);
107+
108+
URI resolved = source.getCipherManager().resolveFormatUrl(httpInterface, formats.getPlayerScriptUrl(), selectedFormat);
109+
URI transformed = client.transformPlaybackUri(selectedFormat.getUrl(), resolved);
110+
YoutubePersistentHttpStream httpStream = new YoutubePersistentHttpStream(httpInterface, transformed, selectedFormat.getContentLength());
111+
112+
boolean streamValidated = false;
113+
114+
try {
115+
int statusCode = httpStream.checkStatusCode();
116+
streamValidated = statusCode == 200;
117+
118+
if (statusCode != 200) {
119+
log.debug("REST streaming with {} for {} returned status code {} when opening video stream", client.getIdentifier(), videoId, statusCode);
120+
}
121+
} catch (Throwable t) {
122+
if ("Not success status code: 403".equals(t.getMessage())) {
123+
log.debug("REST streaming with {} for {} returned status code 403 when opening video stream", client.getIdentifier(), videoId);
124+
} else {
125+
IOUtils.closeQuietly(httpStream, httpInterface);
126+
throw t;
127+
}
128+
}
129+
130+
if (!streamValidated) {
131+
IOUtils.closeQuietly(httpStream);
132+
continue;
133+
}
134+
135+
StreamingResponseBody buffer = (os) -> {
136+
int bytesRead;
137+
byte[] copy = new byte[1024];
138+
139+
try (httpStream; httpInterface) {
140+
while ((bytesRead = httpStream.read(copy, 0, copy.length)) != -1) {
141+
os.write(copy, 0, bytesRead);
142+
}
143+
}
144+
};
145+
146+
return ResponseEntity.ok()
147+
.contentLength(selectedFormat.getContentLength())
148+
.contentType(MediaType.parseMediaType(selectedFormat.getType().getMimeType()))
149+
.body(buffer);
150+
}
151+
152+
IOUtils.closeQuietly(httpInterface);
153+
154+
if (foundFormats) {
155+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No formats found with the requested itag.");
156+
}
157+
158+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not find formats for the requested videoId.");
159+
}
160+
161+
@GetMapping("/youtube")
162+
public MinimalConfigResponse getYoutubeConfig() {
163+
return MinimalConfigResponse.from(getYoutubeSource());
164+
}
165+
166+
@PostMapping("/youtube")
167+
@ResponseStatus(HttpStatus.NO_CONTENT)
168+
public void updateYoutubeConfig(@RequestBody MinimalConfigRequest config) {
169+
YoutubeAudioSourceManager source = getYoutubeSource();
48170
String refreshToken = config.getRefreshToken();
49171

50172
if (!"x".equals(refreshToken)) {

0 commit comments

Comments
 (0)