Skip to content

Commit 173b04d

Browse files
authored
Merge pull request #431 from Red5/feature/mediabunny
Feature/mediabunny
2 parents b4ad883 + 82b28d3 commit 173b04d

File tree

12 files changed

+30162
-2
lines changed

12 files changed

+30162
-2
lines changed

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
<red5-io.version>${project.version}</red5-io.version>
100100
<red5-server-common.version>${project.version}</red5-server-common.version>
101101
<red5-service.version>${project.version}</red5-service.version>
102+
<red5-moq-pkgr.version>1.3.4</red5-moq-pkgr.version>
102103
<slf4j.version>2.0.17</slf4j.version>
103104
<logback.version>1.5.18</logback.version>
104105
<bc.version>1.83</bc.version>
@@ -385,6 +386,11 @@
385386
<artifactId>red5-server-common</artifactId>
386387
<version>${red5-server-common.version}</version>
387388
</dependency>
389+
<dependency>
390+
<groupId>org.red5</groupId>
391+
<artifactId>red5-moq-pkgr</artifactId>
392+
<version>${red5-moq-pkgr.version}</version>
393+
</dependency>
388394
<dependency>
389395
<groupId>org.apache.httpcomponents</groupId>
390396
<artifactId>httpclient</artifactId>

server/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,10 @@
140140
<groupId>com.google.code.gson</groupId>
141141
<artifactId>gson</artifactId>
142142
</dependency>
143+
<dependency>
144+
<groupId>org.red5</groupId>
145+
<artifactId>red5-moq-pkgr</artifactId>
146+
</dependency>
143147
</dependencies>
144148
<profiles>
145149
<profile>

