+my $MUSIC = $ENV{MUSIC} // "/srv/music";
+my $host = $ENV{MPD_HOST} // "localhost";
+my $port = $ENV{MPD_PORT} // "6600";
+my $sock;
+
+my ($albumid, $title);
+my %artistids;
+my $menu;
+my $mode = "top";
+
+# Quotes the argument so that it is presented as a single argument to MPD
+# at the protocol level. This also works OK for most FVWM arguments.
+sub escape {
+ my $s = @_[0] // $_;
+
+ # No way to encode literal newlines in the protocol, so we
+ # convert any newlines in the arguments into a space, which
+ # can help with quoting.
+ $s =~ s/\n/ /g;
+
+ if (/[ \t\\"]/) {
+ $s =~ s/[\\"]/\\$&/g;
+ return "\"$s\"";
+ }
+
+ $s =~ s/^\s*$/"$&"/;
+ return $s;
+}
+
+# Submit a command to the MPD server; each argument to this function
+# is quoted and sent as a single argument to MPD.
+sub mpd_exec {
+ my $cmd = join(' ', map { escape } @_);
+
+ print $sock "$cmd\n";
+}
+
+sub fvwm_cmd_unquoted {
+ print join(' ', @_), "\n";
+}
+
+sub fvwm_cmd {
+ fvwm_cmd_unquoted(map { escape } @_);
+}
+
+# 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";
+}
+
+# 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;
+}
+
+# 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, "--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", "KillMenuMPD", "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", "KillMenuMPD", "I", "DestroyFunc", "Make$name");
+
+ return ("Popup", $name);
+}
+
+# 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 = "%";
+ }
+
+ 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", escape($menu),
+ escape($entry->{thumb}),
+ "Exec", "exec", "geeqie", $cover);
+ }
+}
+
+# Generate the "Title:" entry in the top menu.
+sub top_track_title {
+ my ($entry) = @_;
+
+ my @submenu = make_submenu("$menu-TopTrack",
+ "--title=$entry->{Title}");
+
+ 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) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID");
+ @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid") if $mbid;
+
+ fvwm_cmd("AddToMenu", $menu,
+ fvwm_label_escape("Album:\t$entry->{Album}"),
+ @submenu);
+}
+
+# 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;
+
+ return \%matches unless ($mbid);
+ mpd_exec("search", "(MUSICBRAINZ_ALBUMID == \"$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;
+ }
+ }
+
+ add_track_metadata($entry, $1, $2);
+ }
+ }
+
+ return \%matches;