Search code examples
c#.net-corevideogoogle-drive-apiasp.net-core-webapi

How to change the content-length of a filestreamresult response


I want to build a media gateway web API which retrieves images and videos from a google drive via the API and returns it as a response to a front-end web application. In the gateway I want to check if a user is allowed to retrieve the media. My first step is to make it possible to let the web API provide the media at all.

I succeeded to make it work for images. For videos it is working as well but not the way I would like to. I cannot make it work with range header values. Without it, it means the whole video (size is 260 MB) is downloaded which feels it takes long. This should be preventable by streaming the video but I haven't succeeded yet in getting this working.

This is the code I have that works, although it can happen that the video is going in a loading state while playing which causes to download the video fully from google drive again.

[HttpGet("video")]
public async Task<IActionResult> GetVideo()
{
    string credentialsPath = "Api-key-for-google-drive.json";
    string folderId = "{folderId}";
   
    using var credentialsFileStream = new FileStream(credentialsPath, FileMode.Open, 
    FileAccess.Read);
    var scopes = new[] { DriveService.ScopeConstants.DriveFile };
    var googleCredential = GoogleCredential.FromStream(credentialsFileStream).CreateScoped(scopes);
    var baseClientService = new BaseClientService.Initializer
    {
        HttpClientInitializer = googleCredential,
        ApplicationName = "A name"
    };
    var driveService = new DriveService(baseClientService);
    var parents = new List<string> { folderId };

    var downloadRequest = driveService.Files.Get("{fileId}");
    var memoryStream = new MemoryStream();
    await downloadRequest.DownloadAsync(memoryStream);
    memoryStream.Position = 0;
    var result = new FileStreamResult(memoryStream, "video/mp4")
    {
        EnableRangeProcessing = true
    };

    return result;
}

According to the documentation Google Drive API supports range header values too and this part seems to work. But now, the video only plays the first 4 seconds and then it stops. This is the code that only returns the first few seconds:

[HttpGet("video")]
public async Task<IActionResult> GetVideo()
{
    var chunkSize = 1024 * 1024 * 10;

    var requestRange = Request.Headers.Range;

    //TODO implement retrieving the range better in the future
    var start = requestRange.Count > 0
        ? long.Parse(requestRange.First()!.Replace("bytes=", "").Replace("-", ""))
        : 0;

    string credentialsPath = "Api-key-for-google-drive.json";
    string folderId = "{fileId}";

    using var credentialsFileStream = new FileStream(credentialsPath, FileMode.Open, FileAccess.Read);
    var scopes = new[] { DriveService.ScopeConstants.DriveFile };
    var googleCredential = GoogleCredential.FromStream(credentialsFileStream).CreateScoped(scopes);
    var baseClientService = new BaseClientService.Initializer
    {
        HttpClientInitializer = googleCredential,
        ApplicationName = "Google Drive Upload Console App"
    };
    var driveService = new DriveService(baseClientService);
    var parents = new List<string> { folderId };

    var downloadRequest = driveService.Files.Get("{fileId}");
    downloadRequest.Fields = "size";
    var googleFile = await downloadRequest.ExecuteAsync();
    var size = googleFile.Size ?? -1;

    var end = start + chunkSize <= size ? start + chunkSize : size;

    RangeHeaderValue rangeHeaderValue = new(start, end);
    var memoryStream = new MemoryStream();

    await downloadRequest.DownloadRangeAsync(memoryStream, rangeHeaderValue);
    memoryStream.Position = 0;
    Response.ContentLength = size; //<-- makes no different, ContentLength has not the correct length
    var result = new FileStreamResult(memoryStream, "video/mp4")
    {
        EnableRangeProcessing = true
    };

    return result;
}

My best guess is that the FileStreamResult only receives the first part of the video via the memory stream. This causes the response to set the Content-Length to the length of what is in the stream and not the actual length. I can see in the response headers in the dev tools of chrome that this is the case:

Accept-Ranges: bytes
Content-Length: 10485761
Content-Type: video/mp4
Date: Wed, 24 Jan 2024 15:57:52 GMT
Server: Kestrel

And I think this also causes to set the Response Status Code to 200 where I expected a 206. According to documentation the EnableRangeProcessing = true should set the Status Code to 206.

Request URL: https://localhost:7026/file/video
Request Method: GET
Status Code: 200 OK
Remote Address: [::1]:7026
Referrer Policy: strict-origin-when-cross-origin

I think I am very close in what I try to achieve. My best guess is that the Content-Length must be changed to the correct length but I cannot figure out how. I want to know if it is possible to change the Content-Length and if yes, how can I do that.


Solution

  • After more investigation I figured out that calling File or creating an instance of the FileStreamResult is limited in what I want. I figured this out by checking the source code. Fortunately, the source code also helped me a lot by finding the solution for my problem, which is creating my own version of the FileContentResult:

    public class PartialFileContentResult(MemoryStream partialFileStream, string contentType, long fileLength, long from, long to) : ActionResult
    {
        private const string AcceptRangeHeaderValue = "bytes";
    
        private readonly MemoryStream partialFileStream = partialFileStream;
        private readonly string contentType = contentType;
        private readonly long fileLength = fileLength;
        private readonly long from = from;
        private readonly long to = to < fileLength ? to : fileLength - 1;
    
        private readonly long rangeLength = to - from + 1;
    
        public override Task ExecuteResultAsync(ActionContext context)
        {
            ArgumentNullException.ThrowIfNull(context);
            var response = context.HttpContext.Response;
            response.ContentType = contentType;
            response.ContentLength = rangeLength;
            response.StatusCode = StatusCodes.Status206PartialContent;
            response.Headers.AcceptRanges = AcceptRangeHeaderValue;
    
            var httpResponseHeaders = response.GetTypedHeaders();
            httpResponseHeaders.ContentRange = new ContentRangeHeaderValue(from, to, fileLength);
    
            using (partialFileStream)
            {
                try
                {
                    partialFileStream.Seek(0, SeekOrigin.Begin);
                    return StreamCopyOperation.CopyToAsync(partialFileStream, context.HttpContext.Response.Body, rangeLength, (int)partialFileStream.Length, context.HttpContext.RequestAborted);
                }
                catch (OperationCanceledException)
                {
                    context.HttpContext.Abort();
                    return Task.CompletedTask;
                }
            }
        }
    }
    

    And this is called like this:

    new PartialFileContentResult(memoryStream, "video/mp4", size, start, end);
    

    instead of

    new FileStreamResult(memoryStream, "video/mp4")
    {
        EnableRangeProcessing = true
    };
    

    The PartialFileContentResult class impelemantion is currently very basic and lacks a lot of implementation like for instance the Etag, the Filename, Last modified. But I believe this code is a nice start for usages described in the question.