diff --git a/bundles/core/org.eclipse.smarthome.core.audio/META-INF/MANIFEST.MF b/bundles/core/org.eclipse.smarthome.core.audio/META-INF/MANIFEST.MF index 74ba5de1504..2eeaae4cdfd 100644 --- a/bundles/core/org.eclipse.smarthome.core.audio/META-INF/MANIFEST.MF +++ b/bundles/core/org.eclipse.smarthome.core.audio/META-INF/MANIFEST.MF @@ -6,7 +6,9 @@ Bundle-Version: 0.9.0.qualifier Bundle-Vendor: Eclipse.org/SmartHome Bundle-RequiredExecutionEnvironment: JavaSE-1.7 Bundle-ClassPath: . -Import-Package: org.apache.commons.io, +Import-Package: javax.servlet, + javax.servlet.http, + org.apache.commons.io, org.apache.commons.lang, org.eclipse.smarthome.config.core, org.eclipse.smarthome.core.audio, @@ -20,6 +22,7 @@ Import-Package: org.apache.commons.io, org.eclipse.smarthome.io.console, org.eclipse.smarthome.io.console.extensions, org.osgi.framework, + org.osgi.service.http, org.slf4j Export-Package: org.eclipse.smarthome.core.audio Service-Component: OSGI-INF/*.xml diff --git a/bundles/core/org.eclipse.smarthome.core.audio/OSGI-INF/AudioServlet.xml b/bundles/core/org.eclipse.smarthome.core.audio/OSGI-INF/AudioServlet.xml new file mode 100644 index 00000000000..aa2ffe4ca0f --- /dev/null +++ b/bundles/core/org.eclipse.smarthome.core.audio/OSGI-INF/AudioServlet.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/bundles/core/org.eclipse.smarthome.core.audio/src/main/java/org/eclipse/smarthome/core/audio/AudioHTTPServer.java b/bundles/core/org.eclipse.smarthome.core.audio/src/main/java/org/eclipse/smarthome/core/audio/AudioHTTPServer.java new file mode 100644 index 00000000000..8a69cf356b1 --- /dev/null +++ b/bundles/core/org.eclipse.smarthome.core.audio/src/main/java/org/eclipse/smarthome/core/audio/AudioHTTPServer.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2014-2016 by the respective copyright holders. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.smarthome.core.audio; + +import java.net.URL; + +import org.eclipse.smarthome.core.audio.internal.AudioServlet; + +/** + * This is an interface that is implemented by {@link AudioServlet} and which allows exposing audio streams through + * HTTP. + * Streams are only served a single time and then discarded. + * + * @author Kai Kreuzer - Initial contribution and API + * + */ +public interface AudioHTTPServer { + + /** + * Creates a url for a given {@link AudioStream} where it can be requested a single time. + * + * @param stream the stream to serve on HTTP + * @return the absolute URL to access the stream (using the primary network interface) + */ + URL serve(AudioStream stream); + + /** + * Creates a url for a given {@link AudioStream} where it can be requested a single time. + * This method makes sure that the HTTP response contains the "Content-Length" header as some clients require this. + * Note that this should only be used if really needed, since it might mean that the whole stream has to be read + * locally first in order to determine its length. + * + * @param stream the stream to serve on HTTP + * @return the absolute URL to access the stream (using the primary network interface) + */ + URL serveWithSize(AudioStream stream); + +} diff --git a/bundles/core/org.eclipse.smarthome.core.audio/src/main/java/org/eclipse/smarthome/core/audio/internal/AudioServlet.java b/bundles/core/org.eclipse.smarthome.core.audio/src/main/java/org/eclipse/smarthome/core/audio/internal/AudioServlet.java new file mode 100644 index 00000000000..7766daa1ab7 --- /dev/null +++ b/bundles/core/org.eclipse.smarthome.core.audio/src/main/java/org/eclipse/smarthome/core/audio/internal/AudioServlet.java @@ -0,0 +1,229 @@ +/** + * Copyright (c) 2014-2016 by the respective copyright holders. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.smarthome.core.audio.internal; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.UnknownHostException; +import java.util.Hashtable; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import javax.servlet.ServletException; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.StringUtils; +import org.eclipse.smarthome.core.audio.AudioFormat; +import org.eclipse.smarthome.core.audio.AudioHTTPServer; +import org.eclipse.smarthome.core.audio.AudioStream; +import org.osgi.framework.BundleContext; +import org.osgi.service.http.HttpContext; +import org.osgi.service.http.HttpService; +import org.osgi.service.http.NamespaceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A servlet that serves audio streams via HTTP. + * + * @author Kai Kreuzer - Initial contribution and API + * + */ +public class AudioServlet extends HttpServlet implements AudioHTTPServer { + + private static final long serialVersionUID = -3364664035854567854L; + + private static final String SERVLET_NAME = "/audio"; + + private final Logger logger = LoggerFactory.getLogger(AudioServlet.class); + + private Map streams = new ConcurrentHashMap<>(); + + protected HttpService httpService; + private BundleContext bundleContext; + + protected void activate(BundleContext bundleContext) { + this.bundleContext = bundleContext; + } + + protected void deactivate(BundleContext bundleContext) { + this.bundleContext = null; + } + + protected void setHttpService(HttpService httpService) { + this.httpService = httpService; + + try { + logger.debug("Starting up the audio servlet at " + SERVLET_NAME); + Hashtable props = new Hashtable(); + httpService.registerServlet(SERVLET_NAME, this, props, createHttpContext()); + } catch (NamespaceException e) { + logger.error("Error during servlet startup", e); + } catch (ServletException e) { + logger.error("Error during servlet startup", e); + } + } + + protected void unsetHttpService(HttpService httpService) { + httpService.unregister(SERVLET_NAME); + this.httpService = null; + } + + /** + * Creates an {@link HttpContext}. + * + * @return an {@link HttpContext} that grants anonymous access + */ + protected HttpContext createHttpContext() { + // TODO: Once we have a role-based permission system in place, we need to make sure that we create an + // HttpContext here, which allows accessing the servlet without any authentication. + HttpContext httpContext = httpService.createDefaultHttpContext(); + return httpContext; + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + String streamId = StringUtils.substringAfterLast(req.getRequestURI(), "/"); + if (!streams.containsKey(streamId)) { + logger.debug("Received request for invalid stream id at {}", req.getRequestURI()); + resp.sendError(HttpServletResponse.SC_NOT_FOUND); + } else { + logger.debug("Stream to serve is {}", streamId); + + AudioStream stream = streams.remove(streamId); + + // try to set the content-type, if possible + String mimeType = null; + if (stream.getFormat().getCodec() == AudioFormat.CODEC_MP3) { + mimeType = "audio/mpeg"; + } else if (stream.getFormat().getContainer() == AudioFormat.CONTAINER_WAVE) { + mimeType = "audio/wav"; + } else if (stream.getFormat().getContainer() == AudioFormat.CONTAINER_OGG) { + mimeType = "audio/ogg"; + } + if (mimeType != null) { + resp.setContentType(mimeType); + } + + // try to set the content-length, if possible + if (stream instanceof DiscreteAudioStream) { + Integer size = ((DiscreteAudioStream) stream).size(); + if (size != null) { + resp.setContentLength(size); + } + } + + ServletOutputStream os = resp.getOutputStream(); + IOUtils.copy(stream, os); + resp.flushBuffer(); + IOUtils.closeQuietly(stream); + } + } + + @Override + public URL serve(AudioStream stream) { + String streamId = UUID.randomUUID().toString(); + streams.put(streamId, stream); + return getURL(streamId); + } + + @Override + public URL serveWithSize(AudioStream stream) { + if (stream.markSupported()) { + DiscreteAudioStream streamWithSize = new DiscreteAudioStream(stream); + return serve(streamWithSize); + } else { + // TODO: We should also support streams without mark support, but this will mean that we have to read it + // first to memory or file system and create a new AudioStream from there. + logger.warn("Stream cannot be reset, so it is served without size through HTTP"); + return serve(stream); + } + } + + private URL getURL(String streamId) { + try { + String ipAddress = InetAddress.getLocalHost().getHostAddress(); // we use the primary interface; if a client + // knows it any better, he can himself + // change the url according to his needs. + String port = bundleContext.getProperty("org.osgi.service.http.port"); // we do not use SSL as it can cause + // certificate validation issues. + return new URL("http://" + ipAddress + ":" + port + SERVLET_NAME + "/" + streamId); + } catch (UnknownHostException | MalformedURLException e) { + logger.error("Failed to construct audio stream URL: {}", e.getMessage(), e); + return null; + } + } + + /** + * This is a wrapper class around {@link AudioStream}, which additionally provides information about its size. + * Currently, it only support mark-supporting AudioStreams, which are read and reset in order to dertermine the + * stream size. + */ + class DiscreteAudioStream extends AudioStream { + + private Integer size; + private AudioStream stream; + + public DiscreteAudioStream(AudioStream stream) { + this.stream = stream; + } + + public Integer size() { + if (size == null) { + size = calculateSize(); + } + return size; + } + + private Integer calculateSize() { + if (stream.markSupported()) { + return readStreamAndReset(stream); + } + return null; + } + + private Integer readStreamAndReset(AudioStream s) { + int bytes = 0; + int avail = 0; + try { + while ((avail = s.available()) > 0) { + bytes += avail; + s.skip(avail); + } + s.reset(); + } catch (IOException e) { + logger.warn("Cannot determine size of audio stream!", e); + return null; + } + return bytes; + } + + @Override + public AudioFormat getFormat() { + return stream.getFormat(); + } + + @Override + public int read() throws IOException { + return stream.read(); + } + + @Override + public void close() throws IOException { + stream.close(); + } + + } +} \ No newline at end of file