Recursive AI

Edit MP4, MKV and MP3 tags in C#

All onemedia recipes

Set a field on media.Tags and save: OneMedia writes it into every tag system the file has (iTunes ilst in MP4, Matroska Tags, ID3v2 in MP3…) and copies the audio and video bytes unchanged. The same code edits MP4/MOV, MKV/WebM, MP3, FLAC, Ogg, WAV, AIFF and WMA files. The inputs are the Sintel trailer (CC BY 3.0, © copyright Blender Foundation | durian.blender.org) and copies of it made with FFmpeg as MKV (stream copy) and MP3; each carries FFmpeg’s tags from the trailer.

Terminal
dotnet new console -o EditTags && cd EditTags
dotnet add package RecursiveAI.OneMedia --version 0.3.0-preview.1
curl -L -o sintel.mp4 https://download.blender.org/durian/trailer/sintel_trailer-480p.mp4
x="-v error -i sintel.mp4 -metadata major_brand= -metadata minor_version= -metadata compatible_brands="
ffmpeg $x -c copy sintel.mkv
ffmpeg $x -vn -c:a libmp3lame -q:a 4 sintel.mp3

Put this in Program.cs and run dotnet run:

C#
using RecursiveAI.OneMedia;
using RecursiveAI.OneMedia.Tags;
 
foreach (string path in new[] { "sintel.mp4", "sintel.mkv", "sintel.mp3" })
{
    string tagged = Path.GetFileNameWithoutExtension(path) + "-tagged" + Path.GetExtension(path);
 
    // 1. Edit and save to a new file. The original is not touched.
    using (MediaFile media = Media.Open(path))
    {
        Console.WriteLine($"{path}: \"{media.Tags.Title}\" by {media.Tags.Artist}");
        media.Tags.Title = "Sintel (trailer)";
        media.Tags.Artist = "Blender Foundation";
        media.Tags.Comment = "CC BY 3.0";
        TagWriteReport report = media.SaveAs(tagged);
        foreach (SystemWriteReport system in report.Systems)
            Console.WriteLine($"  {system.System}: wrote {string.Join(", ", system.Written)}");
        foreach (string warning in report.Warnings) Console.WriteLine($"  warning: {warning}");
    }
 
    // 2. A small follow-up edit in place: it fits in the padding the first save left.
    using (MediaFile media = Media.Open(tagged))
    {
        media.Tags.Title = "Sintel, the trailer";
        TagWriteReport report = media.SaveInPlace();
        WriteReport? write = report.Write;
        Console.WriteLine($"  in place: {write?.BytesWritten} bytes written, {write?.PaddingAvailable} bytes of padding available");
    }
 
    // 3. The original has no room for a longer title, so an in-place save refuses and changes nothing.
    using (MediaFile media = Media.Open(path))
    {
        media.Tags.Title = "Sintel, a short film by the Blender Foundation (trailer)";
        try { media.SaveInPlace(); }
        catch (PaddingRequiredException e) { Console.WriteLine($"  original: {e.Message}"); }
    }
}
Text
sintel.mp4: "Sintel Trailer" by Durian Open Movie Team
  ilst: wrote Title, Artist, Comment
  in place: 307 bytes written, 1024 bytes of padding available
  original: The edit does not fit in the existing padding: 42 more byte(s) of padding are needed (0 available). Save to a new file instead.
sintel.mkv: "Sintel Trailer" by Durian Open Movie Team
  mkv: wrote Title, Artist, Comment
  mkv-info: wrote Title
  in place: 65 bytes written, 2048 bytes of padding available
  original: The edit does not fit in the existing padding: 112 more byte(s) of padding are needed (0 available). Save to a new file instead.
sintel.mp3: "Sintel Trailer" by Durian Open Movie Team
  id3v2: wrote Title, Artist, Comment
  in place: 165 bytes written, 1024 bytes of padding available
  original: The edit does not fit in the existing padding: 1055 more byte(s) of padding are needed (10 available). Save to a new file instead.

