Java tutorial
/* * Author: Scott Ware <scoot.software@gmail.com> * Copyright (c) 2015 Scott Ware * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.scooter1556.sms.server.service; import com.scooter1556.sms.server.dao.JobDao; import com.scooter1556.sms.server.dao.MediaDao; import com.scooter1556.sms.server.domain.AudioTranscode; import com.scooter1556.sms.server.domain.Job; import com.scooter1556.sms.server.domain.MediaElement; import com.scooter1556.sms.server.domain.MediaElement.AudioStream; import com.scooter1556.sms.server.domain.MediaElement.MediaElementType; import com.scooter1556.sms.server.domain.MediaElement.VideoStream; import com.scooter1556.sms.server.domain.TranscodeProfile; import com.scooter1556.sms.server.domain.TranscodeProfile.StreamType; import com.scooter1556.sms.server.domain.VideoTranscode; import com.scooter1556.sms.server.domain.VideoTranscode.VideoQuality; import com.scooter1556.sms.server.io.AdaptiveStreamingProcess; import com.scooter1556.sms.server.utilities.MediaUtils; import com.scooter1556.sms.server.utilities.TranscodeUtils; import java.awt.Dimension; import java.io.IOException; import java.io.StringWriter; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.UUID; import java.util.logging.Level; import java.util.logging.Logger; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import org.apache.commons.math3.util.Precision; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.w3c.dom.Document; import org.w3c.dom.Element; @Service public class AdaptiveStreamingService { private static final String CLASS_NAME = "AdaptiveStreamingService"; public static final Integer HLS_SEGMENT_DURATION = 10; public static final Integer DASH_SEGMENT_DURATION = 5; // The number of stream alternatives to transcode by default public static final Integer DEFAULT_STREAM_COUNT = 2; @Autowired private JobDao jobDao; @Autowired private MediaDao mediaDao; @Autowired private TranscodeService transcodeService; private final ArrayList<AdaptiveStreamingProcess> processes = new ArrayList<>(); public AdaptiveStreamingProcess initialise(TranscodeProfile profile, int num) { // Check that this is an adaptive streaming job if (profile.getType() == StreamType.DIRECT) { return null; } // Get offset if (num > 0) { // Start transcoding from the previous segment num -= 1; profile.setOffset(num * HLS_SEGMENT_DURATION); } // Get transcode command String[][] commands = transcodeService.getTranscodeCommand(profile); if (commands == null) { return null; } // Start transcoding AdaptiveStreamingProcess process = getProcessById(profile.getID()); if (process == null) { process = new AdaptiveStreamingProcess(profile.getID()); processes.add(process); } // Update process with required information process.setCommands(commands); process.setMediaElement(profile.getMediaElement()); process.setAudioTranscodes(profile.getAudioTranscodes()); process.setTranscoder(transcodeService.getTranscoder()); // Check if post-processing of segments is required for client if (profile.getMediaElement().getType().equals(MediaElementType.VIDEO)) { switch (profile.getClient()) { case "chromecast": process.setPostProcessEnabled(true); break; default: break; } } process.initialise(); return process; } public DOMSource generateDashPlaylist(UUID id, String baseUrl) { Job job = jobDao.getJobByID(id); if (job == null) { return null; } MediaElement mediaElement = mediaDao.getMediaElementByID(job.getMediaElement()); if (mediaElement == null) { return null; } baseUrl = baseUrl + "/stream/segment/" + id + "/"; try { DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance(); DocumentBuilder docBuilder; docBuilder = docFactory.newDocumentBuilder(); // Root elements Document playlist = docBuilder.newDocument(); Element mpd = playlist.createElement("MPD"); playlist.appendChild(mpd); mpd.setAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"); mpd.setAttribute("xmlns", "urn:mpeg:dash:schema:mpd:2011"); mpd.setAttribute("xsi:schemaLocation", "urn:mpeg:DASH:schema:MPD:2011 DASH-MPD.xsd"); //mpd.setAttribute("profiles", "urn:mpeg:dash:profile:full:2011"); mpd.setAttribute("profiles", "urn:mpeg:dash:profile:isoff-live:2011"); mpd.setAttribute("minBufferTime", "PT" + DASH_SEGMENT_DURATION + "S"); mpd.setAttribute("type", "static"); mpd.setAttribute("mediaPresentationDuration", "PT" + mediaElement.getDuration() + "S"); Element period = playlist.createElement("Period"); mpd.appendChild(period); period.setAttribute("duration", "PT" + mediaElement.getDuration() + "S"); Element adaptationSet = playlist.createElement("AdaptationSet"); period.appendChild(adaptationSet); adaptationSet.setAttribute("segmentAlignment", "true"); adaptationSet.setAttribute("contentType", "audio"); Element representation = playlist.createElement("Representation"); adaptationSet.appendChild(representation); representation.setAttribute("id", "audio"); representation.setAttribute("mimeType", "audio/mp4"); representation.setAttribute("codecs", "mp4a.40.2"); representation.setAttribute("audioSamplingRate", "44100"); representation.setAttribute("bandwidth", "128000"); Element audioChannelConfig = playlist.createElement("AudioChannelConfiguration"); representation.appendChild(audioChannelConfig); audioChannelConfig.setAttribute("schemeIdUri", "urn:mpeg:dash:23003:3:audio_channel_configuration:2011"); audioChannelConfig.setAttribute("value", "2"); Element segmentTemplate = playlist.createElement("SegmentTemplate"); representation.appendChild(segmentTemplate); segmentTemplate.setAttribute("duration", "5000"); segmentTemplate.setAttribute("initialization", baseUrl + "init-stream0.m4s"); segmentTemplate.setAttribute("media", baseUrl + "chunk-stream0-$Number%05d$.m4s"); segmentTemplate.setAttribute("startNumber", "1"); DOMSource result = new DOMSource(playlist); return result; } catch (ParserConfigurationException ex) { return null; } } public void sendDashPlaylist(UUID id, HttpServletRequest request, HttpServletResponse response) throws IOException { try { // Get the request base URL so we can use it in our playlist String baseUrl = request.getRequestURL().toString().replaceFirst("/stream(.*)", ""); // Get playlist DOMSource playlist = generateDashPlaylist(id, baseUrl); if (playlist == null) { response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unable to generate Dash playlist."); return; } // Write playlist to buffer StringWriter playlistWriter = new StringWriter(); StreamResult result = new StreamResult(playlistWriter); TransformerFactory transformerFactory = TransformerFactory.newInstance(); Transformer transformer = transformerFactory.newTransformer(); transformer.transform(playlist, result); // Set Header Parameters response.setContentType("application/dash+xml"); response.setContentLength(playlistWriter.toString().length()); // Write playlist out to the client response.getWriter().write(playlistWriter.toString()); } catch (TransformerException ex) { Logger.getLogger(AdaptiveStreamingService.class.getName()).log(Level.SEVERE, null, ex); } } public List<String> generateHLSVariantPlaylist(UUID id, String baseUrl) { Job job = jobDao.getJobByID(id); if (job == null) { return null; } MediaElement mediaElement = mediaDao.getMediaElementByID(job.getMediaElement()); if (mediaElement == null) { return null; } TranscodeProfile profile = transcodeService.getTranscodeProfile(id); if (profile == null) { return null; } List<String> playlist = new ArrayList<>(); playlist.add("#EXTM3U"); if (mediaElement.getType() == MediaElementType.AUDIO && profile.getAudioTranscodes() != null) { for (int i = 0; i < profile.getAudioTranscodes().length; i++) { AudioTranscode transcode = profile.getAudioTranscodes()[i]; // Get audio bandwidth int bandwidth = -1; if (profile.getQuality() != null) { bandwidth = (TranscodeUtils.AUDIO_QUALITY_MAX_BITRATE[profile.getQuality()] * 1000); } if (bandwidth < 0) { bandwidth = 384000; } playlist.add("#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=" + bandwidth + ", CODECS=\"" + TranscodeUtils.getIsoSpecForAudioCodec(transcode.getCodec()) + "\""); playlist.add(baseUrl + "/stream/playlist/" + id + "/audio/" + i + ".m3u8"); } } else if (mediaElement.getType() == MediaElementType.VIDEO && profile.getVideoTranscodes() != null) { String audio = ""; boolean subtitles = false; // Process audio streams if (profile.getAudioTranscodes() != null) { for (int a = 0; a < profile.getAudioTranscodes().length; a++) { AudioTranscode transcode = profile.getAudioTranscodes()[a]; AudioStream stream = TranscodeUtils .getAudioStreamById(profile.getMediaElement().getAudioStreams(), transcode.getId()); String isDefault = "NO"; if (transcode.getId().equals(profile.getAudioStream())) { isDefault = "YES"; } if (transcode.getCodec().equals("copy")) { audio = TranscodeUtils.getIsoSpecForAudioCodec(stream.getCodec()); } else { audio = TranscodeUtils.getIsoSpecForAudioCodec(transcode.getCodec()); } playlist.add("#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",LANGUAGE=\"" + stream.getLanguage() + "\",NAME=\"" + stream.getTitle() + "\",AUTOSELECT=YES,DEFAULT=" + isDefault + ",URI=\"" + baseUrl + "/stream/playlist/" + id + "/audio/" + a + ".m3u8\""); } } /* // Process subtitle streams if(profile.getSubtitleTranscodes() != null) { for(int s = 0; s < profile.getSubtitleTranscodes().length; s++) { SubtitleTranscode transcode = profile.getSubtitleTranscodes()[s]; SubtitleStream stream = mediaElement.getSubtitleStreams().get(s); String selected = "NO"; if(profile.getSubtitleTrack() != null) { if(profile.getSubtitleTrack().equals(s)) { selected = "YES"; } } playlist.add("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"" + stream.getLanguage() + "\",NAME=\"" + stream.getName() + "\",AUTOSELECT=YES,DEFAULT=" + selected + ",URI=\"" + baseUrl + "/stream/playlist/" + id + "/subtitle/" + s + ".m3u8\""); subtitles = true; } } */ for (int i = 0; i < TranscodeUtils .getVideoTranscodesById(profile.getVideoTranscodes(), profile.getVideoStream()).size(); i++) { VideoTranscode transcode = TranscodeUtils .getVideoTranscodesById(profile.getVideoTranscodes(), profile.getVideoStream()).get(i); // Get video stream VideoStream videoStream = MediaUtils.getVideoStreamById(profile.getMediaElement().getVideoStreams(), transcode.getId()); // Determine bitrate int bitrate = -1; if (transcode.getQuality() != null) { bitrate = TranscodeUtils.VIDEO_QUALITY_BITRATE[transcode.getQuality()]; } if (bitrate < 0) { bitrate = MediaUtils.getAverageBitrate(videoStream, mediaElement.getBitrate()); } LogService.getInstance().addLogEntry(LogService.Level.DEBUG, CLASS_NAME, "Bitrate: " + bitrate, null); // Determine resolution Dimension resolution = transcode.getResolution(); if (resolution == null) { resolution = videoStream.getResolution(); } StringBuilder builder = new StringBuilder(); builder.append("#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH="); builder.append(String.valueOf(bitrate * 1000)); builder.append(",RESOLUTION=").append(String.format("%dx%d", resolution.width, resolution.height)); builder.append(",CLOSED-CAPTIONS=NONE"); builder.append(",CODECS=\""); if (profile.getQuality() > VideoQuality.HIGH) { builder.append("avc1.640028"); } else { builder.append("avc1.42e01e"); } if (!audio.isEmpty()) { builder.append(",").append(audio).append("\",AUDIO=\"audio\""); } /* if(subtitles) { builder.append(",SUBTITLES=\"subs\""); } */ playlist.add(builder.toString()); // Url playlist.add(baseUrl + "/stream/playlist/" + id + "/video/" + i + ".m3u8"); } } else { return null; } return playlist; } public List<String> generateHLSPlaylist(UUID id, String baseUrl, String type, Integer extra) { // Check variables if (type == null || extra == null) { return null; } Job job = jobDao.getJobByID(id); if (job == null) { return null; } MediaElement mediaElement = mediaDao.getMediaElementByID(job.getMediaElement()); if (mediaElement == null) { return null; } List<String> playlist = new ArrayList<>(); playlist.add("#EXTM3U"); playlist.add("#EXT-X-VERSION:4"); playlist.add("#EXT-X-TARGETDURATION:" + String.valueOf(HLS_SEGMENT_DURATION + 1)); playlist.add("#EXT-X-MEDIA-SEQUENCE:0"); playlist.add("#EXT-X-PLAYLIST-TYPE:VOD"); // Get Video Segments for (int i = 0; i < Math.floor(mediaElement.getDuration() / HLS_SEGMENT_DURATION); i++) { playlist.add("#EXTINF:" + HLS_SEGMENT_DURATION.floatValue() + ","); playlist.add(baseUrl + "/stream/segment/" + id + "/" + type + "/" + extra + "/" + i); } // Determine the duration of the final segment. double remainder = mediaElement.getDuration() % HLS_SEGMENT_DURATION; if (remainder > 0) { long i = Double.valueOf(Math.floor(mediaElement.getDuration() / HLS_SEGMENT_DURATION)).longValue(); playlist.add("#EXTINF:" + Precision.round(remainder, 1, BigDecimal.ROUND_HALF_UP) + ","); playlist.add(baseUrl + "/stream/segment/" + id + "/" + type + "/" + extra + "/" + i); } playlist.add("#EXT-X-ENDLIST"); return playlist; } public void sendHLSPlaylist(UUID id, String type, Integer extra, HttpServletRequest request, HttpServletResponse response) throws IOException { // Get the request base URL so we can use it in our playlist String baseUrl = request.getRequestURL().toString().replaceFirst("/stream(.*)", ""); List<String> playlist; // Get playlist as a string array if (type == null) { playlist = generateHLSVariantPlaylist(id, baseUrl); } else { playlist = generateHLSPlaylist(id, baseUrl, type, extra); } if (playlist == null) { LogService.getInstance().addLogEntry(LogService.Level.WARN, CLASS_NAME, "Unable to generate HLS playlist.", null); response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unable to generate HLS playlist."); return; } // Write playlist to buffer so we can get the content length StringWriter playlistWriter = new StringWriter(); for (String line : playlist) { playlistWriter.write(line + "\n"); } // Set Header Parameters response.reset(); response.setContentType("application/x-mpegurl"); response.setContentLength(playlistWriter.toString().length()); // Enable CORS response.setHeader(("Access-Control-Allow-Origin"), "*"); response.setHeader("Access-Control-Allow-Methods", "GET"); response.setIntHeader("Access-Control-Max-Age", 3600); // Write playlist out to the client response.getWriter().write(playlistWriter.toString()); /*********************** DEBUG: Response Headers *********************************/ String requestHeader = "\n***************\nResponse Header:\n***************\n"; Collection<String> responseHeaderNames = response.getHeaderNames(); for (int i = 0; i < responseHeaderNames.size(); i++) { String header = (String) responseHeaderNames.toArray()[i]; String value = response.getHeader(header); requestHeader += header + ": " + value + "\n"; } // Log Headers LogService.getInstance().addLogEntry(LogService.Level.INSANE, CLASS_NAME, requestHeader, null); /********************************************************************************/ // Log playlist LogService.getInstance().addLogEntry(LogService.Level.INSANE, CLASS_NAME, "\n************\nHLS Playlist\n************\n" + playlistWriter.toString(), null); } public void sendSubtitleSegment(HttpServletResponse response) throws IOException { List<String> segment = new ArrayList<>(); segment.add("WEBVTT"); // Write segment to buffer so we can get the content length StringWriter segmentWriter = new StringWriter(); for (String line : segment) { segmentWriter.write(line + "\n"); } // Set Header Parameters response.reset(); response.setContentType("text/vtt"); response.setContentLength(segmentWriter.toString().length()); // Enable CORS response.setHeader(("Access-Control-Allow-Origin"), "*"); response.setHeader("Access-Control-Allow-Methods", "GET"); response.setIntHeader("Access-Control-Max-Age", 3600); // Write segment out to the client response.getWriter().write(segmentWriter.toString()); } public void addProcess(AdaptiveStreamingProcess process) { if (process != null) { processes.add(process); } } public AdaptiveStreamingProcess getProcessById(UUID id) { for (AdaptiveStreamingProcess process : processes) { if (process.getId().compareTo(id) == 0) { return process; } } return null; } public void removeProcessById(UUID id) { int index = 0; for (AdaptiveStreamingProcess process : processes) { if (process.getId().compareTo(id) == 0) { processes.remove(index); break; } index++; } } public void endProcess(UUID id) { AdaptiveStreamingProcess process = getProcessById(id); if (process != null) { process.end(); removeProcessById(id); } } }