server/src/main/assembly/server.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@
150150
</includes>
151151
</fileSet>
152152
<!-- Live -->
153+
<fileSet>
154+
<directory>${project.basedir}/src/main/server/webapps/live</directory>
155+
<outputDirectory>webapps/live</outputDirectory>
156+
</fileSet>
153157
<fileSet>
154158
<directory>${project.basedir}/src/main/server/webapps/live/WEB-INF</directory>
155159
<outputDirectory>webapps/live/WEB-INF</outputDirectory>
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package org.red5.server.net.mediabunny;
2+
3+
import java.io.IOException;
4+
import java.io.OutputStream;
5+
import java.util.concurrent.BlockingQueue;
6+
import java.util.concurrent.ExecutorService;
7+
import java.util.concurrent.Executors;
8+
9+
import org.red5.server.api.IServer;
10+
import org.red5.server.api.scope.IGlobalScope;
11+
import org.red5.server.api.scope.IScope;
12+
import org.red5.server.scope.WebScope;
13+
import org.red5.server.util.ScopeUtils;
14+
import org.slf4j.Logger;
15+
import org.slf4j.LoggerFactory;
16+
import org.springframework.web.context.WebApplicationContext;
17+
import org.springframework.web.context.support.WebApplicationContextUtils;
18+
19+
import jakarta.servlet.AsyncContext;
20+
import jakarta.servlet.AsyncEvent;
21+
import jakarta.servlet.AsyncListener;
22+
import jakarta.servlet.ServletContext;
23+
import jakarta.servlet.ServletException;
24+
import jakarta.servlet.http.HttpServlet;
25+
import jakarta.servlet.http.HttpServletRequest;
26+
import jakarta.servlet.http.HttpServletResponse;
27+
28+
/**
29+
* HTTP endpoint for MediaBunny fMP4 streaming.
30+
* Usage: /mediabunny?stream={name}
31+
*/
32+
public class MediaBunnyServlet extends HttpServlet implements AsyncListener {
33+
34+
private static final long serialVersionUID = 1L;
35+
36+
private static final Logger log = LoggerFactory.getLogger(MediaBunnyServlet.class);
37+
38+
private transient WebApplicationContext webAppCtx;
39+
40+
private transient IServer server;
41+
42+
private transient WebScope webScope;
43+
44+
private final ExecutorService executor = Executors.newCachedThreadPool();
45+
46+
private static final int INIT_PREFIX_BYTES = 32;
47+
48+
@SuppressWarnings("null")
49+
@Override
50+
public void init() throws ServletException {
51+
super.init();
52+
ServletContext ctx = getServletContext();
53+
try {
54+
webAppCtx = WebApplicationContextUtils.getRequiredWebApplicationContext(ctx);
55+
} catch (IllegalStateException e) {
56+
webAppCtx = (WebApplicationContext) ctx.getAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE);
57+
}
58+
if (webAppCtx != null) {
59+
server = (IServer) webAppCtx.getBean("red5.server");
60+
webScope = (WebScope) webAppCtx.getBean("web.scope");
61+
log.info("MediaBunny servlet initialized");
62+
} else {
63+
log.warn("No web application context available");
64+
}
65+
}
66+
67+
@Override
68+
public void destroy() {
69+
executor.shutdownNow();
70+
super.destroy();
71+
}
72+
73+
@Override
74+
protected void doOptions(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
75+
handleCORS(req, resp);
76+
resp.setStatus(HttpServletResponse.SC_OK);
77+
}
78+
79+
@Override
80+
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
81+
String streamName = req.getParameter("stream");
82+
log.debug("Mediabunny get request: {}", streamName);
83+
handleCORS(req, resp);
84+
if (streamName == null || streamName.isBlank()) {
85+
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing stream parameter");
86+
return;
87+
}
88+
IScope scope = getScope(req);
89+
if (scope == null) {
90+
resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Scope not available");
91+
return;
92+
}
93+
MediaBunnyStreamRegistry.StreamSubscription subscription;
94+
try {
95+
subscription = MediaBunnyStreamRegistry.getInstance().subscribe(scope, streamName);
96+
} catch (Exception e) {
97+
resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Stream not found: " + streamName);
98+
return;
99+
}
100+
resp.setStatus(HttpServletResponse.SC_OK);
101+
resp.setContentType("video/mp4");
102+
resp.setHeader("Cache-Control", "no-cache");
103+
resp.setHeader("Connection", "keep-alive");
104+
105+
AsyncContext asyncContext = req.startAsync();
106+
asyncContext.setTimeout(0);
107+
asyncContext.addListener(this);
108+
109+
executor.execute(() -> streamQueue(asyncContext, subscription));
110+
}
111+
112+
private void streamQueue(AsyncContext asyncContext, MediaBunnyStreamRegistry.StreamSubscription subscription) {
113+
boolean loggedFirstChunk = false;
114+
boolean loggedSecondChunk = false;
115+
int chunkCount = 0;
116+
try (OutputStream out = asyncContext.getResponse().getOutputStream()) {
117+
BlockingQueue<byte[]> queue = subscription.getQueue();
118+
while (true) {
119+
byte[] chunk = queue.take();
120+
chunkCount++;
121+
if (!loggedFirstChunk) {
122+
loggedFirstChunk = true;
123+
log.info("MediaBunny first chunk ({} bytes) prefix={}", chunk.length, hexPrefix(chunk, INIT_PREFIX_BYTES));
124+
} else if (!loggedSecondChunk) {
125+
loggedSecondChunk = true;
126+
log.info("MediaBunny second chunk ({} bytes) prefix={}", chunk.length, hexPrefix(chunk, INIT_PREFIX_BYTES));
127+
} else if (chunkCount % 50 == 0 && log.isDebugEnabled()) {
128+
log.debug("MediaBunny chunk {} ({} bytes)", chunkCount, chunk.length);
129+
}
130+
out.write(chunk);
131+
out.flush();
132+
}
133+
} catch (Exception e) {
134+
log.debug("MediaBunny stream ended: {}", e.getMessage());
135+
} finally {
136+
subscription.close();
137+
try {
138+
asyncContext.complete();
139+
} catch (IllegalStateException e) {
140+
log.debug("AsyncContext already completed or in error state", e);
141+
}
142+
}
143+
}
144+
145+
private void handleCORS(HttpServletRequest req, HttpServletResponse resp) {
146+
String origin = req.getHeader("Origin");
147+
if (origin != null) {
148+
resp.setHeader("Access-Control-Allow-Origin", origin);
149+
} else {
150+
resp.setHeader("Access-Control-Allow-Origin", "*");
151+
}
152+
resp.setHeader("Access-Control-Allow-Credentials", "true");
153+
resp.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
154+
resp.setHeader("Access-Control-Allow-Headers", "Accept, Content-Type");
155+
resp.setHeader("Access-Control-Max-Age", "3600");
156+
// Ensure web application context is available
157+
if (webAppCtx == null) {
158+
ServletContext ctx = getServletContext();
159+
try {
160+
webAppCtx = WebApplicationContextUtils.getRequiredWebApplicationContext(ctx);
161+
} catch (IllegalStateException e) {
162+
webAppCtx = (WebApplicationContext) ctx.getAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE);
163+
}
164+
if (webAppCtx != null) {
165+
server = (IServer) webAppCtx.getBean("red5.server");
166+
webScope = (WebScope) webAppCtx.getBean("web.scope");
167+
log.info("MediaBunny servlet initialized");
168+
} else {
169+
log.warn("No web application context available");
170+
}
171+
}
172+
}
173+
174+
private IScope getScope(HttpServletRequest req) {
175+
if (webScope == null || server == null) {
176+
return null;
177+
}
178+
IGlobalScope globalScope = server.getGlobal("default");
179+
if (globalScope == null) {
180+
return null;
181+
}
182+
String path = req.getContextPath();
183+
if (path == null || path.equals("/")) {
184+
return ScopeUtils.resolveScope(globalScope, "/live");
185+
}
186+
String[] parts = path.split("/");
187+
if (parts.length > 1) {
188+
String appName = parts[1];
189+
IScope appScope = ScopeUtils.resolveScope(globalScope, appName);
190+
if (appScope != null && ScopeUtils.isApp(appScope)) {
191+
return appScope;
192+
}
193+
}
194+
return ScopeUtils.resolveScope(globalScope, "/live");
195+
}
196+
197+
@Override
198+
public void onComplete(AsyncEvent event) throws IOException {
199+
// no-op
200+
}
201+
202+
@Override
203+
public void onTimeout(AsyncEvent event) throws IOException {
204+
// no-op
205+
}
206+
207+
@Override
208+
public void onError(AsyncEvent event) throws IOException {
209+
// no-op
210+
}
211+
212+
@Override
213+
public void onStartAsync(AsyncEvent event) throws IOException {
214+
// no-op
215+
}
216+
217+
private static String hexPrefix(byte[] data, int maxBytes) {
218+
if (data == null || data.length == 0) {
219+
return "<empty>";
220+
}
221+
int limit = Math.min(data.length, maxBytes);
222+
StringBuilder sb = new StringBuilder(limit * 2 + 6);
223+
for (int i = 0; i < limit; i++) {
224+
sb.append(String.format("%02x", data[i]));
225+
}
226+
if (data.length > limit) {
227+
sb.append("...");
228+
}
229+
return sb.toString();
230+
}
231+
}

0 commit comments

Comments
 (0)