级别: 初级 Teodor Zlatanov (tzz@bu.edu), 程序员
2004 年 3 月 01 日
Ted 继续介绍如何通过他的 autotag.pl 应用程序,同时借助 Perl、FreeDB 和各种 CPAN 模块来处理和猜测 MP3 标签。
本文是系列文章两部分中的第二部分。在阅读本文之前,请先阅读
第
1 部分,那篇文章将向您介绍 autotag.pl 应用程序和其中用到的各个模块的基本原理。我们直接从上一次结束的地方继续讲述。
准备主循环
autotag.pl 的主循环将识别出音乐并为其添加标签。为此,要按次序做一些准备。首先,我用
WebService::FreeDB 模块创建了一个
FreeDB 检索对象。
检索对象自 autotag.pl 继承
DEBUG 设置,所以用户不必记住为 autotag.pl 设置的
FREEDB_DEBUG 。主机也同样由
autotag.pl 配置来提供。
清单 1. 创建 WebService::FreeDB 对象
my $cddb = WebService::FreeDB->new(DEBUG => $config->DEBUG(),
HOST => $config->FREEDB_HOST);
die "Could not initialize the FreeDB service"
unless defined $cddb;
|
接下来创建一些散列:
%discs 、
%olddiscinfo 和
%disc_counts 。同样地,创建了
@common 列表。所有这些变量将在主循环中用到。注意,
FreeDB
中每一个检索结果都由唯一的 ID 来标识,这也是一直到后面程序中我所存贮的全部内容。
遍历所有用户提供的命令行检索开关,比如
-artist 和
-album (使用散列
%freedb_searches 的键,而不是手工列出开关)。AppConfig
的
get() 方法可以用来得到各个参数的值;由于感兴趣的参数总是数组引用,我自动地对它们进行解引用。如果没有提供任何检索开关,则进入交互模式,在这里用户可以交互式地提供检索标准。
清单 2. 检索开关打开了吗?
my $search_count = 0;
foreach my $search (keys %freedb_searches)
{
$search_count += scalar @{$config->get($search)};
}
print "Search count is $search_count\n"
if $config->DEBUG();
|
这看起来可能不算什么,但是使用
%freedb_searches 散列来得到检索开关列表会使代码更短,而且容易维护。您应该始终寻找这样的方法以消除程序中常量和字符串文字的重复。
有了检索计数知识,可能需要进入交互式查询模式(用户会被询问是否希望这样做,如果不,程序会温柔地退出)。在交互式模式,用户开始时使用名副其实的
guess_aritist_and_track() 函数来对艺术家和乐曲名进行简单的猜测。这些猜测会遍历所有提交到 autotag.pl 的文件,并且猜测累加到
%guessed 散列中,在其中使用子散列以使得在所有的 MP3 文件中对一个艺术家名的重复查找只生成一个猜测。然后用户会被询问这些猜测是否有助于每次检索。例如,当要求进行艺术家检索时,对艺术家名的猜测会首先提供给用户。
这样,按期望的次序开始进行检索的交互式查询。
%freedb_searches 散列中的每一次检索,用户都会添加更多检索。如果他只是按下了回车,
read_line()
函数将返回一个空字符串,这样的输入会被认为是一种用户希望继续进行检索的暗示。
清单 3. 检索的交互式查询
while (my $data =
read_line("Add a search by $search or ENTER to go on: ", ''))
{
last unless defined $data && length $data;
push @{$config->get($search)}, $data;
}
|
同样,我用 AppConfig 的 get
get() 方法来得到对配置列表的数组引用,并将用户给出的数据压入到数组中。
初始检索
FreeDB 检索依照前面给出的检索标准完成,或者以交互的方式,或者以命令行开关的方式。同样,我使用
%freedb_searches 散列来生成一个检索的列表。对每一次检索,我将用户给出的检索词列入清单,并将给出的词添加到
-all 开关。注意,每个“词”都可以包含空格;对于 autotag.pl 和 FreeDB 来说,这些词是单一的检索单位。
清单 4. 检索 FreeDB
foreach my $search (keys %freedb_searches)
{
# @keywords will contain all the keywords (e.g. -artist "Pink Floyd")
my @keywords = @{$config->get($search)};
# we join in the -all keywords for every search
push @keywords, @{$config->get(SEARCH_ALL)};
print "Asked for keywords @keywords, search $search\n"
if $config->DEBUG();
# remember the searches and keywords done
push @{$freedb_searches{$search}->{keywords}}, @keywords;
# do the search
foreach my $keyword (@keywords)
{
print "Searching with keyword $keyword, search $search\n"
if $config->DEBUG();
my %found_discs = $cddb->getdiscs($keyword, [$search]);
if ($config->OR()) # any search with OR
{
push @common, keys %found_discs;
}
elsif (scalar @common) # second or more search without OR
{
my @new = keys %found_discs;
my $lc = List::Compare->new(\@common, \@new);
@common = $lc->get_intersection();
}
else # first search without OR
{
@common = keys %found_discs
}
foreach my $disc (keys %found_discs)
{
$discs{$disc} = $found_discs{$disc};
$disc_counts{$disc}++; # we'll use this to remove matches later
}
} # foreach @keywords
} # foreach keys %freedb_searches
|
当结果从 FreeDB 数据库中返回,它们会被装入到
%found_disks 散列中。这个散列为每一个关键字创建,所以旧的结果不会显示。如果用户指定了
-or 开关,则只需要将一个结果添加到其他结果中即可。另外,
@common 数组用于查看哪些结果与先前的结果相同(AND 模式是隐含的,说明默认情况下检索结果必须与所有请求的关键字相匹配)。
List::Compare
模块用来生成两个列表的交集。自己动手来做这些是可能的,也并不困难,但是既然已有经过了测试而且可能更快速的实现,为什么还要花费时间和精力呢?
再次注意,所有的检索结果只不过是对应于 FreeDB 数据库中唱片的字符串 ID。这样,我可以让它们作为散列的键、将要被检索的字符串列表,等等。
最后,将过滤后的结果添加到
%discs 散列中,并且将每一个找到的唱片对应的
%disc_counts 条目数增加。唱片计数在后面还会用到。顺便说一下,使用“disc”而不是“disk”作为变量名,是因为
FreeDB 的用法如此。
当结果各就其位后,我会删除那些没有在
@common 中的结果。试想,如果使用的是
-or ,
@common 数组将包含所有的结果。
清单 5. 使用 @common 来删除期望之外的结果;如果没有期望的结果则退出
foreach my $disc (keys %discs)
{
next if grep { $_ eq $disc} @common;
print "Deleting search result $disc, it was not in all searches\n"
if $config->DEBUG();
delete $discs{$disc};
}
unless (scalar keys %discs)
{
print "The search you requested returned no discs, sorry. Exiting.\n";
exit;
}
|
循环遍历
%discs 的键很简单,用
grep() 来查看一个唱片是否在于
@common 中。可以使用一个散列来进一步优化这个循环,但是坦白讲我并不认为这有多重要,除非用户要在无数的唱片中查找。
如果在
%discs 中没有了唱片,我将完成任务,给出一个消息,并得体地退出。
选择感兴趣的唱片
我现在拥有了用户认为他想要的对应的唱片列表。这个列表通常会比用户真正期望的要大,所以现在我给用户一个机会来选择那些他所感兴趣的唱片。这也让用户在没有找到他们所期望的唱片时有机会退出。给
autotag.pl 指定
-accept_all 开关,则会绕过这一选择菜单,让用户在所有找到的唱片上操作。最终的结果,不管是如何得到的,将存放于
@selecteddiscs 列表中。
清单 6: 选择唱片并打印列表
my @selecteddiscs;
if ($config->ACCEPT_ALL())
{
@selecteddiscs = keys %discs;
}
else
{
print "Enter the albums of interest for files [@ARGV]\n";
@selecteddiscs = $cddb->ask4discurls(\%discs);
}
unless (scalar @selecteddiscs)
{
print "You selected no albums, exiting...\n";
exit 0;
}
%olddiscinfo = %discs; # save the old data for ask2discurls
%discs = (); # clear the search results
# populate %discs with full search results
foreach my $disc (@selecteddiscs)
{
my %discinfo = $cddb->getdiscinfo($disc);
$discs{$disc} = \%discinfo;
}
if ($config->DUMP())
{
print Dumper \%discs;
exit 0;
}
|
WebService::FreeDB 模块中有一个非常好的
ask4discurls() 函数,因为如果没有它,我将不得不自己去写一个。它打印出唱片的列表,并让用户挑选期望的那些。
既然最终的唱片列表保存在
@selecteddiscs 中,
%discs 中只有那些存在对应 ID 的唱片。
getdiscinfo() 函数向唱片条目填充乐曲和唱片信息。这是个比较慢的函数,所以我只在此时剩余唱片不多的情况下用它。
最后,主循环
对命令行(
@ARGV 中)上给出的每一个文件,我得到
ID3 标签,如果需要则创建它。对于那些由于种种原因不能使用
ID3 标签的文件,打印一条消息给出信息,并跳过它们。完全不存在的文件
―― 例如,一个目录以 MP3 文件的名字命名 ―― 和不能访问的文件,例如没有访问权限的文件,这两者是不同的。
清单 7. 得到 ID3 标签
foreach my $file (@ARGV)
{
my $tag = get_tag($file, 1);
unless (defined $tag)
{
if (-r $file && -f $file)
{
print "Could not get a tag from file $file, skipping";
}
else
{
print "Nonexistent file $file, skipping";
}
next;
}
... the rest of this loop is explained later ...
}
|
%discs_of_interest 散列是
%discs 的一个拷贝。我尝试用近似的(模糊的)字符串匹配模块来限制对感兴趣唱片的选择。例如,尝试以模糊的方式匹配唱片名(精度从 50% 到 90%),没有一个设置工作是有效的。问题在于一些词非常常见,比如“love”,而其他一些词太短,比如“U2”。可能会有限制选择的好算法,于是我把
%discs_of_interest 散列放在那里等待好算法的出现,不过,就我的个人经验来看,最好是让人的大脑在 0.01 秒内作出选择。有时,试图用计算机来解决问题不如借助几百万年演化而来的能力效率高。
现在登场是
while(1) 循环。这是个无穷的循环,反映出用户在做出最后的选择之前经常是如何地犹豫不决。我本可以用变量控制来写这个循环,但是在一个无穷的循环中使用
next() 和
last() 好像更为自然。
我用下面的循环得到了一个单独的唱片:
清单 8. 只选择一个唱片
my @chosen = ();
# do the following unless only one album is selected
if (1 == scalar keys %discs_of_interest)
{
@chosen = (keys %discs_of_interest)[0];
}
else
{
# get the ask4discurls special format back from %olddiscinfo
my %ask4discurls_special_hash;
foreach (keys %discs)
{
$ask4discurls_special_hash{$_} = $olddiscinfo{$_};
}
do
{
print_tag_info($file, $tag);
print "Choose a single album or none (to skip file) from the current list\n";
@chosen = $cddb->ask4discurls(\%ask4discurls_special_hash);
} while (scalar @chosen > 1);
};
last if scalar @chosen == 0;
next if scalar @chosen != 1;
my $disc = $discs{$chosen[0]};
|
总之,如果只存在一个唱片,我就选择它。否则,我用
ask4discurls() 函数得到感兴趣的唱片列表。注意,在那之前用
pring_tag_info() 打印出了文件标签信息,这样就可以提醒用户查看这些文件信息。用户往往健忘,所以程序员所提供的每一个快捷方式和提示都会让用户更满意。用户也有缺点,所以我不假定仅仅是因为我曾经告诉他们选择一个唱片,他们就会那样做。如果使用一个
GUI,类似的选择规则可以通过列表框来强制实现 ―― 但是在 autotag.pl 的文本界面中,输入确认不得不这样做。实际上,那并不完全正确:有一些
CPAN 模块对此有所帮助,但是 autotag.pl 的规模和范围好像并不适合文本模式 UI 框架。
如果没有唱片被选择,我会跳到下一个文件。
现在,
$disc 中包含的是对用户正在检查的当前文件特定可用的唱片。
清单 9. 曲目数量难以找到
my $track_number_guess = guess_track_number($file, $tag);
my $tracks = $disc->{trackinfo};
my $track_number;
do
{
# ask the user for the track number, while trying to be helpful
print_tag_info($file, $tag, "Old tag");
$cddb->outstd($disc);
$track_number =
read_line(
sprintf(
'Choose a track number 1 - %d, 0 to quit, -1 to select another album: ',
scalar @$tracks),
$track_number_guess);
} while (not defined $track_number ||
$track_number < -1 ||
$track_number > scalar @$tracks);
# cycle to the album selection again if the user wants to select another album
next if $track_number == -1;
|
另一个无穷循环:在用户选择出合适的曲目数量之前,完全不能继续下去。曲目数量就如同 Moby Dick 对于 autotag.pl 的 Ahab
一样。必须找到它,不然会前功尽弃。我再次打印出标签信息,提醒用户我们正在讨论什么,然后用
WebService::FreeDB 的
outstd()
函数打印出唱片信息。
用户能够输入的默认曲目数量是通过对文件名、或者先前已有的曲目数量的猜测而得到的。这只是一个建议,但是如果用户希望接受它,只需要敲击回车。这样,此项服务就实现了。
当曲目数量找到后,并且确认它是正确的,给 MP3 文件打标签的工作就完成了:
清单 10. 打标签完成
# if the user selected a track...
if ($track_number > 0)
{
my $new_tag = make_tag_from_freedb($disc, $track_number);
print_tag_info($file, $new_tag, "New tag info") if defined $new_tag;
# do this if the new tag was created, DRYRUN was not specified, and the
# user says YES
if ($new_tag
&& !$config->DRYRUN()
&& read_yes_no(
"Apply new tag (you'll get a chance to modify it)?", 1))
{
my $modify_tags = read_yes_no("Modify tag elements?", 0);
# copy each new element (but don't overwrite valid old ones)
foreach my $element (keys %$new_tag)
{
my $old_tag_element = $tag->{$element} || '';
if ($modify_tags)
{
# the user can press Up Arrow to get the old tag element
$term->addhistory($old_tag_element);
$new_tag->{$element} =
read_line("New value of $element (was '$old_tag_element'): ",
$new_tag->{$element});
# put the artist and album $new_tag changes back in $disc so the
# next file can also use them
if (exists $info2freedb{$element})
{
$disc->{$info2freedb{$element}} = $new_tag->{$element};
}
}
$tag->{$element} = $new_tag->{$element};
}
set_tag ($file, $tag);
} # if apply_new_tag...
} # if $track_number > 0
last;
|
首先,别忘了现在是在一个无限的
while(1) 循环中。最后的
last() 表示如果再继续执行,就会退出这个循环。
我开始使用
make_tag_from_freedb() 函数来从
FreeDB 标签生成一个
ID3 标签。这隐藏到了一个函数中,因为它不是直接的映射。
如果用户给出新的标签和最终的“yes”,我将以此来给文件打标签。用户现在有机会修改每一个单独的标签元素。每次选择都保存在输入对象历史中
(阅读
Term::Readline 文档以详细了解)。那样,用户可以按下向上箭头以得到以前的输入,而不用再重新敲击输入。最终,当用户需要给多个文件打标签时确实会更轻松,我将修改过的需要永久保持信息存贮回
%$disc 散列中。这样,如果一个用户修改了唱片的艺术家,对下一个文件来说这个修改过的姓名将成为默认的姓名。
当上述所有的步骤都完成以后,标签使用
set_tag() 来设置。
用法
我已经使用 autotag.pl 有一个多月的时间,来对我收集的 MP3 进行编目和重新打标签(它以前只有 ID3 1.1 标签)。我倾向于认为使用 autotag.pl 很简单,但可能是由于我对它的缺陷已经太习惯了,以致于不再去注意它们。请勇于提出改进 autotag.pl 的建议,尤其是关于命令行开关和文本 UI 的。
如果您的一些 MP3 已经有“好的”标签,只是想以通用格式对它们进行重命名,使用
-ro 选项。
如果您的一些 MP3 的曲目数量是错误的,用
-g 选项来快速得到曲目数量。所得到的曲目数量只是一个猜测,所以您将需要去确认每一个猜测。
如果您的一些 MP3 的注释有误,用
-sc 选项来删除那些注释。也可以显式地用
“COMM=” 来进行批量标记,但是用
-sc 更为方便。
如果您的一些 MP3 来自于同一张唱片,并且您知道将要设置的这些标签,那么使用
-m 选项,并以您所需的方式来设置标签条目。
-help
选项将打印出支持的标签条目列表(ID3 中的帧)。
结束语
编写 autotag.pl 是很痛苦的,但它又是一种乐趣。我用到了模糊字符串匹配、FreeDB 检索、ID3 版本 1 和 2,以及许多文本模式用户交互。这些都集合于我已经彻底测试了一个多月的一个应用程序中。
编写 autotag.pl 最困难的部分是为工作选择适当的模块。在此由两部分构成的系列的
第一篇文章
中,我提到了所有被 autotag.pl 所放弃的模块。我不是只阅读了它们的文档就放弃了它们;文档并不会是永远正确的,而且有时是想当然的。对每一个模块最佳的测试是为其编写程序,看它如何工作。这样,我为
autotag.pl 所作的每一个选择都经过实际的测试,并且,我希望如果您从事 ID3 相关的 Perl 工作,这些选择也能对您有所帮助。
我计划让 autotag.pl 可以永久下载,并保持对它的改进,因此欢迎您对其特性和改进提出建议。我将乐于解决被指出的缺陷,所以如果您发现了任何缺陷请告诉我。
参考资料
关于作者  | 
|  | Teodor Zlatanov 于 1999 年获取了波士顿大学计算机工程系硕士学位。他从 1992 年就开始做程序员,使用 Perl、Java、C 和 C++。他的兴趣在于开放源代码工作,致力于文本解析、3 层客户机-服务器数据库体系结构、UNIX 系统管理、CORBA 和项目管理。可以通过
tzz@bu.edu
与他联系。
|
对本文的评价
|