65.9K
CodeProject 正在变化。 阅读更多。
Home

将标签和详细信息从您的 MP3 收藏中提取到 XML

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.38/5 (13投票s)

2005年7月31日

7分钟阅读

viewsIcon

78610

downloadIcon

1066

从目录树中的 MP3 文件中提取各种信息(ID3vN 标签和一般信息)并将其转换为 XML 格式。

引言

好的,首先,让我们定义一下目标。我们想选择一个文件夹,查找其中及其子文件夹中的所有MP3文件,读取其中包含的信息,并使用某种介质存储。介质选择将是XML;我修改了Erhan Hosca的代码,并将其用作我类的“骨架”代码,以避免自己枚举目录和文件的麻烦。你可以在这里找到它。

该类不适用于存档或加密的头部。在我的MP3收藏中(相信我,它真的很大)进行测试,没有发现类似的情况,所以我想它并没有被任何人真正使用。如果你发现任何其他错误或遗漏,请联系我。坦率地说,我是在两年前写这段代码来熟悉C#的(这是我继Hello World之后的第二个C#程序),所以它可能远非最佳。我特别不喜欢它使用了大量的字节数组。另一方面,它速度快,并且可以很容易地重写以符合C#纯粹主义者的最高标准。其目的是展示一种解析二进制文件的方法,或者更确切地说,解析我们都熟悉和喜爱的MP3文件中使用的字节和位的布局。

读取标签

首先,让我们开发一个类来从单个MP3文件读取数据。我们创建一个空白解决方案并在其中添加新类,并将其更改为static,因为除了文件名之外没有实例特定的信息。这就是我们得到的

namespace Mp3_Lister
{
    static class Mp3Reader
    {
    }
}

让我们加入一些东西。首先,我们将一些常见的ID3v2标签的代码映射到我们的XML属性的描述性名称。为了将它们与ID3v1标签区分开来,我添加了v2-前缀。然后,我们添加映射,以便轻松地从MP3文件头中用于表示比特率和采样率值的神秘字节中解码这些值。然后我们添加了一些版本、层、声道模式和流派的数组,所有这些都按照文件中表示它们的代码放置。最后,有一个小的static方法,我们用它根据数字获取流派名称。所有这些都在一个static构造函数中初始化,所以当我们的类首次使用时,它将可用。

        private static Hashtable TagMap;
        private static Hashtable BitrateMap;
        private static Hashtable RateMap;
        private static int picCounter;
        
        static Mp3Reader()
        {
            TagMap = new Hashtable();
            TagMap.Add("TIT2","v2-song-title");
            //...

            TagMap.Add("TCON","v2-genre");
            BitrateMap = new Hashtable();
            BitrateMap.Add("011","free");
            //...
            BitrateMap.Add("1523","bad");
            RateMap = new Hashtable();            
            RateMap.Add("01","44100");
            //...

            RateMap.Add("33","bad");
        }
        
        private static double[] versions = {2.5,0,2,1};

        private static int[] layers = {0,3,2,1};

        private static string[] channelModes = {"Stereo",
                       "JointStereo","DualChannel","Mono"};

        private static string[] genres = {"Blues", 
               "Classic Rock", "Country", "Dance", "Disco", 
               "Funk","Grunge", "Hip-Hop",  
                //...

               "Thrash Metal", "Anime", "Jpop", "Synthpop"};

        public static string Genre(int index)
        {
            return ((index < genres.Length) ? 
                                 genres[index] : "Unknown");
        }

