Skip to content

Commit

Permalink
MP4/M4A : Better handling of non-standard fields [#159]
Browse files Browse the repository at this point in the history
  • Loading branch information
Zeugma440 committed Aug 14, 2022
1 parent 38ba69b commit 3aa3da9
Show file tree
Hide file tree
Showing 5 changed files with 186 additions and 23 deletions.
24 changes: 24 additions & 0 deletions ATL.test/IO/MetaData/ID3v2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,30 @@ public void TagIO_RW_ID3v2_Unsupported_Empty()
test_RW_Unsupported_Empty(emptyFile);
}

[TestMethod]
public void TagIO_RW_ID3v2_NonStandardField()
{
string testFileLocation = TestUtils.CopyAsTempTestFile("MP3/empty.mp3");
AudioDataManager theFile = new AudioDataManager(AudioDataIOFactory.GetInstance().GetFromPath(testFileLocation));

// Add a field outside ID3v2 standards
TagData theTag = new TagData();
theTag.AdditionalFields = new List<MetaFieldInfo>();
MetaFieldInfo info = new MetaFieldInfo(MetaDataIOFactory.TagType.ID3V2, "BLAHBLAH", "heyheyhey");
theTag.AdditionalFields.Add(info);

Assert.IsTrue(theFile.UpdateTagInFile(theTag, tagType));

Assert.IsTrue(theFile.ReadFromFile(false, true));
Assert.IsNotNull(theFile.ID3v2);
Assert.IsTrue(theFile.ID3v2.Exists);

Assert.IsTrue(theFile.ID3v2.AdditionalFields.ContainsKey("BLAHBLAH"));
Assert.AreEqual("heyheyhey", theFile.ID3v2.AdditionalFields["BLAHBLAH"]);

if (Settings.DeleteAfterSuccess) File.Delete(testFileLocation);
}