SaveAs writes a new file; Save() replaces the original (both below). Each system gets the field under its own key (©nam in MP4, TITLE in Matroska, TIT2 in ID3v2; Matroska’s Segment title too). A growing edit leaves 1,024 bytes of padding behind the tags, which is why the second, in-place edit fits; it writes only the bytes that differ. PaddingRequiredException tells you how many bytes were missing when it doesn’t.

Read them back with ffprobe

Terminal
for f in sintel-tagged.mp4 sintel-tagged.mkv sintel-tagged.mp3; do echo "$f"; ffprobe -v error -show_entries format_tags=title,artist,comment -of default=nw=1 $f; done
Text
sintel-tagged.mp4
TAG:title=Sintel, the trailer
TAG:artist=Blender Foundation
TAG:comment=CC BY 3.0
sintel-tagged.mkv
TAG:ARTIST=Blender Foundation
TAG:TITLE=Sintel, the trailer
TAG:COMMENT=CC BY 3.0
sintel-tagged.mp3
TAG:title=Sintel, the trailer
TAG:artist=Blender Foundation
TAG:comment=CC BY 3.0

The video and audio packets are untouched; ffprobe gives the same payload hashes for the original and the tagged MP4:

Terminal
for f in sintel.mp4 sintel-tagged.mp4; do ffprobe -v error -show_entries packet=stream_index,pts,size,flags -show_data_hash MD5 -of csv $f | md5sum; done
Text
f2def6fafaed6682de882044ace566e5  -
f2def6fafaed6682de882044ace566e5  -

Saving: which call to use

The project’s writer review sets the terms: “Editing can be advertised for Save / SaveAs, with the limits below. SaveInPlace can be advertised as ‘padding-only, not atomic’.”

  • SaveAs(path) writes a new file; it won’t overwrite an existing file unless you pass overwrite: true.
  • Save() writes a temporary file and renames it over the original. The review’s verdict on “Save is atomic” is “Holds with listed limits”: in 500 kill rounds no half-written file was left. The limits are replace semantics (the result is a new file: new inode, the saving user’s owner and group; extended attributes, ACLs and hard links of the original are not kept), and a killed save leaves its temporary file behind (.name.onemedia-<host>-<pid>-<random>.tmp), which the next save of the same file removes. In the review’s words, “The folder is not fsynced after the rename”; OneMedia makes no claim about a power loss during or just after a save.
  • SaveInPlace() is “padding-only, not atomic”. It writes only inside existing padding and otherwise refuses with the file untouched, as above. It makes no claim about a crash or power loss: review finding 9 reads “SaveInPlace can tear on a kill or power loss for ranges that take more than one write”, and in the kill test 3 of 300 runs left a torn file. Use it for files you can afford to lose or have a copy of; use Save otherwise.

Limits

  • Formats. Tag editing covers MP4/MOV/3GP (ilst, udta, mdta, XMP), Matroska/WebM, MP3 (ID3v2, ID3v1, APEv2), FLAC, Ogg Vorbis/Opus/FLAC, WAV/RF64 (LIST INFO, bext, iXML, ID3), AIFF and ASF/WMA/WMV. Not AVI LIST INFO, W64, CAF, MPEG-TS, ASF Metadata/Library objects, or XMP outside ISO files.
  • Fields a system lacks are listed in SystemWriteReport.NotRepresentable and not written (MP4’s mdta has no track number, for example).
  • Dates. Setting Date writes one field per system; other date tags in the file stay and are named in report.Warnings.
  • Pictures. media.Tags.SetPicture(bytes, "image/jpeg", PictureType.FrontCover, "") adds cover art; FLAC picture blocks are written with width and height 0.
  • The open file is stale after a save. Open the saved file again to read it. Per-system rules: Tag writing.

To read every tag a file has, see Read MP4 metadata and MP3, FLAC and WAV tags.