How to transparently stream a file to the browser?
Asked Answered
N

3

8

CONTROLLED ENVIRONMENT: IE8, IIS 7, ColdFusion

When issuing a GET request pointing to a media file such as .mp3, .mpeg, etc. from IE, the browser will launch the associated application (Window Media Player) and I guess that the way IIS serves the file allows the application to stream it.

We would like to be able to have a full control over the streaming process of a file so that we can allow it at certain times and to certain users only. For that reason we cannot simply let IIS directly serve the file and we wanted to use ColdFusion to serve the file instead.

We have tried a few different approaches, but in every case the browser is downloading the entire file content before launching the external application. That's what we want to avoid.

Please note that we do not want an NTFS-permission based solution.

The solution that looked the most promising was Using ColdFusion To Stream Files To The Client Without Loading The Entire File Into Memory but the only advantage that seemed to offer was that the file wouldn't be entirely loaded in memory when served to the browser, but the browser still waits until the end of the transfer and then opens up the file in Winwodw Media Player.

Is there a way to use ColdFusion or Java to stream a file to the browser and having the browser delegate the handling to the associated application just like when we let IIS directly serve the file?

Nephology answered 15/10, 2013 at 15:31 Comment(6)
I don't believe you can stream media with default IIS. I believe you need a media server/services. See this IIS Media Services. There is also IIS Smooth Streaming (I'm pretty sure both are free) Here is a blog post on the topic.Mote
@Mote Thanks for the references, I will have a look at them. Do you know how default IIS currently serves the file to the browser so that it's opening it up in WMP without waiting until the file has fully downloaded then? As far as I know I do not have any special media services installed and that's the behaviour I am looking for.Nephology
I'm confused? Do you know how default IIS currently serves the file to the browser so that it's opening it up in WMP without waiting... - I thought the issue was that WMP was waiting for the entire file to download before playing?Mote
@Miguel-F, No if the browser performs a request directly to somedomain.com/somefile.mp3, it will open WMP which will effectively stream the file without issues (not sure how IIS is serving the file). However I cannot let users open the file directly so I tried various ways to serve the file to the browser using ColdFusion. It works but the browser will NOT open WMP until the file has finished to download, unlike when the browser GET somedomain.com/somefile.mp3.Nephology
My assumption would be that when you request somefile.mp3 directly from the browser it is handing off the processing to WMP. WMP fires up and is able to initiate communications over your network via UDP (it's preferred protocol, see my answer). Using UDP between WMP and IIS it "streams" the file. When you use ColdFusion to download somefile.mp3 the browser is handling the communications so UDP is out. It must communicate using HTTP to IIS and is unable to "stream" the file. Note this is just how I believe it works, I am not an expert at thisMote
@Mote Thanks for all the references, they might help in the future. I have found an answer that suits our needs and was quite simple for now.Nephology
N
2

WORKING SOLUTION:

I finally managed to find a solution that supports seeking and that doesn't involve too much work. I basically created an HttpForwardRequest component that delegates the request handling to the web server by issuing a new HTTP request to the specified media URL while preserving other initial servlet request details, such as HTTP headers. The web server's response will then be piped into the servlet's response output stream.

In our case, since the web server (ISS 7.0) already know how to do HTTP streaming, that's the only thing we have to do.

Note: I have tried with getRequestDispatcher('some_media_url').forward(...) but it seems that it cannot serve media files with the correct headers.

HttpForwardRequest code:

<cfcomponent output="no">

    <cffunction name="init" access="public" returntype="HttpForwardRequest" output="no">
        <cfargument name="url" type="string" required="yes" hint="The URL to which the request should be forwarded to.">
        <cfargument name="requestHeaders" type="struct" required="yes" hint="The HTTP request headers.">
        <cfargument name="response" type="any" required="yes" hint=" The servlet's response object.">
        <cfargument name="responseHeaders" type="struct" required="no" default="#{}#" hint="Custom response headers to override the initial request response headers.">

        <cfset variables.instance = {
            url = arguments.url,
            requestHeaders = arguments.requestHeaders,
            response = arguments.response,
            responseHeaders = arguments.responseHeaders
        }>

        <cfreturn this>
    </cffunction>

    <cffunction name="send" access="public" returntype="void" output="no">
        <cfset var response = variables.instance.response>
        <cfset var outputStream = response.getOutputStream()>
        <cfset var buffer = createBuffer()>

        <cftry>

            <cfset var connection = createObject('java', 'java.net.URL')
                    .init(variables.instance.url)
                    .openConnection()>

            <cfset setRequestHeaders(connection)>

            <cfset setResponseHeaders(connection)>

            <cfset var inputStream = connection.getInputStream()>

            <cfset response.setStatus(connection.getResponseCode(), connection.getResponseMessage())>

            <cfloop condition="true">
                <cfset var bytesRead = inputStream.read(buffer, javaCast('int', 0), javaCast('int', arrayLen(buffer)))>

                <cfif bytesRead eq -1>
                    <cfbreak>
                </cfif>

                <cftry>
                    <cfset outputStream.write(buffer, javaCast('int', 0), bytesRead)>

                    <cfset outputStream.flush()>

                    <!--- 
                    Connection reset by peer: socket write error

                    The above error occurs when users are seeking a video.
                    That is probably normal since I assume the client (e.g. Window Media Player) 
                    closes the connection when seeking.
                    --->
                    <cfcatch type="java.net.SocketException">
                        <cfbreak>
                    </cfcatch>
                </cftry>
            </cfloop>

            <cffinally>

                <cfif not isNull(inputStream)>
                    <cfset inputStream.close()>
                </cfif>

                <cfif not isNull(connection)>
                    <cfset connection.disconnect()>
                </cfif>

            </cffinally>
        </cftry>

    </cffunction>

    <cffunction name="setRequestHeaders" access="private" returntype="void" output="no">

        <cfargument name="connection" type="any" required="yes">

        <cfset var requestHeaders = variables.instance.requestHeaders>

        <cfloop collection="#requestHeaders#" item="local.key">
            <cfset arguments.connection.setRequestProperty(key, requestHeaders[key])>
        </cfloop>

    </cffunction>

    <cffunction name="setResponseHeaders" access="private" returntype="void" output="no">
        <cfargument name="connection" type="any" required="yes">

        <cfset var response = variables.instance.response>
        <cfset var responseHeaders = variables.instance.responseHeaders>
        <cfset var i = -1>

        <!--- Copy connection headers --->
        <cfloop condition="true">

            <cfset i = javaCast('int', i + 1)>

            <cfset var key = arguments.connection.getHeaderFieldKey(i)>

            <cfset var value = arguments.connection.getHeaderField(i)>

            <cfif isNull(key)>
                <cfif isNull(value)>
                    <!--- Both, key and value are null, break --->
                    <cfbreak>
                </cfif>

                <!--- Sometimes the key is null but the value is not, just ignore and keep iterating --->
                <cfcontinue>
            </cfif>

            <cfset setResponseHeader(key, value)>
        </cfloop>

        <!--- Apply custom headers --->
        <cfloop collection="#responseHeaders#" item="key">
            <cfset setResponseHeader(key, responseHeaders[key])>
        </cfloop>

    </cffunction>

    <cffunction name="setResponseHeader" access="private" returntype="void" output="no">
        <cfargument name="key" type="string" required="yes">
        <cfargument name="value" type="string" required="yes">

        <cfset var response = variables.instance.response>

        <cfif arguments.key eq 'Content-Type'>
            <cfset response.setContentType(arguments.value)>
        <cfelse>
            <cfset response.setHeader(arguments.key, arguments.value)>
        </cfif>
    </cffunction>

    <cffunction name="createBuffer" access="private" returntype="any" output="no">
        <cfreturn repeatString("12345", 1024).getBytes()>
    </cffunction>

</cfcomponent>

cf_streamurl code:

<cfparam name="attributes.url" type="url">

<cfif thisTag.executionMode neq 'start'>
    <cfexit>
</cfif>

<cfset pageContext = getPageContext()>

<cfset requestHeaders = {
    'Authorization' = 'Anonymous'
}>

<cfset structAppend(requestHeaders, getHTTPRequestData().headers, false)>

<cfset pageContext.setFlushOutput(false)>

<!--- Forward the request to IIS --->
<cfset new references.cfc.servlet.HttpForwardRequest(
    attributes.url,
    requestHeaders,
    pageContext.getResponse().getResponse()
).send()>

You can then use the cf_streamurl custom tag like:

<cf_streamurl url="http://sh34lprald94/media_stream/unprotected/trusts.mp4"/>

IMPORTANT: It only supports Anonymous authentication for now.


First half-working attempt (historical purpose only):

We found a solution (which was actually quite simple) that suits our needs by inspecting the HTTP headers of the response packet and looking at the mime type returned by IIS when letting it server the media file.

The issue was that when trying to serve the file content to the browser using ColdFusion, we had to use one of the Window Media Services mime types to force the browser to delegate the handling to Window Media Player directly (which is then able to stream the file).

File extension MIME type 
.asf video/x-ms-asf 
.asx video/x-ms-asf 
.wma audio/x-ms-wma 
.wax audio/x-ms-wax 
.wmv audio/x-ms-wmv 
.wvx video/x-ms-wvx 
.wm video/x-ms-wm 
.wmx video/x-ms-wmx 
.wmz application/x-ms-wmz 
.wmd application/x-ms-wmd

The first step for solving the issue was to write a function that would resolve the mime type correctly based on the file's extension. IIS has that knowledge already, however I haven't found a way of querying it's MIME registry yet.

Note: wmsMimeTypes is a struct used as a map to lookup WMS mime types.

<cffunction name="getMimeType" access="public" returntype="string">
    <cfargument name="fileName" type="string" required="yes">

    <cfset var mimeType = 'application/x-unknown'>
    <cfset var ext = this.getFileExtension(arguments.fileName)>

    <cfif structKeyExists(this.wmsMimeTypes, ext)>
        <cfreturn this.wmsMimeTypes[ext]>
    </cfif>

    <!--- TODO: Is there a way to read the IIS MIME registry? --->
    <cfregistry action="get" branch="HKEY_CLASSES_ROOT\.#ext#" entry="Content Type" variable="mimeType">

    <cfreturn mimeType>

</cffunction>

Then we implemented a stream method like below that encapsulates the streaming process based on the implementation found in Using ColdFusion To Stream Files To The Client Without Loading The Entire File Into Memory

Note: It also works with cfcontent, but I read that it was quite inefficient because it's consuming too much resources, especially because it loads the entire file in memory before flushing to the browser.

<cffunction name="stream" access="public" returntype="void">
    <cfargument name="file" type="string" required="yes">
    <cfargument name="mimeType" type="string" required="no">

    <cfscript>
        var fileName = getFileFromPath(arguments.file);
        var resolvedMimeType = structKeyExists(arguments, 'mimeType')? arguments.mimeType : this.getMimeType(fileName);
        var javaInt0 = javaCast('int', 0);
        var response = getPageContext().getResponse().getResponse();
        var binaryOutputStream = response.getOutputStream();
        var bytesBuffer = repeatString('11111', 1024).getBytes();
        var fileInputStream = createObject('java', 'java.io.FileInputStream').init(javaCast('string', getRootPath() & arguments.file));

        getPageContext().setFlushOutput(javaCast('boolean', false));

        response.resetBuffer();
        response.setContentType(javaCast('string', resolvedMimeType));

        try {
            while (true) {
                bytesRead = fileInputStream.read(bytesBuffer, javaInt0, javaCast('int', arrayLen(bytesBuffer)));

                if (bytesRead eq -1) break;

                binaryOutputStream.write(bytesBuffer, javaInt0, javaCast('int', bytesRead));
                binaryOutputStream.flush();
            }               
            response.reset();
         } finally {
             if (not isNull(fileInputStream)) fileInputStream.close();
             if (not isNull(binaryOutputStream)) binaryOutputStream.close();
         }
    </cfscript>
</cffunction>

You must NOT set the Content-Disposition header or the browser will download the file instead of delegating the control to WMP.

Note: Letting the web server to stream the file to the client (or the CF solution we used) will never be as efficient as using a media server, like stated in the article that @Miguel-F suggested.

MAJOR DOWNSIDE: The previous implementation will not support seeking which actually might make the solution almost unusable.

Nephology answered 15/10, 2013 at 18:28 Comment(7)
Great, glad you got it working! It might be helpful to others if you include the ColdFusion code that you are using to deliver the file. cfcontent/cfheader I presume.Mote
@Mote I added the implementation, however I have realized that it will not support seeking. I am currently trying to find out if there's a way I could implement support for it.Nephology
You cannot seek at all or it is limited in some way? Doesn't the file need to be downloaded in order for WMP seeking? (Catch-22) I have seen where WMP will not allow seeking when the player is paused.Mote
@Mote I cannot seek at all, however it works well when IIS is serving the file directly. I haven't found a great deal of information on the HTTP streaming protocol that's used by default IIS, however I had another idea which might be simpler. Perhaps I could just use ColdFusion as a proxy so that CF would issue an http request cloning the request details of the client and would flush back what IIS responds. Obviously it will not work with cfhttp because I need to flush as I get new content from IIS. I'll have to check how it could be done with Java classes.Nephology
Again, I'm no expert at this, but my assumption is that because IIS is not a streaming media server it does not provide all of the information necessary for WMP to implement seeking For example, WMP does not know how large the file actually is until it receives the whole file. That's just my guess.Mote
@Mote The fact is, it works well when letting IIS stream the file which mean that we can seek without issues. However I cannot let IIS stream directly for the reasons highlighted in the question so I've implemented another streaming solution with ColdFusion which doesn't support seeking since I just write the whole file independently of the request.Nephology
@Mote I have managed to make it work. It might not be the optimal solution but seems to be fine for now. Have a look if you are interested.Nephology
M
0

This was too long for a comment
I found another reference that may be helpful to you - TechNet Windows Media Encoder

Excerpt from that article:

Understanding the differences between a Windows Media server and a Web server

You can stream Windows Media-based content either from a server running Windows Media Services or from a Web server to a player, such as Windows Media Player. The server and player can be used either on the Internet or an intranet, and they can be separated by a firewall. Although a Windows Media server is designed specifically for streaming Windows Media-based content, a standard Web server is not. If you decide to use a Web server, you need to be aware of the differences in the way the content is delivered, which can affect the quality of the playback.

The method of sending data differs between a Web server and Windows Media server. A Web server is designed to send as much data as it can, as quickly as possible. This is the preferred method for sending packets containing static images, text, and Web page script, but it is not the best method for sending packets containing streaming media. Streaming media should be delivered in real time, not in large bursts, and the player should receive packets just ahead of rendering them.

The Windows Media server meters the delivery of packets according to feedback information it receives while sending a stream to a player. When a player receives packets in this way, the presentation is much more likely to be smooth. Because bandwidth use is controlled, more users can connect concurrently to your site and receive streams that are free of interruptions.

Web servers do not support multiple-bit-rate video. When a file streams from a Web server, the quality of the delivery is not monitored, and no adjustment to the bit rate can be made. Web servers cannot use the preferred delivery protocol, User Datagram Protocol (UDP), so delivery of a stream is more likely to be interrupted by periods of silence while the player buffers data. Live streaming and multicasting are also not possible with a Web server.

Mote answered 15/10, 2013 at 18:2 Comment(0)
C
0

A little observation to the working solution (perhaps helps somebody)....

In my case, I use CF9 (but tested on CF11 and same results) and the file is streamed from a CouchDB server.

When the streamed file is a text file, the first row of the file is overrited with 0 value at the first attempt (impair) and is correct at the second attempt (very odd inded) but the file is not downloaded anyway.

My solution (I don't have a real explanation) was to move the cfbreak in setResponseHeaders function

I changed this code:

        <cfif isNull(key)>
            <cfif isNull(value)>
                <!--- Both, key and value are null, break --->
                <cfbreak>
            </cfif>

            <!--- Sometimes the key is null but the value is not, just ignore and keep iterating --->
            <cfcontinue>
        </cfif>

With this one:

        <cfif isNull(key)>
            <cfbreak>
        </cfif>

Thank you @plalx for your solution. Helped me a lot!

Later edit with some details

The structure of streamed data was like:

  1. Headers
  2. An empty row
  3. b9b (I think it is the flag for start of file content)
  4. File content
  5. 0 (zero value - I think it is the flag for the end of file content )

In my opinion, getHeaderFieldKey and getHeaderField functions, thinks that "b9b" row is a {key: 'b9b', value:(content of the file) } pair and writes it as a header, and after that, reads 0 as {key: null, value:0} pair and overrides position from header b9b: (...first row from streamed file) with {key: null, value:0} pair

Moving cfbreak on isNull(key), stops reading (wrongly) the file content as a header field.

But is just a guess...

Ceraceous answered 27/4, 2021 at 8:51 Comment(2)
I wonder how a null header key is meaningful, but it looks like it is. What was the value attached to that null header in your case? Glad I could help! Don't forget to vote haha ;)Nephology
Voted! The attached value was 0 (zero). I've updated the answer.Ceraceous

© 2022 - 2024 — McMap. All rights reserved.