V tomto článku ponúkam návod ako implementovať video streaming v ASP.NET Core.
Pri tvorbe midllweru som vychádzal z ukážky pre ASP.NET Web Api.
V prvom rade si vytvoríme rozhranie popisujúce služby nad video súbormi. Prvá metóda vráti zoznam dostupných videí – slúži na ich vyhľadanie v kontrolery (pre zobrazenie používateľského rozhrania), no pre samotný streaming nie je podstatná.
Druhá vracia inštanciu rozhrania IVideoFile, pomocou ktorého je možné pristupovať k video súboru, podľa ID-čka. Vracia jeho veľkosť, meno content type, a metódy na zápis jeho obsahu do streamu. Metóda CopyTo s parametrami begin a end skopíruje do streamu iba časť súbor od – do v bajtoch.
public interface IVideoServices
{
List<VideoFileInfo> FindVideoFiles();
IVideoFile GetVideoFile(string id);
}
public interface IVideoFile
{
string ContentType
{
get;
}
string Name
{
get;
}
long Size
{
get;
}
Task CopyTo(Stream outputStream);
Task CopyTo(Stream outputStream, long begin, long end);
}
public class VideoFileInfo
{
public string Id
{
get;
internal set;
}
public string DisplayName
{
get;
internal set;
}
public VideoFileInfo()
{
}
}
Nasledujúci kód predstavuje ukážkovú implementáciu rozhrania IVideoServices pre súborový systém. V konštruktore je uvedená cesta k adresáru s videosúbormi a filter pre ich vyhľadávanie. Podobnú službu ide spraviť napríklad pomocou MongoDb a GridFs.
public class FileVideoServices : IVideoServices
{
private readonly string filter;
private readonly string videoFolderParh;
public FileVideoServices()
{
//TODO: nacitavanie z konfiguracie
this.videoFolderParh = @"D:\example\video";
this.filter = "*.mp4";
}
public List<VideoFileInfo> FindVideoFiles()
{
DirectoryInfo info = new DirectoryInfo(this.videoFolderParh);
List<VideoFileInfo> result = new List<VideoFileInfo>();
foreach (FileInfo fie in info.GetFiles("*.mp4"))
{
VideoFileInfo videoFile = new VideoFileInfo()
{
DisplayName = fie.Name,
Id = fie.Name
};
result.Add(videoFile);
}
return result;
}
public IVideoFile GetVideoFile(string id)
{
if (id == null) throw new ArgumentNullException(nameof(id));
string path = Path.Combine(this.videoFolderParh, id);
FileInfo info = new FileInfo(path);
if (info.Exists)
{
FileVideoFile videoFile = new FileVideoFile(info);
return videoFile;
}
else
{
return null;
}
}
}
internal class FileVideoFile : IVideoFile
{
private const int bufferLenght = 1024 * 1024;
private readonly string fullPath;
public string ContentType
{
get;
protected set;
}
public string Name
{
get;
protected set;
}
public long Size
{
get;
protected set;
}
public FileVideoFile(FileInfo info)
{
if (info == null) throw new ArgumentNullException(nameof(info));
this.fullPath = info.FullName;
this.Name = info.Name;
this.Size = info.Length;
}
public async Task CopyTo(Stream outputStream)
{
if (outputStream == null) throw new ArgumentNullException(nameof(outputStream));
using (FileStream fs = new FileStream(this.fullPath, FileMode.Open))
{
await fs.CopyToAsync(outputStream);
}
}
public async Task CopyTo(Stream outputStream, long begin, long end)
{
if (outputStream == null) throw new ArgumentNullException(nameof(outputStream));
long remainingBytes = end - begin + 1;
long position = begin;
int count;
byte[] buffer = new byte[bufferLenght];
using (FileStream fs = new FileStream(this.fullPath, FileMode.Open, FileAccess.Read))
{
fs.Seek(begin, SeekOrigin.Begin);
do {
if (remainingBytes > bufferLenght)
{
count = await fs.ReadAsync(buffer, 0, bufferLenght).ConfigureAwait(false);
await outputStream.WriteAsync(buffer, 0, count).ConfigureAwait(false);
}
else
{
count = await fs.ReadAsync(buffer, 0, (int)remainingBytes).ConfigureAwait(false);
await outputStream.WriteAsync(buffer, 0, count).ConfigureAwait(false);
}
position += count;
remainingBytes -= count;
}
while (position <= end);
}
}
Ako bolo uvedené v pôvodnom zdroji, tak pokiaľ požadované video neexistuje vráti sa HTTP status kód 404 Not Found. Ak existuje ale v hlavičke requestu sa nenachádza hlavička Range vráti sa celý obsah súboru zo statusom 200 OK (túto časť je možné vynechať ak chceme zabrániť neoprávnenému sťahovaniu obsahu). Ak sa v ňom nachádza, hlavička Range, tak ju prečíta.
Ak sú v nej bytové rozsahy v poriadku vráti HTTP status 206 Partial Content a zapíše do výstupného streamu príslušnú časť obsahu, inak vráti HTTP status 416 Requested Range Not Satisfiable a ukončí spojenie.
Nasledujúci kód predstavuje implementáciu samotného ASP.NET Core midllweru, ktorý sa stará o parciálne servírovanie obsahu videa pomocou HTTP statusu 206.
V konštante maxTransfer je hodnota v bajtoch, ktorá určuje maximálnu veľkosť naraz preneseného bloku dát z videa.
public class VideoStreamMiddleware
{
private readonly IVideoServices videoServices;
private readonly RequestDelegate next;
private const long maxTransfer = 1024 * 1024;
public VideoStreamMiddleware(RequestDelegate next, IVideoServices videoServices)
{
if (next == null) throw new ArgumentNullException(nameof(next));
if (videoServices == null) throw new ArgumentNullException(nameof(videoServices));
this.next = next;
this.videoServices = videoServices;
}
public async Task Invoke(HttpContext httpContext)
{
if (!httpContext.Request.Path.StartsWithSegments(new PathString("/videostream")))
{
await this.next(httpContext);
return;
}
string id = httpContext.Request.Query["file"];
IVideoFile videoFile = this.videoServices.GetVideoFile(id);
if (videoFile == null)
{
httpContext.Response.StatusCode = 404;
return;
}
string header = httpContext.Request.Headers["Range"];
long begin, end;
if (this.TryRangeParse(header, videoFile, out begin, out end))
{
end = Math.Min(begin + maxTransfer, end);
if (begin >= videoFile.Size || end > videoFile.Size)
{
httpContext.Response.StatusCode = 416;
httpContext.Response.Headers.Add("Content-Range", $"bytes */{videoFile.Size}");
return;
}
httpContext.Response.StatusCode = 206;
string rangeOut = $"bytes {begin}-{end}/{videoFile.Size}";
httpContext.Response.ContentType = videoFile.ContentType;
httpContext.Response.Headers.Add("Accept-Ranges", "bytes");
httpContext.Response.Headers.Add("Content-Range", new Microsoft.Extensions.Primitives.StringValues(rangeOut));
httpContext.Response.Headers.Add("Cache-Control", "no-cache");
await videoFile.CopyTo(httpContext.Response.Body, begin, end);
}
else
{
httpContext.Response.ContentType = videoFile.ContentType;
httpContext.Response.Headers.Add("Accept-Ranges", "bytes");
httpContext.Response.Headers.Add("Cache-Control", "no-cache");
await videoFile.CopyTo(httpContext.Response.Body);
}
}
private bool TryRangeParse(string range, IVideoFile videoFile, out long begin, out long end)
{
const string bytesPrefix = "bytes=";
begin = 0L;
end = 0L;
if (string.IsNullOrEmpty(range) || !range.StartsWith(bytesPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string rangeValues = range.Substring(bytesPrefix.Length);
int delimiterIndex = rangeValues.IndexOf('-');
if (!long.TryParse(rangeValues.Substring(0, delimiterIndex), out begin))
{
begin = 0L;
}
if (!long.TryParse(rangeValues.Substring(delimiterIndex + 1), out end))
{
end = videoFile.Size - 1;
}
return true;
}
}
Video je možné jednoducho prehrať cez HTML5 tag video, ktorý pomocou elementu sourse dostane adresu videa.
Napríklad pre video s názvom sample.mp4 to bude /videostream?file=sample.mp4. Video je možné ľubovoľne prehrávať a posúvať.
<video id="mainPlayer" width="640" height="360"
autoplay="autoplay" controls="controls" onloadeddata="onLoad()">
<source src="@Model.VideoUrl" />
<p>This user agents that do not support the video tag.</p>
</video>
<script type="text/javascript">
//<![CDATA[
function onLoad() {
var sec = parseInt(document.location.search.substr(1));
if (!isNaN(sec)) {
mainPlayer.currentTime = sec;
}
}
//]]>
</script>
Pri testovaní je potrebné v triede Startu ASP.NET MVC Core projektu pridať midllwer na video straming do pipline a zaregistrovať príslušnú službu. Pri praktickom nasadení je ešte potrebné zabrániť neoprávnenému sťahovaniu obsahu.