Posted in:

Many of the projects I work on deal with large video files stored in Azure Blob Storage. And we typically wish to view those videos in a web browser.

Of course, it is possible to expose those files by setting the blob container's access level to public, or by generating a SAS Uri for a specific blob that the browser can directly use.

However, you may want additional security and auditing around access to the files, and so you may prefer to create your own API that provides streaming access to the blob.

For very large files you'll want to support "content ranges" which will allow the user to jump around within the file during playback.

In this post, I'll show you how I created a very simple demo ASP.NET Core app to set this up, allowing me to play all the MP4 files within a specific Azure Blob Storage container using the Video.js player.

Registering a BlobServiceClient

Our app will need to use the Azure Blob Storage SDK to access blob storage, so add a reference to the Azure.Storage.Blobs and Azure.Identity NuGet packages.

Then, in the startup, we'll register a BlobServiceClient as a singleton. I'm assuming that in your config, you have a setting for the storage account Uri (which will be something like "https://mystorageaccount.blob.core.windows.net/"), and I've also made "VisualStudioTenantId" configurable so I could run locally more easily (as I use different Azure tenants), but you may not need this.

builder.Services.AddSingleton<BlobServiceClient>((serviceProvider) => {
    var config = serviceProvider.GetRequiredService<IConfiguration>();
    var storageAccountUri = config["StorageAccount:Uri"];
    var accountUri = new Uri(storageAccountUri);
    var azureCredentialOptions = new DefaultAzureCredentialOptions();
    azureCredentialOptions.VisualStudioTenantId = config["VisualStudioTenantId"];
    var credential = new DefaultAzureCredential(azureCredentialOptions);
    var blobServiceClient = new BlobServiceClient(accountUri, credential);
    return blobServiceClient;
});

The streaming endpoint

Now we need the endpoint that can stream a blob. I've mapped this to /stream with a video query string parameter that has the full path of the desired video within the container (in a production scenario you'd probably use some kind of video id instead).

