Corporate YouTube and Video Delivery via SharePoint 2013

Want to deliver an internal/corporate “YouTube” for your organization using SharePoint?  Looking to maximize your SharePoint deployment by incorporating video/media delivery?  Worried about the storage/bandwidth implications of allowing anyone in the enterprise to contribute video/media?  Then then post if for you!  I will outline a solution that addresses many of the limitations to native media delivery in SharePoint 2013.  The solution outlined in this post is also illustrated in the following video:

[View:https://www.youtube.com/watch?v=TX1W8bwvVlw]

 

Steve Fox wrote a great post a few months back on SharePoint 2013 and Windows Azure Media Services.  In it, Steve illustrated the use of Azure Media Services to deliver media within an app for SharePoint.  Steve introduced some powerful concepts that I want to take to the next level.  I envision a complete solution that allows users to contribute any media format, from any asset library in SharePoint, and send it through Azure Media Services for encoding and hosting (possibly around the globe using Azure’s Content Delivery Network).  But why Azure Media Services?  To understand why, it’s important to consider the video capabilities native to SharePoint 2013, their limitations, and past case studies on SharePoint as a media platform.

Video and SharePoint

Out of the box, SharePoint provides very basic video delivery capabilities.  Asset libraries allow users to upload videos, which are stored as SQL BLOBs in content databases (just like any other file in SharePoint).  Videos tend to be larger in size, which are both inefficient for BLOB storage and quickly grow content databases to maximum recommended capacity (200GB).  Videos are also subject to the maximum file upload size in SharePoint, which is 50MB by default with a hard limit of 2GB.  Videos that are uploaded into SharePoint can be consumed by “progressive download” in the exact same format and quality as the contributor uploaded.  This means that a 1080P .wmv uploaded to SharePoint will only be consumable as a 1080P .wmv file.  A progressive download also lacks intelligent fast-forward capabilities.  If a user only cares about the end of a long video, they have to download the entire video to get to the end.  Many enterprises with SharePoint (including Microsoft) have implemented solutions aimed addressing these challenges, including Remote Blob Storage (RBS), Blob Caching, Branch Caching, Bitrate Throttling, Encoding/Transcoding, DRM, and many more.

Academy is Microsoft’s internal social video platform built on SharePoint.  Academy sets a high standard for media delivery maturity, with customized media uploads, geographically distributed streaming, adjustable quality by device/connection, and advanced encoding workflows to accept almost any media format.  Academy is impressive, but represents years of effort, refinement, and lessons learned from digital asset and web content management.  Most organizations would find it very challenging to close the gap between media delivery native to SharePoint and what Microsoft has built internally with Academy…until now!

Academy (Microsoft's internal social video platform built on SharePoint)

The landscape has changed significantly since Academy was first deployed at Microsoft several years ago.  Now, Windows Azure can provide the platform for media storage (Blob Storage), encoding/streaming (Media Services), and global distribution (Azure CDN) with very little CAPEX.  Additionally, the new SharePoint app model can help deliver these Azure capabilities, providing a highly customized media delivery experience to any SharePoint farm…even in SharePoint Online/Office 365.

NOTE: although advanced capabilities like encoding and remote storage aren’t native to SharePoint 2013, Microsoft did incorporate a number of media delivery patterns from Academy. For example, Microsoft recognized that media delivery is much more than just a video…it includes social discussions, analytics, and supporting documentation (ex: slide deck, code, etc). As such, the Document Set or “video set” is the container for videos in SharePoint 2013. Downloads/Podcasting, thumbnail generation, and embed codes are other capabilities carried forward from Academy.

 

The Solution

Our app will exploit the best of both SharePoint 2013 and Windows Azure for media contribution, consumption, and management.  These capabilities will be delivered through a number of pages in our app and ribbon buttons in the host site.  I have detailed each of these solution components below.  The solution also leverages a SQL Azure database to keep track of videos sent through the app and app preferences/settings.

Upload.aspx

Upload.aspx (Empty) Upload.aspx (Processing)

 

We want media contributors to have a familiar experience contributing videos through our app.  Since videos are traditionally uploaded through the ribbon on asset libraries, our solution will leverage the same ribbon to launch the upload page for our app.  The asset library ribbon button will launch the upload page inside the SharePoint dialog.  You can see this ribbon button and the upload dialog in the two screenshots above and the custom action xml to make it happen below:

Custom action for adding ribbon button to asset libraries

<?xml version="1.0" encoding="utf-8"?><Elements xmlns="https://schemas.microsoft.com/sharepoint/">  <CustomAction Id="a76f4430-c8b1-4317-b673-260429ca6dc1.UploadToAzureMediaSvc"                RegistrationType="List"                RegistrationId="851"                Location="CommandUI.Ribbon"                Sequence="10001"                Title="Upload to Azure Media Services"                HostWebDialog="true"                HostWebDialogHeight="420"                HostWebDialogWidth="510">    <CommandUIExtension>      <CommandUIDefinitions>        <CommandUIDefinition Location="Ribbon.Documents.New.Controls._children">          <Button Id="Ribbon.Documents.New.UploadToAzureMediaSvcButton"                  Alt="Upload to Media Services"                  Sequence="1"                  Command="Invoke_UploadToAzureMediaSvcButtonRequest"                  LabelText="Upload to Media Services"                  TemplateAlias="o1"                  Image32by32="data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAAK..."                  Image16by16="data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK..." />        </CommandUIDefinition>      </CommandUIDefinitions>      <CommandUIHandlers>        <CommandUIHandler Command="Invoke_UploadToAzureMediaSvcButtonRequest"                          CommandAction="~remoteAppUrl/Pages/Upload.aspx?{StandardTokens}&amp;HostUrl={HostUrl}&amp;SiteUrl={SiteUrl}&amp;Source={Source}&amp;ListURLDir={ListUrlDir}&amp;SelectedListID={SelectedListId}&amp;DisplayType=iframe"/>      </CommandUIHandlers>    </CommandUIExtension>  </CustomAction></Elements>

 

The upload page is where most of the magic happens, allowing users to send videos to Azure Media Services for encoding, thumbnail generation, and blob storage.  Uploading large videos, sending them to the cloud, and encoding could take significant time.  Most users won’t want to sit around waiting for this to complete.  For the proof of concept (POC), many of these processes are executed on a separate thread so the upload page respond once all the data posts.  Ideally, we would leverage an Azure Worker Process for this long running process (which is similar to a Windows Service).  However, our solution is implemented as an autohosted app for SharePoint for simplicity in deployment/tenancy.  Threading was easier to implement in that model for this POC.

Upload event for starting Azure Encoding thread

protected void btnOk_Click(object sender, EventArgs e){    //get file bytes from the hdnImageBytes or the fileselect control    byte[] mediaBytes = null;    string fileName = "";    if (!String.IsNullOrEmpty(Page.Request["hdnImageBytes"]))    {        //get media from hdnImageBytes        var base64MediaString = Page.Request["hdnImageBytes"];        fileName = Page.Request["hdnFileName"];        base64MediaString = base64MediaString.Substring(base64MediaString.IndexOf(',') + 1);        mediaBytes = Convert.FromBase64String(base64MediaString);    }    if (mediaBytes != null && mediaBytes.Length > 0)    {        //add database record for the media        var spContext = Util.ContextUtil.Current;        AzureMediaServicesJob amsJob = new AzureMediaServicesJob(ContextUtil.Current.ContextDetails);        using (var clientContext = TokenHelper.GetClientContextWithContextToken(spContext.ContextDetails.HostWebUrl, spContext.ContextDetails.ContextTokenString, Request.Url.Authority))        {            //get user information            Web web = clientContext.Web;            User currentUser = web.CurrentUser;            clientContext.Load(currentUser);            clientContext.ExecuteQuery();

            using (AzureMediaModel model = new AzureMediaModel(ConnectionUtil.GetEntityConnectionString(clientContext)))            {                //create the record                Media newMedia = new Media();                newMedia.Title = txtTitle.Text;                newMedia.StatusId = 1;                newMedia.AuthorLoginName = currentUser.LoginName;                newMedia.AuthorEmail = currentUser.Email;                model.Media.AddObject(newMedia);                model.SaveChanges();                amsJob.ItemID = newMedia.Id;

                //get default settings                amsJob.ListUrl = Page.Request["ListUrlDir"];                amsJob.ListID = new Guid(Page.Request["SelectedListId"]);                amsJob.MediaBytes = mediaBytes;                amsJob.MediaFileName = fileName;                amsJob.IOPath = Server.MapPath("~");            }        }                        //set the itemID back on the form so it can check processing        ScriptManager.RegisterStartupScript(updatePanel, updatePanel.GetType(), "checkStatus", String.Format("checkMediaStatus({0});", amsJob.ItemID), true);

        //Start a new thread to perform the Media encoding and document set creation        Thread thread = new Thread(ProcessMediaUtil.UploadMedia);        thread.Name = String.Format("EncodingTask{0}", amsJob.ItemID.ToString());        thread.Start(amsJob);    }    else    {        //TODO: notify user no file provided    }}

 

The solution leverages a ProcessMediaUtil class to upload media to Azure Blob Storage, encode the videos to a common format, publish the encoded media, and generate thumbnails.

UploadMedia method on ProcessMediaUtil class

public static void UploadMedia(object azureMediaServicesJob){    AzureMediaServicesJob amsJob = (AzureMediaServicesJob)azureMediaServicesJob;

    //add new listItem    using (var clientContext = TokenHelper.GetClientContextWithContextToken(amsJob.ContextDetails.HostWebUrl, amsJob.ContextDetails.ContextTokenString, amsJob.ContextDetails.ServerUrl))    {        using (AzureMediaModel model = new AzureMediaModel(Util.ConnectionUtil.GetEntityConnectionString(clientContext)))        {            try            {                //get settings from database                Settings appSettings = model.Settings.FirstOrDefault();

                // Initialize the Azure account information                string connString = String.Format("DefaultEndpointsProtocol={0};AccountName={1};AccountKey={2}",                    "https", appSettings.StorageAccountName, appSettings.StorageAccountKey);                CloudStorageAccount cloudStorageAccount = CloudStorageAccount.Parse(connString);                mediaContext = new CloudMediaContext(appSettings.MediaAccountName, appSettings.MediaAccountKey);                CloudBlobClient blobClient = cloudStorageAccount.CreateCloudBlobClient();

                //upload the asset to blob storage and publish                var asset = UploadBlob(blobClient, amsJob.MediaFileName, amsJob.MediaBytes);                string url = PublishAsset(asset, amsJob.MediaFileName);                mediaContext = new CloudMediaContext(appSettings.MediaAccountName, appSettings.MediaAccountKey);

                //update the status                Media mediaItem = model.Media.FirstOrDefault(i => i.Id == amsJob.ItemID);                mediaItem.StatusId = 2;                model.SaveChanges();

                //Encode the asset                IJob job = EncodeAsset(asset, appSettings, amsJob.MediaFileName);

                //refresh the context and publish the encoded asset                mediaContext = new CloudMediaContext(appSettings.MediaAccountName, appSettings.MediaAccountKey);                job = mediaContext.Jobs.Where(j => j.Id == job.Id).FirstOrDefault();                var encodingTask = job.Tasks.Where(t => t.Name == "Encoding").FirstOrDefault();                var encodedAsset = encodingTask.OutputAssets.FirstOrDefault();                url = String.Format(PublishAsset(encodedAsset, "SmoothStream-" + asset.Name), amsJob.MediaFileName);

                //update the database record with the correct streaming url and the status                mediaItem.MediaSvcUrl = url;                mediaItem.StatusId = 3;                model.SaveChanges();

                //download the thumbnail bytes and publish video set                byte[] thumbBytes = GetThumbnailBytes(blobClient, job);                string targetDocSetUrl = PublishVideoSet(clientContext, amsJob, mediaItem, thumbBytes);

                //send email confirmation                EmailProperties email = new EmailProperties();                email.To = new List<String>() { mediaItem.AuthorEmail };                email.Subject = String.Format("Your video \"{0}\" is ready!", mediaItem.Title);                email.Body = String.Format("<html><body><p>Your video \"{0}\" has finished processing and can be viewed at the following address:</p><p><a href=\"{1}\">{1}</a></p></body></html>", mediaItem.Title, targetDocSetUrl);                Utility.SendEmail(clientContext, email);                clientContext.ExecuteQuery();

                //update status...hard-code aspect ratio for now                mediaItem.StatusId = 4;                mediaItem.Width = 700;                mediaItem.Height = 393;                mediaItem.SharePointUrl = targetDocSetUrl;                model.SaveChanges();             }            catch (Exception ex)            {                //update the status                Media mediaItem = model.Media.FirstOrDefault(i => i.Id == amsJob.ItemID);                mediaItem.StatusId = 5;                mediaItem.ErrorMessage = ex.Message;                model.SaveChanges();            }        }    }}

 

Azure utility methods in ProcessMediaUtil class

private static IAsset UploadBlob(CloudBlobClient blobClient, string publishedName, byte[] fileBytes){ var asset = mediaContext.Assets.Create(publishedName, AssetCreationOptions.None); var writePolicy = mediaContext.AccessPolicies.Create("policy for copying", TimeSpan.FromMinutes(30), AccessPermissions.Write | AccessPermissions.List); var destination = mediaContext.Locators.CreateSasLocator(asset, writePolicy, DateTime.UtcNow.AddMinutes(-5)); var destContainer = blobClient.GetContainerReference(new Uri(destination.Path).Segments[1]); var destBlob = destContainer.GetBlockBlobReference(publishedName); destBlob.UploadByteArray(fileBytes); destBlob.Properties.ContentType = "video/mp4"; destBlob.SetProperties(); return asset;}

private static string PublishAsset(IAsset asset, string publishedName){ var assetFile = asset.AssetFiles.Create(publishedName); assetFile.Update(); asset = mediaContext.Assets.Where(a => a.Id == asset.Id).FirstOrDefault(); var readPolicy = mediaContext.AccessPolicies.Create("policy for access", TimeSpan.FromDays(365 * 3), AccessPermissions.Read | AccessPermissions.List); var readLocator = mediaContext.Locators.CreateSasLocator(asset, readPolicy, DateTime.UtcNow.AddMinutes(-5)); string[] parts = readLocator.Path.Split('?'); return parts[0] + "/{0}?" + parts[1];}

private static IJob EncodeAsset(IAsset asset, Settings appSettings, string publishedName){ var assetToEncode = mediaContext.Assets.Where(a => a.Id == asset.Id).FirstOrDefault(); if (assetToEncode == null) { throw new ArgumentException("Could not find assetId: " + asset.Id); } IJob job = mediaContext.Jobs.Create("Encoding " + assetToEncode.Name + " to " + appSettings.EncodingOptions.DisplayName);

    //add encoding task IMediaProcessor latestWameMediaProcessor = (from p in mediaContext.MediaProcessors where p.Name == "Windows Azure Media Encoder" select p).ToList().OrderBy(wame => new Version(wame.Version)).LastOrDefault(); ITask encodeTask = job.Tasks.AddNew("Encoding", latestWameMediaProcessor, appSettings.EncodingOptions.EncodingConfiguration, TaskOptions.None); encodeTask.InputAssets.Add(assetToEncode); encodeTask.OutputAssets.AddNew("SmoothStream-" + publishedName, AssetCreationOptions.None);

    //add thumbnail task ITask thumbTask = job.Tasks.AddNew("Generate thumbnail", latestWameMediaProcessor, "Thumbnails", TaskOptions.None); thumbTask.InputAssets.Add(assetToEncode); thumbTask.OutputAssets.AddNew("Thumb-" + assetToEncode.Name + ".jpg", AssetCreationOptions.None);

    //Submit the job and wait for it to complete job.StateChanged += new EventHandler<JobStateChangedEventArgs>((sender, jsc) => { //do nothing...could change status here, but we are waiting }); job.Submit(); job.GetExecutionProgressTask(CancellationToken.None).Wait(); return job;}

private static byte[] GetThumbnailBytes(CloudBlobClient blobClient, IJob job){ var thumbTask = job.Tasks.Where(t => t.Name == "Generate thumbnail").FirstOrDefault(); var thumbAsset = mediaContext.Assets.Where(a => a.Id == thumbTask.OutputAssets[0].Id).FirstOrDefault(); var thumbFile = thumbAsset.AssetFiles.FirstOrDefault(); var writePolicy = mediaContext.AccessPolicies.Create("policy for copying", TimeSpan.FromMinutes(30), AccessPermissions.Write | AccessPermissions.List); var destination = mediaContext.Locators.CreateSasLocator(thumbAsset, writePolicy, DateTime.UtcNow.AddMinutes(-5)); var destContainer = blobClient.GetContainerReference(new Uri(destination.Path).Segments[1]); var destBlob = destContainer.GetBlockBlobReference(thumbFile.Name); return destBlob.DownloadByteArray();}

 

The ProcessMediaUtil will also create the "video set" in SharePoint once all the Azure jobs are complete.  The new video set will reference the Azure-hosted video via an embed code (which is stored in the hidden VideoSetEmbedCode column of the video set).

PublishVideoSet method on ProcessMediaUtil class

private static string PublishVideoSet(ClientContext clientContext, AzureMediaServicesJob amsJob, Media mediaItem, byte[] thumbBytes){    //get the media list    List mediaList = clientContext.Web.Lists.GetById(amsJob.ListID);    clientContext.Load(mediaList, i => i.Fields);    clientContext.Load(mediaList, i => i.ParentWebUrl);    clientContext.ExecuteQuery();

    //create the Video document set    ListItemCreationInformation itemCreateInfo = new ListItemCreationInformation();    itemCreateInfo.UnderlyingObjectType = FileSystemObjectType.Folder;    itemCreateInfo.LeafName = mediaItem.Title;    Microsoft.SharePoint.Client.ListItem newMediaItem = mediaList.AddItem(itemCreateInfo);    newMediaItem["Title"] = mediaItem.Title;    newMediaItem["ContentTypeId"] = "0x0120D520A80800E9538ABD5B77E14096B2460EC920FD5E";

    //hard-code the video aspect ratio for now    newMediaItem["VideoSetEmbedCode"] = String.Format("<iframe width='700' height='400' src='{0}/Pages/Player.aspx?Item={1}&SPHostUrl={2}' frameborder='0' style='overflow: hidden' allowfullscreen></iframe>",        String.Format("https://{0}", amsJob.ContextDetails.ServerUrl), amsJob.ItemID.ToString(), amsJob.ContextDetails.HostWebUrl);    newMediaItem.Update();    clientContext.ExecuteQuery();

    //add subfolders folders for the app    string targetDocSetUrl = amsJob.ListUrl + "/" + mediaItem.Title;    Folder folder = clientContext.Web.GetFolderByServerRelativeUrl(targetDocSetUrl);    clientContext.Load(folder, f => f.UniqueContentTypeOrder);    clientContext.ExecuteQuery();

    //add the required subfolders for a Video    var f1 = folder.Folders.Add(targetDocSetUrl + "/Additional Content");    var f2 = folder.Folders.Add(targetDocSetUrl + "/Preview Images");    clientContext.ExecuteQuery();

    //upload thumbnail    FileCreationInformation fileInfo = new FileCreationInformation();    fileInfo.Content = thumbBytes;    fileInfo.Url = amsJob.ListUrl + "/" + mediaItem.Title + "/Preview Images/" + amsJob.MediaFileName + "_thumb.jpg";    Microsoft.SharePoint.Client.File previewImg = f2.Files.Add(fileInfo);    clientContext.Load(previewImg, i => i.ServerRelativeUrl);    clientContext.ExecuteQuery();    newMediaItem["AlternateThumbnailUrl"] = new Microsoft.SharePoint.Client.FieldUrlValue() { Url = previewImg.ServerRelativeUrl };    newMediaItem.Update();    clientContext.ExecuteQuery();

    return amsJob.ContextDetails.HostWebUrl + "/" + targetDocSetUrl;}

 

Our solution will provide the user with status updates as the Azure job(s) process.  To do this, our app will host a REST/JSON status service.  This service is secured by the same context token our app leverages and can be called periodically from client-side script on our page.

StatusService for checking encoding status via REST/JSON

namespace AzureMediaManagerWeb.Services{    [ServiceContract]    [AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Allowed)]    public class StatusService    {        [OperationContract]        [WebInvoke(Method = "POST", BodyStyle = WebMessageBodyStyle.WrappedRequest, RequestFormat = WebMessageFormat.Json, ResponseFormat = WebMessageFormat.Json)]        public int GetStatus(int itemID, string contextToken, string hostWebUrl, string authority)        {            int statusID = 0;            using (var clientContext = TokenHelper.GetClientContextWithContextToken(hostWebUrl, contextToken, authority))            {                using (AzureMediaModel model = new AzureMediaModel(Util.ConnectionUtil.GetEntityConnectionString(clientContext)))                {                    statusID = model.Media.FirstOrDefault(i => i.Id == itemID).MediaStatus.Id;                }            }

            return statusID;        }    }}

 

jQuery script for calling StatusService

function checkMediaStatus(id) {    var json = JSON.parse(getCookie('SPContext'));    var data = {        itemID: id,        contextToken: json.ContextTokenString,        hostWebUrl: json.HostWebUrl,        authority: json.ServerUrl    };    $.ajax({        cache: false,        url: '../Services/StatusService.svc/GetStatus',        data: JSON.stringify(data),        dataType: 'json',        type: 'POST',        contentType: 'application/json; charset=utf-8',        success: function (result) {            $('#hdnStatus').val(result.d);            switch (result.d) {                case 4:                    $('#imgStatus4').css('display', 'block');                    $('#divStatus4').find('.divWaitingDots').css('display', 'none');                    $('#btnOk').removeAttr('disabled');                    $('#btnOk').click(function () {                        closeParentDialog(true);                        return false;                    });                case 3:                    $('#imgStatus3').css('display', 'block');                    $('#divStatus3').find('.divWaitingDots').css('display', 'none');                case 2:                    $('#imgStatus2').css('display', 'block');                    $('#divStatus2').find('.divWaitingDots').css('display', 'none');                case 1:                    $('#imgStatus1').css('display', 'block');                    $('#divStatus5').css('display', 'block');                    $('#divStatus1').find('.divWaitingDots').css('display', 'none');                    break;            }

            //recursively call self            if (result.d != 4)                setTimeout(checkMediaStatus, 10000, id);        },        error: function (result) {            var status = $('#hdnStatus').val();            for (var i = 4; i > parseInt(status) ; i--) {                $('#imgStatus' + i).attr('src', '../images/fail.png');                $('#imgStatus' + i).css('display', 'block');                $('#divStatus' + i).find('.divWaitingDots').css('display', 'none');            }        }    });}

 

Player.aspx

Player.aspx embedded in SharePoint video set page

In SharePoint 2013, videos can be contributed as a file, URL, or embed code (IFRAME to video).  You might be curious why the solution needs a player page instead of just providing SharePoint with a URL to the video hosted in Azure Media Services.  Unfortunately, SharePoint will not accept a parameterized video URL (ex: MyVideo.mp4?contextToken=xzy123) such as the one Azure Media Services will provide.  Even if it did, our solution might want to leverage a different media player that supports smooth streaming or advanced video analytics (ex: how long the user watched the video).  Instead, we will leverage a video embed code, which is ultimately an IFRAME pointing to a page hosting the video.  This is same way we would reference a YouTube video in a SharePoint asset library.  Azure Media Services does not provide a player page, so our app will deliver it.  In the screenshot above, you see the typical video set page displayed in SharePoint...the Player.aspx app page is being displayed in an IFRAME to display the video (the IFRAME is the same size as the video).  To make the player page dynamic, our embed codes will always pass a video id to the player page via URL parameter.  Our player page will use this video id to lookup the streaming URL stored in the app’s SQL Azure database.  We will also pass the host web URL in case the player page needs to get a context token from SharePoint.

Example of YouTube embed code markup

<iframe width="560" height="315" src="https://www.youtube.com/embed/TX1W8bwvVlw" frameborder="0" allowfullscreen></iframe>

 

Example of embed code markup for the solution

<iframe width="700" height="400" src="https://someapp.o365apps.net/Pages/Player.aspx?Item=342&SPHostUrl=https://MyHostWebUrl" frameborder="0" style="overflow: hidden" allowfullscreen></iframe>

 

PageLoad of the Player.aspx to output a video element

public partial class Player : System.Web.UI.Page{    protected void Page_Load(object sender, EventArgs e)    {        if (!String.IsNullOrEmpty(Page.Request["Item"]))        {            var spContext = Util.ContextUtil.Current;            using (var clientContext = TokenHelper.GetClientContextWithContextToken(spContext.ContextDetails.HostWebUrl, spContext.ContextDetails.ContextTokenString, Request.Url.Authority))            {                using (AzureMediaModel model = new AzureMediaModel(ConnectionUtil.GetEntityConnectionString(clientContext)))                {                    //get item with the specified ID                    int itemID = Convert.ToInt32(Page.Request["Item"]);                    Media mediaItem = model.Media.SingleOrDefault(i => i.Id == itemID);                    if (mediaItem != null)                    {                        string mediaMarkup = @"<video id='myVideo' class='pf-video' height='{0}' width='{1}' controls='controls'><source src='{2}' type='video/mp4;codecs=/""avc1.42E01E, mp4a.40.2/""' /></video>";                        divVideo.Controls.Add(new LiteralControl(String.Format(mediaMarkup, mediaItem.Height.ToString(), mediaItem.Width.ToString(), mediaItem.MediaSvcUrl)));                    }                }            }        }    }}

 

The result is a video set in SharePoint that looks exactly like any other.  With so many moving parts, I’ve provided a diagram to clear any confusion:

Diagram of the player page and embed code logic

Default.aspx

Default.aspx page to display media processed by app

With videos being stored in Azure and the video set living in SharePoint, there exists the potential for orphaned items (“video sets” without corresponding videos and videos without corresponding “video sets”).  The default.aspx page allows users to identify potential orphan items or errors that may have occurred during processing.  It is the default page when a user enters the full-screen view of the app.  Nothing fancy here…just a GridView that displays information from our app database.

Settings.aspx

Settings.aspx for configuring app settings

In order for our app to integrate with Azure, it will need an account name and access key for Azure Blob Storage and Azure Media Services.  The settings page will allow an app administrator to configure these account settings, which will be stored in the app’s database.  The settings page will also allow an app administrator to specify additional app administrators and select the output format(s) for encoding.  Nothing interesting here, other than the multi-user people picker control.

Multi-user people picker for setting app admins

The settings page is security trimmed to app administrators.  Because we need at least one app administrator, the app will make use of the app installed remote event to capture the user that installs the app.

Handle App Installed in app project properties

App installed remote event to capture installer as adminisrator

public SPRemoteEventResult ProcessEvent(SPRemoteEventProperties properties){    SPRemoteEventResult result = new SPRemoteEventResult();    using (ClientContext clientContext = TokenHelper.CreateAppEventClientContext(properties, false))    {        if (clientContext != null)        {            //get the current user and seed the database with his information            PeopleManager pm = new PeopleManager(clientContext);            var props = pm.GetMyProperties();            clientContext.Load(props);            clientContext.ExecuteQuery();

            using (AzureMediaModel model = new AzureMediaModel(ConnectionUtil.GetEntityConnectionString(clientContext)))            {                Administrators primaryAdmin = new AzureMediaManagerWeb.Administrators();                primaryAdmin.LoginName = props.AccountName;                primaryAdmin.DisplayName = props.DisplayName;                model.Administrators.AddObject(primaryAdmin);                model.SaveChanges();            }        }    }

    return result;}

 

Final Thoughts

This solution might seem like a lot of work.  However, it addresses most of the serious limitations in native SharePoint video delivery that will only become more obvious as video contribution increases in a farm.  I have provided the code for the solution in the link below.  This is NOT a production ready solution.  However, it could be the start for taking media to the next level in your organization!

App for SharePoint Solution: AzureMediaManager.zip

NOTE: if you download the solution and try to debug, Internet Explorer will not let you use the drag-drop upload in the debugging browser. This is because the debug browser is running elevated (due to running Visual Studio as an administrator) and Windows Explorer NOT running elevated. IE sees this as a security threat. The solution is to open a new browser window while debugging for uploads.