]> git.draconx.ca Git - mpdhacks.git/blob - mpdmenu.pl
241ab8e39076d49d7a07f3502a7c4d2a6b363849
[mpdhacks.git] / mpdmenu.pl
1 #!/usr/bin/perl
2 #
3 # Copyright © 2008,2010,2012,2019 Nick Bowler
4 #
5 # Silly little script to generate an FVWM menu with various bits of MPD
6 # status information and controls.
7 #
8 # License GPLv3+: GNU General Public License version 3 or any later version.
9 # This is free software: you are free to change and redistribute it.
10 # There is NO WARRANTY, to the extent permitted by law.
11
12 use strict;
13
14 use utf8;
15
16 use Encode qw(decode encode);
17 use Encode::Locale qw(decode_argv);
18 decode_argv(Encode::FB_CROAK);
19 binmode(STDOUT, ":utf8");
20
21 use IO::Socket::INET6;
22 use Getopt::Long qw(:config gnu_getopt);
23 use Scalar::Util qw(reftype);
24 use List::Util qw(any max);
25 use FindBin;
26
27 use constant {
28         MPD_MJR_MIN => 0,
29         MPD_MNR_MIN => 21,
30         MPD_REV_MIN => 0,
31 };
32
33 my $SELF = "$FindBin::Bin/$FindBin::Script";
34
35 my $MUSIC = $ENV{MUSIC}    // "/srv/music";
36 my $host  = $ENV{MPD_HOST} // "localhost";
37 my $port  = $ENV{MPD_PORT} // "6600";
38 my $sock;
39
40 my ($albumid, $artist, $title);
41 my $menu;
42 my $mode = "top";
43
44 # Quotes the argument so that it is presented as a single argument to MPD
45 # at the protocol level.  This also works OK for most FVWM arguments.
46 sub escape {
47         my $s = @_[0] // $_;
48
49         # No way to encode literal newlines in the protocol, so we
50         # convert any newlines in the arguments into a space, which
51         # can help with quoting.
52         $s =~ s/\n/ /g;
53
54         if (/[ \t\\"]/) {
55                 $s =~ s/[\\"]/\\$&/g;
56                 return "\"$s\"";
57         }
58
59         $s =~ s/^\s*$/"$&"/;
60         return $s;
61 }
62
63 # Submit a command to the MPD server; each argument to this function
64 # is quoted and sent as a single argument to MPD.
65 sub mpd_exec {
66         my $cmd = join(' ', map { escape } @_);
67
68         print $sock "$cmd\n";
69 }
70
71 sub fvwm_cmd_unquoted {
72         print join(' ', @_), "\n";
73 }
74
75 sub fvwm_cmd {
76         fvwm_cmd_unquoted(map { escape } @_);
77 }
78
79 # Quotes the argument in such a way that it is passed unadulterated by
80 # both FVWM and the shell to a command as a single argument (for use as
81 # an # argument for e.g., the Exec or PipeRead FVWM commands).
82 #
83 # The result must be used with fvwm_cmd_unquoted;
84 sub fvwm_shell_literal {
85         my $s = @_[0] // $_;
86
87         $s =~ s/\$/\$\$/g;
88         if ($s =~ /[' \t]/) {
89                 $s =~ s/'/'\\''/g;
90                 return "'$s'";
91         }
92         $s =~ s/^\s*$/'$&'/;
93         return "$s";
94 }
95
96 # Escapes metacharacters in the argument used in FVWM menu labels.  The
97 # string must still be quoted (e.g., by using fvwm_cmd).
98 sub fvwm_label_escape {
99         my @tokens = split /\t/, $_[0];
100         @tokens[0] =~ s/&/&&/g;
101         my $ret = join "\t", @tokens;
102         $ret =~ s/[\$@%^*]/$&$&/g;
103         return $ret;
104 }
105
106 # make_submenu(name, [args ...])
107 #
108 # Creates a submenu (with the specified name) constructed by invoking this
109 # script with the given arguments.  Returns a list that can be passed to
110 # fvwm_cmd to display the menu.
111 sub make_submenu {
112         my $name = shift;
113         $name =~ s/-/_/g;
114         unshift @_, ("exec", $SELF, "--menu=$name");
115
116         fvwm_cmd("DestroyFunc", "Make$name");
117         fvwm_cmd("AddToFunc", "Make$name");
118         fvwm_cmd("+", "I", "DestroyMenu", $name);
119
120         fvwm_cmd("DestroyMenu", $name);
121         fvwm_cmd("AddToMenu", $name, "DynamicPopupAction", "Make$name");
122         fvwm_cmd("AddToFunc", "KillMenuMPD", "I", "DestroyMenu", $name);
123
124         fvwm_cmd("DestroyFunc", "Make$name");
125         fvwm_cmd("AddToFunc", "Make$name");
126         fvwm_cmd("+", "I", "DestroyMenu", $name);
127         fvwm_cmd("+", "I", "-PipeRead",
128                  join(' ', map { fvwm_shell_literal } @_));
129         fvwm_cmd("AddToFunc", "KillMenuMPD", "I", "DestroyFunc", "Make$name");
130
131         return ("Popup", $name);
132 }
133
134 # get_item_thumbnails({ options }, file, ...)
135 # get_item_thumbnails(file, ...)
136 #
137 # For each music file listed, obtain a thumbnail (if any) for the cover art.
138 #
139 # The first argument is a hash reference to control the mode of operation;
140 # it may be omitted for default options.
141 #
142 #   get_item_thumbnails({ small => 1 }, ...) - smaller thumbnails
143 #
144 # The returned list consists of strings (in the same order as the filename
145 # arguments) suitable for use directly in FVWM menus; by default the filename
146 # is bracketed by asterisks (e.g., "*thumbnail.png*"); in small mode it is
147 # surrounded by % (e.g., "%thumbnail.png%").  If no cover art was found, the
148 # empty string is returned for that file.
149 sub get_item_thumbnails {
150         my @results = ();
151         my $flags = {};
152         my @opts = ();
153
154         $flags = shift if (reftype($_[0]) eq "HASH");
155         return @results unless @_;
156
157         my $c = "*";
158         if ($flags->{small}) {
159                 push @opts, "--small";
160                 $c = "%";
161         }
162
163         open THUMB, "-|", "$FindBin::Bin/mpdthumb.sh", @opts, "--", @_;
164         foreach (@_) {
165                 my $thumb = <THUMB>;
166                 chomp $thumb;
167
168                 $thumb = "$c$thumb$c" if (-f $thumb);
169                 push @results, $thumb;
170         }
171         close THUMB;
172         die("mpdthumb failed") if ($?);
173
174         return @results;
175 }
176
177 # add_track_metadata(hashref, key, value)
178 #
179 # Inserts the given key into the referenced hash; if the key already exists
180 # in the hash then the hash element is converted to an array reference (if
181 # it isn't already) and the value is appended to that array.
182 sub add_track_metadata {
183         my ($entry, $key, $value) = @_;
184
185         if (exists($entry->{$key})) {
186                 my $ref = $entry->{$key};
187
188                 if (reftype($ref) ne "ARRAY") {
189                         return if ($ref eq $value);
190
191                         $ref = [$ref];
192                         $entry->{$key} = $ref;
193                 }
194
195                 push(@$ref, $value) unless (any {$_ eq $value} @$ref);
196         } else {
197                 $entry->{$key} = $value;
198         }
199 }
200
201 # get_track_metadata(hashref, key)
202 #
203 # Return the values associated with the given metadata key as a list.
204 sub get_track_metadata {
205         my ($entry, $key) = @_;
206
207         return () unless (exists($entry->{$key}));
208
209         my $ref = $entry->{$key};
210         return @$ref if (reftype($ref) eq "ARRAY");
211         return $ref
212 }
213
214 # Given a music filename, search for the cover art in the same directory.
215 sub mpd_cover_filename {
216         my ($dir) = @_;
217         my $file;
218
219         $dir =~ s/\/[^\/]*$//;
220         foreach ("cover.png", "cover.jpg", "cover.tiff", "cover.bmp") {
221                 if (-f "$dir/$_") {
222                         $file = "$dir/$_";
223                         last;
224                 }
225         }
226         return unless defined $file;
227
228         # Follow one level of symbolic link to get to the scans directory.
229         $file = readlink($file) // $file;
230         $file = "$dir/$file" unless ($file =~ /^\//);
231         return $file;
232 }
233
234 # Generate the cover art entry in the top menu.
235 sub top_track_cover {
236         my ($entry) = @_;
237
238         ($entry->{thumb}) = get_item_thumbnails($entry->{file});
239         print "$entry->{thumb}\n";
240         if ($entry->{thumb}) {
241                 my $file = "$MUSIC/$entry->{file}";
242                 my $cover = mpd_cover_filename($file);
243
244                 $cover = fvwm_shell_literal($cover // $file);
245                 fvwm_cmd_unquoted("AddToMenu", escape($menu),
246                                   escape($entry->{thumb}),
247                                   "Exec", "exec", "geeqie", $cover);
248         }
249 }
250
251 # Generate the "Title:" entry in the top menu.
252 sub top_track_title {
253         my ($entry) = @_;
254
255         my @submenu = make_submenu("$menu-TopTrack",
256                                    "--title=$entry->{Title}");
257
258         fvwm_cmd("AddToMenu", $menu,
259                  fvwm_label_escape("Title:\t$entry->{Title}"),
260                  @submenu);
261 }
262
263 # Generate the "Artist:" entry in the top menu.
264 sub top_track_artist {
265         my ($entry) = @_;
266
267         my @submenu = make_submenu("$menu-TopArtist",
268                                    "--artist=$entry->{Artist}");
269
270         fvwm_cmd("AddToMenu", $menu,
271                  fvwm_label_escape("Artist:\t$entry->{Artist}"),
272                  @submenu);
273 }
274
275 # Generate the "Album:" entry in the top menu.
276 sub top_track_album {
277         my ($entry) = @_;
278         my @submenu;
279
280         my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID");
281         @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid") if $mbid;
282
283         fvwm_cmd("AddToMenu", $menu,
284                  fvwm_label_escape("Album:\t$entry->{Album}"),
285                  @submenu);
286 }
287
288 # Given a release MBID, return a hash reference containing all its
289 # associated tracks in the MPD database.  The hash keys are filenames.
290 sub get_tracks_by_release_mbid {
291         my ($mbid) = @_;
292         my %matches;
293         my $entry;
294
295         return \%matches unless ($mbid);
296         mpd_exec("search", "(MUSICBRAINZ_ALBUMID == \"$mbid\")");
297         while (<$sock>) {
298                 last if (/^OK/);
299                 die($_) if (/^ACK/);
300
301                 if (/^(\w+): (.*)$/) {
302                         if ($1 eq "file") {
303                                 if (exists($matches{$2})) {
304                                         $entry = $matches{$2};
305                                 } else {
306                                         $entry = {};
307                                         $matches{$2} = $entry;
308                                 }
309                         }
310
311                         add_track_metadata($entry, $1, $2);
312                 }
313         }
314
315         return \%matches;
316 }
317
318 # Given a filename, return the IDs (if any) for that file in the
319 # current MPD play queue.
320 sub get_ids_by_filename {
321         my ($file) = @_;
322         my @results = ();
323
324         mpd_exec("playlistfind", "file", $file);
325         while (<$sock>) {
326                 last if (/^OK/);
327                 die($_) if (/^ACK/);
328
329                 if (/^(\w+): (.*)$/) {
330                         push @results, $2 if ($1 eq "Id");
331                 }
332         }
333
334         return @results;
335 }
336
337 # albumsort(matches, a, b)
338 #
339 # Sort hash keys (a, b) by disc/track number for album menus.
340 sub albumsort {
341         my ($matches, $a, $b) = @_;
342
343         return $matches->{$a}->{Disc} <=> $matches->{$b}->{Disc}
344             || $matches->{$a}->{Track} <=> $matches->{$b}->{Track}
345             || $a cmp $b;
346 }
347
348 # datesort(matches, a, b)
349 #
350 # Sort hash keys (a, b) by release date
351 sub datesort {
352         my ($matches, $a, $b) = @_;
353
354         return $matches->{$a}->{Date} cmp $matches->{$b}->{Date}
355             || $a cmp $b;
356 }
357
358 # menu_trackname(entry)
359 #
360 # Format the track name for display in an FVWM menu, where entry
361 # is a hash reference containing the track metadata.
362 sub menu_trackname {
363         my ($entry) = @_;
364         my $fmt = "$entry->{trackfmt}$entry->{Artist} - $entry->{Title}";
365         return "$entry->{thumb}" . fvwm_label_escape($fmt);
366 }
367
368 sub print_version {
369         print <<EOF
370 mpdmenu.pl 0.8
371 Copyright © 2019 Nick Bowler
372 License GPLv3+: GNU General Public License version 3 or any later version.
373 This is free software: you are free to change and redistribute it.
374 There is NO WARRANTY, to the extent permitted by law.
375 EOF
376 }
377
378 sub print_usage {
379         my $fh = $_[1] // *STDERR;
380
381         print $fh "Usage: $0 [options]\n";
382         print "Try $0 --help for more information.\n" unless (@_ > 0);
383 }
384
385 sub print_help {
386         print_usage(*STDOUT);
387         print <<EOF
388 This is "mpdmenu": a menu-based MPD client for FVWM.
389
390 Options:
391   -h, --host=HOST   Connect to the MPD server on HOST, overriding defaults.
392   -p, --port=PORT   Connect to the MPD server on PORT, overriding defaults.
393   -m, --menu=NAME   Set the name of the generated menu.
394   --album-id=MBID   Generate a menu for the given release MBID.
395   -V, --version     Print a version message and then exit.
396   -H, --help        Print this message and then exit.
397 EOF
398 }
399
400 GetOptions(
401         'host|h=s'   => \$host,
402         'port|p=s'   => \$port,
403         'menu|m=s'   => \$menu,
404
405         'album-id=s' => sub { $albumid = $_[1]; $mode = "album"; },
406         'artist=s'   => sub { $artist = $_[1]; $mode = "artist"; },
407         'title=s'    => sub { $title = $_[1]; $mode = "track"; },
408
409         'V|version'  => sub { print_version(); exit },
410         'H|help'     => sub { print_help(); exit },
411 ) or do { print_usage; exit 1 };
412
413 # Connect to MPD.
414 $sock = new IO::Socket::INET6(
415         PeerAddr => $host,
416         PeerPort => $port,
417         Proto => 'tcp',
418         Timeout => 2
419 ) or die("could not open socket: $!.\n");
420 binmode($sock, ":utf8");
421
422 die("could not connect to MPD: $!.\n")
423         if (!(<$sock> =~ /^OK MPD ([0-9]+)\.([0-9]+)\.([0-9]+)$/));
424
425 die("MPD version $1.$2.$3 insufficient.\n")
426         if (  ($1 <  MPD_MJR_MIN)
427            || ($1 == MPD_MJR_MIN && $2 <  MPD_MNR_MIN)
428            || ($1 == MPD_MJR_MIN && $2 == MPD_MNR_MIN && $3 < MPD_REV_MIN));
429
430 if ($mode eq "top") {
431         my %current;
432         my %state;
433
434         $menu //= "MenuMPD";
435
436         mpd_exec("status");
437         while (<$sock>) {
438                 last if (/^OK/);
439                 die($_) if (/^ACK/);
440
441                 if (/^(\w+): (.*)$/) {
442                         $state{$1} = $2;
443                 }
444         }
445
446         mpd_exec("currentsong");
447         while (<$sock>) {
448                 last if (/^OK/);
449                 die($_) if (/^ACK/);
450
451                 if (/^(\w+): (.*)$/) {
452                         add_track_metadata(\%current, $1, $2);
453                 }
454         }
455
456         my $playstate = $state{state} eq "play"  ? "Playing"
457                       : $state{state} eq "stop"  ? "Stopped"
458                       : $state{state} eq "pause" ? "Paused"
459                       : "Unknown";
460         fvwm_cmd("AddToMenu", $menu, $playstate, "Title");
461
462         if (exists($current{file})) {
463                 top_track_cover(\%current);
464                 top_track_title(\%current);
465                 top_track_artist(\%current);
466                 top_track_album(\%current);
467         } else {
468                 fvwm_cmd("AddToMenu", $menu, "[current track unavailable]");
469         }
470
471         if ($state{state} =~ /^p/) {
472                 my $pp = $state{state} eq "pause" ? "lay" : "ause";
473
474                 fvwm_cmd("AddToMenu", $menu, "", "Nop");
475                 fvwm_cmd("AddToMenu", $menu, "Next%next.svg:16x16%",
476                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "next");
477                 fvwm_cmd("AddToMenu", $menu, "P$pp%p$pp.svg:16x16%",
478                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "p$pp");
479                 fvwm_cmd("AddToMenu", $menu, "Stop%stop.svg:16x16%",
480                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "stop");
481                 fvwm_cmd("AddToMenu", $menu, "Prev%prev.svg:16x16%",
482                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "previous");
483         } elsif ($state{state} eq "stop") {
484                 fvwm_cmd("AddToMenu", $menu, "", "Nop");
485                 fvwm_cmd("AddToMenu", $menu, "Play%play.svg:16x16%",
486                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "play");
487         }
488 } elsif ($mode eq "album") {
489         my $matches = get_tracks_by_release_mbid($albumid);
490         my @notqueued = ();
491
492         $menu //= "MenuMPDAlbum";
493
494         my $track_max = max(map { $_->{Track} } values %$matches);
495         my $disc_max = max(map { $_->{Disc} } values %$matches);
496
497         # CDs have a max of 99 tracks and I hope 100+-disc-releases
498         # don't exist so this is fine.
499         my $track_digits = $track_max >= 10 ? 2 : 1;
500         my $disc_digits = $disc_max > 1 ? $disc_max >= 10 ? 2 : 1 : 0;
501         my $currentdisc;
502
503         fvwm_cmd("AddToMenu", $menu);
504         fvwm_cmd("+", "Release not found", "Title") unless keys %$matches;
505         foreach my $file (sort { albumsort($matches, $a, $b) } keys %$matches) {
506                 my $entry = $matches->{$file};
507
508                 # Format disc/track numbers
509                 $entry->{trackfmt} = sprintf("%*.*s%.*s%*d\t",
510                                   $disc_digits, $disc_digits, $entry->{Disc},
511                                   $disc_digits, "-",
512                                   $track_digits, $entry->{Track});
513                 $entry->{trackfmt} =~ s/ /\N{U+2007}/g;
514
515                 unless (exists $entry->{Id}) {
516                         my ($id) = get_ids_by_filename($file);
517                         if (defined $id) {
518                                 $entry->{Id} = $id;
519                         } else {
520                                 push @notqueued, $entry;
521                                 next;
522                         }
523                 }
524
525                 if (defined $currentdisc && $currentdisc != $entry->{Disc}) {
526                         fvwm_cmd("+", "", "Nop");
527                 }
528                 $currentdisc = $entry->{Disc};
529
530                 fvwm_cmd("+", menu_trackname($entry), "Exec",
531                          "exec", "$FindBin::Bin/mpdexec.pl",
532                          "playid", $entry->{Id});
533         }
534
535         fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
536         foreach my $entry (@notqueued) {
537                 fvwm_cmd("+", menu_trackname($entry));
538         }
539 } elsif ($mode eq "artist") {
540         # Create an artist menu.
541         my %matches;
542         my $entry;
543
544         $menu //= "MenuMPDArtist";
545
546         mpd_exec("playlistfind", "artist", $artist);
547         while (<$sock>) {
548                 last if (/^OK/);
549                 die($_) if (/^ACK/);
550
551                 if (/^(\w+): (.*)$/) {
552                         $entry = {} if ($1 eq "file");
553                         $matches{$2} //= $entry if ($1 eq "MUSICBRAINZ_ALBUMID");
554                         add_track_metadata($entry, $1, $2);
555                 }
556         }
557
558         my @mbids = sort { datesort(\%matches, $a, $b) } keys %matches;
559         my @files = map { $matches{$_}->{file} } @mbids;
560         my @thumbs = get_item_thumbnails({ small => 1 }, @files);
561         fvwm_cmd("AddToMenu", $menu, "No releases found", "Title") unless @mbids;
562
563         foreach my $mbid (@mbids) {
564                 my $entry = $matches{$mbid};
565                 my $thumb = shift @thumbs;
566
567                 my @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid");
568                 fvwm_cmd("AddToMenu", $menu,
569                          $thumb . fvwm_label_escape($entry->{Album}),
570                          @submenu);
571         }
572 } elsif ($mode eq "track") {
573         # Create a title menu.
574         my @titles;
575         my $entry;
576
577         $menu = "MenuMPDTitle" unless defined $menu;
578
579         # Open and close brackets.
580         my ($ob, $cb) = ("[\[~〜<〈(ー−-]", "[\]~〜>〉)ー−-]");
581
582         $_ = $title;
583
584         # Deal with specific cases.
585         s/ちいさな(?=ヘミソフィア)//;                 # ヘミソフィア
586         s/ "mix on air flavor" dear EIKO SHIMAMIYA//; # Spiral wind
587         s/ "So,you need me" Style//;                  # I need you
588         s/ ::Symphony Second movement:://;            # Disintegration
589         s/-\[instrumental\]//;                        # 青い果実
590         s/ -Practice Track-//;                        # Fair Heaven
591         s/〜世界で一番アナタが好き〜//;               # Pure Heart
592         s/〜彼方への哀歌//;                           # 十二幻夢
593         s/ sora no uta ver.//;                       # 美しい星
594
595         s/\s*-remix-$//; # Otherwise "D-THREAD -remix-" doesn't work right.
596
597         # Deal with titles like "blah (ABC version)".
598         s/\s*$ob.*(style|mix|edit|edition|ver\.?|version|melody|カラオケ)$cb?$//i;
599
600         # Deal with titles like "blah (without XYZ)".
601         s/\s*$ob\s*((e\.)?piano|english|japanese|inst|tv|without|w\/o|off|back|short|karaoke|game).*//i;
602
603         # Deal with titles like "blah instrumental".
604         s/\s+(instrumental|off vocal|short|tv)([\s-]+(mix|size|version))?$//i;
605         s/\s+without\s+\w+$//i;
606
607         # Deal with separate movements in classical pieces.
608         s/: [IVX]+\..*//;
609
610         my $basetitle  = $_;
611         my $_basetitle = $basetitle;
612
613         $_basetitle =~ s/"/\\"/g;
614         print $sock "playlistsearch title \"$_basetitle\"\n";
615         while (<$sock>) {
616                 last if (/^OK/);
617                 die($_) if (/^ACK/);
618
619                 if (/^(\w+): (.*)$/) {
620                         if ($1 eq "file") {
621                                 push @titles, $entry if (keys(%$entry) > 0);
622                                 $entry = {};
623                         }
624
625                         $entry->{$1} = $2;
626                 }
627         }
628         push @titles, $entry if (keys(%$entry) > 0);
629
630 { # work around 'use locale' breaking s///i
631         use locale;
632
633         my @thumbs = get_item_thumbnails({ small => 1 },
634                                           map { $_->{file} } @titles);
635         for (my $i = 0; $i < @titles; $i++) {
636                 $titles[$i]->{thumb} = $thumbs[$i];
637         }
638
639         foreach (sort titlesort @titles) {
640                 my ($t_file, $t_artist, $t_title, $t_id, $thumb) = (
641                         $_->{file},
642                         $_->{Artist},
643                         $_->{Title},
644                         $_->{Id},
645                         $_->{thumb}
646                 );
647
648                 # MPD searches are case-insensitive.
649                 next if (!($t_title =~ m/(\P{Latin}|^)\Q$basetitle\E(\P{Latin}|$)/ || $t_title =~ m/\Q$basetitle\E/i));
650
651                 $t_artist = sanitise($t_artist, 1);
652                 $t_title  = sanitise($t_title, 1);
653
654                 cmd("AddToMenu $menu \"$thumb$t_artist - $t_title\""
655                     ." Exec exec $FindBin::Bin/mpdexec.pl"
656                     ." playid $t_id");
657         }
658 } # end use locale workaround
659 }
660
661 # Finished.
662 print $sock "close\n";
663
664 sub sanitise
665 {
666         $_ = $_[0];
667         s/&/&&/g if ($_[1]);
668         s/([\$@%^*])/\1\1/g;
669         s/"/\\"/g;
670         return $_;
671 }
672
673 sub titlesort
674 {
675         return ($a->{Album}  cmp $b->{Album})  if($a->{Album}  ne $b->{Album});
676         return ($a->{Artist} cmp $b->{Artist}) if($a->{Artist} ne $b->{Artist});
677         return ($a->{Title}  cmp $b->{Title});
678 }
679
680 sub cmd
681 {
682         print "$_[0]\n";
683 }