by Brett Andrew 23/02/2025
Finally, the low down on Streaming media to most browsers (including Safari) from a server, which is fetching the file from another web based service (e.g. Public Azure Storage).
Tech stack:
Our service is behind Azure Front Door. We have a route setup to cache at the Azure front door for any URL starting with api/websiteCached/ - so any media files we want to cache we can use this. We do not allow public access to the Azure storage (we actually have quite a lot more security than the below to ensure only specific files are accessible but simplified for sharing purposes).
The Azure Front Door was working fine, the caching capabilities are great, but our own service was not implementing "Range" (where browsers request to fast forward through a file). The final issue was that if the file was not cached, Safari could not load it. Turns out Safari requires the support of the Range header as mandatory. To implement Range, this took our team several days of coding (trial and error) to work out the complexities. Our final "ah ha!" moment was when we realised we are the middle-man. The headers we are receiving, we need to pass onto the HTTP request (which is the Azure Storage Service but it could be anywhere on the web the files are stored) and the headers we get back from that service, we need to pass them back to the client/browser.
See the below working code, this works with cached and non-cached files.
Hope this helps someone who is on their 30+ hours of working this out!
Video example here is the using the code (it is cached at Azure though which serves Range automatically for cached files). The HTML is nothing more than Video tag with src.
Lessons learnt (this could help other developers in general)
- We at first tried to create our own Range processing engine, trying to capture the start and end of the files, but since we don't actually store any files we only retrieve them via public end point, this was not required.
- The Header of 'Accept-Ranges' tells the browser whether you accept a byte range, so for Safari if this is a "no" the it will not play the video, ever. We originally just hard coded this, but then went back and let the service tell us, even Azure Storage does not send this back but supports it, so we overwrote it for Azure Storage and returned "bytes" (meaning this accepts range in the method of bytes which is the only method Range works anyhow). This seems to be hidden by browsers or ignored for 206 responses (which makes sense). Safari will send a request for 1 byte at the start, 1 byte at the end and a few random bytes in the middle to test the file, so you can't trick it.
- Headers of Range coming in was important, if its not asking for a partial file it wants the full file.
- We originally were sending back Content-Disposition, but you don't need to the FileStreamResult does this for you.
- When you save the file to Azure Storage, make sure you set the right content type (we have a function that sets this to the right content type based on the file extension). If you haven't done that, you should manually set the content type to return, as json/text wont cut it, it has to be video format / MP4 in this specific case (the HTML src is set to content type Mp4).
- Caching "Cache-Control" is required if you want Azure to cache it, it wont honor the caching unless this is set. - If you return a 200 message with "ok", this tells Azure to cache it, be sure to return a 206 partial cache and not a 200 "ok" for Range requests. This is important as Azure will never cache a 206 request, but if you return a partial request with a 200, Azure will cache it!
- When testing turn Azure caching off. You always have to test in a new in-private window and close all other in-private windows first (if there is a 2nd in-private window open it caches the file and you cant test range properly).
- You know when its working when you can open a private window, before the video fully downloads you are able to seek (fast forward) to close to the end and it plays without restarting at the beginning.
- The header response "Content-Length" was essential, when a browser asks for 2 bytes, Content-Length should show 2 (not the full file length!)
<video width="100%" controls="">
<source
src="https://www.formition.com/api/WebsiteCached/DownloadFile?
FileGuid=ad9c9f7f-43b8-42a3-8b53-85855592c460#-sourcefile.mp4"
type="video/mp4">
Your browser does not support the video tag.
</video>
[HttpGet("[action]")]
public async Task<object> DownloadFile(string BlobUri, string Filename)
{
Dictionary<string, string> headersDictionary =
Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString());
return await FileStreamerExample.DownloadFileAsync(Response, BlobUri, Filename, headersDictionary, true);
}
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
namespace Members.Common.Filetools
{
class FileStreamerExample{
/// <summary>
/// /// Ultimately this is just the messenger, retrieving a file either in full or via ranges (bytes) from another service
/// What we had to do was pass the range request to the service and manage the content range and content lengths returned
/// Once we worked this out, the two services talked perfectly to each other and all browsers could stream content.
/// </summary>
/// <param name="Response"></param>
/// <param name="BlobUri"></param>
/// <param name="filename"></param>
/// <param name="headers"></param>
/// <param name="Cache"></param>
/// <returns></returns>
public async Task<IActionResult> DownloadFileAsync(HttpResponse Response, string BlobUri, string filename, IDictionary<string, string> headers = null,bool Cache = false)
{
//download entire file, then send file to client
HttpClient xClient = new HttpClient();
//HttpClient xClient = httpClientFactory.CreateClient("DownloadFileAsync");
string RangeHeader = "";
// Pass through the range header if it exists (we are just the messenger)
if (headers != null && headers.TryGetValue("Range", out var rangeHeader))
{
//lets ignore this request
if (rangeHeader != "bytes=0-")
{
xClient.DefaultRequestHeaders.Add("Range", rangeHeader);
RangeHeader = rangeHeader;
}
}
//check the contentlength
var httpResponse = await xClient.GetAsync(BlobUri, HttpCompletionOption.ResponseHeadersRead);
//Response.Headers.Append("Data-Test", RangeHeader);
if (httpResponse.IsSuccessStatusCode)
{
//set headers on (need content type
Response.Headers.Append("Content-Type", httpResponse.Content.Headers.ContentType.ToString());
//Response.Headers.Append("Data-Test-StatusCode", httpResponse.StatusCode.ToString());
//check if the source accepts ranges
//Tell the client whether the source accepts ranges or not (dont ask us we are just the messenger)
if (httpResponse.Content.Headers.TryGetValues("Accept-Ranges", out var acceptRanges))
{
Response.Headers.Append("Accept-Ranges", string.Join(",", acceptRanges));
//Response.Headers.Append("Data-TestAccept-Ranges1", "bytes");
}
else
{
// Handle the case where the "Accept-Ranges" header is missing or null
if (BlobUri.Contains("blob.core.windows.net"))
{
Response.Headers.Append("Accept-Ranges", "bytes");
//Response.Headers.Append("Data-TestAccept-Ranges2", "bytes");
}
}
//full file, allow caching - if you are Caching this at Azure front door, you have to set the age here
if (httpResponse.Content.Headers != null && httpResponse.Content.Headers.ContentLength != null)
{
Response.Headers.Append("Content-Length", httpResponse.Content.Headers.ContentLength.ToString());
}
if (httpResponse.Content.Headers != null && httpResponse.Content.Headers.ContentRange != null)
{
Response.Headers.Append("Content-Range", httpResponse.Content.Headers.ContentRange.ToString());
}
if (httpResponse.Content.Headers != null && httpResponse.Content.Headers.ContentEncoding != null)
{
Response.Headers.Append("Content-Encoding", httpResponse.Content.Headers.ContentEncoding.ToString());
}
Response.StatusCode = (int)httpResponse.StatusCode;
//if cache is requested, lets only cache status ok
//in azure front door we cache all urls under /api/websiteCached/ but they only cache if the Cache-Control is set.
if ((int)httpResponse.StatusCode == (int)HttpStatusCode.OK)
{
if (Cache)
{
Response.Headers.Add("Cache-Control", "public, max-age=86400");
}
else
{
Response.Headers.Add("Cache-Control", "no-cache");
}
}
else
{
Response.Headers.Add("Cache-Control", "no-cache");
}
var stream = await httpResponse.Content.ReadAsStreamAsync();
return new FileStreamResult(stream, httpResponse.Content.Headers.ContentType.ToString())
{
FileDownloadName = filename,
EnableRangeProcessing = true
};
}
else
{
//Response.Headers.Append("Data-Test-Error", httpResponse.Content.ToString());
Response.Headers.Append("Data-Test", RangeHeader);
//Response.Headers.Append("Data-StatusCode", httpResponse.StatusCode.ToString());
Response.StatusCode = (int)httpResponse.StatusCode;
return null;
}
}
}
}
REQUEST
Authority: www.formition.com
Method: GET
Path: /api/WebsiteCached/DownloadFile?FileGuid=ad9c9f7f-43b8-42a3-8b53-85855592c460
Scheme: https
Accept: */*
Accept-Encoding: identity;q=1, *;q=0
Accept-Language: en-US,en;q=0.5
Priority: i
Range: bytes=0-
Referer: https://www.formition.com/
Sec-CH-UA: "Not(A:Brand";v="99", "Brave";v="133", "Chromium";v="133"
Sec-CH-UA-Mobile: ?0
Sec-CH-UA-Platform: "Windows"
Sec-Fetch-Dest: video
Sec-Fetch-Mode: no-cors
Sec-Fetch-Site: same-origin
Sec-GPC: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36
RESPONSE
Request URL: https://www.formition.com/api/WebsiteCached/DownloadFile?FileGuid=ad9c9f7f-43b8-42a3-8b53-85855592c460
Request Method: GET
Status Code: 206 Partial Content
Remote Address: 13.107.246.32:443
Referrer Policy: strict-origin
ARR-Disable-Session-Affinity: true
Cache-Control: public, max-age=7776000
Content-Disposition: attachment; filename="Mition Platform.mp4"; filename*=UTF-8''Mition%20Platform.mp4
Content-Length: 25819282
Content-Range: bytes 0-25819281/25819282
Content-Type: video/mp4
Date: Sun, 23 Feb 2025 20:54:19 GMT
Strict-Transport-Security: max-age=2592000
X-Azure-Ref: 20250223T205419Z-1758bfd9bb5pf6flhC1MELks2n0000000z0g00000000ch8p
X-Cache: TCP_HIT <-Azure cache sent this
X-Cache-Info: L1_T2
X-Content-Type-Options: nosniff
X-FD-Int-Roxy-PurgeID: 83725211
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-XSS-Protection: 1; mode=block
Powered by mition