现在我们已经定义完毕,让我们添加一个简单的方法来删除MP3标签中可能遇到的奇怪字符,以避免XML错误。此方法还将处理MP3文件中具有相同名称的双(或多个)标签。这将获取包含来自MP3文件的潜在危险数据的属性的名称和值。XML文档和要操作的XML元素通过引用传递给我们的方法,以便我们可以将数据附加到它们。这非常符合我们的模式,目录枚举类可能会获取文件数据(大小、修改日期等),创建代表此特定文件的XML元素,然后将其传递给我们的方法,允许我们添加我们将要提取的所有附加数据。

        private static void SetXmlAttribute(string attributeName, 
                         string attributeValue, ref XmlDocument xmlDoc,
                         ref XmlElement xmlElement)
        {            
            XmlAttribute xmlAttrib;
            string separator = "";

            if (xmlElement.GetAttributeNode(attributeName) == null)
            {
                xmlAttrib = xmlDoc.CreateAttribute(attributeName);
                xmlElement.Attributes.Append(xmlAttrib);
                xmlAttrib.Value = "";
            }
            else
            {
                separator = "; ";
                xmlAttrib = xmlElement.GetAttributeNode(attributeName);
            }
                
            for (int i = 0; i < attributeValue.Length; i++)
            {
                if ((attributeValue[i] < '\x20') && 
                        (attributeValue[i] != '\t') && 
                        (attributeValue[i] != '\n') && 
                        (attributeValue[i] != '\r'))
                {
                    attributeValue = attributeValue.Remove(i, 1);
                    i--;
                }
            }
            xmlAttrib.Value += 
                    separator + attributeValue.Replace("\"", """);
        }

然后,让我们添加我们的类将使用的主要方法,即读取实际文件并提取信息的方法。我立即将文件打开例程放入其中。我们还创建一个字节数组用于读取小的头部数据。

        public static void getTagInfo(string fileName, 
             ref XmlElement tagInfo, ref XmlDocument xmlDoc)
        {
            FileStream mp3File;
            long startPos = 0;
            byte[] ba = new byte[6];
            XmlAttribute xmlAttrib;
            try
            {
                mp3File = new FileStream(fileName, 
                              FileMode.Open, FileAccess.Read);
            }
            catch (Exception e)
            {
                xmlAttrib = xmlDoc.CreateAttribute("file-error");
                xmlAttrib.Value = e.Message;
                tagInfo.Attributes.Append(xmlAttrib);
                return;
            }
        }

好的,我们首先检查文件开头的头部。这很简单——如果文件以“ID3”开头,则存在头部;如果没有,则不存在头部。我们创建一个属性来指示其存在。

            mp3File.Read(ba, 0, 6);
            xmlAttrib = xmlDoc.CreateAttribute("id3v2");
            if ((((char)ba[0]).ToString() + 
                         ((char)ba[1]).ToString() + 
                         ((char)ba[2]).ToString()) == "ID3")
            {
                xmlAttrib.Value = "1";
                tagInfo.Attributes.Append(xmlAttrib);
            }
            else
            {
                xmlAttrib.Value = "0";
                tagInfo.Attributes.Append(xmlAttrib);
            }

如果存在头部,我们可能想解析它。首先,我们从第四个字节获取标签的版本,并为一些变量(如标签名称长度)设置值,以便进一步处理。然后,我们从第六个标志字节中的位中提取有关扩展头部的信息。

                int version = ba[3];
                int thsize;
                int tfsize;
                thsize = (version > 2) ? 4 : 3;
                tfsize = (version > 2) ? 2 : 0;
                bool isExtended = false;
                //check 6th byte of ba for flags 
                //( left bits : unsync-extended-experimental ) 

                if ((byte)(ba[5] << 1) > 127)
                {
                    isExtended = true;
                }
                mp3File.Read(ba, 0, 4);

好的,接下来是什么?接下来,我们实现一个static函数,它将帮助我们进行头部解析。它获取文件中四个字节中加密的长度,并从特殊的位移格式解密为实际数字。代码如下:

        private static int GetLength(byte[] ba)
        {
            int len = (ba[3] + (byte)( ba[2] << 7 ));
            len += ((byte)(ba[2] >> 1) +  (byte)(ba[1] << 6))*256;
            len += ((byte)(ba[1] >> 2) +  (byte)(ba[0] << 5))*65536;
            len += (byte)(ba[0] >> 3)*16776960;
            return len;
        }

接下来,我们使用它来找出头部和扩展头部的长度,假设它存在。我们跳过扩展头部并将主头部读入我们的字节数组。我们还准备了一些变量用于标签提取。

                int headerLength = GetLength(ba);
                if (isExtended)
                {
                    mp3File.Read(ba, 0, 4);
                    int extHeaderLength = GetLength(ba) - 4;
                    ba = new byte[extHeaderLength];
                    mp3File.Read(ba, 0, extHeaderLength);
                }
                ba = new byte[headerLength];
                mp3File.Read(ba, 0, headerLength);
                startPos = mp3File.Position;

                int pos = 0;
                byte[] tag = new byte[thsize];
                byte[] len = new byte[thsize];
                byte[] str;
                string tagName, tagContent;
                int tagLength = 0;

我们添加一个简单的循环来遍历标签。当我们超出头部,或者当在预期位置找不到标签名称时,循环结束。

                do
                {
                    if ((pos + 10) > headerLength)
                    {
                        break;
                    }

                }
                while (tagLength > 0);

我们将标签名称和长度放置到位。然后,我们检查标签的内容是否已加密或压缩。如果是,我们添加相应的信息。在整个过程中,我们仔细维护在解析字节数组时的位置(即pos变量)。压缩和加密标志显示头部中还有额外的内容——我的简单类中没有处理它。

                    tagName = ""; tagContent = "";
                    Array.Copy(ba, pos, tag, 0, thsize);
                    pos += thsize;
                    Array.Copy(ba, pos, len, 0, thsize);
                    pos += thsize;
                    if (tfsize > 0)
                    {
                        int shift = 0;
                        if (ba[pos + 1] > 127)
                        {
                            shift += 4;
                            tagContent += "compressed; ";
                        }
                        if ((byte)(ba[pos + 1] << 1) > 127)
                        {
                            shift += 1;
                            tagContent += "encrypted; ";
                        }
                        if ((byte)(ba[pos + 1] << 1) > 127)
                        {
                            shift += 1;
                        }
                        pos += (2 + shift);
                    }

之后,我们计算标签长度(它使用一种不同的、直接的算法,所以getlength函数不适用),并将其填充到另一个字节数组中。

//tagLength = len[0]*65536*256+len[1]*65536+len[2]*256+len[3];
    tagLength = 0;
    for (int i = thsize - 1; i >= 0; i--)
    {
        int ml = 1;
        for (int j = i; j < thsize - 1; j++)
        {
            ml *= 256;
        }
        //get multiplier

        tagLength += len[i] * ml;
    }
    str = new byte[tagLength];
    if (tagLength > ba.Length)
    {
        //means someone was too bored to stuff the end of the header with 
        //\0s and used fancy text instead so out length detection screwed up
        SetXmlAttribute("potential-error", "1", ref xmlDoc, ref tagInfo);
        break;
    }
    Array.Copy(ba, pos, str, 0, tagLength);
    pos += tagLength;
    tagName = System.Text.Encoding.ASCII.GetString(tag);

最后,我们将标签内容获取到另一个数组中,如果一切正常,就将其添加到我们的XML元素中。

                    if ((tagLength > 0) && (tagName.Length > 0))
                    {
                        tagContent = Mp3Reader.TransformV2Tag(tagName, str);
                        tagName = ((Mp3Reader.TagMap.Contains(tagName)) ? 
                                     ((string)Mp3Reader.TagMap[tagName]) : 
                                                         "v2-tag-" + tagName);
                        SetXmlAttribute(tagName,tagContent,
                                          ref xmlDoc,ref tagInfo);
                    }

最后一部分代码是TransformV2Tag函数。它可以用于将不同标签的数据转换为某种可读形式,或者如果您想修改代码一段时间,可以将其公开为事件。在我的简单情况下,它会从标签中删除\0,将长度数据转换为人类可读的格式,并提取图片。ID3v2标签中可以存储许多图片,但我都以相同的方式处理它们,舍弃了可用的类型信息(如封面、标签标志等)和描述,并将它们保存到名为imageN的文件中。然后将图像名称写入标签内容而不是实际的二进制数据。代码如下:

        public static string TransformV2Tag(string tagName, 
                                     byte[] tagContentArray)
        {
            //the only binary tag we are going to handle
            string rv = "";
            if (tagName != "APIC")
            {
                rv = System.Text.Encoding.ASCII.GetString(tagContentArray);
            }

            string tmp;
            if (tagName == "TLEN")
            {
                rv = rv.Replace('\0', ' ').Trim();
                int sLength = Int32.Parse(rv);
                rv = "";
                int ln;
                sLength = sLength / 1000;
                if (sLength > 3600)
                {
                    ln = (int)Math.Floor((double)sLength / 3600);
                    rv += ln.ToString();
                    sLength -= ln * 3600;
                    rv += ":";
                }
                if (sLength > 60)
                {
                    ln = ((int)Math.Floor((double)sLength / 60));
                    tmp = ln.ToString();
                    if (tmp.Length == 1) tmp = "0" + tmp;
                    rv += tmp;
                    sLength -= ln * 60;
                    rv += ":";
                }
                else
                {
                    rv += "00:";
                }
                tmp = sLength.ToString();
                if (tmp.Length == 1) tmp = "0" + tmp;
                rv += tmp;
            }
            if (tagName == "APIC")
            {
                byte[] tmpStart = new byte[40];
                Array.Copy(tagContentArray, tmpStart, 
                            Math.Min(40,tagContentArray.Length));
                string tagContent = 
                   System.Text.Encoding.ASCII.GetString(tmpStart);

                int zeroCount = 0, ii = tagContent.IndexOf("image/");
                while (zeroCount < 3)
                {
                    if (tagContentArray[ii] == 0)
                    {
                        zeroCount++;
                    }
                    ii++;
                } 
                
                tagContent = tagContent.Remove(0, 
                                tagContent.IndexOf("image/") + 6);
                string imgExt = tagContent.Substring(0, 
                                        tagContent.IndexOf('\0'));

                if ((tagContentArray.Length - ii) > 0)
                {
                    FileStream picFile = new FileStream("image" + 
                                    picCounter.ToString() + "." + 
                                    imgExt, FileMode.Create, 
                                    FileAccess.Write);
                    picCounter++;

                    picFile.Write(tagContentArray, ii, 
                                    tagContentArray.Length - ii);
                    /*for (int i = ii; i < tagContentArray.Length; i++)
                    {
                        picFile.WriteByte((byte)tagContentA[i]);
                    }*/
                    picFile.Close();
                    rv = "image" + (picCounter - 1).ToString() + "." + imgExt;
                }
                else
                {
                    rv = "empty";
                }
            }

            rv = rv.Replace('\0', ' ').Trim();
            return rv;
        }

好了,ID3v2部分我们已经完成了,现在开始解析老式的v1标签。v1不像v2,它使用文件末尾的128个字节来存储一些数据,没有任何标签头或其他任何东西。每个数据位都在预定义的某个位置开始和结束。检查标签很简单——它以“TAG”开头。

            mp3File.Seek(-128, SeekOrigin.End);
            ba = new byte[128];
            mp3File.Read(ba, 0, 128);
            //Console.WriteLine((((char)ba[0]).ToString()+
            //  ((char)ba[1]).ToString()+((char)ba[2]).ToString() ));
            if ((((char)ba[0]).ToString() + ((char)ba[1]).ToString() + 
                                      ((char)ba[2]).ToString()) == "TAG")
            {
                xmlAttrib = xmlDoc.CreateAttribute("id3v1");
                xmlAttrib.Value = "1";
                tagInfo.Attributes.Append(xmlAttrib);
                string tagContent;
                tagContent = Mp3Reader.GetV1Tag(ba, 3, 33);
                if (tagContent.Length > 0)
                {
                    SetXmlAttribute("song-title", 
                              tagContent, ref xmlDoc, ref tagInfo);
                }
                tagContent = Mp3Reader.GetV1Tag(ba, 33, 63);
                if (tagContent.Length > 0)
                {
                    SetXmlAttribute("artist", tagContent, 
                                          ref xmlDoc, ref tagInfo);
                }
                tagContent = Mp3Reader.GetV1Tag(ba, 63, 93);
                if (tagContent.Length > 0)
                {
                    SetXmlAttribute("album-title", tagContent, 
                                          ref xmlDoc, ref tagInfo);
                }
                tagContent = Mp3Reader.GetV1Tag(ba, 93, 97);
                if (tagContent.Length > 0)
                {
                    SetXmlAttribute("year", tagContent, 
                                          ref xmlDoc, ref tagInfo);
                }
                tagContent = Mp3Reader.GetV1Tag(ba, 97, 126);
                if (tagContent.Length > 0)
                {
                    SetXmlAttribute("comment", tagContent, 
                                          ref xmlDoc, ref tagInfo);
                }
                tagContent = Mp3Reader.GetV1Tag(ba, 126, 127);
                if ((tagContent.Length > 0) && (ba[125] == '\0'))
                {
                    SetXmlAttribute("track", 
                                  ((int)tagContent[0]).ToString(), 
                                  ref xmlDoc, ref tagInfo);
                }
                tagContent = Mp3Reader.GetV1Tag(ba, 127, 128);
                if (tagContent.Length > 0)
                {
                    SetXmlAttribute("genre", 
                              Mp3Reader.Genre((int)tagContent[0]), 
                              ref xmlDoc, ref tagInfo);
                }
            }
            else
            {
                xmlAttrib = xmlDoc.CreateAttribute("id3v1");
                xmlAttrib.Value = "0";
                tagInfo.Attributes.Append(xmlAttrib);
            }

您会看到这里使用了另一个函数,GetV1Tag。这是一个简单的函数,它将包含以null结尾的string的字节数组转换为实际的string。代码如下:

        private static string GetV1Tag(byte[] ba, int sp, int ep)
        {
            string tagContent = "";
            for (int i = sp; i < ep; i++)
            {
                if (ba[i] == 0)
                {
                    break;
                }
                tagContent += (char)ba[i];
            }
            return tagContent;
        }

然后,我们还有MP3文件的其余部分可以操作。让我们从它里面提取一些东西。

            mp3File.Seek(startPos, SeekOrigin.Begin);
            ba = new byte[2];
            mp3File.Read(ba, 0, 2);
            while ((ba[0] != 255) || (ba[1] < 224))
            {
                ba[0] = ba[1];
                mp3File.Read(ba, 1, 1);
            }
            byte tmp = ba[1];
            ba = new byte[3];
            ba[0] = tmp;
            mp3File.Read(ba, 1, 2);
            byte mpegLayer, mpegVersion, bitrateBits, rateBits, chanMode;
            mpegVersion = (byte)(((byte)(ba[0] << 3)) >> 6);
            mpegLayer = (byte)(((byte)(ba[0] << 5)) >> 6);
            bitrateBits = (byte)(ba[1] >> 4);
            rateBits = (byte)(((byte)(ba[1] << 4)) >> 6);
            chanMode = (byte)(ba[2] >> 6);
            xmlAttrib = xmlDoc.CreateAttribute("mpeg-version");
            xmlAttrib.Value = versions[mpegVersion].ToString();
            tagInfo.Attributes.Append(xmlAttrib);
            xmlAttrib = xmlDoc.CreateAttribute("mpeg-layer");
            xmlAttrib.Value = layers[mpegLayer].ToString();
            tagInfo.Attributes.Append(xmlAttrib);
            string mask = bitrateBits.ToString() + 
                   ((int)Math.Floor(versions[mpegVersion])).ToString() + 
                   layers[mpegLayer].ToString();
            xmlAttrib = xmlDoc.CreateAttribute("bitrate");
            xmlAttrib.Value = (string)Mp3Reader.BitrateMap[mask] + "Kbps";
            tagInfo.Attributes.Append(xmlAttrib);
            mask = rateBits.ToString() + 
                    ((int)Math.Ceiling(versions[mpegVersion])).ToString();
            xmlAttrib = xmlDoc.CreateAttribute("sampling-rate");
            xmlAttrib.Value = (string)Mp3Reader.RateMap[mask] + "Hz";
            tagInfo.Attributes.Append(xmlAttrib);
            xmlAttrib = xmlDoc.CreateAttribute("channel-mode");
            xmlAttrib.Value = (string)Mp3Reader.channelModes[chanMode];
            tagInfo.Attributes.Append(xmlAttrib);
            
            mp3File.Close();

首先,我们跳过头部,找到第一个包含声音的块。它有自己的头部。我们取第一个头部(这意味着VBR文件的比特率将显示不正确,我可以去寻找额外的块,但这会大大减慢整个过程)。从该块中,我们使用之前定义的HashTables提取所有有关音质的宝贵信息。最后,我们关闭文件。就是这样。

读取多个文件

现在,让我们创建另一个类并将其命名为“Mp3DirectoryEnumerator”。我将添加一个方法来创建带名称属性的XML元素(我在此XML表示中不使用任何文本节点)。还有一个XML属性创建方法直接从Mp3Lister类复制。唯一的区别是它不是static的,并且它接受实例特定的XML文档,而不是通过引用接收一个。

    class Mp3DirectoryEnumerator
    {
        XmlDocument xmlDoc;

        // modified lister from 
        // https://codeproject.org.cn/csharp/XMLDirectoryTreeGen.asp
        public Mp3DirectoryEnumerator() 
        {
        }
        
        private XmlElement XmlElement(string elementName,
                                              string elementValue)
        {
            XmlElement xmlElement = xmlDoc.CreateElement(elementName);
            xmlElement.Attributes.Append(XmlAttribute("name",
                                                       elementValue));
            return xmlElement;
        }         
    }

现在让我们为我们的类添加主要功能。

        public XmlDocument GetFileSystemInfoList(string StartFolder) 
        {
            xmlDoc = new XmlDocument();
            try
            {
                XmlDeclaration xmlDec = 
                     xmlDoc.CreateXmlDeclaration("1.0", null, "yes");
                xmlDoc.PrependChild ( xmlDec );
                XmlElement nodeElem = xmlDoc.CreateElement("list");
                xmlDoc.AppendChild(nodeElem);
                XmlElement rootElem = XmlElement("folder",
                              new DirectoryInfo(StartFolder).Name);
                nodeElem.AppendChild(AddElements(rootElem,
                                                     StartFolder));
            }
            catch (Exception ex) 
            {
                xmlDoc.AppendChild(XmlElement("error",ex.Message));
                return xmlDoc;
            }
            return xmlDoc;
        }

此方法设置我们的 XML 文档,并为父节点调用 AddElements 方法。该方法是递归的,它会遍历给定目录中的所有子目录。如果树中有 MP3 文件,它会获取它们并对它们应用 Mp3Reader 主方法,提取文件信息。

private XmlElement AddElements(XmlElement startNode,
                                                   string Folder)
{
    try
    {
        DirectoryInfo dir = new DirectoryInfo(Folder);
        DirectoryInfo[] subDirs = dir.GetDirectories();
        FileInfo[] files = dir.GetFiles();
        foreach(FileInfo fi in files)
        {
            if ( fi.Extension.ToLower().IndexOf("mp3") >= 0  )
            {
                Console.Write(fi.FullName+"\r\n");
                XmlElement fileElem = XmlElement("file",fi.Name);
                fileElem.Attributes.Append(XmlAttribute("size",
                                          fi.Length.ToString()));
                fileElem.Attributes.Append(XmlAttribute("creation-time",
                                             fi.CreationTime.ToString()));
                fileElem.Attributes.Append(XmlAttribute("last-write-time",
                                             fi.LastWriteTime.ToString()));
                Mp3Reader.getTagInfo(fi.FullName,ref fileElem,ref xmlDoc);
                startNode.AppendChild(fileElem);
            }
        }
        foreach (DirectoryInfo sd in subDirs) 
        {
            XmlElement folderElem = XmlElement("folder",sd.Name);
            startNode.AppendChild(AddElements(folderElem,sd.FullName));
        }
        return startNode;
    }
    catch (Exception ex) 
    {
        return XmlElement("error",ex.Message);
    }
}

我已经在源代码中包含了一个小的 WinForms 项目,以便您可以立即测试该类。

许可证

本文未附加明确的许可证,但可能在文章文本或下载文件本身中包含使用条款。如有疑问,请通过下面的讨论区联系作者。

作者可能使用的许可证列表可以在此处找到。

© . All rights reserved.