]> git.draconx.ca Git - mpdhacks.git/blobdiff - mpdmenu.pl
mpdthumb: Fix failure when readpicture/albumart both return data.
[mpdhacks.git] / mpdmenu.pl
index 94ec8d5088ab1fdc9475b4595dbe4bfc7f34a6cf..71613c5fb015d8a9e5489999f0934c88b9850454 100755 (executable)
 #!/usr/bin/perl
+#
+# Copyright © 2008,2010,2012,2020-2021 Nick Bowler
+#
+# Silly little script to generate an FVWM menu with various bits of MPD
+# status information and controls.
+#
+# License GPLv3+: GNU General Public License version 3 or any later version.
+# This is free software: you are free to change and redistribute it.
+# There is NO WARRANTY, to the extent permitted by law.
 
 use strict;
 
-use Getopt::Long;
-use IO::Socket::INET6;
+use utf8;
+
+use Encode qw(decode encode);
+use Encode::Locale qw(decode_argv);
+decode_argv(Encode::FB_CROAK);
+binmode(STDOUT, ":utf8");
+
+use Getopt::Long qw(:config gnu_getopt);
+use Scalar::Util qw(reftype);
+use List::Util qw(any max);
+use FindBin;
+
+use lib "$FindBin::Bin";
+use MPDHacks;
 
 use constant {
        MPD_MJR_MIN => 0,
-       MPD_MNR_MIN => 13,
+       MPD_MNR_MIN => 21,
        MPD_REV_MIN => 0,
 };
 
-use utf8;
-use encoding 'utf8';
-use Encode;
+my $SELF = "$FindBin::Bin/$FindBin::Script";
+
+my $MUSIC = $ENV{MUSIC}    // "/srv/music";
+my ($sock, $mpd_have_binarylimit);
 
