package com.github.kmkt.util.mjpeg; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.util.Arrays; import org.apache.http.HttpEntity; import org.apache.http.HttpResponse; import org.apache.http.auth.AuthScope; import org.apache.http.auth.Credentials; import org.apache.http.auth.UsernamePasswordCredentials; import org.apache.http.client.ClientProtocolException; import org.apache.http.client.CredentialsProvider; import org.apache.http.client.HttpClient; import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.BasicCredentialsProvider; import org.apache.http.impl.client.HttpClientBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.github.kmkt.util.StreamSplitter; /** * MJPEG over HTTP ストリームを受け取り、callback するクラス * * License : MIT License */ public class MjpegHTTPReader { private static final Logger logger = LoggerFactory.getLogger(MjpegHTTPReader.class); /** * フレーム受信毎に呼び出される callback interface */ public interface RecvFrameCallback { /** * フレーム受信毎に呼び出される callback * @param frame_data 1フレーム分のデータ */ void onRecvFrame(byte[] frame_data); } /** * 状態変化時に呼び出される callback interface */ public interface StateChangeCallback { /** * ストリーム終了時に呼び出される callback */ void onStreamClosed(); /** * 受信スレッド終了時に呼び出される callback */ void onFinished(); } public static long StatisticsDispleyPeriod = 60*1000; // private URI target = null; private StreamReaderThread streamReadLoop = null; private RecvFrameCallback recv_callback = null; private StateChangeCallback state_callback = null; private Credentials credential = null; /** * URL から MJPEG を受信するインスタンスを生成する * @param target MJPEG 配信元 URL * @param recv_callback フレーム受信毎に呼び出される callback * @param state_callback 状態変化時に呼び出される callback */ public MjpegHTTPReader(URI target, RecvFrameCallback recv_callback, StateChangeCallback state_callback) { if (target == null) throw new IllegalArgumentException("target should not be null"); this.target = target; this.recv_callback = recv_callback; this.state_callback = state_callback; } /** * Basic 認証付き URL から MJPEG を受信するインスタンスを生成する * @param target MJPEG 配信元 URL * @param recv_callback フレーム受信毎に呼び出される callback * @param state_callback 状態変化時に呼び出される callback * @param user Basic 認証ユーザ名 null 時は Basic認証を行わない * @param pass Basic 認証パスワード null 時は Basic認証を行わない */ public MjpegHTTPReader(URI target, RecvFrameCallback recv_callback, StateChangeCallback state_callback, String user, String pass) { if (target == null) throw new IllegalArgumentException("target should not be null"); this.target = target; this.recv_callback = recv_callback; this.state_callback = state_callback; if (user != null && pass != null) { this.credential = new UsernamePasswordCredentials(user, pass); } } public synchronized boolean isActive() { if (this.streamReadLoop == null) return false; return this.streamReadLoop.isAlive(); } /** * MJPEG の受信を開始する * * @param connecte_timeout 接続タイムアウト * @param read_timeout Socket Reead タイムアウト * @throws ClientProtocolException * @throws IOException */ public synchronized void start(int connecte_timeout, int read_timeout) throws ClientProtocolException, IOException { if (connecte_timeout < 0) throw new IllegalArgumentException("connecte_timeout should be positive"); if (isActive()) throw new IllegalStateException("Already started"); RequestConfig config = RequestConfig.custom() .setConnectTimeout(connecte_timeout) .setSocketTimeout(read_timeout).build(); HttpClient httpclient; if (credential == null) { httpclient = HttpClientBuilder.create().setDefaultRequestConfig(config).build(); } else { CredentialsProvider provider = new BasicCredentialsProvider(); provider.setCredentials(AuthScope.ANY, credential); httpclient = HttpClientBuilder.create().setDefaultCredentialsProvider(provider).setDefaultRequestConfig(config).build(); } HttpGet httpget = new HttpGet(target); try { // GET リクエスト HttpResponse response = httpclient.execute(httpget); int status = response.getStatusLine().getStatusCode(); if (status != 200) { httpget.abort(); throw new IOException("HTTP Response is not 200 but " + status); } HttpEntity entity = response.getEntity(); String[] content_type = entity.getContentType().getValue().split("\\s*;\\s*"); logger.debug("Content-type '{}'", entity.getContentType().getValue()); if (!"multipart/x-mixed-replace".equals(content_type[0])) { httpget.abort(); throw new IOException("Content-type is not multipart/x-mixed-replace but " + content_type[0]); } if (!content_type[1].matches("^boundary\\s*=\\s*.+$")) { httpget.abort(); throw new IOException("Content-type should have boundary option"); } // boundary バイト列作成 String boundary_str = "--"+content_type[1].replaceFirst("boundary\\s*=\\s*(--)?", ""); ByteArrayOutputStream b = new ByteArrayOutputStream(); b.write(boundary_str.getBytes()); b.write((byte) 0x0d); b.write((byte) 0x0a); byte[] boundary = b.toByteArray(); streamReadLoop = new StreamReaderThread(entity.getContent(), boundary, recv_callback, state_callback); streamReadLoop.start(); } catch(ClientProtocolException e) { httpget.abort(); throw e; } catch(IOException e) { httpget.abort(); throw e; } } public synchronized void stop() throws InterruptedException, IOException { if (!isActive()) return; streamReadLoop.requestStop(); streamReadLoop.join(); streamReadLoop = null; } /** * HTTP body を読み込み、MJPEGからJPEGを切り出す Thread */ private class StreamReaderThread extends Thread { private StreamSplitter splitter = null; private InputStream inputStream = null; private RecvFrameCallback recv_callback = null; private StateChangeCallback state_callback = null; private volatile boolean threadLoop = true; /** * * @param is 元InputStream * @param boundary 境界バイト列 * @param recv_callback ブロック受信時の callback null では呼ばれない * @param state_callback 状態変化時の callback */ public StreamReaderThread(InputStream is, byte[] boundary, RecvFrameCallback recv_callback, StateChangeCallback state_callback) { if (is == null) throw new IllegalArgumentException("is should not be null"); if (boundary == null) throw new IllegalArgumentException("boundary should not be null"); this.inputStream = is; this.splitter = new StreamSplitter(is, boundary); this.recv_callback = recv_callback; this.state_callback = state_callback; } /** * 受信停止を要求する * @throws IOException */ public void requestStop() throws IOException { threadLoop = false; if (this.getState() == Thread.State.BLOCKED || this.getState() == Thread.State.WAITING || this.getState() == Thread.State.TIMED_WAITING) { streamReadLoop.interrupt(); } splitter.close(); } @Override public void run() { long recv_frames = 0; long recv_bytes = 0; long error_frames = 0; long notify_frames = 0; long notify_bytes = 0; long last_shown_statistics = System.currentTimeMillis(); logger.info("Start recv thread"); try { byte[] deleimter_of_header = new byte[]{(byte) 0x0d, (byte) 0x0a, (byte) 0x0d, (byte) 0x0a}; byte[] readbuf = new byte[4*1024]; // 読み出しバッファ while (threadLoop) { int len = 0; logger.trace("Wait next stream"); InputStream is = splitter.nextStream(); if (is == null) { logger.info("Stream ended"); if (state_callback != null) { state_callback.onStreamClosed(); } break; } ByteArrayOutputStream bos = new ByteArrayOutputStream(); logger.trace("Wait recving"); while (true) { len = is.read(readbuf); if (len == -1) break; bos.write(readbuf, 0, len); } byte[] recv_block = bos.toByteArray(); logger.trace("Recv {} byte", recv_block.length); recv_frames++; recv_bytes += recv_block.length; // TODO multipart のヘッダ確認 // body 部の取り出し int body_pos = -1; found_jpegbody: for (int i = 0; i < recv_block.length; i++) { for (int j = 0; j < deleimter_of_header.length && i + j < recv_block.length; j++) { if (recv_block[i + j] != deleimter_of_header[j]) break; if (j == deleimter_of_header.length - 1) { // found delimiter body_pos = i + deleimter_of_header.length; break found_jpegbody; } } } if (body_pos < 0) continue; // JPEG部のみ抽出 int pos_soi = -1; int pos_eoi = -1; byte[] SOI = new byte[]{(byte) 0xff, (byte) 0xd8}; byte[] EOI = new byte[]{(byte) 0xff, (byte) 0xd9}; found_SOI: for (int i = body_pos; i < recv_block.length; i++) { for (int j = 0; j < SOI.length && i + j < recv_block.length; j++) { if (recv_block[i + j] != SOI[j]) break; if (j == SOI.length - 1) { // found soi pos_soi = i; break found_SOI; } } } found_EOI: for (int i = recv_block.length - EOI.length; pos_soi < i; i--) { for (int j = 0; j < EOI.length && i + j < recv_block.length; j++) { if (recv_block[i + j] != EOI[j]) break; if (j == EOI.length - 1) { // found eoi pos_eoi = i; break found_EOI; } } } if (pos_soi < 0 || pos_eoi < 0) { logger.warn("Invalid JPEG frame received. Cannot found SOI or EOI."); error_frames++; continue; } byte[] jpeg_frame = Arrays.copyOfRange(recv_block, pos_soi, pos_eoi + 2); logger.trace("Frame size {} byte", jpeg_frame.length); if (recv_callback != null) { recv_callback.onRecvFrame(jpeg_frame); notify_frames++; notify_bytes += jpeg_frame.length; } if (StatisticsDispleyPeriod < System.currentTimeMillis() - last_shown_statistics) { last_shown_statistics = System.currentTimeMillis(); logger.debug("Statistics [Frames Recv: {}, Send: {}, Error: {}, Size Recv: {}, Send: {}]", recv_frames, notify_frames, error_frames, recv_bytes, notify_bytes); } } } catch (IOException e) { if (threadLoop) { logger.error("IOException when stream reading", e); } } finally { try { inputStream.close(); } catch (IOException e) { logger.error("IOException when stream closing", e); } } logger.info("Stop recv thread"); if (state_callback != null) { state_callback.onFinished(); } } } }