IBM®
跳转到主要内容
    中国 [选择]    使用条款
 
 
Select a scope: Search for:    
    首页    产品    服务与解决方案     支持与下载    个性化服务    
跳转到主要内容

developerWorks 中国  >  Linux  >

功能丰富的 Perl: 在 MP3 和 Perl 中寻找乐趣,第 2 部分

用 Perl 进一步操纵和猜测 MP3 标签

developerWorks
文档选项

未显示需要 JavaScript 的文档选项


级别: 初级

Teodor Zlatanov (tzz@bu.edu), 程序员

2004 年 3 月 01 日

Column iconTed 继续介绍如何通过他的 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::FreeDBoutstd() 函数打印出唱片信息。

用户能够输入的默认曲目数量是通过对文件名、或者先前已有的曲目数量的猜测而得到的。这只是一个建议,但是如果用户希望接受它,只需要敲击回车。这样,此项服务就实现了。

当曲目数量找到后,并且确认它是正确的,给 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 与他联系。




对本文的评价










回页首


IBM 公司保留在 developerWorks 网站上发表的内容的著作权。未经IBM公司或原始作者的书面明确许可,请勿转载。如果您希望转载,请通过 提交转载请求表单 联系我们的编辑团队。
    关于 IBM 隐私条约 联系 IBM 使用条款