/* * Copyright 2014 Mario Guggenberger <mg@protyposis.net> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package net.protyposis.android.mediaplayer.dash; import android.net.Uri; import android.util.Log; import android.util.Xml; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import java.io.IOException; import java.io.InputStream; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.Date; import java.util.List; import java.util.TimeZone; import java.util.regex.Matcher; import java.util.regex.Pattern; import net.protyposis.android.mediaplayer.UriSource; import okhttp3.Headers; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; /** * Created by maguggen on 27.08.2014. */ public class DashParser { private static final String TAG = DashParser.class.getSimpleName(); private static Pattern PATTERN_TIME = Pattern.compile("PT((\\d+)H)?((\\d+)M)?((\\d+(\\.\\d+)?)S)"); private static Pattern PATTERN_TEMPLATE = Pattern.compile("\\$(\\w+)(%0\\d+d)?\\$"); private static DateFormat ISO8601UTC; static { ISO8601UTC = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ"); ISO8601UTC.setTimeZone(TimeZone.getTimeZone("UTC")); } private static class SegmentTemplate { private static class SegmentTimelineEntry { /** * The segment start time in timescale units. Optional value. * Default is 0 for the first element in a timeline, and t+d*(r+1) of previous element * for all subsequent elements. */ long t; /** * The segment duration in timescale units. */ long d; /** * The segment repeat count. Specifies the number of contiguous segments with * duration d, that follow the first segment (r=2 means first segment plus two * following segments, a total of 3). * A negative number tells that there are contiguous segments until the start of the * next timeline entry, the end of the period, or the next MPD update. * The default is 0. */ int r; long calculateDuration() { return d * (r + 1); } } long presentationTimeOffsetUs; long timescale; String init; String media; long duration; int startNumber; List<SegmentTimelineEntry> timeline = new ArrayList<>(); long calculateDurationUs() { return calculateUs(duration, timescale); } boolean hasTimeline() { return !timeline.isEmpty(); } } private Date serverDate; /** * Parses an MPD XML file. This needs to be executed off the main thread, else a * NetworkOnMainThreadException gets thrown. * @param source the URl of an MPD XML file * @param httpClient the http client instance to use for the request * @return a MPD object * @throws android.os.NetworkOnMainThreadException if executed on the main thread */ public MPD parse(UriSource source, OkHttpClient httpClient) throws DashParserException { MPD mpd = null; Headers.Builder headers = new Headers.Builder(); if(source.getHeaders() != null && !source.getHeaders().isEmpty()) { for(String name : source.getHeaders().keySet()) { headers.add(name, source.getHeaders().get(name)); } } Uri uri = source.getUri(); Request.Builder request = new Request.Builder() .url(uri.toString()) .headers(headers.build()); try { Response response = httpClient.newCall(request.build()).execute(); if(!response.isSuccessful()) { throw new IOException("error requesting the MPD"); } // Determine this MPD's default BaseURL by removing the last path segment (which is the MPD file) Uri baseUrl = Uri.parse(uri.toString().substring(0, uri.toString().lastIndexOf("/") + 1)); // Get the current datetime from the server for live stream time syncing serverDate = response.headers().getDate("Date"); // Parse the MPD file mpd = parse(response.body().byteStream(), baseUrl); } catch (IOException e) { Log.e(TAG, "error downloading the MPD", e); throw new DashParserException("error downloading the MPD", e); } catch (XmlPullParserException e) { Log.e(TAG, "error parsing the MPD", e); throw new DashParserException("error parsing the MPD", e); } return mpd; } /** * Parses an MPD XML file. This needs to be executed off the main thread, else a * NetworkOnMainThreadException gets thrown. * @param source the URl of an MPD XML file * @return a MPD object * @throws android.os.NetworkOnMainThreadException if executed on the main thread */ public MPD parse(UriSource source) throws DashParserException { return parse(source, new OkHttpClient()); } private MPD parse(InputStream in, Uri baseUrl) throws XmlPullParserException, IOException, DashParserException { try { XmlPullParser parser = Xml.newPullParser(); parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); parser.setInput(in, null); MPD mpd = new MPD(); Period currentPeriod = null; int type = 0; while((type = parser.next()) >= 0) { if(type == XmlPullParser.START_TAG) { String tagName = parser.getName(); if(tagName.equals("MPD")) { mpd.isDynamic = getAttributeValue(parser, "type", "static").equals("dynamic"); if (mpd.isDynamic) { Log.i(TAG, "dynamic MPD not supported yet, but giving it a try..."); // Set a dummy duration to get the stream to work for some time mpd.mediaPresentationDurationUs = 1l /* h */ * 60 * 60 * 1000000; mpd.timeShiftBufferDepthUs = getAttributeValueTime(parser, "timeShiftBufferDepth", "PT0S"); mpd.maxSegmentDurationUs = getAttributeValueTime(parser, "maxSegmentDuration", "PT0S"); mpd.suggestedPresentationDelayUs = getAttributeValueTime(parser, "suggestedPresentationDelay", "PT0S"); // TODO add support for dynamic streams with unknown duration String date = getAttributeValue(parser, "availabilityStartTime"); try { if (date.length() == 19) { date = date + "Z"; } mpd.availabilityStartTime = ISO8601UTC.parse(date.replace("Z", "+00:00")); } catch (ParseException e) { Log.e(TAG, "unable to parse date: " + date); } } else { // type == static mpd.mediaPresentationDurationUs = getAttributeValueTime(parser, "mediaPresentationDuration"); } mpd.minBufferTimeUs = getAttributeValueTime(parser, "minBufferTime"); } else if(tagName.equals("Period")) { currentPeriod = new Period(); currentPeriod.id = getAttributeValue(parser, "id"); currentPeriod.startUs = getAttributeValueTime(parser, "start"); currentPeriod.durationUs = getAttributeValueTime(parser, "duration"); currentPeriod.bitstreamSwitching = getAttributeValueBoolean(parser, "bitstreamSwitching"); } else if(tagName.equals("BaseURL")) { baseUrl = extendUrl(baseUrl, parser.nextText()); Log.d(TAG, "base url: " + baseUrl); } else if(tagName.equals("AdaptationSet")) { currentPeriod.adaptationSets.add(readAdaptationSet(mpd, currentPeriod, baseUrl, parser)); } } else if(type == XmlPullParser.END_TAG) { String tagName = parser.getName(); if(tagName.equals("MPD")) { break; } else if(tagName.equals("Period")) { mpd.periods.add(currentPeriod); currentPeriod = null; } } } Log.d(TAG, mpd.toString()); return mpd; } finally { in.close(); } } private AdaptationSet readAdaptationSet(MPD mpd, Period period, Uri baseUrl, XmlPullParser parser) throws XmlPullParserException, IOException, DashParserException { AdaptationSet adaptationSet = new AdaptationSet(); adaptationSet.group = getAttributeValueInt(parser, "group"); adaptationSet.mimeType = getAttributeValue(parser, "mimeType"); adaptationSet.maxWidth = getAttributeValueInt(parser, "maxWidth"); adaptationSet.maxHeight = getAttributeValueInt(parser, "maxHeight"); adaptationSet.par = getAttributeValueRatio(parser, "par"); SegmentTemplate segmentTemplate = null; int type = 0; while((type = parser.next()) >= 0) { if(type == XmlPullParser.START_TAG) { String tagName = parser.getName(); if(tagName.equals("SegmentTemplate")) { segmentTemplate = readSegmentTemplate(parser, baseUrl, null); } else if(tagName.equals("Representation")) { try { adaptationSet.representations.add(readRepresentation( mpd, period, adaptationSet, baseUrl, parser, segmentTemplate)); } catch (Exception e) { Log.e(TAG, "error reading representation: " + e.getMessage(), e); } } } else if(type == XmlPullParser.END_TAG) { String tagName = parser.getName(); if(tagName.equals("AdaptationSet")) { return adaptationSet; } } } throw new DashParserException("invalid state"); } private Representation readRepresentation(MPD mpd, Period period, AdaptationSet adaptationSet, Uri baseUrl, XmlPullParser parser, SegmentTemplate segmentTemplate) throws XmlPullParserException, IOException, DashParserException { Representation representation = new Representation(); representation.id = getAttributeValue(parser, "id"); representation.codec = getAttributeValue(parser, "codecs"); representation.mimeType = getAttributeValue(parser, "mimeType", adaptationSet.mimeType); if(representation.mimeType.startsWith("video/")) { representation.width = getAttributeValueInt(parser, "width"); representation.height = getAttributeValueInt(parser, "height"); representation.sar = getAttributeValueRatio(parser, "sar"); } representation.bandwidth = getAttributeValueInt(parser, "bandwidth"); int type = 0; while((type = parser.next()) >= 0) { String tagName = parser.getName(); if(type == XmlPullParser.START_TAG) { if (tagName.equals("Initialization")) { String sourceURL = getAttributeValue(parser, "sourceURL"); String range = getAttributeValue(parser, "range"); sourceURL = sourceURL != null ? extendUrl(baseUrl, sourceURL).toString() : baseUrl.toString(); representation.initSegment = new Segment(sourceURL, range); Log.d(TAG, "Initialization: " + representation.initSegment.toString()); } else if(tagName.equals("SegmentList")) { long timescale = getAttributeValueLong(parser, "timescale", 1); long duration = getAttributeValueLong(parser, "duration"); representation.segmentDurationUs = (long)(((double)duration / timescale) * 1000000d); } else if(tagName.equals("SegmentURL")) { String media = getAttributeValue(parser, "media"); String mediaRange = getAttributeValue(parser, "mediaRange"); String indexRange = getAttributeValue(parser, "indexRange"); media = media != null ? extendUrl(baseUrl, media).toString() : baseUrl.toString(); representation.segments.add(new Segment(media, mediaRange)); if(indexRange != null) { Log.v(TAG, "skipping unsupported indexRange in SegmentURL"); } } else if(tagName.equals("SegmentBase")) { String indexRange = getAttributeValue(parser, "indexRange"); if(indexRange != null) { throw new DashParserException("single segment / indexRange is not supported yet"); } } else if(tagName.equals("SegmentTemplate")) { // Overwrite passed template with newly parsed one segmentTemplate = readSegmentTemplate(parser, baseUrl, segmentTemplate); } else if(tagName.equals("BaseURL")) { baseUrl = extendUrl(baseUrl, parser.nextText()); Log.d(TAG, "new base url: " + baseUrl); } else if(tagName.equals("RepresentationIndex")) { throw new DashParserException("RepresentationIndex is not supported yet"); } } else if(type == XmlPullParser.END_TAG) { if(tagName.equals("Representation")) { if(!representation.segments.isEmpty()) { // a SegmentList has been parsed, nothing to do here } else if(segmentTemplate != null) { // We have a SegmentTemplate, expand it to a list of segments if(segmentTemplate.hasTimeline()) { if(segmentTemplate.timeline.size() > 1) { /* TODO Add support for individual segment lengths * To support multiple timeline entries, the segmentDurationUs * must be moved from the representation to the individual segments, * because their length is not necessarily constant and can change * over time. */ throw new DashParserException("timeline with multiple entries is not supported yet"); } SegmentTemplate.SegmentTimelineEntry current, previous, next; for(int i = 0; i < segmentTemplate.timeline.size(); i++) { current = segmentTemplate.timeline.get(i); //previous = i > 0 ? segmentTemplate.timeline.get(i - 1) : null; next = i < segmentTemplate.timeline.size() - 1 ? segmentTemplate.timeline.get(i + 1) : null; int repeat = current.r; if(repeat < 0) { long duration = next != null ? next.t - current.t : calculateTimescaleTime(mpd.mediaPresentationDurationUs, segmentTemplate.timescale) - current.t; repeat = (int)(duration / current.d) - 1; } representation.segmentDurationUs = calculateUs(current.d, segmentTemplate.timescale); // init segment String processedInitUrl = processMediaUrl( segmentTemplate.init, representation.id, null, representation.bandwidth, null); representation.initSegment = new Segment(processedInitUrl); // media segments long time = current.t; for (int number = segmentTemplate.startNumber; number < repeat + 1; number++) { String processedMediaUrl = processMediaUrl( segmentTemplate.media, representation.id, number, representation.bandwidth, time); representation.segments.add(new Segment(processedMediaUrl)); time += current.d; } } } else { representation.segmentDurationUs = segmentTemplate.calculateDurationUs(); int numSegments = (int) Math.ceil((double) mpd.mediaPresentationDurationUs / representation.segmentDurationUs); int dynamicStartNumberOffset = 0; if(mpd.isDynamic) { // Simulate availabilityStartTime support by converting it to a startNumber Date now = new Date(); Calendar calendar = Calendar.getInstance(); calendar.setTime(now); calendar.setTimeZone(TimeZone.getTimeZone("UTC")); now = calendar.getTime(); // sync local time with server time (from http date header) if(serverDate != null) { now = serverDate; } /* Calculate the time delta between the availability start time * and the current time, for that we know at which position we * currently are in the live stream. */ long availabilityDeltaTimeUs = (now.getTime() - mpd.availabilityStartTime.getTime()) * 1000; // shift by the period start availabilityDeltaTimeUs -= period.startUs; // shift by the presentationTimeOffset availabilityDeltaTimeUs -= segmentTemplate.presentationTimeOffsetUs; // go back in time by the buffering period (else the segments to be buffered are not available yet) availabilityDeltaTimeUs -= Math.max(mpd.minBufferTimeUs, 10 * 1000000L); // go back in time by the suggested presentation delay availabilityDeltaTimeUs -= mpd.suggestedPresentationDelayUs; // convert the delta time to the number of corresponding segments // add it to the start number (which by default is 0 if not specified) dynamicStartNumberOffset = (int)(availabilityDeltaTimeUs / representation.segmentDurationUs); } // init segment String processedInitUrl = processMediaUrl( segmentTemplate.init, representation.id, null, representation.bandwidth, null); representation.initSegment = new Segment(processedInitUrl); // media segments for (int i = segmentTemplate.startNumber + dynamicStartNumberOffset; i < segmentTemplate.startNumber + dynamicStartNumberOffset + numSegments; i++) { String processedMediaUrl = processMediaUrl( segmentTemplate.media, representation.id, i, representation.bandwidth, null); representation.segments.add(new Segment(processedMediaUrl)); } } } else { /* When there is no SegmentList or SegmentTemplate, the only option left is * a single file/segment representation. */ // Subtitle are not supported yet and can be ignored if(representation.mimeType != null && representation.mimeType.startsWith("text/")) { Log.i(TAG, "unsupported subtitle representation"); } // Video and audio representations are vital for the player and cannot be ignored else { throw new DashParserException("single-segment representations are not supported yet"); // TODO implement single-file/single-segment support // TODO add SegmentBase and sidx downloading and parsing } } Log.d(TAG, representation.toString()); return representation; } } } throw new DashParserException("invalid state"); } private void skip(XmlPullParser parser) throws XmlPullParserException, IOException { if (parser.getEventType() != XmlPullParser.START_TAG) { throw new IllegalStateException(); } int depth = 1; while (depth != 0) { switch (parser.next()) { case XmlPullParser.END_TAG: depth--; break; case XmlPullParser.START_TAG: depth++; break; } } } private SegmentTemplate readSegmentTemplate(XmlPullParser parser, Uri baseUrl, SegmentTemplate parent) throws IOException, XmlPullParserException, DashParserException { SegmentTemplate st = new SegmentTemplate(); // Read properties from template or carry them over from a parent st.timescale = getAttributeValueLong(parser, "timescale", parent != null ? parent.timescale : 1); long presentationTimeOffset = getAttributeValueLong(parser, "presentationTimeOffsetUs", parent != null ? parent.presentationTimeOffsetUs : 0); st.presentationTimeOffsetUs = calculateUs(presentationTimeOffset, st.timescale); st.duration = getAttributeValueLong(parser, "duration", parent != null ? parent.duration : 0); st.startNumber = getAttributeValueInt(parser, "startNumber", parent != null ? parent.startNumber : 1); String initialization = getAttributeValue(parser, "initialization"); if(initialization != null) { st.init = extendUrl(baseUrl, initialization).toString(); } else if(parent != null) { st.init = parent.init; } String media = getAttributeValue(parser, "media"); if(media != null) { st.media = extendUrl(baseUrl, media).toString(); } else if(parent != null) { st.media = parent.media; } int type = 0; while((type = parser.next()) >= 0) { if(type == XmlPullParser.START_TAG) { String tagName = parser.getName(); if(tagName.equals("S")) { SegmentTemplate.SegmentTimelineEntry e = new SegmentTemplate.SegmentTimelineEntry(); long defaultTime = 0; if(!st.timeline.isEmpty()) { SegmentTemplate.SegmentTimelineEntry previous = st.timeline.get(st.timeline.size() - 1); defaultTime = previous.t + previous.calculateDuration(); } e.t = getAttributeValueLong(parser, "t", defaultTime); e.d = getAttributeValueLong(parser, "d"); e.r = getAttributeValueInt(parser, "r"); st.timeline.add(e); } else if(tagName.equals("RepresentationIndex")) { throw new DashParserException("RepresentationIndex is not supported yet"); } } else if(type == XmlPullParser.END_TAG) { String tagName = parser.getName(); if(tagName.equals("SegmentTemplate")) { return st; } } } throw new DashParserException("invalid state"); } /** * Parse a timestamp and return its duration in microseconds. * http://en.wikipedia.org/wiki/ISO_8601#Durations */ private static long parseTime(String time) { Matcher matcher = PATTERN_TIME.matcher(time); if(matcher.matches()) { long hours = 0; long minutes = 0; double seconds = 0; String group = matcher.group(2); if (group != null) { hours = Long.parseLong(group); } group = matcher.group(4); if (group != null) { minutes = Long.parseLong(group); } group = matcher.group(6); if (group != null) { seconds = Double.parseDouble(group); } return (long) (seconds * 1000 * 1000) + minutes * 60 * 1000 * 1000 + hours * 60 * 60 * 1000 * 1000; } return -1; } /** * Extends an URL with an extended path if the extension is relative, or replaces the entire URL * with the extension if it is absolute. */ private static Uri extendUrl(Uri url, String urlExtension) { urlExtension = urlExtension.replace(" ", "%20"); // Convert spaces Uri newUrl = Uri.parse(urlExtension); if(newUrl.isRelative()) { /* Uri.withAppendedPath appends the extension to the end of the "real" server path, * instead of the end of the uri string. * Example: http://server.com/foo?file=http://server2.net/ + file1.mp4 * => http://server.com/foo/file1.mp4?file=http://server2.net/ * To avoid this, we need to join as strings instead. */ newUrl = Uri.parse(url.toString() + urlExtension); } return newUrl; } /** * Converts a time/timescale pair to microseconds. */ private static long calculateUs(long time, long timescale) { return (long)(((double)time / timescale) * 1000000d); } private static long calculateTimescaleTime(long time, long timescale) { return (long)((time / 1000000d) * timescale); } private static String getAttributeValue(XmlPullParser parser, String name, String defValue) { String value = parser.getAttributeValue(null, name); return value != null ? value : defValue; } private static String getAttributeValue(XmlPullParser parser, String name) { return getAttributeValue(parser, name, null); } private static int getAttributeValueInt(XmlPullParser parser, String name) { return Integer.parseInt(getAttributeValue(parser, name, "0")); } private static int getAttributeValueInt(XmlPullParser parser, String name, int defValue) { return Integer.parseInt(getAttributeValue(parser, name, defValue+"")); } private static long getAttributeValueLong(XmlPullParser parser, String name) { return Long.parseLong(getAttributeValue(parser, name, "0")); } private static long getAttributeValueLong(XmlPullParser parser, String name, long defValue) { return Long.parseLong(getAttributeValue(parser, name, defValue+"")); } private static long getAttributeValueTime(XmlPullParser parser, String name) { return parseTime(getAttributeValue(parser, name, "PT0S")); } private static long getAttributeValueTime(XmlPullParser parser, String name, String defValue) { return parseTime(getAttributeValue(parser, name, defValue)); } private static float getAttributeValueRatio(XmlPullParser parser, String name) { String value = getAttributeValue(parser, name); if(value != null) { String[] values = value.split(":"); return (float)Integer.parseInt(values[0]) / Integer.parseInt(values[1]); } return 0; } private static boolean getAttributeValueBoolean(XmlPullParser parser, String name) { String value = getAttributeValue(parser, name, "false"); return value.equals("true"); } /** * Processes templates in media URLs. * * Example: $RepresentationID$_$Number%05d$.ts * * 5.3.9.4.4 Template-based Segment URL construction * Table 16 - Identifiers for URL templates */ private static String processMediaUrl(String url, String representationId, Integer number, Integer bandwidth, Long time) { // RepresentationID if(representationId != null) { url = url.replace("$RepresentationID$", representationId); } // Number, Bandwidth & Time with formatting support // The following block converts DASH segment URL templates to a Java String.format expression List<String> templates = Arrays.asList("Number", "Bandwidth", "Time"); Matcher matcher = PATTERN_TEMPLATE.matcher(url); while(matcher.find()) { String template = matcher.group(1); String pattern = matcher.group(2); int index = templates.indexOf(template); if(pattern != null) { url = url.replace("$" + template + pattern + "$", "%" + (index + 1) + "$" + pattern.substring(1)); } else { // Table 16: If no format tag is present, a default format tag with width=1 shall be used. url = url.replace("$" + template + "$", "%" + (index + 1) + "$01d"); } } url = String.format(url, number, bandwidth, time); // order must match templates list above // $$ // Replace this at the end, else it breaks directly consecutive template expressions, // e.g. $Bandwidth$$Number$. url = url.replace("$$", "$"); return url; } }