private void checkTrackDiscZeroes(FileStream fs)
{
using (BinaryReader r = new BinaryReader(fs))
Expand Down
99 changes: 94 additions & 5 deletions ATL.test/IO/MetaData/MP4.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
using ATL.AudioData.IO;
using static ATL.Logging.Log;
using ATL.Logging;
using Commons;
using System.Text;

namespace ATL.test.IO.MetaData
{
Expand Down Expand Up @@ -201,7 +203,7 @@ public void TagIO_RW_MP4_Existing()
{
using (Image picture = Image.FromStream(new MemoryStream(pic.PictureData)))
{
Assert.AreEqual(ImageFormat.Png, picture.RawFormat);
Assert.AreEqual(System.Drawing.Imaging.ImageFormat.Png, picture.RawFormat);
Assert.AreEqual(175, picture.Width);
Assert.AreEqual(168, picture.Height);
}
Expand Down Expand Up @@ -305,7 +307,7 @@ public void TagIO_RW_MP4_Unsupported_Empty()
{
using (Image picture = Image.FromStream(new MemoryStream(pic.PictureData)))
{
Assert.AreEqual(ImageFormat.Jpeg, picture.RawFormat);
Assert.AreEqual(System.Drawing.Imaging.ImageFormat.Jpeg, picture.RawFormat);
Assert.AreEqual(600, picture.Height);
Assert.AreEqual(900, picture.Width);
}
Expand All @@ -315,7 +317,7 @@ public void TagIO_RW_MP4_Unsupported_Empty()
{
using (Image picture = Image.FromStream(new MemoryStream(pic.PictureData)))
{
Assert.AreEqual(ImageFormat.Jpeg, picture.RawFormat);
Assert.AreEqual(System.Drawing.Imaging.ImageFormat.Jpeg, picture.RawFormat);
Assert.AreEqual(290, picture.Height);
Assert.AreEqual(900, picture.Width);
}
Expand All @@ -332,7 +334,7 @@ public void TagIO_RW_MP4_Unsupported_Empty()
theTag.AdditionalFields.Add(fieldInfo);

// Remove additional picture
picInfo = new PictureInfo(PictureInfo.PIC_TYPE.Generic, 1);
picInfo = new PictureInfo(PIC_TYPE.Generic, 1);
picInfo.MarkedForDeletion = true;
theTag.Pictures.Add(picInfo);

Expand Down Expand Up @@ -360,7 +362,7 @@ public void TagIO_RW_MP4_Unsupported_Empty()
{
using (Image picture = Image.FromStream(new MemoryStream(pic.PictureData)))
{
Assert.AreEqual(ImageFormat.Jpeg, picture.RawFormat);
Assert.AreEqual(System.Drawing.Imaging.ImageFormat.Jpeg, picture.RawFormat);
Assert.AreEqual(290, picture.Height);
Assert.AreEqual(900, picture.Width);
}
Expand All @@ -375,6 +377,93 @@ public void TagIO_RW_MP4_Unsupported_Empty()
if (Settings.DeleteAfterSuccess) File.Delete(testFileLocation);
}

[TestMethod]
public void TagIO_RW_MP4_NonStandard_MoreThan4Chars()
{
new ConsoleLogger();
ArrayLogger log = new ArrayLogger();

// Source : tag-free M4A
string testFileLocation = TestUtils.CopyAsTempTestFile(emptyFile);
AudioDataManager theFile = new AudioDataManager(AudioDataIOFactory.GetInstance().GetFromPath(testFileLocation));

Assert.IsTrue(theFile.ReadFromFile(false, true));

// Add a field outside MP4 standards, without namespace
TagData theTag = new TagData();
theTag.AdditionalFields = new List<MetaFieldInfo>();
MetaFieldInfo infoKO = new MetaFieldInfo(MetaDataIOFactory.TagType.NATIVE, "BLAHBLAH", "heyheyhey");
theTag.AdditionalFields.Add(infoKO);
Assert.IsFalse(theFile.UpdateTagInFile(theTag, MetaDataIOFactory.TagType.NATIVE));
IList<LogItem> logItems = log.GetAllItems(LV_ERROR);
Assert.IsTrue(logItems.Count > 0);
bool found = false;
foreach (LogItem l in logItems)
{
if (l.Message.Contains("must have a namespace")) found = true;
}
Assert.IsTrue(found);

// Add a field outside MP4 standards, with or without the leading '----'
theTag = new TagData();
theTag.AdditionalFields = new List<MetaFieldInfo>();
MetaFieldInfo infoOK = new MetaFieldInfo(MetaDataIOFactory.TagType.NATIVE, "my.namespace:BLAHBLAH", "heyheyhey");
MetaFieldInfo infoOK2 = new MetaFieldInfo(MetaDataIOFactory.TagType.NATIVE, "----:my.namespace:BLAHBLAH2", "hohoho");
theTag.AdditionalFields.Add(infoOK);
theTag.AdditionalFields.Add(infoOK2);

Assert.IsTrue(theFile.UpdateTagInFile(theTag, MetaDataIOFactory.TagType.NATIVE));

Assert.IsTrue(theFile.ReadFromFile(false, true));
Assert.IsNotNull(theFile.NativeTag);
Assert.IsTrue(theFile.NativeTag.Exists);

Assert.IsTrue(theFile.NativeTag.AdditionalFields.ContainsKey("----:my.namespace:BLAHBLAH"));
Assert.AreEqual("heyheyhey", theFile.NativeTag.AdditionalFields["----:my.namespace:BLAHBLAH"]);
Assert.IsTrue(theFile.NativeTag.AdditionalFields.ContainsKey("----:my.namespace:BLAHBLAH2"));
Assert.AreEqual("hohoho", theFile.NativeTag.AdditionalFields["----:my.namespace:BLAHBLAH2"]);

if (Settings.DeleteAfterSuccess) File.Delete(testFileLocation);
}

[TestMethod]
public void TagIO_RW_MP4_NonStandard_WM()
{
new ConsoleLogger();
ArrayLogger log = new ArrayLogger();

// Source : tag-free M4A
string testFileLocation = TestUtils.CopyAsTempTestFile(emptyFile);
AudioDataManager theFile = new AudioDataManager(AudioDataIOFactory.GetInstance().GetFromPath(testFileLocation));

Assert.IsTrue(theFile.ReadFromFile(false, true));

// Add a field outside Microsoft standards
TagData theTag = new TagData();
theTag.AdditionalFields = new List<MetaFieldInfo>();
MetaFieldInfo infoOK = new MetaFieldInfo(MetaDataIOFactory.TagType.NATIVE, "WM/ParentalRating", "M for Mature");
theTag.AdditionalFields.Add(infoOK);

Assert.IsTrue(theFile.UpdateTagInFile(theTag, MetaDataIOFactory.TagType.NATIVE));

Assert.IsTrue(theFile.ReadFromFile(false, true));
Assert.IsNotNull(theFile.NativeTag);
Assert.IsTrue(theFile.NativeTag.Exists);

Assert.IsTrue(theFile.NativeTag.AdditionalFields.ContainsKey("WM/ParentalRating"));
Assert.AreEqual("M for Mature", theFile.NativeTag.AdditionalFields["WM/ParentalRating"]);

// Check that it has indeed been added to the Xtra atom
using (FileStream fs = new FileStream(testFileLocation, FileMode.Open, FileAccess.Read))
{
Assert.AreEqual(true, StreamUtils.FindSequence(fs, Utils.Latin1Encoding.GetBytes("Xtra")));
Assert.AreEqual(true, StreamUtils.FindSequence(fs, Utils.Latin1Encoding.GetBytes("WM/ParentalRating")));
Assert.AreEqual(true, StreamUtils.FindSequence(fs, Encoding.Unicode.GetBytes("M for Mature")));
}

if (Settings.DeleteAfterSuccess) File.Delete(testFileLocation);
}

[TestMethod]
public void TagIO_RW_MP4_Chapters_Nero_Edit()
{
Expand Down
14 changes: 10 additions & 4 deletions ATL/AudioData/IO/ID3v2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -369,13 +369,13 @@ public int TextFieldSizeRestriction
}
public bool HasPictureEncodingRestriction
{
get { return (((TagRestrictions & 0x04) >> 2) > 0); }
get { return ((TagRestrictions & 0x04) >> 2) > 0; }
}
public int PictureSizeRestriction
{
get
{
switch ((TagRestrictions & 0x03))
switch (TagRestrictions & 0x03)
{
case 0: return -1; // No restriction
case 1: return 256; // 256x256 or less
Expand Down Expand Up @@ -440,7 +440,7 @@ protected override MetaDataIOFactory.TagType getImplementedTagType()
/// <inheritdoc/>
public override byte FieldCodeFixedLength
{
get { return 0; } // Actually 4 when strictly applying specs, but thanks to TXXX fields, any code is supported
get { return 0; } // Actually 3 or 4 when strictly applying ID3v2.3 / ID3v2.4 specs, but thanks to TXXX fields, any code is supported
}


Expand Down Expand Up @@ -483,6 +483,12 @@ protected override Field getFrameMapping(string zone, string ID, byte tagVersion
return supportedMetaId;
}

/// <inheritdoc/>
protected override bool canHandleNonStandardField(string code, string value)
{
return true; // Will be transformed to a TXXX field
}


private bool readHeader(BufferedBinaryReader SourceFile, TagInfo Tag, long offset)
{
Expand Down Expand Up @@ -766,7 +772,7 @@ private bool readFrame(

string[] tabS = strData.Split('\0');
Frame.ID = tabS[0];
// Handle multiple values (ID3v2.4 only theoretically)
// Handle multiple values (ID3v2.4 only, theoretically)
if (tabS.Length > 1) strData = string.Join(Settings.InternalValueSeparator + "", tabS, 1, tabS.Length - 1);
else strData = ""; //if the 2nd part of the array isn't there, value is non-existent (TXXX...KEY\0\0 or TXXX...KEY\0)

Expand Down
52 changes: 42 additions & 10 deletions ATL/AudioData/IO/MP4.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class MP4 : MetaDataIO, IAudioDataIO
private const string ZONE_MP4_NOMETA = "nometa"; // Placeholder for missing 'meta' atom
private const string ZONE_MP4_ILST = "ilst"; // When editing a file with an existing 'meta' atom
private const string ZONE_MP4_CHPL = "chpl"; // Nero chapters
private const string ZONE_MP4_XTRA = "Xtra"; // Specific fields (e.g. rating) inserted by Windows instead of using standard MP4 fields
private const string ZONE_MP4_XTRA = "Xtra"; // Specific fields (e.g. rating) inserted by Microsoft instead of using standard MP4 fields
private const string ZONE_MP4_QT_CHAP_NOTREF = "qt_notref"; // Placeholder for missing track reference atom
private const string ZONE_MP4_QT_CHAP_CHAP = "qt_chap_chap"; // Quicktime chapters track reference
private const string ZONE_MP4_QT_CHAP_TXT_TRAK = "qt_trak_txt"; // Quicktime chapters text track
Expand Down Expand Up @@ -115,6 +115,7 @@ private sealed class MP4Sample
private uint initialPaddingSize;
private byte[] chapterTextTrackEdits = null;
private byte[] chapterPictureTrackEdits = null;
private long udtaOffset;

private byte headerTypeID;
private byte bitrateTypeID;
Expand Down Expand Up @@ -199,6 +200,15 @@ protected override Field getFrameMapping(string zone, string ID, byte tagVersion

return supportedMetaId;
}
/// <inheritdoc/>
protected override bool canHandleNonStandardField(string code, string value)
{
// Belongs to the XTRA zone + parent UDTA atom has been located => OK
if (code.StartsWith("WM/", StringComparison.OrdinalIgnoreCase)) return true;
string cleanedCode = code.Replace("----:", "");
if (cleanedCode.Contains(":")) return true; // Is part of the standard way of reprsenting non-standard fields
else throw new NotSupportedException("Non-standard fields must have a namespace (e.g. namespace:fieldName). Found : '" + cleanedCode + '"');
}


// ---------- CONSTRUCTORS & INITIALIZERS
Expand All @@ -218,6 +228,7 @@ protected void resetData()
initialPaddingOffset = -1;
AudioDataOffset = -1;
AudioDataSize = 0;
udtaOffset = 0;

chapterTextTrackEdits = null;
chapterPictureTrackEdits = null;
Expand Down Expand Up @@ -1083,7 +1094,7 @@ private void readUserData(BinaryReader source, ReadTagParams readTagParams, long
if (!udtaFound)
{
LogDelegator.GetLogDelegate()(Log.LV_INFO, "udta atom could not be found");
// Create a placeholder to create a new UDTA atom from scratch, located as ad direct child of MOOV
// Create a placeholder to create a new UDTA atom from scratch, located as a direct child of MOOV
if (readTagParams.PrepareForWriting)
{
structureHelper.AddSize(moovPosition - 8 + moovSize, atomSize, ZONE_MP4_NOMETA);
Expand All @@ -1096,6 +1107,7 @@ private void readUserData(BinaryReader source, ReadTagParams readTagParams, long
}

udtaPosition = source.BaseStream.Position;
udtaOffset = udtaPosition;
if (readTagParams.PrepareForWriting)
{
structureHelper.AddSize(source.BaseStream.Position - 8, atomSize, ZONE_MP4_NOMETA);
Expand Down Expand Up @@ -1487,6 +1499,22 @@ protected override bool read(BinaryReader source, ReadTagParams readTagParams)
}
}

/// <inheritdoc/>
protected override void preprocessWrite(TagData dataToWrite)
{
// Scan AdditionalData for the need to create the Xtra zone
foreach (MetaFieldInfo info in dataToWrite.AdditionalFields)
{
// Belongs to the XTRA zone + parent UDTA atom has been located => OK
if (info.NativeFieldCode.StartsWith("WM/", StringComparison.OrdinalIgnoreCase) && udtaOffset > 0)
{
// Allow creating the 'xtra' atom / zone from scratch
structureHelper.AddZone(udtaOffset, 0, ZONE_MP4_XTRA);
break;
}
}
}

protected override int write(TagData tag, BinaryWriter w, string zone)
{
long tagSizePos;
Expand Down Expand Up @@ -1698,25 +1726,29 @@ private void writeTextFrame(BinaryWriter writer, string frameCode, string text)
// == METADATA HEADER ==
frameSizePos1 = writer.BaseStream.Position;
writer.Write(0); // Frame size placeholder to be rewritten in a few lines
if (frameCode.StartsWith("----")) // Specific metadata
if (frameCode.Length > FieldCodeFixedLength && !frameCode.StartsWith("WM/", StringComparison.OrdinalIgnoreCase)) // Specific non-Microsoft custom metadata
{
string[] frameCodeComponents = frameCode.Split(':');
if (3 == frameCodeComponents.Length)
bool isComplete = frameCodeComponents.Length > 2 && frameCodeComponents[0] == "----";
if (isComplete || frameCodeComponents.Length > 1)
{
writer.Write(Utils.Latin1Encoding.GetBytes("----"));

writer.Write(StreamUtils.EncodeBEInt32(frameCodeComponents[1].Length + 4 + 4 + 4));
string nmespace = isComplete ? frameCodeComponents[1] : frameCodeComponents[0];
string fieldCode = isComplete ? frameCodeComponents[2] : frameCodeComponents[1];

writer.Write(StreamUtils.EncodeBEInt32(nmespace.Length + 4 + 4 + 4));
writer.Write(Utils.Latin1Encoding.GetBytes("mean"));
writer.Write(frameFlags);
writer.Write(Utils.Latin1Encoding.GetBytes(frameCodeComponents[1]));
writer.Write(Utils.Latin1Encoding.GetBytes(nmespace));

writer.Write(StreamUtils.EncodeBEInt32(frameCodeComponents[2].Length + 4 + 4 + 4));
writer.Write(StreamUtils.EncodeBEInt32(fieldCode.Length + 4 + 4 + 4));
writer.Write(Utils.Latin1Encoding.GetBytes("name"));
writer.Write(frameFlags);
writer.Write(Utils.Latin1Encoding.GetBytes(frameCodeComponents[2]));
writer.Write(Utils.Latin1Encoding.GetBytes(fieldCode));
}
}
else
else if (!frameCode.StartsWith("WM/", StringComparison.OrdinalIgnoreCase))
{
writer.Write(Utils.Latin1Encoding.GetBytes(frameCode));
}
Expand Down Expand Up @@ -1828,7 +1860,7 @@ private void writePictureFrame(BinaryWriter writer, byte[] pictureData, ImageFor

private int writeXtraFrames(TagData tag, BinaryWriter w)
{
IEnumerable<MetaFieldInfo> xtraTags = tag.AdditionalFields.Where(fi => (fi.TagType.Equals(MetaDataIOFactory.TagType.ANY) || fi.TagType.Equals(getImplementedTagType())) && !fi.MarkedForDeletion && fi.NativeFieldCode.ToLower().StartsWith("wm/"));
IEnumerable<MetaFieldInfo> xtraTags = tag.AdditionalFields.Where(fi => (fi.TagType.Equals(MetaDataIOFactory.TagType.ANY) || fi.TagType.Equals(getImplementedTagType())) && !fi.MarkedForDeletion && fi.NativeFieldCode.ToLower().StartsWith("wm/", StringComparison.OrdinalIgnoreCase));

if (!xtraTags.Any()) return 0;

Expand Down
Loading

0 comments on commit 3aa3da9

Please sign in to comment.