Skip to content

Commit

Permalink
MP4/M4A : Custom fields are now supported with com.apple.iTunes as th…
Browse files Browse the repository at this point in the history
…eir default namespace [#243]
  • Loading branch information
Zeugma440 committed Jan 6, 2024
1 parent 4ea2b42 commit c401e4f
Show file tree
Hide file tree
Showing 4 changed files with 40 additions and 111 deletions.
98 changes: 12 additions & 86 deletions ATL.unit-test/IO/MetaData/MP4.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public MP4()
testData.RecordingDate = null;
*/
testData.Date = DateTime.Parse("01/01/1997");
testData.Conductor = null; // TODO - Should be supported; extended field makes it harder to manipulate by the generic test code
testData.Conductor = "John Williams";
testData.Publisher = null;
testData.Genre = "Household"; // "House" was generating a 'gnre' numeric field whereas ATL standard way of tagging is '(c)gen' string field => Start with a non-standard Genre
testData.ProductId = "THIS IS A GOOD ID";
Expand All @@ -89,7 +89,7 @@ public MP4()

testData.AdditionalFields = new Dictionary<string, string>
{
{ "----:com.apple.iTunes:TEST", "xxx" }
{ "TESTFIELD", "xxx" }
};

PictureInfo pic = fromBinaryData(File.ReadAllBytes(TestUtils.GetResourceLocationRoot() + "_Images/pic1.jpeg"), PIC_TYPE.Unsupported, MetaDataIOFactory.TagType.ANY, 13);
Expand All @@ -105,7 +105,7 @@ public void TagIO_R_MP4()
{
new ConsoleLogger();

// Source : M4A with existing tag incl. unsupported picture (Cover Art (Fronk)); unsupported field (MOOD)
// Source : M4A with existing tag incl. unsupported picture (Cover Art (Fronk)); unsupported field (TESTFIELD)
string location = TestUtils.GetResourceLocationRoot() + notEmptyFile;
AudioDataManager theFile = new AudioDataManager(AudioDataIOFactory.GetInstance().GetFromPath(location));
readExistingTagsOnFile(theFile, 1);
Expand Down Expand Up @@ -148,15 +148,15 @@ public void TagIO_RW_MP4_Existing()
{
new ConsoleLogger();

// Source : file with existing tag incl. unsupported picture (Cover Art (Fronk)); unsupported field (MOOD)
// Source : file with existing tag incl. unsupported picture (Cover Art (Fronk)); unsupported field (TESTFIELD)
String testFileLocation = TestUtils.CopyAsTempTestFile(notEmptyFile);
AudioDataManager theFile = new AudioDataManager(AudioDataIOFactory.GetInstance().GetFromPath(testFileLocation));

// Add a new supported field and a new supported picture
Assert.IsTrue(theFile.ReadFromFile());

TagHolder theTag = new TagHolder();
theTag.Conductor = "John Jackman";
theTag.Publisher = "John Jackman";

byte[] data = File.ReadAllBytes(TestUtils.GetResourceLocationRoot() + "_Images/pic1.png");
PictureInfo picInfo = PictureInfo.fromBinaryData(data, PictureInfo.PIC_TYPE.Generic, MetaDataIOFactory.TagType.ANY, 13);
Expand Down Expand Up @@ -200,20 +200,18 @@ public void TagIO_RW_MP4_Existing()
readExistingTagsOnFile(theFile, 2);

// Additional supported field
Assert.AreEqual("John Jackman", theFile.NativeTag.Conductor);
Assert.AreEqual("John Jackman", theFile.NativeTag.Publisher);

#pragma warning disable CA1416
byte nbFound = 0;
foreach (PictureInfo pic in theFile.NativeTag.EmbeddedPictures)
{
if (pic.PicType.Equals(PIC_TYPE.Generic) && (1 == nbFound))
{
using (Image picture = Image.FromStream(new MemoryStream(pic.PictureData)))
{
Assert.AreEqual(System.Drawing.Imaging.ImageFormat.Png, picture.RawFormat);
Assert.AreEqual(175, picture.Width);
Assert.AreEqual(168, picture.Height);
}
using Image picture = Image.FromStream(new MemoryStream(pic.PictureData));
Assert.AreEqual(System.Drawing.Imaging.ImageFormat.Png, picture.RawFormat);
Assert.AreEqual(175, picture.Width);
Assert.AreEqual(168, picture.Height);
}
nbFound++;
}
Expand All @@ -222,7 +220,7 @@ public void TagIO_RW_MP4_Existing()

// Remove the additional supported field
theTag = new TagHolder();
theTag.Conductor = "";
theTag.Publisher = "";

// Remove additional picture
picInfo = new PictureInfo(PIC_TYPE.Back);
Expand All @@ -235,7 +233,7 @@ public void TagIO_RW_MP4_Existing()
readExistingTagsOnFile(theFile);

// Additional removed field
Assert.AreEqual("", theFile.NativeTag.Conductor);
Assert.AreEqual("", theFile.NativeTag.Publisher);


// Check that the resulting file (working copy that has been tagged, then untagged) remains identical to the original file (i.e. no byte lost nor added)
Expand Down Expand Up @@ -390,78 +388,6 @@ public void TagIO_RW_MP4_Unsupported_Empty()
if (Settings.DeleteAfterSuccess) File.Delete(testFileLocation);
}

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

// Source : tag-free M4A
string testFileLocation = TestUtils.CopyAsTempTestFile(notEmptyFile);
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, "BLEHBLEH", "heyheyhey");
theTag.AdditionalFields.Add(infoKO);

Assert.IsTrue(theFile.UpdateTagInFileAsync(theTag, MetaDataIOFactory.TagType.NATIVE).GetAwaiter().GetResult());

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);

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