All we need to do is get a container client (I've made the container name configurable), and create a BlobClient for that blob. Then we call OpenReadAsync to get a .NET Stream of the blob contents. Note that you should not wrap that stream in a using statement as the stream will be kept open after your method returns.

The magic happens in the Results.Stream method which we configure with enableRangeProcessing set to true to allow seeking within the stream.

app.MapGet("/stream", async (BlobServiceClient blobServiceClient, 
    HttpRequest req, IConfiguration configuration) => {
    var video = req.Query["video"];
    var containerName = configuration["StorageAccount:ContainerName"];
    var container = blobServiceClient.GetBlobContainerClient(containerName);
    var blob = container.GetBlobClient(HttpUtility.UrlDecode(video));
    // https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.results.stream?view=aspnetcore-7.0
    var stream = await blob.OpenReadAsync(); // don't use a "using" statement here as the stream must live on
    return Results.Stream(stream, "video/mp4", enableRangeProcessing: true);
});

Listing all files

I wanted a web page to list all the MP4 files in the container, so made a simple Razor page that lists them out, with a link to a "watch" page for each video:

<ul>
@foreach (var video in Model.Videos)
{
    <li><a asp-page="/watch" asp-route-video="@video">@video</a></li>
}
</ul>

The page model class simply uses a BlobServiceClient to find all the MP4 files in the container:

public async Task OnGetAsync()
{
    var containerName = configuration["StorageAccount:ContainerName"];
    var container = blobServiceClient.GetBlobContainerClient(containerName);
    var blobs = container.GetBlobsAsync();
    await foreach(var blob in blobs)
    {
        if (blob.Name.EndsWith(".mp4"))
        {
            Videos.Add(blob.Name);
        }
    }
}

Playback with Video.js

I chose Video.js for playback, so in my _Layout.cshtml I reference the video.js CSS and Javascript files from their CDN:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>VideoJS test</title>
    <link href="https://vjs.zencdn.net/8.3.0/video-js.css" rel="stylesheet" />
</head>
<body>
    <h3>VideoJS Test</h3>

    @RenderBody()
    <script src="https://vjs.zencdn.net/8.3.0/video.min.js"></script>
</body>
</html>

And then finally on my /watch razor page, we fetch the video path from the video query string parameter, and then pass that on to the /stream endpoint as the src property for the video <source> element.

@page
@using VideoJS.Pages

@{var video = HttpContext.Request.Query["video"];}

<video
    id="my-video"
    class="video-js"
    controls
    preload="auto"
    width="960"
    poster="//vjs.zencdn.net/v/oceans.png"
    data-setup="{}"
>
    <source src="/stream?video=@video" type="video/mp4" />
    <p class="vjs-no-js">
    To view this video please enable JavaScript, and consider upgrading to a
    web browser that
    <a href="https://videojs.com/html5-video-support/" target="_blank"
        >supports HTML5 video</a
    >
    </p>
</video>

Deploying to Azure

To deploy my app, I used the Azure CLI to create an app service plan and web app:

$APP_SERVICE_PLAN = "myappserviceplan"
$SUBSCRIPTION = "bf038c01-7cdb-4682-80aa-2ddf24aae438"
$LOCATION = "North Europe"
$RESOURCE_GROUP = "myvideotest"
$APP_NAME = "videostreamtest"

az account set --subscription $SUBSCRIPTION

az group create --name $RESOURCE_GROUP --location $LOCATION

az appservice plan create --name $APP_SERVICE_PLAN `
  --resource-group $RESOURCE_GROUP --location $LOCATION --sku B1

az webapp create --name $APP_NAME --resource-group $RESOURCE_GROUP `
  --plan $APP_SERVICE_PLAN

dotnet publish -c Release
$publishFolder = "bin\Release\net7.0\publish"
# package web app to zip
$zipFilename = "$APP_NAME.zip"
Compress-Archive -Path "$publishFolder\*" -DestinationPath $zipFilename -Force

# publish zip to web app
az webapp deployment source config-zip --resource-group $RESOURCE_GROUP `
  --name $APP_NAME --src $zipFilename

I wanted to use managed identities for securing access to my storage account, and so we need to ensure that the web app has a managed identity, and that it has been granted a role like "Storage Blob Data Contributor" for the target storage account:

# configure web app with a managed identity
az webapp identity assign --name $APP_NAME --resource-group $RESOURCE_GROUP

# get the managed identity id
$identityId = az webapp identity show --name $APP_NAME `
  --resource-group $RESOURCE_GROUP --query principalId --output tsv

# create a role assignment for the managed identity
$STORAGE_ACCOUNT_SUBSCRIPTION = "adc5b04e-c6f2-4c89-9c6c-730bfcb1e9d7"
$STORAGE_ACCOUNT_RESOURCE_GROUP = "storage-res-group"
$STORAGE_ACCOUNT_NAME = "mystorageaccount"

az role assignment create --assignee $identityId `
  --role "Storage Blob Data Contributor" `
  --scope "/subscriptions/$STORAGE_ACCOUNT_SUBSCRIPTION/resourceGroups/$STORAGE_ACCOUNT_RESOURCE_GROUP/providers/Microsoft.Storage/storageAccounts/$STORAGE_ACCOUNT_NAME"

With that in place, you should be able to view the videos (assuming you've correctly set up your configuration to point at the storage account).

Summary

The ASP.NET Core Result.Stream method makes it trivially easy to create a streaming endpoint that makes a video from blob storage available to a browser, and supports seeking within the stream.

Obviously you need to take performance into account. I had no problems playing high bitrate videos using this technique, but you are proxying a lot of data through your webapp, so at high volumes of users, you'll probably need to scale out to multiple web servers.

And this demo doesn't show the setup of authorization which is one of the motivators for doing this in the first place. For my demo app I actually configured "easy auth" on my web app to only allow access to users from a particular Azure Active Directory tenant. But of course you can use anything that works with ASP.NET Core to authorize access to the videos, as well as auditing that access if required.