Media Delivery to iPhones and iPads

Issue:

Try to deliver video or audio dynamically to iPhones/iPad via server side scripts, such as Coldfusion or PHP.  Instead of the video or audio being playable you get the dreaded “no-play” icon.

iPhone No Play Media Screen

Solution: The response needs to support Accept-Ranges

Background: 

The request from an iOS client will include the HTTP header “Range”.  In my testing, the initial load of a page using the HTML video tag had a Range value of “bytes=0-1” which I believe is done just to verify the server properly accepts the Range header.

Apple’s Media Server Requirement

Implementation:

In Coldfusion it is simple to serve binary content using cfcontent tag.  It allows you to deliver files outside of the web root when strong security is needed.  The only problem with cfcontent is, as of CF8, it does not have the ability to serve partial files.  That’s where Java comes in and we can use the OutputStream class to serve this need.

<cfset videoPath = "C:\videos\myvideo.mp4">

<cfheader name="Last-Modified" value="Fri, 06 May 2011 19:08:04 GMT"><!--- Use file meta-data here --->
<cfheader name="ETag" value="#hash(videoPath, 'MD5')#">
<cfheader name="Content-Location" value="http://example.com/servefile.cfm">

<cfif structKeyExists(GetHttpRequestData().headers, 'Range')>
    <cfset rangeDownload(videoPath)>
<cfelse>
<cfscript>
    context = getPageContext();
    context.setFlushOutput(false);
    response = context.getResponse().getResponse();
    response.setContentType("video/mp4");
    response.setContentLength(arrayLen(theData));
    
    out = response.getOutputStream();
    out.write(theData);
    out.flush();  
    out.close();
</cfscript>
</cfif>

The function to process the Range request

	
	<cffunction name="rangeDownload" returnType="void" output="yes">
	<cfargument name="file" type="string" required="true" hint="path to file">
	
	<cfset var l = {}>
	<cfset l.request = GetHttpRequestData()>
	
 	<cffile action="readbinary" file="#ARGUMENTS.file#" variable="l.theData">
 	
	<cfset l.size = arrayLen(theData)>
	<cfset l.length = l.size>
	<cfset l.start  = 0>
	<cfset l.end = l.size - 1>
	
	<!--- Now that we've gotten so far without errors we send the accept range header
	/* At the moment we only support single ranges.
	 * Multiple ranges requires some more work to ensure it works correctly
	 * and comply with the spesifications: http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html#sec19.2
	 *
	 * Multirange support annouces itself with:
	 * header('Accept-Ranges: bytes');
	 *
	 * Multirange content must be sent with multipart/byteranges mediatype,
	 * (mediatype = mimetype)
	 * as well as a boundry header to indicate the various chunks of data.
	 */
	--->
	<!--- <cfheader name="Accept-Ranges" value="0-#l.length#"> --->
	<cfheader name="Accept-Ranges" value="bytes">
	<!--- 
	  multipart/byteranges
	  http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html#sec19.2 --->
	<cfif structKeyExists(l.request.headers, 'Range')>
 
		<cfset l.c_start = l.start>
		<cfset l.c_end = l.end>
		
		<!--- Extract the range string --->
		<cfset l.range = ListGetAt(l.request.headers.range, 2, '=')>
		<!--- Make sure the client hasn't sent us a multibyte range --->
		<cfif l.range contains ','>
			<!--- (?) Should this be issued here, or should the first
			 range be used? Or should the header be ignored and
			 we output the whole content?
			--->
			<cfheader statusCode = "416" statusText = "Requested Range Not Satisfiable">
			<cfheader name="Content-Range" value="bytes #l.start#-#l.end#/#l.size#">
			<!--- (?) Echo some info to the client? --->
			<cfabort>
		</cfif>
		<!--- If the range starts with an '-' we start from the beginning
			If not, we forward the file pointer
			And make sure to get the end byte if specified --->
		<cfif Left(l.range, 1) eq '-'>
		<!--- The n-number of the last bytes is requested --->
			<cfset l.c_start = l.size - Mid(l.range, 2, Len(l.range))>
		<cfelse>
			<cfset l.rangeArray = ListToArray(l.range, '-')>
			<cfset l.c_start = l.rangeArray[1]>
			<cfif ArrayLen(l.rangeArray) eq 2 and val(l.rangeArray[2]) gt 0>
				<cfset l.c_end = l.rangeArray[2]>
			<cfelse>
				<cfset l.c_end = l.size>
			</cfif>
		</cfif>
		<!---
		/* Check the range and make sure it's treated according to the specs.
		 * http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
		 */
		// End bytes can not be larger than l.end. --->
		<cfif l.c_end gt l.end>
			<cfset l.c_end = l.end>
		</cfif>
		
		<!--- Validate the requested range and return an error if it's not correct. --->
		<cfif l.c_start gt l.c_end || l.c_start gt (l.size - 1) || l.c_end gte l.size>
			<cfheader statusCode = "416" statusText = "Requested Range Not Satisfiable">
			<cfheader name="Content-Range" value="bytes #l.start#-#l.end#/#l.size#">
			<!--- (?) Echo some info to the client? --->
			<cfabort>
		</cfif>
		
		<cfset l.start = l.c_start>
		<cfset l.end = l.c_end>
		<cfset l.length = l.end - l.start + 1><!--- Calculate new content length --->
		
		<cfheader statusCode = "206" statusText = "Partial Content">
		
		<cfscript>
		    context = getPageContext();
		    context.setFlushOutput(false);
		    response = context.getResponse().getResponse();
		    response.setContentType("video/mp4");
		    response.setContentLength(l.length);
		</cfscript>
		
	</cfif>
	
	<!--- Notify the client the byte range we'll be outputting --->
	<cfheader name="Content-Range" value="bytes #l.start#-#l.end#/#l.size#">
	<!--- <cfheader name="Content-Length" value="#l.length#"> --->
	
 	<cfscript>
		// Start buffered download
		out = response.getOutputStream();
		
		// write the portion requested
		out.write(l.theData, javacast('int', l.start), javacast('int', l.length));
		out.flush();
		out.close();
 	</cfscript>
</cffunction>


Supplemental:

Here are a couple of curl commands that help with diagnosing request/response issues.

The curl command to test the Byte-Range.  You expect the response to only send the number of bytes requested, in this example 2 bytes.

curl --range 0-1 http://example.com/servefile.cfm -o /dev/null

The curl command to see the header response

curl -I -H "Range:bytes=0-1" http://example.com/servefile.cfm

Credits:

Original blog post that identified the problem for me: http://mobiforge.com/developing/story/content-delivery-mobile-devices