Recursive AI

Read video metadata from a URL without downloading it in C#

All onemedia recipes

Media.OpenAsync(length, readRange) reads a file through any function that returns a byte range, such as an HTTP range request, cloud blob storage or a browser Blob. Probing a file reads only its structure, so you get the duration, tracks, codecs and tags of a remote video for a small part of its size. The file here is the Sintel trailer on Blender’s server, 4.4 MB (CC BY 3.0, Blender Foundation).

Terminal
dotnet new console -o RemoteProbe && cd RemoteProbe
dotnet add package RecursiveAI.OneMedia --version 0.1.0-preview.1

Put this in Program.cs and run dotnet run:

C#
using System.Net.Http.Headers;
using RecursiveAI.OneMedia;
 
string url = "https://download.blender.org/durian/trailer/sintel_trailer-480p.mp4";
using var http = new HttpClient();
 
// The length, from a HEAD request.
using var head = await http.SendAsync(new HttpRequestMessage(HttpMethod.Head, url));
long length = head.Content.Headers.ContentLength
              ?? throw new InvalidOperationException("No Content-Length");
 
long fetched = 0;
int requests = 0;
async ValueTask<int> ReadRange(long offset, Memory<byte> buffer, CancellationToken ct)
{
    using var request = new HttpRequestMessage(HttpMethod.Get, url);
    request.Headers.Range = new RangeHeaderValue(offset, offset + buffer.Length - 1);
    using var response = await http.SendAsync(request, ct);
    response.EnsureSuccessStatusCode();
    byte[] bytes = await response.Content.ReadAsByteArrayAsync(ct);
    bytes.AsSpan(0, Math.Min(bytes.Length, buffer.Length)).CopyTo(buffer.Span);
    fetched += bytes.Length;
    requests++;
    return Math.Min(bytes.Length, buffer.Length);
}
 
await using MediaFile media = await Media.OpenAsync(length, ReadRange);
var metadata = await media.ReadMetadataAsync();
 
Console.WriteLine($"Duration: {media.Duration}");
foreach (MediaTrack t in media.Tracks)
    Console.WriteLine($"Track {t.Index}: {t.Kind} {t.Codec}" +
                      (t.Video is { } v ? $" {v.Width}x{v.Height}" : ""));
Console.WriteLine($"Title:    {metadata.Tags.Title}");
Console.WriteLine($"Fetched {fetched:N0} of {length:N0} bytes in {requests} requests");
Text
Duration: 00:00:52.2090000
Track 0: Video h264 854x480
Track 1: Audio aac
Title:    Sintel Trailer
Fetched 43,704 of 4,372,373 bytes in 7 requests

Over a ranged source, call ReadMetadataAsync() before reading media.Tags or the facts: the synchronous properties need a source they can read on demand. This file’s movie index is at its end, after 4.3 MB of media data (its first video packet starts at byte 48). OneMedia steps over the media data box by its header to reach the index, without reading the media in between. The project measured a 4.38 GB MP4 with the index at the end (local reads): 165,681 B in 34 reads, against 1,741,689 B in 32 reads for ffprobe and 23,628,223 B in 138 reads for MediaInfo.

Packets work over the same source with media.PacketsAsync(track). Each one is a request here, so for a whole track, read bigger ranges and cache them in your readRange.

Limits

  • The server must honor ranges. ReadRange assumes a 206 Partial Content answer. A server that ignores Range sends the whole file each time; check response.StatusCode if you don’t control it.
  • No decoding. Only container structure and tags are read; nothing is decoded, and no thumbnail can be made.
  • Fragmented MP4 durations come from the headers, not from scanning the fragments, which keeps a remote probe cheap but can differ from a full read. See MP4, MOV and 3GP.
  • Matroska over HTTP reads the elements up to the first Cluster. Tags and Attachments stored after the Clusters are found through the SeekHead and cost a read each, never a scan; each is fetched whole, up to MediaLimits.MaxAttachmentBytes.

The project’s byte and request counts for 213 files, over HTTP range requests and local reads, are in docs/io-results.txt.