// Make sure the field has indeed been ignored
Assert.IsFalse(theFile.NativeTag.AdditionalFields.ContainsKey("----:BLEHBLEH"));
Assert.IsFalse(theFile.NativeTag.AdditionalFields.ContainsKey("BLEHBLEH"));

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

[TestMethod]
public void TagIO_RW_MP4_NonStandard_MoreThan4Chars_OK()
{
new ConsoleLogger();

// Add a field outside MP4 standards, with or without the leading '----'
string testFileLocation = TestUtils.CopyAsTempTestFile(notEmptyFile);
AudioDataManager theFile = new AudioDataManager(AudioDataIOFactory.GetInstance().GetFromPath(testFileLocation));

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

TagData 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.UpdateTagInFileAsync(theTag, MetaDataIOFactory.TagType.NATIVE).GetAwaiter().GetResult());

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()
{
Expand Down
Binary file modified ATL.unit-test/Resources/MP4/mp4.m4a
Binary file not shown.
Binary file modified ATL.unit-test/Resources/MP4/mp4_date_in_©day.m4a
Binary file not shown.
53 changes: 28 additions & 25 deletions ATL/AudioData/IO/MP4.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ class MP4 : MetaDataIO, IAudioDataIO
public const byte MP4_BITRATE_TYPE_CBR = 1; // CBR
public const byte MP4_BITRATE_TYPE_VBR = 2; // VBR

// de facto default namespace for custom fields
private const string DEFAULT_NAMESPACE = "com.apple.iTunes";

private static readonly byte[] FILE_HEADER = Utils.Latin1Encoding.GetBytes("ftyp");

private static readonly byte[] ILST_CORE_SIGNATURE = { 0, 0, 0, 8, 105, 108, 115, 116 }; // (int32)8 followed by "ilst" field code
Expand Down Expand Up @@ -73,7 +76,8 @@ class MP4 : MetaDataIO, IAudioDataIO
{ "©pub", Field.PUBLISHER },
{ "rldt", Field.PUBLISHING_DATE},
{ "prID", Field.PRODUCT_ID},
{ "----:com.apple.iTunes:CONDUCTOR", Field.CONDUCTOR },
{ "©con", Field.CONDUCTOR },
{ "CONDUCTOR", Field.CONDUCTOR }, // aka ----:com.apple.iTunes:CONDUCTOR
{ "soal", Field.SORT_ALBUM },
{ "soaa", Field.SORT_ALBUM_ARTIST },
{ "soar", Field.SORT_ARTIST },
Expand Down Expand Up @@ -187,16 +191,11 @@ 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

LogDelegator.GetLogDelegate()(Log.LV_ERROR, "Non-standard fields must have a namespace (e.g. namespace:fieldName) Field '" + cleanedCode + "' will be ignored.");
return false;
return true;
}