-sub cmd
-{
-       print "$_[0]\n";
+my ($albumid, $albumname, $trackid, $recordingid);
+my ($topmenu, $menu);
+my $mode = "top";
+my %artistids;
+
+sub fvwm_cmd_unquoted {
+       print join(' ', @_), "\n";
 }
 
-# Global hash for tracking what is to be "accepted".
-my %accept = ();
+sub fvwm_cmd {
+       fvwm_cmd_unquoted(map { MPD::escape } @_);
+}
 
-my $FVWM = (defined $ENV{FVWM_USERDIR}) ? $ENV{FVWM_USERDIR}
-                                        : $ENV{HOME}."/.fvwm";
-my $icons = "$FVWM/icons";
+# Quotes the argument in such a way that it is passed unadulterated by
+# both FVWM and the shell to a command as a single argument (for use as
+# an # argument for e.g., the Exec or PipeRead FVWM commands).
+#
+# The result must be used with fvwm_cmd_unquoted;
+sub fvwm_shell_literal {
+       my $s = @_[0] // $_;
+
+       $s =~ s/\$/\$\$/g;
+       if ($s =~ /[' \t]/) {
+               $s =~ s/'/'\\''/g;
+               return "'$s'";
+       }
+       $s =~ s/^\s*$/'$&'/;
+       return "$s";
+}
 
-# Default values for stuff.
-my ($album, $artist, $title, $menu) = (undef, undef, undef, undef);
-my $host = (defined $ENV{MPD_HOST}) ? $ENV{MPD_HOST} : "localhost";
-my $port = (defined $ENV{MPD_PORT}) ? $ENV{MPD_PORT} : "6600";
+# Escapes metacharacters in the argument used in FVWM menu labels.  The
+# string must still be quoted (e.g., by using fvwm_cmd).
+sub fvwm_label_escape {
+       my @tokens = split /\t/, $_[0];
+       @tokens[0] =~ s/&/&&/g;
+       my $ret = join "\t", @tokens;
+       $ret =~ s/[\$@%^*]/$&$&/g;
+       return $ret;
+}
 
-GetOptions(
-       'host|h=s'   => \&host,   # Host that MPD is running on.
-       'port|p=s'   => \&port,   # Port that MPD is listening on.
-       'menu|m=s'   => \$menu,   # Name of the menu to create.
-       'album=s'    => \$album,  # Album to get tracks from
-       'artist=s'   => \$artist, # Artist to limit results to
-       'title=s'    => \$title,  # Title to create menu for
-);
-
-$album  = decode_utf8($album)  if defined($album);
-$artist = decode_utf8($artist) if defined($artist);
-$title  = decode_utf8($title)  if defined($title);;
+# make_submenu(name, [args ...])
+#
+# Creates a submenu (with the specified name) constructed by invoking this
+# script with the given arguments.  Returns a list that can be passed to
+# fvwm_cmd to display the menu.
+sub make_submenu {
+       my $name = shift;
+       $name =~ s/-/_/g;
+       unshift @_, ("exec", $SELF, "--topmenu=$topmenu", "--menu=$name");
+
+        fvwm_cmd("DestroyFunc", "Make$name");
+        fvwm_cmd("AddToFunc", "Make$name");
+        fvwm_cmd("+", "I", "DestroyMenu", $name);
+
+        fvwm_cmd("DestroyMenu", $name);
+        fvwm_cmd("AddToMenu", $name, "DynamicPopupAction", "Make$name");
+        fvwm_cmd("AddToFunc", "Kill$topmenu", "I", "DestroyMenu", $name);
+
+        fvwm_cmd("DestroyFunc", "Make$name");
+        fvwm_cmd("AddToFunc", "Make$name");
+        fvwm_cmd("+", "I", "DestroyMenu", $name);
+        fvwm_cmd("+", "I", "-PipeRead",
+                join(' ', map { fvwm_shell_literal } @_));
+        fvwm_cmd("AddToFunc", "Kill$topmenu", "I", "DestroyFunc", "Make$name");
+
+       return ("Popup", $name);
+}
 
-# Connect to MPD.
-my $sock = new IO::Socket::INET6(
-       PeerAddr => $host,
-       PeerPort => $port,
-       Proto => 'tcp'
-) or die("could not open socket: $!.\n");
-binmode($sock, ":utf8");
-
-die("could not connect to MPD: $!.\n")
-       if (!(<$sock> =~ /^OK MPD ([0-9]+)\.([0-9]+)\.([0-9]+)$/));
-
-die("MPD version $1.$2.$3 insufficient.\n")
-       if (  ($1 <  MPD_MJR_MIN)
-          || ($1 == MPD_MJR_MIN && $2 <  MPD_MNR_MIN)
-          || ($1 == MPD_MJR_MIN && $2 == MPD_MNR_MIN && $3 < MPD_REV_MIN));
-
-if (defined $album) {
-       # Create an album menu.
-       my @playlist = ();
+# get_item_thumbnails({ options }, file, ...)
+# get_item_thumbnails(file, ...)
+#
+# For each music file listed, obtain a thumbnail (if any) for the cover art.
+#
+# The first argument is a hash reference to control the mode of operation;
+# it may be omitted for default options.
+#
+#   get_item_thumbnails({ small => 1 }, ...) - smaller thumbnails
+#
+# The returned list consists of strings (in the same order as the filename
+# arguments) suitable for use directly in FVWM menus; by default the filename
+# is bracketed by asterisks (e.g., "*thumbnail.png*"); in small mode it is
+# surrounded by % (e.g., "%thumbnail.png%").  If no cover art was found, the
+# empty string is returned for that file.
+sub get_item_thumbnails {
+       my @results = ();
+       my $flags = {};
+       my @opts = ();
+
+       $flags = shift if (reftype($_[0]) eq "HASH");
+       return @results unless @_;
+
+       my $c = "*";
+       if ($flags->{small}) {
+               push @opts, "--small";
+               $c = "%";
+       }
+
+       if ($mpd_have_binarylimit) {
+               # --embedded implies and requires binarylimit support
+               push @opts, "--embedded";
+       } else {
+               push @opts, "--no-binarylimit";
+       }
+
+       open THUMB, "-|", "$FindBin::Bin/mpdthumb.sh", @opts, "--", @_;
+       foreach (@_) {
+               my $thumb = <THUMB>;
+               chomp $thumb;
+
+               $thumb = "$c$thumb$c" if (-f $thumb);
+               push @results, $thumb;
+       }
+       close THUMB;
+       die("mpdthumb failed") if ($?);
+
+       return @results;
+}
+
+# add_track_metadata(hashref, key, value)
+#
+# Inserts the given key into the referenced hash; if the key already exists
+# in the hash then the hash element is converted to an array reference (if
+# it isn't already) and the value is appended to that array.
+sub add_track_metadata {
+       my ($entry, $key, $value) = @_;
+
+       if (exists($entry->{$key})) {
+               my $ref = $entry->{$key};
+
+               if (reftype($ref) ne "ARRAY") {
+                       return if ($ref eq $value);
+
+                       $ref = [$ref];
+                       $entry->{$key} = $ref;
+               }
+
+               push(@$ref, $value) unless (any {$_ eq $value} @$ref);
+       } else {
+               $entry->{$key} = $value;
+       }
+}
+
+# get_track_metadata(hashref, key)
+#
+# Return the values associated with the given metadata key as a list.
+sub get_track_metadata {
+       my ($entry, $key) = @_;
+
+       return () unless (exists($entry->{$key}));
+
+       my $ref = $entry->{$key};
+       return @$ref if (reftype($ref) eq "ARRAY");
+       return $ref
+}
+
+# Given a music filename, search for the cover art in the same directory.
+sub mpd_cover_filename {
+       my ($dir) = @_;
+       my $file;
+
+       $dir =~ s/\/[^\/]*$//;
+       foreach ("cover.png", "cover.jpg", "cover.tiff", "cover.bmp") {
+               if (-f "$dir/$_") {
+                       $file = "$dir/$_";
+                       last;
+               }
+       }
+       return unless defined $file;
+
+       # Follow one level of symbolic link to get to the scans directory.
+       $file = readlink($file) // $file;
+       $file = "$dir/$file" unless ($file =~ /^\//);
+       return $file;
+}
+
+# Generate the cover art entry in the top menu.
+sub top_track_cover {
+       my ($entry) = @_;
+
+       ($entry->{thumb}) = get_item_thumbnails($entry->{file});
+       print "$entry->{thumb}\n";
+       if ($entry->{thumb}) {
+               my $file = "$MUSIC/$entry->{file}";
+               my $cover = mpd_cover_filename($file);
+
+               $cover = fvwm_shell_literal($cover // $file);
+               fvwm_cmd_unquoted("AddToMenu", MPD::escape($menu),
+                                 MPD::escape($entry->{thumb}),
+                                 "Exec", "exec", "geeqie", $cover);
+       }
+}
+
+# Generate the "Title:" entry in the top menu.
+sub top_track_title {
+       my ($entry) = @_;
+       my @submenu;
+
+       my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_RELEASETRACKID");
+       if ($mbid) {
+               @submenu = make_submenu("$menu-$mbid", "--track-id=$mbid")
+       } else {
+               ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID");
+               @submenu = make_submenu("$menu-track-$mbid", "--recording-id=$mbid")
+                       if ($mbid);
+       }
+
+       fvwm_cmd("AddToMenu", $menu,
+                fvwm_label_escape("Title:\t$entry->{Title}"),
+                @submenu);
+}
+
+# Generate the "Artist:" entry in the top menu.
+sub top_track_artist {
+       my ($entry) = @_;
+       my @submenu;
+
+       # TODO: multi-artist tracks should get multiple artist menus; for now
+       # just combine the releases from all artists.
+       my @mbids = get_track_metadata($entry, "MUSICBRAINZ_ARTISTID");
+       if (@mbids) {
+               @submenu = make_submenu("$menu-TopArtist",
+                                       map { "--artist-id=$_" } @mbids);
+       }
+
+       fvwm_cmd("AddToMenu", $menu,
+                fvwm_label_escape("Artist:\t$entry->{Artist}"),
+                @submenu);
+}
+
+# Generate the "Album:" entry in the top menu.
+sub top_track_album {
+       my ($entry) = @_;
+       my @submenu;
+       my $mbid;
+
+       if (($mbid) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID")) {
+               @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid");
+       } elsif (($mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID")) {
+               # Standalone recording
+               my @a = get_track_metadata($entry, "MUSICBRAINZ_ARTISTID");
+               my ($album) = get_track_metadata($entry, "Album");
+
+               @submenu = make_submenu("$menu-$mbid", "--album-name=$album",
+                                       map { "--artist-id=$_" } @a);
+
+       }
+
+       fvwm_cmd("AddToMenu", $menu,
+                fvwm_label_escape("Album:\t$entry->{Album}"),
+                @submenu);
+}
+
+# Generate the "MusicBrainz:" entry in the top menu.
+sub top_track_musicbrainz {
+       my ($entry) = @_;
+       my ($track_mbid, $recording_mbid, $release_mbid);
+       my @artist_mbids;
+       my $label = "MB:";
+       my %idmap;
+
+       ($track_mbid) = get_track_metadata($entry, "MUSICBRAINZ_RELEASETRACKID");
+       ($recording_mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID");
+       ($release_mbid) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID");
+       @artist_mbids = get_track_metadata($entry, "MUSICBRAINZ_ARTISTID");
+       return unless $track_mbid // $recording_mbid
+                  // $release_mbid // @artist_mbids;
+
+       foreach (get_track_metadata($entry, "Comment")) {
+               $idmap{$1} = $2 if /^([^=]*)=(.*) \(idmap\)$/;
+       }
+
+       fvwm_cmd("AddToMenu", $menu, "", "Nop");
+       if ($track_mbid) {
+               fvwm_cmd("AddToMenu", $menu, "$label\tShow track",
+                       "Exec", "exec", "xdg-open",
+                       "https://musicbrainz.org/track/$track_mbid");
+               $label = "";
+       } elsif ($recording_mbid) {
+               fvwm_cmd("AddToMenu", $menu, "$label\tShow recording",
+                       "Exec", "exec", "xdg-open",
+                       "https://musicbrainz.org/recording/$recording_mbid");
+               $label = "";
+       } elsif ($release_mbid) {
+               fvwm_cmd("AddToMenu", $menu, "$label\tShow",
+                       "Exec", "exec", "xdg-open",
+                       "https://musicbrainz.org/release/$release_mbid");
+               $label = "";
+       }
+
+       foreach my $mbid (@artist_mbids) {
+               my $name = " $idmap{$mbid}" if $idmap{$mbid};
+
+               fvwm_cmd("AddToMenu", $menu, "$label\tShow artist$name",
+                       "Exec", "exec", "xdg-open",
+                       "https://musicbrainz.org/artist/$mbid");
+               $label = "";
+       }
+}
+
+# Given a work MBID, return a hash reference containing all tracks
+# linked to that work.  The hash keys are filenames.
+sub get_tracks_by_work_mbid {
+       my %matches;
        my $entry;
 
-       $menu = "MenuMPDAlbum" unless defined $menu;
-       
-       $album =~ s/"/\\"/g;
-       print $sock "playlistfind album \"$album\"\n";
-       while (<$sock>) {
-               last if (/^OK/);
-               die($_) if (/^ACK/);
-       
-               if (/^(\w+): (.*)$/) {
-                       if ($1 eq "file") {
-                               if (keys(%$entry) > 0) {
-                                       addalbumentry(\@playlist, $entry)
+       foreach my $mbid (@_) {
+               MPD::exec("search", "(MUSICBRAINZ_WORKID == \"$mbid\")");
+               while (<$sock>) {
+                       last if (/^OK/);
+                       die($_) if (/^ACK/);
+
+                       if (/^(\w+): (.*)$/) {
+                               if ($1 eq "file") {
+                                       if (exists($matches{$2})) {
+                                               $entry = $matches{$2};
+                                       } else {
+                                               $entry = {};
+                                               $matches{$2} = $entry;
+                                       }
                                }
-       
-                               $entry = {};
+
+                               add_track_metadata($entry, $1, $2);
                        }
-       
-                       $entry->{$1} = $2;
                }
        }
-       addalbumentry(\@playlist, $entry) if (keys(%$entry) > 0);
-       
-       die("No tracks found.\n") if (!@playlist);
-       foreach (sort albumsort @playlist) {
-               my ($t_file, $t_trackno, $t_artist, $t_title, $t_id) = (
-                       $_->{file},
-                       $_->{Track},
-                       $_->{Artist},
-                       $_->{Title},
-                       $_->{Id},
-               );
-       
-               next if (defined $artist && !$accept{albumdir($t_file)});
-       
-               $t_artist = sanitise($t_artist);
-               $t_title  = sanitise($t_title);
-               
-               my $cmd = sprintf "AddToMenu $menu \"%d\t%s - %s\""
-                                 ." Exec mpc playid %d",
-                                 $t_trackno, $t_artist, $t_title, $t_id;
-       
-               cmd($cmd);
-       }
-} elsif (defined $artist) {
-       # Create an artist menu.
-       my %albums = ();
-       my $file;
-       my $quoteartist = $artist;
 
-       $menu = "MenuMPDArtist" unless defined $menu;
+       return \%matches;
+}
+
+# Given a track MBID, return a hash reference containing all "related"
+# tracks in the MPD database.  The hash keys are filenames.
+#
+# Currently tracks are considered "related" if their associated recordings
+# have at least one work in common.
+sub get_tracks_by_track_mbid {
+       my ($mbid, $tagname) = (@_, "MUSICBRAINZ_RELEASETRACKID");
+       my %source;
+       my %matches;
+       my $entry;
 
-       $quoteartist =~ s/"/\\"/g;
-       print $sock "playlistfind artist \"$quoteartist\"\n";
+       return \%matches unless ($mbid);
+       MPD::exec("search", "($tagname == \"$mbid\")");
        while (<$sock>) {
                last if (/^OK/);
                die($_) if (/^ACK/);
-       
+
                if (/^(\w+): (.*)$/) {
-                       $file       = $2    if ($1 eq "file");
-                       $albums{$2} = $file if ($1 eq "Album");
+                       add_track_metadata(\%source, $1, $2);
                }
        }
 
-       die("No albums found.\n") if (!keys(%albums));
-
-{ # work around 'use locale' breaking s///i
-       my $i = 0;
-       use locale;
-       foreach (sort keys(%albums)) {
-               my $key      = $_;
-               my $a_album  = sanitise($key);
+       # Always include the current track
+       $matches{$source{file}} = \%source;
 
-               open THUMB, "-|", "$FVWM/scripts/thumbnail.zsh",
-                                        "--small", "--music", $albums{$key};
-               my $thumb = <THUMB>;
-               close THUMB;
-               die("Incompetent use of thumbnail.zsh") if ($?);
+       # Find all tracks related by work
+       foreach my $mbid (get_track_metadata(\%source, "MUSICBRAINZ_WORKID")) {
+               my $related = get_tracks_by_work_mbid($mbid);
+               foreach (keys %$related) {
+                       $matches{$_} //= $related->{$_};
+               }
+       }
 
-               $thumb =~ s/\n//sg;
-               $thumb = "%$thumb%" if (-f $thumb);
+       return \%matches;
+}
 
-               cmd("AddToMenu $menu \"$thumb$a_album\" Popup MenuMPDArt_$i");
+sub get_tracks_by_recording_mbid {
+       return get_tracks_by_track_mbid($_[0], "MUSICBRAINZ_TRACKID");
+}
 
-               cmd("AddToMenu MenuMPDArt_$i DynamicPopUpAction MakeMenuMPDArt_$i");
+# Given a release MBID, return a hash reference containing all its
+# associated tracks in the MPD database.  The hash keys are filenames.
+sub get_tracks_by_release_mbid {
+       my ($mbid) = @_;
+       my %matches;
+       my $entry;
 
-               cmd("DestroyFunc MakeMenuMPDArt_$i");
-               cmd("AddToFunc   MakeMenuMPDArt_$i
-                    + I DestroyMenu MenuMPDArt_$i
-                    + I -PipeRead \"exec $FVWM/scripts/mpdmenu.pl "
-                          ."--menu MenuMPDArt_$i "
-                          ."--album  ".shellify($key, 1)." "
-                          ."--artist ".shellify($artist, 1)."\"");
+       return \%matches unless ($mbid);
+       MPD::exec("search", "(MUSICBRAINZ_ALBUMID == \"$mbid\")");
+       while (<$sock>) {
+               last if (/^OK/);
+               die($_) if (/^ACK/);
 
-               cmd("AddToFunc KillMenuMPD I DestroyMenu MenuMPDArt_$i");
-               cmd("AddToFunc KillMenuMPD I DestroyFunc MakeMenuMPDArt_$i");
+               if (/^(\w+): (.*)$/) {
+                       if ($1 eq "file") {
+                               if (exists($matches{$2})) {
+                                       $entry = $matches{$2};
+                               } else {
+                                       $entry = {};
+                                       $matches{$2} = $entry;
+                               }
+                       }
 
-               $i++;
+                       add_track_metadata($entry, $1, $2);
+               }
        }
-} # end use locale workaround
-} elsif (defined $title) {
-       # Create a title menu.
-       my @titles;
-       my $entry;
 
-       $menu = "MenuMPDTitle" unless defined $menu;
-
-       # Open and close brackets.
-       my ($ob, $cb) = ("[\[~〜<(ー−-]", "[\]~〜>)ー−-]");
+       return \%matches;
+}
 
-       $_ = $title;
+# Insert the given entry into the referenced hash if it represents a
+# standalone recording (not associated with a release).  The recording
+# MBID is used as the hash key.
+sub check_standalone {
+       my ($outhash, $entry) = @_;
+       my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID");
 
-       # Deal with specific cases.
-       s/ちいさな(?=ヘミソフィア)//;                 # ヘミソフィア
-       s/ "mix on air flavor" dear EIKO SHIMAMIYA//; # Spiral wind
-       s/ "So,you need me" Style//;                  # I need you
-       s/ ::Symphony Second movement:://;            # Disintegration
-       s/-\[instrumental\]//;                        # 青い果実
-       s/ -Practice Track-//;                        # Fair Heaven
-       s/〜世界で一番アナタが好き〜//;               # Pure Heart
-       s/〜彼方への哀歌//;                           # 十二幻夢
+       return if exists $entry->{MUSICBRAINZ_ALBUMID};
+       $outhash->{$mbid} = $entry if ($mbid);
+}
 
-       s/\s*-remix-$//; # Otherwise "D-THREAD -remix-" doesn't work right.
+# Given an artist MBID, return a list of two hash refererences.  The
+# first contains the associated releases in the MPD database and the
+# hash keys are release MBIDs.  The second contains the artist's
+# standalone recordings and the hash keys are recording MBIDs.
+#
+# In scalar context only the release hash is returned.
+#
+# Since MPD returns results on a per-track basis, each entry in the
+# hash has the metadata for one unspecified track from that release.
+sub get_releases_by_artist_mbid {
+       my (%releases, %standalones);
+       my $entry;
 
-       # Deal with titles like "blah (ABC version)".
-       s/\s*$ob.*(style|mix|edit|edition|ver\.?|version|カラオケ)$cb?$//i;
+       foreach my $mbid (@_) {
+               MPD::exec("search", "(MUSICBRAINZ_ARTISTID == \"$mbid\")");
+               while (<$sock>) {
+                       last if (/^OK/);
+                       die($_) if (/^ACK/);
+
+                       if (/^(\w+): (.*)$/) {
+                               if ($1 eq "file") {
+                                       check_standalone(\%standalones, $entry);
+                                       $entry = {};
+                               } elsif ($1 eq "MUSICBRAINZ_ALBUMID") {
+                                       $releases{$2} //= $entry;
+                               }
 
-       # Deal with titles like "blah (without XYZ)".
-       s/\s*$ob((e\.)?piano|english|japanese|inst|tv|without|w\/o|off|back|short|karaoke|game).*//i;
+                               add_track_metadata($entry, $1, $2);
+                       }
+               }
+               check_standalone(\%standalones, $entry);
+       }
 
-       # Deal with titles like "blah instrumental".
-       s/\s+(instrumental|off vocal|short)(\s+(size|version|s))?$//i;
-       s/\s+without\s+\w+$//i;
+       return wantarray ? (\%releases, values %standalones) : \%releases;
+}
 
-       my $basetitle  = $_;
-       my $_basetitle = $basetitle;
+# Given a filename, return the IDs (if any) for that file in the
+# current MPD play queue.
+sub get_ids_by_filename {
+       my ($file) = @_;
+       my @results = ();
 
-       $_basetitle =~ s/"/\\"/g;
-       print $sock "playlistsearch title \"$_basetitle\"\n";
+       MPD::exec("playlistfind", "file", $file);
        while (<$sock>) {
                last if (/^OK/);
                die($_) if (/^ACK/);
-       
+
                if (/^(\w+): (.*)$/) {
-                       if ($1 eq "file") {
-                               push @titles, $entry if (keys(%$entry) > 0);
-                               $entry = {};
-                       }
-       
-                       $entry->{$1} = $2;
+                       push @results, $2 if ($1 eq "Id");
                }
        }
-       push @titles, $entry if (keys(%$entry) > 0);
-
-{ # work around 'use locale' breaking s///i
-       use locale;
-       foreach (sort titlesort @titles) {
-               my ($t_file, $t_artist, $t_title, $t_id) = (
-                       $_->{file},
-                       $_->{Artist},
-                       $_->{Title},
-                       $_->{Id},
-               );
-
-               # MPD searches are case-insensitive.
-               next if (!($t_title =~ m/(\P{Latin}|^)\Q$basetitle\E(\P{Latin}|$)/));
-               $t_artist = sanitise($t_artist);
-               $t_title  = sanitise($t_title);
-
-               open THUMB, "-|", "$FVWM/scripts/thumbnail.zsh",
-                                        "--small", "--music", $t_file;
-               my $thumb = <THUMB>;
-               close(THUMB);
-               die("Incompetent use of thumbnail.zsh") if ($?);
 
-               $thumb =~ s/\n//sg;
-               $thumb = "%$thumb%" if (-f $thumb);
+       return @results;
+}
 
-               cmd("AddToMenu $menu \"$thumb$t_artist - $t_title\" Exec mpc playid $t_id");
+sub update_entry_ids {
+       my @notqueued = ();
+
+       foreach my $entry (@_) {
+               unless (exists $entry->{Id}) {
+                       my ($id) = get_ids_by_filename($entry->{file});
+                       if (defined $id) {
+                               $entry->{Id} = $id;
+                       } else {
+                               push @notqueued, $entry;
+                               next;
+                       }
+               }
        }
-} # end use locale workaround
-} else {
-       # Make MPD base menu
-       my ($state, $songid) = (undef, undef);
-       my %entry = ();
 
-       $menu = "MenuMPD" unless defined $menu;
+       return @notqueued;
+}
+
+# albumsort(matches, a, b)
+#
+# Sort hash keys (a, b) by disc/track number for album menus.
+sub albumsort {
+       my ($matches, $a, $b) = @_;
+
+       return $matches->{$a}->{Disc} <=> $matches->{$b}->{Disc}
+           || $matches->{$a}->{Track} <=> $matches->{$b}->{Track}
+           || $a cmp $b;
+}
+
+# datesort(matches, a, b)
+#
+# Sort hash keys (a, b) by release date
+sub datesort {
+       my ($matches, $a, $b) = @_;
+
+       return $matches->{$a}->{Date} cmp $matches->{$b}->{Date}
+           || $a cmp $b;
+}
+
+# menu_trackname(entry)
+#
+# Format the track name for display in an FVWM menu, where entry
+# is a hash reference containing the track metadata.
+sub menu_trackname {
+       my ($entry) = @_;
+       my $fmt = "$entry->{trackfmt}$entry->{Artist} - $entry->{Title}";
+       return "$entry->{thumb}" . fvwm_label_escape($fmt);
+}
+
+sub print_version {
+       print <<EOF
+mpdmenu.pl 0.9
+Copyright © 2021 Nick Bowler
+License GPLv3+: GNU General Public License version 3 or any later version.
+This is free software: you are free to change and redistribute it.
+There is NO WARRANTY, to the extent permitted by law.
+EOF
+}
+
+sub print_usage {
+       my ($fh) = (@_, *STDERR);
+
+       print $fh "Usage: $0 [options]\n";
+       print $fh "Try $0 --help for more information.\n" unless (@_ > 0);
+}
+
+sub print_help {
+       print_usage(*STDOUT);
+       print <<EOF
+This is "mpdmenu": a menu-based MPD client for FVWM.
+
+Options:
+  -h, --host=HOST   Connect to the MPD server on HOST, overriding defaults.
+  -p, --port=PORT   Connect to the MPD server on PORT, overriding defaults.
+  -m, --menu=NAME   Set the name of the generated menu.
+  --album-id=MBID   Generate a menu for the given release MBID.
+  --album-name=NAME
+                    Generate a menu for standalone tracks with the given
+                   "album" NAME.  An artist MBID must be supplied.
+  --artist-id=MBID  Generate a menu for the given artist MBID.
+  --track-id=MBID   Generate a menu for the given track MBID.
+  --recording-id=MBID
+                    Generate a menu for the given recording MBID.
+  -V, --version     Print a version message and then exit.
+  -H, --help        Print this message and then exit.
+EOF
+}
+
+GetOptions(
+       'host|h=s'       => \$MPD::host,
+       'port|p=s'       => \$MPD::port,
+       'menu|m=s'       => \$menu,
+
+       'artist-id=s'    => sub { $artistids{$_[1]} = 1; $mode = "artist"; },
+       'album-id=s'     => sub { $albumid = $_[1]; $mode = "album"; },
+       'album-name=s'   => sub { $albumname = $_[1]; $mode = "albumname"; },
+       'track-id=s'     => sub { $trackid = $_[1]; $mode = "track"; },
+       'recording-id=s' => sub { $recordingid = $_[1]; $mode = "recording"; },
+
+       'V|version'      => sub { print_version(); exit },
+       'H|help'         => sub { print_help(); exit },
+
+       'topmenu=s'      => \$topmenu, # top menu name (for submenu generation)
+) or do { print_usage; exit 1 };
+
+$mode = "albumname" if ($albumname && $mode eq "artist");
 
-       print $sock "status\n";
+unless (defined $menu) {
+       $topmenu //= "MenuMPD";
+       $menu = $topmenu . ($mode ne "top" ? $mode : "");
+}
+$topmenu //= $menu;
+
+# Connect to MPD.
+$sock = MPD::connect();
+die("MPD version $MPD::major.$MPD::minor.$MPD::revision insufficient.")
+       unless MPD::min_version(MPD_MJR_MIN, MPD_MNR_MIN, MPD_REV_MIN);
+
+MPD::exec("binarylimit", 64);
+while (<$sock>) {
+       $mpd_have_binarylimit = 1 if /^OK/;
+       last if /^OK/ or /^ACK/;
+}
+
+if ($mode eq "top") {
+       my %current;
+       my %state;
+
+       $menu //= "MenuMPD";
+
+       MPD::exec("status");
        while (<$sock>) {
                last if (/^OK/);
                die($_) if (/^ACK/);
 
                if (/^(\w+): (.*)$/) {
-                       $state  = $2 if ($1 eq "state");
-                       $songid = $2 if ($1 eq "songid");
+                       $state{$1} = $2;
                }
        }
-       die("Failed status query\n") unless (defined $state && defined $songid);
 
-       print $sock "playlistid $songid\n";
+       MPD::exec("currentsong");
        while (<$sock>) {
                last if (/^OK/);
                die($_) if (/^ACK/);
 
                if (/^(\w+): (.*)$/) {
-                       $entry{$1} = $2;
+                       add_track_metadata(\%current, $1, $2);
                }
        }
-       die("Failed data query\n") unless (keys(%entry) > 0);
-
-       open THUMB, "-|", "$FVWM/scripts/thumbnail.zsh",
-                                "--image", "--music",  $entry{file};
-       my $thumb = <THUMB>;
-       my $scan  = <THUMB>;
-       close(THUMB);
-       die("Incompetent use of thumbnail.zsh") if ($?);
-
-       $thumb =~ s/\n//sg;
-       $scan  =~ s/\n//sg;
-
-       cmd("AddToMenu $menu Playing Title") if ($state eq "play");
-       cmd("AddToMenu $menu Paused Title")  if ($state eq "pause");
-       cmd("AddToMenu $menu Stopped Title") if ($state eq "stop");
-       if (-f $thumb) {
-               cmd("AddToMenu $menu \"*$thumb*\" "
-                       ."Exec exec gqview ".shellify($scan, 0));
-       }
-       cmd("AddToMenu $menu \"Title:   ".sanitise($entry{Title})."\" "
-               ."Popup MenuMPDTitle");
-       cmd("AddToMenu $menu \"Artist:  ".sanitise($entry{Artist})."\" "
-               ."Popup MenuMPDArtist");
-       cmd("AddToMenu $menu \"Album:   ".sanitise($entry{Album})."\" "
-               ."Popup MenuMPDAlbum");
-       cmd("AddToMenu $menu \"\" Nop");
-
-       if ($state eq "play" || $state eq "pause") {
-               cmd("AddToMenu $menu \"\t\tNext%$icons/next.svg:16x16%\" "
-                       ."Exec exec mpc next");
-               cmd("AddToMenu $menu \"\t\tPause%$icons/pause.svg:16x16%\" "
-                       ."Exec exec mpc pause") if ($state eq "play");
-               cmd("AddToMenu $menu \"\t\tPlay%$icons/play.svg:16x16%\" "
-                       ."Exec exec mpc play") if ($state eq "pause");
-               cmd("AddToMenu $menu \"\t\tStop%$icons/stop.svg:16x16%\" "
-                       ."Exec exec mpc stop");
-               cmd("AddToMenu $menu \"\t\tPrev%$icons/prev.svg:16x16%\" "
-                       ."Exec exec mpc prev");
-       } elsif ($state eq "stop") {
-               cmd("AddToMenu $menu \"\t\tPlay%$icons/play.svg:16x16%\" "
-                       ."Exec exec mpc play");
+
+       my $playstate = $state{state} eq "play"  ? "Playing"
+                     : $state{state} eq "stop"  ? "Stopped"
+                     : $state{state} eq "pause" ? "Paused"
+                     : "Unknown";
+       fvwm_cmd("AddToMenu", $menu, $playstate, "Title");
+
+       if (exists($current{file})) {
+               top_track_cover(\%current);
+               top_track_title(\%current);
+               top_track_artist(\%current);
+               top_track_album(\%current);
+               top_track_musicbrainz(\%current);
        } else {
-               die("Unknown MPD state!\n");
-       }
-
-       cmd("AddToMenu $menu \"\" Nop");
-       cmd("AddToMenu $menu \"\t\tShuffle%$icons/shuffle.svg:16x16%\" "
-               ."Exec exec mpc shuffle");
-
-       cmd("DestroyMenu MenuMPDTitle");
-       cmd("AddToMenu   MenuMPDTitle  DynamicPopUpAction MakeMenuMPDTitle");
-       cmd("DestroyMenu MenuMPDArtist");
-       cmd("AddToMenu   MenuMPDArtist DynamicPopUpAction MakeMenuMPDArtist");
-       cmd("DestroyMenu MenuMPDAlbum");
-       cmd("AddToMenu   MenuMPDAlbum  DynamicPopUpAction MakeMenuMPDAlbum");
-
-       cmd("DestroyFunc MakeMenuMPDTitle");
-       cmd("AddToFunc   MakeMenuMPDTitle
-            + I DestroyMenu MenuMPDTitle
-            + I -PipeRead \"exec $FVWM/scripts/mpdmenu.pl "
-                          ."--menu MenuMPDTitle "
-                          ."--title ".shellify($entry{Title}, 1)."\"");
-
-       cmd("DestroyFunc MakeMenuMPDAlbum");
-       cmd("AddToFunc   MakeMenuMPDAlbum
-            + I DestroyMenu MenuMPDAlbum
-            + I -PipeRead \"exec $FVWM/scripts/mpdmenu.pl "
-                          ."--menu MenuMPDAlbum "
-                          ."--album  ".shellify($entry{Album}, 1)." "
-                          ."--artist ".shellify($entry{Artist}, 1)."\"");
-       
-       cmd("DestroyFunc MakeMenuMPDArtist");
-       cmd("AddToFunc   MakeMenuMPDArtist
-            + I DestroyMenu MenuMPDArtist
-            + I -PipeRead \"exec $FVWM/scripts/mpdmenu.pl "
-                          ."--menu MenuMPDArtist "
-                          ."--artist ".shellify($entry{Artist}, 1)."\"");
-
-       cmd("DestroyFunc KillMenuMPD");
-       cmd("AddToFunc   KillMenuMPD I Nop");
-}
+               fvwm_cmd("AddToMenu", $menu, "[current track unavailable]");
+       }
 
-# Finished.
-print $sock "close\n";
+       if ($state{state} =~ /^p/) {
+               my $pp = $state{state} eq "pause" ? "lay" : "ause";
+
+               fvwm_cmd("AddToMenu", $menu, "", "Nop");
+               fvwm_cmd("AddToMenu", $menu, "Next%next.svg:16x16%",
+                      "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "next");
+               fvwm_cmd("AddToMenu", $menu, "P$pp%p$pp.svg:16x16%",
+                      "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "p$pp");
+               fvwm_cmd("AddToMenu", $menu, "Stop%stop.svg:16x16%",
+                      "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "stop");
+               fvwm_cmd("AddToMenu", $menu, "Prev%prev.svg:16x16%",
+                      "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "previous");
+       } elsif ($state{state} eq "stop") {
+               fvwm_cmd("AddToMenu", $menu, "", "Nop");
+               fvwm_cmd("AddToMenu", $menu, "Play%play.svg:16x16%",
+                      "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "play");
+       }
+} elsif ($mode eq "album") {
+       my $matches = get_tracks_by_release_mbid($albumid);
+       my @notqueued = ();
+
+       $menu //= "MenuMPDAlbum";
+
+       my $track_max = max(map { $_->{Track} } values %$matches);
+       my $disc_max = max(map { $_->{Disc} } values %$matches);
+
+       # CDs have a max of 99 tracks and I hope 100+-disc-releases
+       # don't exist so this is fine.
+       my $track_digits = $track_max >= 10 ? 2 : 1;
+       my $disc_digits = $disc_max > 1 ? $disc_max >= 10 ? 2 : 1 : 0;
+       my $currentdisc;
+
+       fvwm_cmd("AddToMenu", $menu);
+       fvwm_cmd("+", "Release not found", "Title") unless keys %$matches;
+       foreach my $file (sort { albumsort($matches, $a, $b) } keys %$matches) {
+               my $entry = $matches->{$file};
+
+               # Format disc/track numbers
+               $entry->{trackfmt} = sprintf("%*.*s%.*s%*d\t",
+                                 $disc_digits, $disc_digits, $entry->{Disc},
+                                 $disc_digits, "-",
+                                 $track_digits, $entry->{Track});
+               $entry->{trackfmt} =~ s/ /\N{U+2007}/g;
+
+               unless (exists $entry->{Id}) {
+                       my ($id) = get_ids_by_filename($file);
+                       if (defined $id) {
+                               $entry->{Id} = $id;
+                       } else {
+                               push @notqueued, $entry;
+                               next;
+                       }
+               }
 
-sub sanitise
-{
-       $_ = $_[0];
-       s/([\$&@%^*])/\1\1/g;
-       s/"/\\"/g;
-       return $_;
-}
+               if (defined $currentdisc && $currentdisc != $entry->{Disc}) {
+                       fvwm_cmd("+", "", "Nop");
+               }
+               $currentdisc = $entry->{Disc};
 
-sub addalbumentry
-{
-       my ($playlist, $entry) = @_;
+               fvwm_cmd("+", menu_trackname($entry), "Exec",
+                        "exec", "$FindBin::Bin/mpdexec.pl",
+                        "playid", $entry->{Id});
+       }
 
-       push(@$playlist, $entry);
+       fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
+       foreach my $entry (@notqueued) {
+               fvwm_cmd("+", menu_trackname($entry));
+       }
+} elsif ($mode eq "artist") {
+       # Create an artist menu.
+       my ($matches, @recs) = get_releases_by_artist_mbid(keys %artistids);
+
+       $menu //= "MenuMPDArtist";
 
-       if (defined $artist && $artist eq $entry->{Artist}) {
-               my $albumdir = albumdir($entry->{file});
-               $accept{$albumdir} = 1;
+       my @mbids = sort { datesort($matches, $a, $b) } keys %$matches;
+       my @files = map { $matches->{$_}->{file} } @mbids;
+       my @thumbs = get_item_thumbnails({ small => 1 }, @files);
+
+       unless (@mbids) {
+               fvwm_cmd("AddToMenu", $menu, "No releases found", "Title")
        }
-}
 
-sub albumdir
-{
-       my $file = $_[0];
+       foreach my $mbid (@mbids) {
+               my $entry = $matches->{$mbid};
+               my $thumb = shift @thumbs;
 
-       $file =~ s:(/Disk [0-9]+[^/]*)?/[^/]*$::;
-       return $file
-}
+               my @submenu = make_submenu("$topmenu-$mbid",
+                                          "--album-id=$mbid");
+               fvwm_cmd("AddToMenu", $menu,
+                        $thumb . fvwm_label_escape($entry->{Album}),
+                        @submenu);
+       }
 
-sub albumsort
-{
-       return ($a->{Disc} <=> $b->{Disc}) if ($a->{Disc} != $b->{Disc});
-       return ($a->{Track} <=> $b->{Track});
-}
+       my @artists = map { "--artist-id=$_" } keys %artistids;
+       my %nonalbums = map { $_->{Album} => $_ } @recs;
+       foreach my $name (sort keys %nonalbums) {
+               my $mbid = $nonalbums{$name}->{MUSICBRAINZ_TRACKID};
+               my @submenu = make_submenu("$topmenu-$mbid", @artists,
+                                          "--album-name=$name");
+               fvwm_cmd("AddToMenu", $menu, fvwm_label_escape($name), @submenu);
+       }
+} elsif ($mode eq "albumname") {
+       # Create a standalone recordings menu
+       my ($releases, @recs) = get_releases_by_artist_mbid(keys %artistids);
+
+       $menu //= "MenuMPDRecordings";
+       my @tracks = sort { $a->{Title} cmp $b->{Title} }
+                    grep { $_->{Album} eq $albumname } @recs;
+
+       # Show thumbnails for standalone recordings
+       my @thumbs = get_item_thumbnails({ small => 1 },
+                                        map { $_->{file} } @tracks);
+       foreach my $entry (@tracks) {
+               $entry->{thumb} = shift @thumbs;
+       }
 
-sub titlesort
-{
-       return ($a->{Album}  cmp $b->{Album})  if($a->{Album}  ne $b->{Album});
-       return ($a->{Artist} cmp $b->{Artist}) if($a->{Artist} ne $b->{Artist});
-       return ($a->{Title}  cmp $b->{Title});
-}
+       my @notqueued = update_entry_ids(@tracks);
 
-sub shellify
-{
-       my ($str, $quoted) = @_;
-       $str =~ s/'/'\\''/g;
-       if ($quoted) {
-               $str =~ s/\\/\\\\/g;
-               $str =~ s/"/\\"/g;
+       fvwm_cmd("AddToMenu", $menu);
+       fvwm_cmd("+", "No tracks found", "Title") unless @tracks;
+
+       foreach my $entry (@tracks) {
+               next unless exists $entry->{Id};
+
+               fvwm_cmd("+", menu_trackname($entry), "Exec",
+                        "exec", "$FindBin::Bin/mpdexec.pl",
+                        "playid", $entry->{Id});
+       }
+
+       fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
+       foreach my $entry (@notqueued) {
+               fvwm_cmd("+", menu_trackname($entry));
+       }
+} elsif ($mode eq "track" || $mode eq "recording") {
+       my ($matches, $mbid);
+       my @notqueued;
+
+       if ($mode eq "track") {
+               $matches = get_tracks_by_track_mbid($trackid)
+       } else {
+               $matches = get_tracks_by_recording_mbid($recordingid)
+       }
+
+       $menu //= "MenuMPDTrack";
+       fvwm_cmd("DestroyMenu", $menu);
+
+       my @files = sort { datesort($matches, $a, $b) } keys %$matches;
+       my @thumbs = get_item_thumbnails({ small => 1 }, @files);
+
+       fvwm_cmd("AddToMenu", $menu);
+       fvwm_cmd("+", "No tracks found", "Title") unless @files;
+       foreach my $file (@files) {
+               my $entry = $matches->{$file};
+               $entry->{thumb} = shift @thumbs;
+
+               unless (exists $entry->{Id}) {
+                       my ($id) = get_ids_by_filename($file);
+                       if (defined $id) {
+                               $entry->{Id} = $id;
+                       } else {
+                               push @notqueued, $entry;
+                               next;
+                       }
+               }
+
+               fvwm_cmd("+", menu_trackname($entry), "Exec",
+                        "exec", "$FindBin::Bin/mpdexec.pl",
+                        "playid", $entry->{Id});
+       }
+
+       fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
+       foreach my $entry (@notqueued) {
+               fvwm_cmd("+", menu_trackname($entry));
        }
-       return "'$str'";
 }
+
+# Finished.
+print $sock "close\n";