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






4.38/5 (13投票s)
2005年7月31日
7分钟阅读

78610

1066
从目录树中的 MP3 文件中提取各种信息(
引言
好的,首先,让我们定义一下目标。我们想选择一个文件夹,查找其中及其子文件夹中的所有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 项目,以便您可以立即测试该类。
许可证
本文未附加明确的许可证,但可能在文章文本或下载文件本身中包含使用条款。如有疑问,请通过下面的讨论区联系作者。
作者可能使用的许可证列表可以在此处找到。