Skip to content

File.Save() corrupts Nero chapter (chpl`) atoms in MPEG-4 containers #376

@sevenrats

Description

@sevenrats

Summary

When TagLib-Sharp saves an MPEG-4 file (M4A/M4B) that contains a Nero-style chapter atom (moov/udta/chpl), it rewrites version 1 chpl atoms using version 0 format without adjusting the payload layout. This silently corrupts the chapter data and can cause other parsers that read chpl before ilst to fail to read any metadata from the file.

Affected Version

TagLibSharp-Lidarr 2.2.0.19 (fork of TagLib-Sharp). Believed to also affect upstream TagLib-Sharp — the chpl handling code has not been changed in the fork.

Steps to Reproduce

  1. Take any M4A or M4B file that contains a version 1 Nero chapter atom at moov/udta/chpl.
  2. Open the file with TagLib.File.Create(path).
  3. Modify any tag (e.g. set tag.Title).
  4. Call file.Save().
  5. Inspect the chpl atom in the saved file.

Expected Behavior

The chpl atom should be preserved byte-for-byte (TagLib-Sharp does not read or expose chapter data, so it should pass the atom through unmodified), or at minimum be rewritten with a structurally valid format.

Actual Behavior

TagLib-Sharp rewrites the chpl atom with version byte 0x00 but retains the payload layout from version 1. The two versions have different structures:

Version 1 layout:

[4 bytes: atom size] [4 bytes: "chpl"] [1 byte: version=1] [3 bytes: flags]
[1 byte: reserved]                     ← version 1 has this extra byte
[4 bytes: chapter count (big-endian)]
[repeated: 8-byte timestamp + 1-byte name length + name bytes]

Version 0 layout:

[4 bytes: atom size] [4 bytes: "chpl"] [1 byte: version=0] [3 bytes: flags]
                                       ← no reserved byte
[4 bytes: chapter count (big-endian)]
[repeated: 8-byte timestamp + 1-byte name length + name bytes]

After file.Save(), the atom is written as version 0 but the payload still includes the version 1 reserved byte. This shifts the chapter count and all subsequent chapter data by 1 byte. The result is:

  • The chapter count field reads garbage (the reserved byte 0x00 becomes the high byte of the count).
  • All chapter timestamps and names are shifted and unreadable.
  • Parsers that strictly validate the chpl atom may reject it entirely.

Downstream Impact

Parsers that process udta children sequentially and encounter the corrupted chpl before reaching ilst (the iTunes metadata atom) may abort parsing. This has been observed with:

  • ATL (z440.atl.core) — the audio metadata library used by Jellyfin
  • Jellyfin — fails to read any metadata from files with corrupted chpl atoms

This means a simple tag write by TagLib-Sharp can render a file's metadata completely unreadable to Jellyfin and other applications, even though TagLib-Sharp itself can still read the file (because it reads ilst directly by seeking to it).

Workaround

We currently post-process every MPEG-4 file after file.Save() by locating the chpl atom at moov/udta/chpl and replacing the 4-byte type field with "free" to neutralize it:

// After file.Save(), if TagTypes includes Apple:
using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite))
{
    var chplOffset = FindNestedAtom(fs, 0, fs.Length, "moov", "udta", "chpl");
    if (chplOffset < 0) return;

    // Validate: read version byte
    fs.Seek(chplOffset + 8, SeekOrigin.Begin);
    var version = fs.ReadByte();

    if (version == 0)
    {
        // Version 0 written by TagLib-Sharp — likely corrupted from version 1
        // Neutralize by replacing atom type with "free"
        fs.Seek(chplOffset + 4, SeekOrigin.Begin);
        fs.Write(new byte[] { 0x66, 0x72, 0x65, 0x65 }, 0, 4); // "free"
    }
}

This destroys chapter data but preserves file structure and unblocks downstream parsers. It's not a proper fix — chapter information is lost.

Suggested Fix

One of:

  1. Preserve chpl verbatim — Since TagLib-Sharp doesn't expose chapter data, the chpl atom should be passed through unchanged during save. This is the simplest and safest fix.
  2. Respect version on rewrite — If the atom must be rewritten (e.g., due to offset changes), preserve the original version byte and write the payload in the corresponding format.
  3. Omit chpl on rewrite — If correct rewriting is too complex, drop the atom rather than writing a corrupt one. Losing chapters is better than breaking all metadata readers.

Environment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions