LyricsFetcher - 查找歌曲歌词的最简单方法






4.93/5 (82投票s)
一篇描述开发一个非简单的 C#/.NET 应用程序来获取歌曲歌词的文章。
前言
那些奶酪制造商有福了……
正如蒙提·派森所观察到的,听不清楚别人说了什么可能会产生深远的影响。所以,当我听歌时,我喜欢知道歌曲到底在唱什么。但是,我也很懒——为我购买的每一首新歌/专辑追踪歌词并不符合我天生的懒惰。于是 LyricsFetcher 应运而生,它省去了查找歌曲歌词并将它们更新到您的音乐库中的工作。
我知道还有其他机制可以做到这一点(例如 iLyrics 或 MiniLyrics),但它们都没有完全按照我想要的方式工作。此外,当我有空时,这似乎是一个有趣的任务。
应用程序的用户指南可以在这里找到。本文是为那些想要使用/理解代码的程序员编写的指南。
程序员简介
LyricsFetcher 提供了一个完整、非简单的应用程序,展示了以下技术:
- 多线程。LyricsFetcher 的几个功能是长时间运行的操作。应用程序以多线程方式处理它们,以保持响应。
- iTunes 和 Windows Media Player 集成。iTunes 和 Windows Media Player 是 Windows 上最常见的两种媒体管理应用程序(是的,我知道还有其他的)。LyricsFetcher 展示了如何与这些系统进行交互。
- COM 交互。LyricsFetcher 提供了一个示例,说明通过 COM 接口与其他应用程序进行交互是多么容易。
- Web 资源。LyricsFetcher 提供了一个使用 SOAP 服务以及通过简单 HTTP 访问资源的示例。
应用程序概述
LyricsFetcher 的功能描述很简单:它从音乐库中加载歌曲列表,然后尝试查找这些歌曲的歌词。它还可以尝试查找歌曲的元数据(标题和艺术家)。从这个简单的描述中,很明显项目可以清晰地分为四个功能区:
- 歌曲管理。这包括我们如何找到音乐库,如何加载歌曲信息,以及如何将更改写回库中。
- 歌词获取。一旦我们有了歌曲列表,该区域涵盖了我们如何找到它们的歌词:我们在哪里查找,如何获取它们。
- 元数据获取。对于那些标题或艺术家缺失或可疑的歌曲,我们如何找到它们的元数据?
- 用户界面。我们如何让用户访问上述功能区?
从歌曲开始
我们首先处理歌曲管理子系统。
我们的第一个任务是决定我们要管理哪个音乐库。这是一个设计决策,我们将同时使用 iTunes 和 Windows Media Player,但我们如何知道用户想使用哪个?LyricsFetcher 默认选择 iTunes(如果已安装);否则,它使用 Windows Media Player(用户可以在应用程序中轻松更改此设置)。
我们如何知道 iTunes 是否已安装?最简单的方法是尝试运行它——如果未安装,它会抛出错误。
public bool HasITunes {
get {
if (this.iTunesApp == null) {
try {
this.iTunesApp = new iTunesAppClass();
}
catch (Exception) {
// Couldn’t create iTunes app.
// It’s probably not installed
}
}
return (this.iTunesApp != null);
}
}
这很好,除了一个问题:如果 iTunes 已安装,它实际上会运行 iTunes。这通常是需要的——但并非总是如此。如果用户同时安装了 iTunes 和 WMP,但正在使用 LyricsFetcher for WMP,他们不希望每次启动 LyricsFetcher 时都运行 iTunes。所以,我们需要另一种方式来判断 iTunes 是否已安装。
iTunes 附带一个名为 ITDetector 的奇怪 OCX 文件。稍加探究就会发现这正是我们需要的:它会在不运行应用程序的情况下告诉我们 iTunes 是否已安装。我向 LyricsFetcher 项目添加了对这个 OCX 的引用,现在我们可以判断 iTunes 是否已安装,如下所示:
public bool HasITunes {
get {
iTunesDetectorClass detector = new iTunesDetectorClass();
return detector.IsiTunesAvailable;
}
}
然而,这是一个经典的聪明愚蠢的例子。我应该立刻明白(但没有)这个 OCX 是由 iTunes 安装的,所以如果 iTunes 未安装,ITDetector OCX 也不会存在来告诉我 iTunes 是否已安装。此外,一些人报告说在 64 位 Windows 上,这个 OCX 不起作用。所以,我只好求助于低技术解决方案,只是在注册表中查找一个键——非常无聊。
public bool HasITunes {
get {
// Resort to low tech solution
string regKey = @"HKEY_LOCAL_MACHINE\SOFTWARE\Apple Computer, Inc.\iTunes";
string value = Registry.GetValue(regKey, "ProgramFolder", "") as string;
return !String.IsNullOrEmpty(value);
}
}
[更新:2009年10月] 这很无聊——而且是错误的。在 64 位 Windows 版本上,检查这个注册表键不起作用,因为 Windows-on-Windows(允许 32 位应用程序在 64 位操作系统上运行的子系统)对注册表做了奇怪的事情。所以,在 64 位 Windows 上,这个测试也失败了。
与 iTunes 通信
一旦我们知道将使用哪个音乐库,我们 then 需要一种方法从音乐库中读取所有歌曲。iTunes 和 WMP 都支持 COM,所以这是我在 .NET 应用程序中玩弄 COM 的机会。[在此讨论中,我只谈论 iTunes,尽管使用 WMP 类似。]
在 .NET 应用程序中使用 COM 极其简单。将对类型库或 OCX 的引用添加到您的项目中,.NET 的魔力会处理所有其他事情。COM 对象可以作为完整的 C# 对象使用,所有转换都透明处理,甚至 IntelliCode 也完美无缺地工作。这与 COM 开发的早期时代相去甚远。
因此,要通过 iTunes 的 COM 接口控制 iTunes,我们只需向 LyricsFetcher 项目添加对 iTunes 类型库的引用。为此,右键单击您的项目并选择“添加引用...” 在最终打开的对话框中,选择“COM”选项卡,然后滚动列表查找“iTunes X.X Type Library”。选择最新版本,然后单击“确定”。
Visual Studio(或 SharpDevelop)将生成一个互操作程序集,然后所有 iTunes 对象都将在应用程序中可用。您可能需要下载 iTunes SDK 以获取完整的文档。
既然我们能够处理 iTunes,我们需要一个模型对象来表示每首歌曲。它可能看起来像这样:
using iTunesLib; // iTunes COM classes live in this namespace
public class Song
{
#region Constructors
public Song() {
}
public Song(string title, string artist, string album, string genre) {
this.Title = title;
this.Artist = artist;
this.Album = album;
this.Genre = genre;
}
public Song(IITTrack track) :
this(track.Name, track.Artist, track.Album, track.Genre) {
IITFileOrCDTrack fileTrack = track as IITFileOrCDTrack;
if (fileTrack != null) {
try {
this.Lyrics = fileTrack.Lyrics;
}
catch (COMException) {
// If the file is corrupt, missing
// or just plain obstinate, this can fail.
}
}
}
#endregion
#region Public properties
public string Album { get; set; }
public string Artist { get; set; }
public string Genre { get; set; }
public string Lyrics { get; set; }
public string Title { get; set; }
#endregion
}
这没什么特别有趣的:几个构造函数和公共属性。我个人不介意公共字段,但很多人对这类东西有非理性的厌恶,所以我使用了 C# 的缩写属性声明而不是简单的公共字段。
唯一有趣的部分是实际获取歌词。iTunes 库是 IITTrack
的集合,但 IITTrack
对象没有 Lyrics
属性——只有 IITFileOrCDTrack
对象才有。所以,我们必须向下转型轨道,如果成功,我们尝试获取歌词。尽管看起来很简单,但底层发生了很多事情,其中许多都可能出错。所以,我们捕获并忽略 COM 异常,因为我们对此无能为力。
加载 - 第一次尝试
一旦我们有了基础模型类,我们就可以尝试从 iTunes 加载歌曲。我第一次从 iTunes 库加载歌曲的代码看起来像这样:
iTunesAppClass iTunesApp = new iTunesAppClass();
IITTrackCollection tracks = iTunesApp.LibraryPlaylist.Tracks;
List<Song> songs = new List<Song>();
for (int i=1; i <= tracks.Count; i++) {
IITTrack track = tracks[i];
if (track.Kind == iTunesLib.ITTrackKind.ITTrackKindFile)
songs.Add(new Song(track));
}
这段代码展示了 iTunes 的一个特点:所有集合都是 1-based,而不是 0-based。此外,集合没有枚举器接口,所以你也不能这样说:
foreach (IITTrack track in iTunesApp.LibraryPlaylist.Tracks)
除了这些陷阱,这段代码清晰、简单、直观——代码就该如此。
但是,当我运行这段代码时,性能糟糕透顶!加载我的音乐库中大约 3000 首歌曲花了 50 秒。我不仅懒,而且也 impatient。50 秒对于我的应用程序加载来说太长了。所以,我需要加快加载速度:那个“大 O”词(不,不是那个,我说的是优化)。
在优化方面,我有两条个人原则:
- 不要做!总是编写最简单、最直观的代码,不要考虑性能。
- 如果你真的必须这样做,不要猜测——使用分析器。代码中最慢的部分几乎总是你意想不到的。分析器会准确地告诉你哪些代码很慢。JetBrain 的 dotTrace 非常出色(商业软件,但有试用版),EQATEC 的 Profiler 也很好(免费!)。
罪魁祸首行是 this.Lyrics = fileTrack.Lyrics;
。
为什么这一行如此缓慢?其他信息存储在库的索引中,但歌词存储在音乐文件本身中。要获取歌词,iTunes 必须打开相关的 MP3(或 AAC)文件,解析结构,并提取歌词标签。显然,这比仅仅读取艺术家字段要耗时得多。
加载 - 第二次尝试
第二次尝试涉及缓存歌词,以便它们只从媒体文件中读取一次。应用程序首次运行时,加载时间仍然很长,但之后每次运行都应该快得多。为了实现这一点,我们需要:
- 一个
LyricsCache
类(此处未显示代码)。 - 一个单独的方法,要求 iTunes 提供歌词。
public class Song
{
public Song(IITTrack track) :
this(track.Name, track.Artist, track.Album, track.Genre) {
this.Track = track;
}
public void GetLyrics() {
IITFileOrCDTrack fileTrack = this.Track as IITFileOrCDTrack;
if (fileTrack != null) {
try {
this.Lyrics = fileTrack.Lyrics;
}
catch (COMException) {
// If the file is corrupt, missing
// or just plain obstinate, this can fail.
}
}
}
}
库加载代码必须更改为尽可能使用缓存:
iTunesAppClass iTunesApp = new iTunesAppClass();
IITTrackCollection tracks = iTunesApp.LibraryPlaylist.Tracks;
List<Song> songs = new List<Song>();
LyricsCache cache = LyricsCache.LoadCache();
for (int i = 1; i <= tracks.Count; i++) {
IITTrack track = tracks[i];
if (track.Kind == iTunesLib.ITTrackKind.ITTrackKindFile) {
Song song = new Song(track);
if (cache.HasLyrics(song))
song.Lyrics = cache.GetLyrics(song);
else
song.GetLyrics();
songs.Add(song);
}
}
这将昂贵的操作(获取歌词)分开,并且只有在缓存无法提供帮助时才使用它。
缓存歌词后,我的 3000 首歌曲的加载时间从 50 秒下降到 20 秒。这好多了,但我仍然不太满意。20 秒对于一个不耐烦的人来说仍然是很长的时间。
加载 - 第三次尝试
对 20 秒加载时间进行分析并没有发现任何特定的热点。如果歌曲要加载得更快,我需要一种新方法。
iTunes 有一个 XMLLibrary
属性,它是保存其音乐库所有索引信息的 XML 文件的路径。对于我的 3000 首歌曲库,这个文件大约有 5.5 兆字节。可能可以直接从 XML 读取歌曲信息。但是,解析所有这些数据肯定不会比简单地向 iTunes 请求它(它已经加载了所有信息)更快吗?为了真正尝试这个,我不得不深入研究 .NET 对 XML 文件的处理。
在 COM 的快乐简单之后,.NET 对 XML 的处理令人失望。我被 Python 的 ElementTree
宠坏了,它简单而优雅。即使是 PHP(从不追求优雅)也以一种至少是显而易见的方式处理 XML。但是,.NET 处理 XML 的方式既不优雅也不显而易见。
在与 XMLTextReader
、XMLReader
和 XMLDocument
苦苦挣扎之后,我最终选择了 XMLPathDocument
及其关联的 XPathNavigator
。这对类允许您将 XML 作为分层结构文档处理(与 XMLReader
及相关类不同)。经过多次尝试和错误,从 XML 文件读取库的代码看起来像这样:
iTunesAppClass iTunesApp = new iTunesAppClass();
int maxSongs = iTunesApp.LibraryPlaylist.Tracks.Count;
XPathDocument doc = new XPathDocument(iTunesApp.XmlPath);
XPathNavigator nav = doc.CreateNavigator();
// Move to plist, then master library and tracks
nav.MoveToChild("plist", "");
nav.MoveToChild("dict", "");
nav.MoveToChild("dict", "");
// Move to first track info
bool success = nav.MoveToChild("dict", "");
// Read each song until we have enough or no more
List<Song> songs = new List<Song>();
while (success && this.Songs.Count < maxSongs) {
success = nav.MoveToFirstChild();
// Read each piece of information about the song
Dictionary<string, string> data = new Dictionary<string, string>();
while (success) {
string key = nav.Value;
nav.MoveToNext();
data[key] = nav.Value;
success = nav.MoveToNext();
}
// Create and add the song if it's not one we want to ignore
if (data.Count > 0) {
Song song = new Song(data["Name"], data["Artist"],
data["Album"], data["Genre"], data["Persistent ID"]);
if (cache.HasLyrics(song))
song.Lyrics = cache.GetLyrics(song);
else
song.GetLyrics();
songs.Add(song);
}
nav.MoveToParent();
success = nav.MoveToNext("dict", "");
}
如果你将它与我们的第一次尝试进行比较,我们已经从简单优雅走了很长一段路。但权衡的是速度。这段代码在大约 1 秒内加载了 3000 首歌曲。我对此很满意。诚然,应用程序第一次加载库时仍然很慢——它仍然需要从媒体文件中解析歌词。然而,在随后的运行中,加载时间几乎可以忽略不计。
展示一些类
对于喜欢类图的人,LyricsFetcher 使用以下结构来管理歌曲:
展示你的成果
现在我们有了即时加载的歌曲列表,我们必须将它们展示给用户。ListView
是显而易见的选择——但我讨厌 ListView
。它们令人恼火,而且编程起来很无聊。你最终可以让他们做你想做的事情,但我宁愿把精力用在其他地方。所以,对于这个项目,我选择使用来自 ObjectListView CodeProject 文章的 ObjectListView
。对于那些从未使用过这些的人来说,ObjectListView
是 ListView
的包装器,它使 ListView
大大更容易使用。您在 IDE 中配置 ObjectListView
,然后它可以将模型对象列表转换为功能齐全的列表视图。
因此,在 IDE 中,我们配置一个 ObjectListView
来显示我们想要的各种内容,当库加载完成后,我们只需要一行代码就可以将歌曲显示给用户:
this.olvSongs.SetObjects(library.Songs);
ObjectListView
会处理所有其他事情:数据提取、图像、排序、通过打字搜索,所有这些都会自动发生。您甚至可以右键单击标题以选择要查看的列。
魔鬼藏在细节中
ObjectListView
处理用户界面的核心部分。对于界面的其余部分,我们需要做更多的工作。
用户界面的下半部分显示了有关当前选中曲目的更多信息。当用户更改选中行时,我们需要知道并更新详细信息:
this.olvSongs.SelectionChanged +=
new System.EventHandler(this.olvSongs_SelectionChanged);
...
private void olvSongs_SelectionChanged(object sender, EventArgs e) {
this.UpdateDetails();
this.EnableControls();
}
private void UpdateDetails() {
Song song = this.olvSongs.SelectedObject as Song;
if (song == null) {
this.textBoxTitle.Text = "";
this.textBoxArtist.Text = "";
this.textBoxAlbum.Text = "";
this.textBoxGenre.Text = "";
this.textBoxLyrics.Text = "";
} else {
this.textBoxTitle.Text = song.Title;
this.textBoxArtist.Text = song.Artist;
this.textBoxAlbum.Text = song.Album;
this.textBoxGenre.Text = song.Genre;
this.textBoxLyrics.Text = song.Lyrics;
}
}
这段代码使用了 SelectionChanged
事件,这是 ObjectListView
提供的一个事件。对于普通的 ListView
,SelectedIndexChanged
是正常的侦听事件,但它有一个主要缺点:每当选中或取消选中一行时,都会调用该事件。这有什么问题?如果用户选中了 1000 行,然后选中了另一行,您将收到 1001 个 SelectedIndexChanged
事件:每个取消选中的行一个,加上选中的行一个。如果当选择更改时您执行任何稍微复杂的操作,应用程序可能会在您的事件处理程序进行 1000 次计算时看起来停滞。相比之下,SelectionChanged
事件只会触发一次,无论选中或取消选中了多少行。
显示详细信息 - 第二次尝试
这对于第一次尝试来说是可以的,但是当用户选择两首或更多歌曲时,它就不能很好地工作了。详细信息部分会简单地变为空白。如果它遵循相当标准的 UI 实践,即显示所有选定对象共有的值,并清空其他值,那会更好。像这样:
为了实现这一点,我们可以编写五个方法,每个方法计算一个字段的共同值。或者,我们可以编写一个方法,并使用一些反射魔术来获取命名属性。或者,我们可以使用来自 _ObjectListView_ 项目的实用类 Munger
。Munger
封装了从模型对象获取(和设置)命名属性的工作。它就像使用反射,但没有那么麻烦。
private void UpdateDetails() {
IList songs = this.olvSongs.SelectedObjects;
this.UpdateOneDetail(this.textBoxTitle, "Title", songs);
this.UpdateOneDetail(this.textBoxArtist, "Artist", songs);
this.UpdateOneDetail(this.textBoxAlbum, "Album", songs);
this.UpdateOneDetail(this.textBoxGenre, "Genre", songs);
this.UpdateOneDetail(this.textBoxLyrics, "Lyrics", songs);
}
private void UpdateOneDetail(TextBox textBox, string propertyName, IList songs) {
if (songs.Count == 0 || songs.Count > 1000)
textBox.Text = "";
else {
Munger munger = new Munger(propertyName);
string value = (string)munger.GetValue(songs[0]);
for (int i = 1; i < songs.Count; i++) {
if (value != (string)munger.GetValue(songs[i])) {
value = "";
break;
}
}
textBox.Text = value;
}
}
智能在 UpdateOneDetail()
方法中。如果没有选定的歌曲,或者选定的歌曲太多,我们只需清空该字段。否则,我们为所需的命名属性创建一个 Munger
,然后从每首歌曲中获取该命名属性的值。如果歌曲的值与其他所有歌曲相同,我们就继续;否则,我们将该字段设置为空白。
寻找歌词
好的。我们已经从 iTunes 加载了我们的歌曲。我们已经将它们展示给了用户。现在,我们如何找到它们的歌词呢(因为这才是整个练习的重点)?
勉强应付 - 第一次尝试
有许多网站可以让你找到歌曲的歌词:ELyrics、MetroLyrics 和 Lyrics007 都非常受欢迎。解决查找歌词问题的一种方法是找出包含给定歌曲歌词的网页,下载该网页,然后从 HTML 中提取歌词。这种历史悠久的技术被称为“抓取”,因为你的程序试图从 HTML 代码的海洋中抓取所需的信息。
LyricsFetcher 的第一个版本就是这样做的:生成一个可能的 URL,下载该页面,并抓取 HTML。当然,每个网站都不同,但经过一番调整,它工作得很好。但是,几周后,它就不那么好用了。每次网站改变布局时,我的抓取技术就不再起作用,我必须再次调整代码。几次之后,我决定必须有更好的方法。
SOAP 和其他简洁方法 - 第二次尝试
一些歌词网站提供了程序化 API,完全不依赖抓取。LyricsWiki 提供了 SOAP 接口(但请参见下文),而 LyrDb、LyricsPlugin 和 LyricsFly 都具有基于 HTTP 的接口。通过这些定义的接口,LyricsFetcher 可以可靠地查找歌词,而无需因网站布局更改而破坏此过程。
在使用这些接口时,我得以使用 .NET 的网络处理类。与 COM 交互一样,.NET 使处理网络资源变得非常简单。
[更新:2009年10月] 在法律行动的威胁下,LyricsWiki 被迫移除其 SOAP 接口。因此,下面讨论的服务已不再存在。然而,对于任何 SOAP 接口,其机制都是相同的,所以我将保留它。
使用 LyricsWiki 的网络界面是所有方法中最简单的。使用 SOAP 服务的关键是网络服务定义 (WSDL) 文件。一旦您知道 WSDL 文件在哪里,您就可以在项目中选择“添加网络引用”,并将 WSDL 的 URI 作为引用。在我们的例子中,LyricsWiki 的 WSDL 文件可以在这里找到:http://lyricwiki.org/server.php?wsdl。将该值插入 URL 字段,Visual Studio 将读取资源并显示可用的服务。
添加引用后,Visual Studio 会生成一堆文件,其最终效果是所有 Web 服务现在都可以作为简单的函数调用使用。
using LyricsFetcher.org.lyricwiki;
// The namespace created to hold the generated class
public string GetLyrics(Song song) {
LyricWiki lyricWiki = new LyricWiki();
LyricsResult lyricsResult = lyricWiki.getSong(song.Artist, song.Title);
return lyricsResult.lyrics;
}
我所有巧妙而复杂的抓取都被这三行代码取代了!那真是美好的一天——我扔掉了大约 500 行代码(向 Ken Thompson 致歉)。
使用 LyrDb 基于 HTTP 的 API 并没有那么容易,但它仍然比抓取方法好得多。LyrDB 使用两步 API:第一步将标题/艺术家组合转换为匹配的歌曲 ID 列表,第二步获取特定歌曲 ID 的歌词。
public string GetLyrics(Song song) {
string queryUrl = String.Format(
"http://webservices.lyrdb.com/lookup.php?q={0}|{1}&for=match",
song.Artist, song.title);
WebClient client = new WebClient();
string result = client.DownloadString(queryUrl);
if (result == String.Empty)
return String.Empty;
foreach (string x in result.Split('\n')) {
string id = x.Split('\\')[0];
Uri lyricsUrl = new Uri("http://webservices.lyrdb.com/getlyr.php?q=" + id);
string lyrics = client.DownloadString(lyricsUrl);
if (lyrics != String.Empty)
return lyrics;
}
return String.Empty;
}
在这段代码中,我们使用 .NET 的 Facade 类 WebClient
来访问 LyrDb 的查找服务。在底层,WebClient
根据 URI 的协议决定使用哪个确切的类。在这里,我们只是想要 HTTP 协议。DownloadString()
方法使读取网络资源非常简单。
LyrDb 对查找请求的响应将是几行,每行看起来像这样:{songId}\{title}\{artist}。我们想要歌曲 ID,这是每行的第一部分。一旦我们有了歌曲 ID,我们使用第二个服务来获取歌曲的歌词。不如 LyricWiki 的 SOAP 服务简单,但同样,比 HTML 抓取有了巨大的改进。
展示更多类
获取歌词的类结构如下:
多线程
歌曲库加载和歌词获取的底层都是线程。这两个操作都需要一些时间来执行,我们不希望在它们运行时 UI 被冻结。
在文章中提到的所有技术中,线程是最难掌握的。它最难调试,也最令人沮丧地尝试进行单元测试。它会产生无法重现的错误——除非您正在向最大的客户进行演示,在这种情况下,它们将很容易重现!
如果您是线程新手,您必须阅读Sasha Barber 出色的系列文章。即使您已经是专家,您可能仍然会学到一些东西。
LyricsFetcher 的线程代码围绕 .NET 的 BackgroundWorker
类构建。它结合了一个单独的执行线程,并具有发出进度信号和取消的能力。在 LyricsFetcher 项目中,我决定聚合而不是子类化 BackgroundWorker
(has-a 而不是 is-a),尽管事实证明子类化也能很好地工作。所有线程处理、取消、连接都收集到 BackgroundWorkerWithProgress
类中。然后,所有长时间运行的任务都子类化此F类并实现 DoWork()
方法。
所以,我们加载 Windows Media Player 库的长时间运行代码(或多或少)看起来像这样:
public class WmpSongLoader : BackgroundWorkerWithProgress
{
protected override object DoWork(DoWorkEventArgs e) {
IWMPPlaylist tracks = Wmp.Instance.AllTracks;
// How many tracks are there and how many songs should we fetch?
int trackCount = tracks.count;
int maxSongs = trackCount;
if (this.MaxSongsToFetch > 0)
maxSongs = Math.Min(trackCount, this.MaxSongsToFetch);
this.ReportProgress(0, "Gettings songs...");
for (int i = 0; i < trackCount && this.Songs.Count <
maxSongs && this.CanContinueRunning; i++) {
IWMPMedia track = tracks.get_Item(i);
this.AddSong(new WmpSong(track));
this.ReportProgress((i * 100) / maxSongs);
}
return true;
}
}
如您所见,这段代码几乎不必担心任何与线程相关的事情——它只需专注于需要做的事情。它只需要定期检查线程是否已被取消,并调用 ReportProgress()
让大家知道它离完成还有多远。
那首歌叫什么名字?
LyricsFetcher 的一个局限性在于它依赖于歌曲的正确名称和艺术家。在 v0.5.1 中,LyricsFetcher 可以仅根据歌曲名称查找歌词,但如果歌曲名称错误,它就会卡住。
LyricsFetcher 现在能够仅根据歌曲的音乐查找歌曲的标题和艺术家。如果歌曲的标题或艺术家缺失或可疑,LyricsFetcher 的元数据查找现在可以尝试查找有关该歌曲的正确信息。例如,在我儿子的歌曲库中,有一首 Usher 的歌曲“Come get me”。这是一首著名的歌曲,但 LyricsFetcher 找不到它的任何歌词。但是当我添加了元数据查找并将其用于该歌曲时,它发现歌曲的标题实际上是“Yeah”。有了这个标题,LyricsFetcher 很容易就找到了歌词。
这项技术被称为声学指纹。LyricsFetcher 使用MusicIP 的开放指纹架构库来创建歌曲的“指纹”,然后使用他们的音频数据库尝试将此指纹与已知歌曲匹配。
实现它
第一次尝试实现此功能时直接使用了开放指纹架构库。这个库似乎只适用于 WAV 格式的文件,所以我收集了各种代码资源来将其他音频格式转换为 WAV 格式。生成指纹后,我直接调用了 MusicDNS 服务。尽管这个方案有效,但它既复杂又缓慢——这是我在代码中非常不喜欢的两个方面。
第二次尝试使用了 MuscIP 提供的命令行程序 genpuid。傲慢是我的另一个特点,但这并不能阻止我使用别人的代码,尤其是当他们的代码比我的快两倍时!在我的第一次实现中,查找歌曲元数据大约需要 20 秒;genpuid 在大约 10 秒内完成了同样的事情。这足以让我放弃我的第一次实现并使用他们的程序。
private string FetchMetaData(string fileName) {
this.cmdLineProcess = new Process();
cmdLineProcess.StartInfo.FileName = "genpuid";
cmdLineProcess.StartInfo.RedirectStandardOutput = true;
cmdLineProcess.StartInfo.UseShellExecute = false;
cmdLineProcess.StartInfo.CreateNoWindow = true;
string arguments = @"{0} -xml -rmd=2 ""{1}""";
cmdLineProcess.StartInfo.Arguments =
String.Format(arguments, CLIENT_ID, fileName);
cmdLineProcess.Start();
cmdLineProcess.PriorityClass = ProcessPriorityClass.BelowNormal;
// have to read before waiting!
string result = cmdLineProcess.StandardOutput.ReadToEnd();
cmdLineProcess.WaitForExit();
this.cmdLineProcess = null;
return result;
}
private Process cmdLineProcess;
使用子进程时要记住的一件事是它们独立于启动进程。如果您的程序退出,其子进程将继续运行。对于 LyricsFetcher 应用程序,这意味着当用户退出应用程序时,我们必须专门杀死任何仍在进行中的 genpuid 进程:
public override void Cancel() {
base.Cancel();
if (this.cmdLineProcess != null && !this.cmdLineProcess.HasExited)
this.cmdLineProcess.Kill();
}
WMP - 让生活变得有趣
心理学家说,人们需要一定程度的压力和挫折才能让生活变得有趣。在这个项目中,与 WMP 的集成让我的生活变得太有趣了。这种额外的兴趣是在尝试将歌词存储回 WMP 时激发的。根据 WMP SDK,这样做应该很容易:
if (!this.Media.isReadOnlyItem("WM/Lyrics")) {
this.Media.setItemInfo("WM/Lyrics", this.Lyrics);
}
确实,这似乎运作良好。但是,当我通过 WMP 的高级标签编辑器查看该曲目时,歌词在那里——但有数百次!每种注册语言一次!
这比我预期的要多一点。更糟糕的是,当我在另一台安装了旧版 WMP(版本 9)的电脑上运行代码时,歌词根本没有更新!代码与微软所说的完全一致,应该可以正常工作——但它没有!现在怎么办?
Google 通常是解决这类难题的答案。几乎所有问题都已被其他人解决。但是,在这种情况下并非如此。我找到了几个人遇到了歌词多语言问题,但他们的问题从未得到回答。如果他们找到了解决方案,他们就会守护他们来之不易的知识。
编辑元数据
当然,一定有更直接的方法来更新媒体文件中的信息。经过多次失败的尝试,这篇关于 MetaDataReader 的 CodeProject 文章为我指明了正确的方向。Window Media 子系统有一个 IWMMetaDataEditor
接口。这个接口,结合 IWMHeaderInfo3
,可以(最终)用于编辑存储在媒体文件中的标签。
最终,解决这个问题只需要三行代码。我把这行代码改成了:
this.Media.setItemInfo("WM/Lyrics", this.Lyrics);
变成这些行:
using (MetaDataEditor editor = new MetaDataEditor(this.Media.sourceURL)) {
editor.SetFieldValue("WM/Lyrics", this.Lyrics);
}
但为了找到这三行正确的代码,我付出了很多努力!
这个新代码适用于 WMP 9、10 和 11。它在 WMP 12 中可能多余,但谁知道呢?
这种将歌词直接写入媒体文件的方法之所以奏效,是因为 WMP 不将其歌词存储在自己的数据库中,而是始终从媒体文件本身读取。此方法不适用于大多数其他属性(如流派或艺术家),因为这些属性仅在首次导入曲目时从媒体文件读取,然后存储在 WMP 的内部数据库中。更改媒体文件中的这些属性不会更改 WMP 中的信息。
此元数据编辑器的一个限制是它只适用于 Windows Media 能够理解的文件格式。WMA 和 MP3 文件处理正常,但 AAC、OGG 和 FLAC 文件无法通过这种方式更新。这是一个限制,但它与 Windows Media Player 本身完全相同,后者已禁用这些格式的元数据编辑。在后续版本中,我可能会重新实现元数据编辑器,以使用广受欢迎的 AudioGenie 库。
其他有趣的部分
网络检测
如果互联网不可用,LyricsFetcher 将无法找到任何歌词。甚至尝试都没有意义。与其尝试失败,不如知道互联网是否可用,并在用户尝试之前告知他们。System.Net.NetworkInformation
命名空间包含执行此操作的类。
NetworkInterface.GetIsNetworkAvailable()
告诉我们计算机是否连接到网络。这与连接到互联网不完全相同,但总比没有好。其次,NetworkChange
类让我们知道网络可用性何时发生变化。将这些部分组合起来,我们有以下代码:
using System.Net.NetworkInformation;
private void InitializeNetworkAvailability() {
// Listen for network availability events
NetworkChange.NetworkAvailabilityChanged +=
new NetworkAvailabilityChangedEventHandler(
delegate(object sender, NetworkAvailabilityEventArgs e) {
this.BeginInvoke(new MethodInvoker(this.CheckNetworkStatus));
}
);
// Display the current state
this.CheckNetworkStatus();
}
private void CheckNetworkStatus() {
// Calculating this value is expensive so we cache it
this.isNetworkAvailable = NetworkInterface.GetIsNetworkAvailable();
// ... now update the UI to reflect the state of the network
}
然后,在我们的代码中,我们使用 isNetworkAvailable
来决定哪些功能应该可用。
网络不可用时的 DTD
事情总是比你想象的更复杂。我很高兴通过读取 iTunes XML 文件来加快库加载。它速度很快,并且工作正常。但是,我第一次尝试在没有网络连接的情况下运行 LyricsFetcher 时,XML 解析器抛出了一个异常。
问题在于 .NET 的 XML 解析器是一个验证解析器,因此它会尝试读取 iTunes XML 库的 DTD。当网络不可用时,此 DTD 无法读取,XML 解析器就会崩溃。我尝试了各种方法,但无法找到关闭验证或让解析器简单地忽略 DTD 的方法。我怀疑可以使用 XMLResolver
来完成,但我未能找到正确的魔法词和挥舞魔杖的组合。
我的临时解决方案是将整个 XML 文件加载到内存中,删除 DTD,然后解析生成的字符串。丑陋,但它有效。
[更新:mikey_reppy 给我展示了正确的做法。只需将 XMLResolver
设置为 null
!现在代码直接加载 XML 文件,无需担心不可用的 DTD。]
待办
- 将歌词写回我们的来源。
- 提供一个用于修改应忽略的文件类型和流派的 UI。
- 提供监视模式。在此模式下,LyricsFetcher 将在后台运行,并在歌曲播放时加载歌词。
- 使应用程序本地化。
- 本文添加一个关于单元测试及其重要性的章节。
结论
LyricsFetcher 是一个真正有用的示例应用程序,它也展示了解决常见问题的合理技术。
我希望其他人觉得它有用。
历史
2009年10月27日 - 版本 0.6.1
- 移除了 LyricWiki 作为歌词来源。在法律行动威胁下,他们被迫移除其 API。
- 更好地处理 iTunes 拒绝接受 COM 命令的情况。
- 改进了歌词的清理和格式化(不再有黑色菱形代替单引号)。
2009年4月10日 - 版本 0.6
- [重大变化] 增加了获取元数据的功能。
- 在某种程度上缩小了搜索条件。v0.5.1 中扩大的搜索有时会导致一些非常奇怪(且完全错误)的匹配。新方案仍然允许仅名称匹配,但名称必须是精确匹配。这仍然可能导致误报,但频率大大降低。
- 歌词现在已解码为 Unicode,因此重音字符现在可以正确检索。
- 歌词缓存现在在所有情况下都会更新——而不仅仅是大多数情况。
- 现在更可靠地检测 iTunes——希望如此。
2009年3月21日 - 版本 0.5.1
- 扩大了搜索条件,使得仅凭曲目名称即可找到歌词。这意味着会发现一些误报,即 LyricsFetcher 有时会找到错误的歌曲歌词。
- 修复了 iTunes 从未安装的机器上 LyricsFetcher 会崩溃的错误。
- 添加了 LyricsPlugin 作为另一个歌词来源。
- 修复了当使用 WMP 作为库并尝试更新其无法更新的格式(如 AAC)的元数据时 LyricsFetcher 会崩溃的错误。
2009年3月14日 - 版本 0.5
- 首次公开发布。