Expand Down Expand Up @@ -1272,10 +1271,7 @@ private void readTag(BinaryReader source, ReadTagParams readTagParams)
tagExists = false;
return;
}
else
{
tagExists = true;
}
tagExists = true;

StringBuilder atomHeaderBuilder = new StringBuilder();
// Browse all metadata
Expand All @@ -1296,7 +1292,12 @@ private void readTag(BinaryReader source, ReadTagParams readTagParams)
return;
}
source.BaseStream.Seek(4, SeekOrigin.Current); // 4-byte flags
atomHeaderBuilder.Append(":").Append(Utils.Latin1Encoding.GetString(source.ReadBytes((int)metadataSize - 8 - 4)));
string nmeSpace = Utils.Latin1Encoding.GetString(source.ReadBytes((int)metadataSize - 8 - 4));

// Only add namespace to the atom name if it's different than the default namespace
if (!nmeSpace.Equals(DEFAULT_NAMESPACE, StringComparison.OrdinalIgnoreCase))
atomHeaderBuilder.Append(':').Append(nmeSpace).Append(':');
else atomHeaderBuilder.Clear();

metadataSize = navigateToAtom(source, "name"); // field type
if (0 == metadataSize)
Expand All @@ -1305,7 +1306,7 @@ private void readTag(BinaryReader source, ReadTagParams readTagParams)
return;
}
source.BaseStream.Seek(4, SeekOrigin.Current); // 4-byte flags
atomHeaderBuilder.Append(":").Append(Utils.Latin1Encoding.GetString(source.ReadBytes((int)metadataSize - 8 - 4)));
atomHeaderBuilder.Append(Utils.Latin1Encoding.GetString(source.ReadBytes((int)metadataSize - 8 - 4)));
}
string atomHeader = atomHeaderBuilder.ToString();

Expand Down Expand Up @@ -1787,16 +1788,18 @@ private void writeTextFrame(BinaryWriter writer, string frameCode, string text)
// == METADATA HEADER ==
var frameSizePos1 = writer.BaseStream.Position;
writer.Write(0); // Frame size placeholder to be rewritten in a few lines
if (frameCode.Length > FieldCodeFixedLength && !frameCode.StartsWith("WM/", StringComparison.OrdinalIgnoreCase)) // Specific non-Microsoft custom metadata

if (!frameCode.StartsWith("WM/", StringComparison.OrdinalIgnoreCase))
{
string[] frameCodeComponents = frameCode.Split(':');
bool isComplete = frameCodeComponents.Length > 2 && frameCodeComponents[0] == "----";
if (isComplete || frameCodeComponents.Length > 1)
// Non-Microsoft custom metadata
if (frameCode.Length > FieldCodeFixedLength)
{
writer.Write(Utils.Latin1Encoding.GetBytes("----"));
string[] frameCodeComponents = frameCode.Split(':');
string nmespace = DEFAULT_NAMESPACE;
if (2 == frameCodeComponents.Length && !frameCodeComponents[0].StartsWith("--")) nmespace = frameCodeComponents[0];
string fieldCode = frameCodeComponents[^1];

string nmespace = isComplete ? frameCodeComponents[1] : frameCodeComponents[0];
string fieldCode = isComplete ? frameCodeComponents[2] : frameCodeComponents[1];
writer.Write(Utils.Latin1Encoding.GetBytes("----"));

writer.Write(StreamUtils.EncodeBEInt32(nmespace.Length + 4 + 4 + 4));
writer.Write(Utils.Latin1Encoding.GetBytes("mean"));
Expand All @@ -1808,10 +1811,10 @@ private void writeTextFrame(BinaryWriter writer, string frameCode, string text)
writer.Write(frameFlags);
writer.Write(Utils.Latin1Encoding.GetBytes(fieldCode));
}
}
else if (!frameCode.StartsWith("WM/", StringComparison.OrdinalIgnoreCase))
{
writer.Write(Utils.Latin1Encoding.GetBytes(frameCode));
else // Standard-looking metadata
{
writer.Write(Utils.Latin1Encoding.GetBytes(frameCode));
}
}

// == METADATA VALUE ==
Expand Down

0 comments on commit c401e4f

Please sign in to comment.