]> git.draconx.ca Git - mpdhacks.git/blob - mpdmenu.pl
2b5fcaddf3a18abcd5d0355277dc2b66666ba8d4
[mpdhacks.git] / mpdmenu.pl
1 #!/usr/bin/perl
2 #
3 # Copyright © 2008,2010,2012,2020-2021 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 Getopt::Long qw(:config gnu_getopt);
22 use Scalar::Util qw(reftype);
23 use List::Util qw(any max);
24 use FindBin;
25
26 use lib "$FindBin::Bin";
27 use MPDHacks;
28
29 use constant {
30         MPD_MJR_MIN => 0,
31         MPD_MNR_MIN => 21,
32         MPD_REV_MIN => 0,
33 };
34
35 my $SELF = "$FindBin::Bin/$FindBin::Script";
36
37 my $MUSIC = $ENV{MUSIC}    // "/srv/music";
38 my $sock;
39
40 my ($albumid, $albumname, $trackid, $recordingid);
41 my ($topmenu, $menu);
42 my $mode = "top";
43 my %artistids;
44
45 sub fvwm_cmd_unquoted {
46         print join(' ', @_), "\n";
47 }
48
49 sub fvwm_cmd {
50         fvwm_cmd_unquoted(map { MPD::escape } @_);
51 }
52
53 # Quotes the argument in such a way that it is passed unadulterated by
54 # both FVWM and the shell to a command as a single argument (for use as
55 # an # argument for e.g., the Exec or PipeRead FVWM commands).
56 #
57 # The result must be used with fvwm_cmd_unquoted;
58 sub fvwm_shell_literal {
59         my $s = @_[0] // $_;
60
61         $s =~ s/\$/\$\$/g;
62         if ($s =~ /[' \t]/) {
63                 $s =~ s/'/'\\''/g;
64                 return "'$s'";
65         }
66         $s =~ s/^\s*$/'$&'/;
67         return "$s";
68 }
69
70 # Escapes metacharacters in the argument used in FVWM menu labels.  The
71 # string must still be quoted (e.g., by using fvwm_cmd).
72 sub fvwm_label_escape {
73         my @tokens = split /\t/, $_[0];
74         @tokens[0] =~ s/&/&&/g;
75         my $ret = join "\t", @tokens;
76         $ret =~ s/[\$@%^*]/$&$&/g;
77         return $ret;
78 }
79
80 # make_submenu(name, [args ...])
81 #
82 # Creates a submenu (with the specified name) constructed by invoking this
83 # script with the given arguments.  Returns a list that can be passed to
84 # fvwm_cmd to display the menu.
85 sub make_submenu {
86         my $name = shift;
87         $name =~ s/-/_/g;
88         unshift @_, ("exec", $SELF, "--topmenu=$topmenu", "--menu=$name");
89
90         fvwm_cmd("DestroyFunc", "Make$name");
91         fvwm_cmd("AddToFunc", "Make$name");
92         fvwm_cmd("+", "I", "DestroyMenu", $name);
93
94         fvwm_cmd("DestroyMenu", $name);
95         fvwm_cmd("AddToMenu", $name, "DynamicPopupAction", "Make$name");
96         fvwm_cmd("AddToFunc", "Kill$topmenu", "I", "DestroyMenu", $name);
97
98         fvwm_cmd("DestroyFunc", "Make$name");
99         fvwm_cmd("AddToFunc", "Make$name");
100         fvwm_cmd("+", "I", "DestroyMenu", $name);
101         fvwm_cmd("+", "I", "-PipeRead",
102                  join(' ', map { fvwm_shell_literal } @_));
103         fvwm_cmd("AddToFunc", "Kill$topmenu", "I", "DestroyFunc", "Make$name");
104
105         return ("Popup", $name);
106 }
107
108 # get_item_thumbnails({ options }, file, ...)
109 # get_item_thumbnails(file, ...)
110 #
111 # For each music file listed, obtain a thumbnail (if any) for the cover art.
112 #
113 # The first argument is a hash reference to control the mode of operation;
114 # it may be omitted for default options.
115 #
116 #   get_item_thumbnails({ small => 1 }, ...) - smaller thumbnails
117 #
118 # The returned list consists of strings (in the same order as the filename
119 # arguments) suitable for use directly in FVWM menus; by default the filename
120 # is bracketed by asterisks (e.g., "*thumbnail.png*"); in small mode it is
121 # surrounded by % (e.g., "%thumbnail.png%").  If no cover art was found, the
122 # empty string is returned for that file.
123 sub get_item_thumbnails {
124         my @results = ();
125         my $flags = {};
126         my @opts = ();
127
128         $flags = shift if (reftype($_[0]) eq "HASH");
129         return @results unless @_;
130
131         my $c = "*";
132         if ($flags->{small}) {
133                 push @opts, "--small";
134                 $c = "%";
135         }
136
137         open THUMB, "-|", "$FindBin::Bin/mpdthumb.sh", @opts, "--", @_;
138         foreach (@_) {
139                 my $thumb = <THUMB>;
140                 chomp $thumb;
141
142                 $thumb = "$c$thumb$c" if (-f $thumb);
143                 push @results, $thumb;
144         }
145         close THUMB;
146         die("mpdthumb failed") if ($?);
147
148         return @results;
149 }
150
151 # add_track_metadata(hashref, key, value)
152 #
153 # Inserts the given key into the referenced hash; if the key already exists
154 # in the hash then the hash element is converted to an array reference (if
155 # it isn't already) and the value is appended to that array.
156 sub add_track_metadata {
157         my ($entry, $key, $value) = @_;
158
159         if (exists($entry->{$key})) {
160                 my $ref = $entry->{$key};
161
162                 if (reftype($ref) ne "ARRAY") {
163                         return if ($ref eq $value);
164
165                         $ref = [$ref];
166                         $entry->{$key} = $ref;
167                 }
168
169                 push(@$ref, $value) unless (any {$_ eq $value} @$ref);
170         } else {
171                 $entry->{$key} = $value;
172         }
173 }
174
175 # get_track_metadata(hashref, key)
176 #
177 # Return the values associated with the given metadata key as a list.
178 sub get_track_metadata {
179         my ($entry, $key) = @_;
180
181         return () unless (exists($entry->{$key}));
182
183         my $ref = $entry->{$key};
184         return @$ref if (reftype($ref) eq "ARRAY");
185         return $ref
186 }
187
188 # Given a music filename, search for the cover art in the same directory.
189 sub mpd_cover_filename {
190         my ($dir) = @_;
191         my $file;
192
193         $dir =~ s/\/[^\/]*$//;
194         foreach ("cover.png", "cover.jpg", "cover.tiff", "cover.bmp") {
195                 if (-f "$dir/$_") {
196                         $file = "$dir/$_";
197                         last;
198                 }
199         }
200         return unless defined $file;
201
202         # Follow one level of symbolic link to get to the scans directory.
203         $file = readlink($file) // $file;
204         $file = "$dir/$file" unless ($file =~ /^\//);
205         return $file;
206 }
207
208 # Generate the cover art entry in the top menu.
209 sub top_track_cover {
210         my ($entry) = @_;
211
212         ($entry->{thumb}) = get_item_thumbnails($entry->{file});
213         print "$entry->{thumb}\n";
214         if ($entry->{thumb}) {
215                 my $file = "$MUSIC/$entry->{file}";
216                 my $cover = mpd_cover_filename($file);
217
218                 $cover = fvwm_shell_literal($cover // $file);
219                 fvwm_cmd_unquoted("AddToMenu", MPD::escape($menu),
220                                   MPD::escape($entry->{thumb}),
221                                   "Exec", "exec", "geeqie", $cover);
222         }
223 }
224
225 # Generate the "Title:" entry in the top menu.
226 sub top_track_title {
227         my ($entry) = @_;
228         my @submenu;
229
230         my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_RELEASETRACKID");
231         if ($mbid) {
232                 @submenu = make_submenu("$menu-$mbid", "--track-id=$mbid")
233         } else {
234                 ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID");
235                 @submenu = make_submenu("$menu-track-$mbid", "--recording-id=$mbid")
236                         if ($mbid);
237         }
238
239         fvwm_cmd("AddToMenu", $menu,
240                  fvwm_label_escape("Title:\t$entry->{Title}"),
241                  @submenu);
242 }
243
244 # Generate the "Artist:" entry in the top menu.
245 sub top_track_artist {
246         my ($entry) = @_;
247         my @submenu;
248
249         # TODO: multi-artist tracks should get multiple artist menus; for now
250         # just combine the releases from all artists.
251         my @mbids = get_track_metadata($entry, "MUSICBRAINZ_ARTISTID");
252         if (@mbids) {
253                 @submenu = make_submenu("$menu-TopArtist",
254                                         map { "--artist-id=$_" } @mbids);
255         }
256
257         fvwm_cmd("AddToMenu", $menu,
258                  fvwm_label_escape("Artist:\t$entry->{Artist}"),
259                  @submenu);
260 }
261
262 # Generate the "Album:" entry in the top menu.
263 sub top_track_album {
264         my ($entry) = @_;
265         my @submenu;
266         my $mbid;
267
268         if (($mbid) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID")) {
269                 @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid");
270         } elsif (($mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID")) {
271                 # Standalone recording
272                 my @a = get_track_metadata($entry, "MUSICBRAINZ_ARTISTID");
273                 my ($album) = get_track_metadata($entry, "Album");
274
275                 @submenu = make_submenu("$menu-$mbid", "--album-name=$album",
276                                         map { "--artist-id=$_" } @a);
277
278         }
279
280         fvwm_cmd("AddToMenu", $menu,
281                  fvwm_label_escape("Album:\t$entry->{Album}"),
282                  @submenu);
283 }
284
285 # Given a work MBID, return a hash reference containing all tracks
286 # linked to that work.  The hash keys are filenames.
287 sub get_tracks_by_work_mbid {
288         my %matches;
289         my $entry;
290
291         foreach my $mbid (@_) {
292                 MPD::exec("search", "(MUSICBRAINZ_WORKID == \"$mbid\")");
293                 while (<$sock>) {
294                         last if (/^OK/);
295                         die($_) if (/^ACK/);
296
297                         if (/^(\w+): (.*)$/) {
298                                 if ($1 eq "file") {
299                                         if (exists($matches{$2})) {
300                                                 $entry = $matches{$2};
301                                         } else {
302                                                 $entry = {};
303                                                 $matches{$2} = $entry;
304                                         }
305                                 }
306
307                                 add_track_metadata($entry, $1, $2);
308                         }
309                 }
310         }
311
312         return \%matches;
313 }
314
315 # Given a track MBID, return a hash reference containing all "related"
316 # tracks in the MPD database.  The hash keys are filenames.
317 #
318 # Currently tracks are considered "related" if their associated recordings
319 # have at least one work in common.
320 sub get_tracks_by_track_mbid {
321         my ($mbid, $tagname) = (@_, "MUSICBRAINZ_RELEASETRACKID");
322         my %source;
323         my %matches;
324         my $entry;
325
326         return \%matches unless ($mbid);
327         MPD::exec("search", "($tagname == \"$mbid\")");
328         while (<$sock>) {
329                 last if (/^OK/);
330                 die($_) if (/^ACK/);
331
332                 if (/^(\w+): (.*)$/) {
333                         add_track_metadata(\%source, $1, $2);
334                 }
335         }
336
337         # Always include the current track
338         $matches{$source{file}} = \%source;
339
340         # Find all tracks related by work
341         foreach my $mbid (get_track_metadata(\%source, "MUSICBRAINZ_WORKID")) {
342                 my $related = get_tracks_by_work_mbid($mbid);
343                 foreach (keys %$related) {
344                         $matches{$_} //= $related->{$_};
345                 }
346         }
347
348         return \%matches;
349 }
350
351 sub get_tracks_by_recording_mbid {
352         return get_tracks_by_track_mbid($_[0], "MUSICBRAINZ_TRACKID");
353 }
354
355 # Given a release MBID, return a hash reference containing all its
356 # associated tracks in the MPD database.  The hash keys are filenames.
357 sub get_tracks_by_release_mbid {
358         my ($mbid) = @_;
359         my %matches;
360         my $entry;
361
362         return \%matches unless ($mbid);
363         MPD::exec("search", "(MUSICBRAINZ_ALBUMID == \"$mbid\")");
364         while (<$sock>) {
365                 last if (/^OK/);
366                 die($_) if (/^ACK/);
367
368                 if (/^(\w+): (.*)$/) {
369                         if ($1 eq "file") {
370                                 if (exists($matches{$2})) {
371                                         $entry = $matches{$2};
372                                 } else {
373                                         $entry = {};
374                                         $matches{$2} = $entry;
375                                 }
376                         }
377
378                         add_track_metadata($entry, $1, $2);
379                 }
380         }
381
382         return \%matches;
383 }
384
385 # Insert the given entry into the referenced hash if it represents a
386 # standalone recording (not associated with a release).  The recording
387 # MBID is used as the hash key.
388 sub check_standalone {
389         my ($outhash, $entry) = @_;
390         my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID");
391
392         return if exists $entry->{MUSICBRAINZ_ALBUMID};
393         $outhash->{$mbid} = $entry if ($mbid);
394 }
395
396 # Given an artist MBID, return a list of two hash refererences.  The
397 # first contains the associated releases in the MPD database and the
398 # hash keys are release MBIDs.  The second contains the artist's
399 # standalone recordings and the hash keys are recording MBIDs.
400 #
401 # In scalar context only the release hash is returned.
402 #
403 # Since MPD returns results on a per-track basis, each entry in the
404 # hash has the metadata for one unspecified track from that release.
405 sub get_releases_by_artist_mbid {
406         my (%releases, %standalones);
407         my $entry;
408
409         foreach my $mbid (@_) {
410                 MPD::exec("search", "(MUSICBRAINZ_ARTISTID == \"$mbid\")");
411                 while (<$sock>) {
412                         last if (/^OK/);
413                         die($_) if (/^ACK/);
414
415                         if (/^(\w+): (.*)$/) {
416                                 if ($1 eq "file") {
417                                         check_standalone(\%standalones, $entry);
418                                         $entry = {};
419                                 } elsif ($1 eq "MUSICBRAINZ_ALBUMID") {
420                                         $releases{$2} //= $entry;
421                                 }
422
423                                 add_track_metadata($entry, $1, $2);
424                         }
425                 }
426                 check_standalone(\%standalones, $entry);
427         }
428
429         return wantarray ? (\%releases, values %standalones) : \%releases;
430 }
431
432 # Given a filename, return the IDs (if any) for that file in the
433 # current MPD play queue.
434 sub get_ids_by_filename {
435         my ($file) = @_;
436         my @results = ();
437
438         MPD::exec("playlistfind", "file", $file);
439         while (<$sock>) {
440                 last if (/^OK/);
441                 die($_) if (/^ACK/);
442
443                 if (/^(\w+): (.*)$/) {
444                         push @results, $2 if ($1 eq "Id");
445                 }
446         }
447
448         return @results;
449 }
450
451 sub update_entry_ids {
452         my @notqueued = ();
453
454         foreach my $entry (@_) {
455                 unless (exists $entry->{Id}) {
456                         my ($id) = get_ids_by_filename($entry->{file});
457                         if (defined $id) {
458                                 $entry->{Id} = $id;
459                         } else {
460                                 push @notqueued, $entry;
461                                 next;
462                         }
463                 }
464         }
465
466         return @notqueued;
467 }
468
469 # albumsort(matches, a, b)
470 #
471 # Sort hash keys (a, b) by disc/track number for album menus.
472 sub albumsort {
473         my ($matches, $a, $b) = @_;
474
475         return $matches->{$a}->{Disc} <=> $matches->{$b}->{Disc}
476             || $matches->{$a}->{Track} <=> $matches->{$b}->{Track}
477             || $a cmp $b;
478 }
479
480 # datesort(matches, a, b)
481 #
482 # Sort hash keys (a, b) by release date
483 sub datesort {
484         my ($matches, $a, $b) = @_;
485
486         return $matches->{$a}->{Date} cmp $matches->{$b}->{Date}
487             || $a cmp $b;
488 }
489
490 # menu_trackname(entry)
491 #
492 # Format the track name for display in an FVWM menu, where entry
493 # is a hash reference containing the track metadata.
494 sub menu_trackname {
495         my ($entry) = @_;
496         my $fmt = "$entry->{trackfmt}$entry->{Artist} - $entry->{Title}";
497         return "$entry->{thumb}" . fvwm_label_escape($fmt);
498 }
499
500 sub print_version {
501         print <<EOF
502 mpdmenu.pl 0.9
503 Copyright © 2021 Nick Bowler
504 License GPLv3+: GNU General Public License version 3 or any later version.
505 This is free software: you are free to change and redistribute it.
506 There is NO WARRANTY, to the extent permitted by law.
507 EOF
508 }
509
510 sub print_usage {
511         my ($fh) = (@_, *STDERR);
512
513         print $fh "Usage: $0 [options]\n";
514         print $fh "Try $0 --help for more information.\n" unless (@_ > 0);
515 }
516
517 sub print_help {
518         print_usage(*STDOUT);
519         print <<EOF
520 This is "mpdmenu": a menu-based MPD client for FVWM.
521
522 Options:
523   -h, --host=HOST   Connect to the MPD server on HOST, overriding defaults.
524   -p, --port=PORT   Connect to the MPD server on PORT, overriding defaults.
525   -m, --menu=NAME   Set the name of the generated menu.
526   --album-id=MBID   Generate a menu for the given release MBID.
527   --album-name=NAME
528                     Generate a menu for standalone tracks with the given
529                     "album" NAME.  An artist MBID must be supplied.
530   --artist-id=MBID  Generate a menu for the given artist MBID.
531   --track-id=MBID   Generate a menu for the given track MBID.
532   --recording-id=MBID
533                     Generate a menu for the given recording MBID.
534   -V, --version     Print a version message and then exit.
535   -H, --help        Print this message and then exit.
536 EOF
537 }
538
539 GetOptions(
540         'host|h=s'       => \$MPD::host,
541         'port|p=s'       => \$MPD::port,
542         'menu|m=s'       => \$menu,
543
544         'artist-id=s'    => sub { $artistids{$_[1]} = 1; $mode = "artist"; },
545         'album-id=s'     => sub { $albumid = $_[1]; $mode = "album"; },
546         'album-name=s'   => sub { $albumname = $_[1]; $mode = "albumname"; },
547         'track-id=s'     => sub { $trackid = $_[1]; $mode = "track"; },
548         'recording-id=s' => sub { $recordingid = $_[1]; $mode = "recording"; },
549
550         'V|version'      => sub { print_version(); exit },
551         'H|help'         => sub { print_help(); exit },
552
553         'topmenu=s'      => \$topmenu, # top menu name (for submenu generation)
554 ) or do { print_usage; exit 1 };
555
556 $mode = "albumname" if ($albumname && $mode eq "artist");
557
558 unless (defined $menu) {
559         $topmenu //= "MenuMPD";
560         $menu = $topmenu . ($mode ne "top" ? $mode : "");
561 }
562 $topmenu //= $menu;
563
564 # Connect to MPD.
565 $sock = MPD::connect();
566 die("MPD version $MPD::major.$MPD::minor.$MPD::revision insufficient.")
567         unless MPD::min_version(MPD_MJR_MIN, MPD_MNR_MIN, MPD_REV_MIN);
568
569 if ($mode eq "top") {
570         my %current;
571         my %state;
572
573         $menu //= "MenuMPD";
574
575         MPD::exec("status");
576         while (<$sock>) {
577                 last if (/^OK/);
578                 die($_) if (/^ACK/);
579
580                 if (/^(\w+): (.*)$/) {
581                         $state{$1} = $2;
582                 }
583         }
584
585         MPD::exec("currentsong");
586         while (<$sock>) {
587                 last if (/^OK/);
588                 die($_) if (/^ACK/);
589
590                 if (/^(\w+): (.*)$/) {
591                         add_track_metadata(\%current, $1, $2);
592                 }
593         }
594
595         my $playstate = $state{state} eq "play"  ? "Playing"
596                       : $state{state} eq "stop"  ? "Stopped"
597                       : $state{state} eq "pause" ? "Paused"
598                       : "Unknown";
599         fvwm_cmd("AddToMenu", $menu, $playstate, "Title");
600
601         if (exists($current{file})) {
602                 top_track_cover(\%current);
603                 top_track_title(\%current);
604                 top_track_artist(\%current);
605                 top_track_album(\%current);
606         } else {
607                 fvwm_cmd("AddToMenu", $menu, "[current track unavailable]");
608         }
609
610         if ($state{state} =~ /^p/) {
611                 my $pp = $state{state} eq "pause" ? "lay" : "ause";
612
613                 fvwm_cmd("AddToMenu", $menu, "", "Nop");
614                 fvwm_cmd("AddToMenu", $menu, "Next%next.svg:16x16%",
615                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "next");
616                 fvwm_cmd("AddToMenu", $menu, "P$pp%p$pp.svg:16x16%",
617                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "p$pp");
618                 fvwm_cmd("AddToMenu", $menu, "Stop%stop.svg:16x16%",
619                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "stop");
620                 fvwm_cmd("AddToMenu", $menu, "Prev%prev.svg:16x16%",
621                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "previous");
622         } elsif ($state{state} eq "stop") {
623                 fvwm_cmd("AddToMenu", $menu, "", "Nop");
624                 fvwm_cmd("AddToMenu", $menu, "Play%play.svg:16x16%",
625                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "play");
626         }
627 } elsif ($mode eq "album") {
628         my $matches = get_tracks_by_release_mbid($albumid);
629         my @notqueued = ();
630
631         $menu //= "MenuMPDAlbum";
632
633         my $track_max = max(map { $_->{Track} } values %$matches);
634         my $disc_max = max(map { $_->{Disc} } values %$matches);
635
636         # CDs have a max of 99 tracks and I hope 100+-disc-releases
637         # don't exist so this is fine.
638         my $track_digits = $track_max >= 10 ? 2 : 1;
639         my $disc_digits = $disc_max > 1 ? $disc_max >= 10 ? 2 : 1 : 0;
640         my $currentdisc;
641
642         fvwm_cmd("AddToMenu", $menu);
643         fvwm_cmd("+", "Release not found", "Title") unless keys %$matches;
644         foreach my $file (sort { albumsort($matches, $a, $b) } keys %$matches) {
645                 my $entry = $matches->{$file};
646
647                 # Format disc/track numbers
648                 $entry->{trackfmt} = sprintf("%*.*s%.*s%*d\t",
649                                   $disc_digits, $disc_digits, $entry->{Disc},
650                                   $disc_digits, "-",
651                                   $track_digits, $entry->{Track});
652                 $entry->{trackfmt} =~ s/ /\N{U+2007}/g;
653
654                 unless (exists $entry->{Id}) {
655                         my ($id) = get_ids_by_filename($file);
656                         if (defined $id) {
657                                 $entry->{Id} = $id;
658                         } else {
659                                 push @notqueued, $entry;
660                                 next;
661                         }
662                 }
663
664                 if (defined $currentdisc && $currentdisc != $entry->{Disc}) {
665                         fvwm_cmd("+", "", "Nop");
666                 }
667                 $currentdisc = $entry->{Disc};
668
669                 fvwm_cmd("+", menu_trackname($entry), "Exec",
670                          "exec", "$FindBin::Bin/mpdexec.pl",
671                          "playid", $entry->{Id});
672         }
673
674         fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
675         foreach my $entry (@notqueued) {
676                 fvwm_cmd("+", menu_trackname($entry));
677         }
678 } elsif ($mode eq "artist") {
679         # Create an artist menu.
680         my ($matches, @recs) = get_releases_by_artist_mbid(keys %artistids);
681
682         $menu //= "MenuMPDArtist";
683
684         my @mbids = sort { datesort($matches, $a, $b) } keys %$matches;
685         my @files = map { $matches->{$_}->{file} } @mbids;
686         my @thumbs = get_item_thumbnails({ small => 1 }, @files);
687
688         unless (@mbids) {
689                 fvwm_cmd("AddToMenu", $menu, "No releases found", "Title")
690         }
691
692         foreach my $mbid (@mbids) {
693                 my $entry = $matches->{$mbid};
694                 my $thumb = shift @thumbs;
695
696                 my @submenu = make_submenu("$topmenu-$mbid",
697                                            "--album-id=$mbid");
698                 fvwm_cmd("AddToMenu", $menu,
699                          $thumb . fvwm_label_escape($entry->{Album}),
700                          @submenu);
701         }
702
703         my @artists = map { "--artist-id=$_" } keys %artistids;
704         my %nonalbums = map { $_->{Album} => $_ } @recs;
705         foreach my $name (sort keys %nonalbums) {
706                 my $mbid = $nonalbums{$name}->{MUSICBRAINZ_TRACKID};
707                 my @submenu = make_submenu("$topmenu-$mbid", @artists,
708                                            "--album-name=$name");
709                 fvwm_cmd("AddToMenu", $menu, fvwm_label_escape($name), @submenu);
710         }
711 } elsif ($mode eq "albumname") {
712         # Create a standalone recordings menu
713         my ($releases, @recs) = get_releases_by_artist_mbid(keys %artistids);
714
715         $menu //= "MenuMPDRecordings";
716         my @tracks = sort { $a->{Title} cmp $b->{Title} }
717                      grep { $_->{Album} eq $albumname } @recs;
718         my @notqueued = update_entry_ids(@tracks);
719
720         fvwm_cmd("AddToMenu", $menu);
721         fvwm_cmd("+", "No tracks found", "Title") unless @tracks;
722
723         foreach my $entry (@tracks) {
724                 next unless exists $entry->{Id};
725
726                 fvwm_cmd("+", menu_trackname($entry), "Exec",
727                          "exec", "$FindBin::Bin/mpdexec.pl",
728                          "playid", $entry->{Id});
729         }
730
731         fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
732         foreach my $entry (@notqueued) {
733                 fvwm_cmd("+", menu_trackname($entry));
734         }
735 } elsif ($mode eq "track" || $mode eq "recording") {
736         my ($matches, $mbid);
737         my @notqueued;
738
739         if ($mode eq "track") {
740                 $matches = get_tracks_by_track_mbid($trackid)
741         } else {
742                 $matches = get_tracks_by_recording_mbid($recordingid)
743         }
744
745         $menu //= "MenuMPDTrack";
746         fvwm_cmd("DestroyMenu", $menu);
747
748         my @files = sort { datesort($matches, $a, $b) } keys %$matches;
749         my @thumbs = get_item_thumbnails({ small => 1 }, @files);
750
751         fvwm_cmd("AddToMenu", $menu);
752         fvwm_cmd("+", "No tracks found", "Title") unless @files;
753         foreach my $file (@files) {
754                 my $entry = $matches->{$file};
755                 $entry->{thumb} = shift @thumbs;
756
757                 unless (exists $entry->{Id}) {
758                         my ($id) = get_ids_by_filename($file);
759                         if (defined $id) {
760                                 $entry->{Id} = $id;
761                         } else {
762                                 push @notqueued, $entry;
763                                 next;
764                         }
765                 }
766
767                 fvwm_cmd("+", menu_trackname($entry), "Exec",
768                          "exec", "$FindBin::Bin/mpdexec.pl",
769                          "playid", $entry->{Id});
770         }
771
772         fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
773         foreach my $entry (@notqueued) {
774                 fvwm_cmd("+", menu_trackname($entry));
775         }
776 }
777
778 # Finished.
779 print $sock "close\n";