3 # Copyright © 2008,2010,2012,2020-2021 Nick Bowler
5 # Silly little script to generate an FVWM menu with various bits of MPD
6 # status information and controls.
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.
16 use Encode qw(decode encode);
17 use Encode::Locale qw(decode_argv);
18 decode_argv(Encode::FB_CROAK);
19 binmode(STDOUT, ":utf8");
21 use Getopt::Long qw(:config gnu_getopt);
22 use Scalar::Util qw(reftype);
23 use List::Util qw(any max);
26 use lib "$FindBin::Bin";
35 my $SELF = "$FindBin::Bin/$FindBin::Script";
37 my $MUSIC = $ENV{MUSIC} // "/srv/music";
38 my ($sock, $mpd_have_binarylimit);
40 my ($albumid, $albumname, $trackid, $recordingid);
45 sub fvwm_cmd_unquoted {
46 print join(' ', @_), "\n";
50 fvwm_cmd_unquoted(map { MPD::escape } @_);
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).
57 # The result must be used with fvwm_cmd_unquoted;
58 sub fvwm_shell_literal {
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;
80 # make_submenu(name, [args ...])
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.
88 unshift @_, ("exec", $SELF, "--topmenu=$topmenu", "--menu=$name");
90 fvwm_cmd("DestroyFunc", "Make$name");
91 fvwm_cmd("AddToFunc", "Make$name");
92 fvwm_cmd("+", "I", "DestroyMenu", $name);
94 fvwm_cmd("DestroyMenu", $name);
95 fvwm_cmd("AddToMenu", $name, "DynamicPopupAction", "Make$name");
96 fvwm_cmd("AddToFunc", "Kill$topmenu", "I", "DestroyMenu", $name);
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");
105 return ("Popup", $name);
108 # get_item_thumbnails({ options }, file, ...)
109 # get_item_thumbnails(file, ...)
111 # For each music file listed, obtain a thumbnail (if any) for the cover art.
113 # The first argument is a hash reference to control the mode of operation;
114 # it may be omitted for default options.
116 # get_item_thumbnails({ small => 1 }, ...) - smaller thumbnails
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 {
128 $flags = shift if (reftype($_[0]) eq "HASH");
129 return @results unless @_;
132 if ($flags->{small}) {
133 push @opts, "--small";
137 if ($mpd_have_binarylimit) {
138 # --embedded implies and requires binarylimit support
139 push @opts, "--embedded";
141 push @opts, "--no-binarylimit";
144 open THUMB, "-|", "$FindBin::Bin/mpdthumb.sh", @opts, "--", @_;
149 $thumb = "$c$thumb$c" if (-f $thumb);
150 push @results, $thumb;
153 die("mpdthumb failed") if ($?);
158 # add_track_metadata(hashref, key, value)
160 # Inserts the given key into the referenced hash; if the key already exists
161 # in the hash then the hash element is converted to an array reference (if
162 # it isn't already) and the value is appended to that array.
163 sub add_track_metadata {
164 my ($entry, $key, $value) = @_;
166 if (exists($entry->{$key})) {
167 my $ref = $entry->{$key};
169 if (reftype($ref) ne "ARRAY") {
170 return if ($ref eq $value);
173 $entry->{$key} = $ref;
176 push(@$ref, $value) unless (any {$_ eq $value} @$ref);
178 $entry->{$key} = $value;
182 # get_track_metadata(hashref, key)
184 # Return the values associated with the given metadata key as a list.
185 sub get_track_metadata {
186 my ($entry, $key) = @_;
188 return () unless (exists($entry->{$key}));
190 my $ref = $entry->{$key};
191 return @$ref if (reftype($ref) eq "ARRAY");
195 # Given a music filename, search for the cover art in the same directory.
196 sub mpd_cover_filename {
200 $dir =~ s/\/[^\/]*$//;
201 foreach ("cover.png", "cover.jpg", "cover.tiff", "cover.bmp") {
207 return unless defined $file;
209 # Follow one level of symbolic link to get to the scans directory.
210 $file = readlink($file) // $file;
211 $file = "$dir/$file" unless ($file =~ /^\//);
215 # Generate the cover art entry in the top menu.
216 sub top_track_cover {
219 ($entry->{thumb}) = get_item_thumbnails($entry->{file});
220 print "$entry->{thumb}\n";
221 if ($entry->{thumb}) {
222 my $file = "$MUSIC/$entry->{file}";
223 my $cover = mpd_cover_filename($file);
225 $cover = fvwm_shell_literal($cover // $file);
226 fvwm_cmd_unquoted("AddToMenu", MPD::escape($menu),
227 MPD::escape($entry->{thumb}),
228 "Exec", "exec", "geeqie", $cover);
232 # Generate the "Title:" entry in the top menu.
233 sub top_track_title {
237 my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_RELEASETRACKID");
239 @submenu = make_submenu("$menu-$mbid", "--track-id=$mbid")
241 ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID");
242 @submenu = make_submenu("$menu-track-$mbid", "--recording-id=$mbid")
246 fvwm_cmd("AddToMenu", $menu,
247 fvwm_label_escape("Title:\t$entry->{Title}"),
251 # Generate the "Artist:" entry in the top menu.
252 sub top_track_artist {
256 # TODO: multi-artist tracks should get multiple artist menus; for now
257 # just combine the releases from all artists.
258 my @mbids = get_track_metadata($entry, "MUSICBRAINZ_ARTISTID");
260 @submenu = make_submenu("$menu-TopArtist",
261 map { "--artist-id=$_" } @mbids);
264 fvwm_cmd("AddToMenu", $menu,
265 fvwm_label_escape("Artist:\t$entry->{Artist}"),
269 # Generate the "Album:" entry in the top menu.
270 sub top_track_album {
275 if (($mbid) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID")) {
276 @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid");
277 } elsif (($mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID")) {
278 # Standalone recording
279 my @a = get_track_metadata($entry, "MUSICBRAINZ_ARTISTID");
280 my ($album) = get_track_metadata($entry, "Album");
282 @submenu = make_submenu("$menu-$mbid", "--album-name=$album",
283 map { "--artist-id=$_" } @a);
287 fvwm_cmd("AddToMenu", $menu,
288 fvwm_label_escape("Album:\t$entry->{Album}"),
292 # Generate the "MusicBrainz:" entry in the top menu.
293 sub top_track_musicbrainz {
295 my ($track_mbid, $recording_mbid, $release_mbid);
300 ($track_mbid) = get_track_metadata($entry, "MUSICBRAINZ_RELEASETRACKID");
301 ($recording_mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID");
302 ($release_mbid) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID");
303 @artist_mbids = get_track_metadata($entry, "MUSICBRAINZ_ARTISTID");
304 return unless $track_mbid // $recording_mbid
305 // $release_mbid // @artist_mbids;
307 foreach (get_track_metadata($entry, "Comment")) {
308 $idmap{$1} = $2 if /^([^=]*)=(.*) \(idmap\)$/;
311 fvwm_cmd("AddToMenu", $menu, "", "Nop");
313 fvwm_cmd("AddToMenu", $menu, "$label\tShow track",
314 "Exec", "exec", "xdg-open",
315 "https://musicbrainz.org/track/$track_mbid");
317 } elsif ($recording_mbid) {
318 fvwm_cmd("AddToMenu", $menu, "$label\tShow recording",
319 "Exec", "exec", "xdg-open",
320 "https://musicbrainz.org/recording/$recording_mbid");
322 } elsif ($release_mbid) {
323 fvwm_cmd("AddToMenu", $menu, "$label\tShow",
324 "Exec", "exec", "xdg-open",
325 "https://musicbrainz.org/release/$release_mbid");
329 foreach my $mbid (@artist_mbids) {
330 my $name = " $idmap{$mbid}" if $idmap{$mbid};
332 fvwm_cmd("AddToMenu", $menu, "$label\tShow artist$name",
333 "Exec", "exec", "xdg-open",
334 "https://musicbrainz.org/artist/$mbid");
339 # Given a work MBID, return a hash reference containing all tracks
340 # linked to that work. The hash keys are filenames.
341 sub get_tracks_by_work_mbid {
345 foreach my $mbid (@_) {
346 MPD::exec("search", "(MUSICBRAINZ_WORKID == \"$mbid\")");
351 if (/^(\w+): (.*)$/) {
353 if (exists($matches{$2})) {
354 $entry = $matches{$2};
357 $matches{$2} = $entry;
361 add_track_metadata($entry, $1, $2);
369 # Given a track MBID, return a hash reference containing all "related"
370 # tracks in the MPD database. The hash keys are filenames.
372 # Currently tracks are considered "related" if their associated recordings
373 # have at least one work in common.
374 sub get_tracks_by_track_mbid {
375 my ($mbid, $tagname) = (@_, "MUSICBRAINZ_RELEASETRACKID");
380 return \%matches unless ($mbid);
381 MPD::exec("search", "($tagname == \"$mbid\")");
386 if (/^(\w+): (.*)$/) {
387 add_track_metadata(\%source, $1, $2);
391 # Always include the current track
392 $matches{$source{file}} = \%source;
394 # Find all tracks related by work
395 foreach my $mbid (get_track_metadata(\%source, "MUSICBRAINZ_WORKID")) {
396 my $related = get_tracks_by_work_mbid($mbid);
397 foreach (keys %$related) {
398 $matches{$_} //= $related->{$_};
405 sub get_tracks_by_recording_mbid {
406 return get_tracks_by_track_mbid($_[0], "MUSICBRAINZ_TRACKID");
409 # Given a release MBID, return a hash reference containing all its
410 # associated tracks in the MPD database. The hash keys are filenames.
411 sub get_tracks_by_release_mbid {
416 return \%matches unless ($mbid);
417 MPD::exec("search", "(MUSICBRAINZ_ALBUMID == \"$mbid\")");
422 if (/^(\w+): (.*)$/) {
424 if (exists($matches{$2})) {
425 $entry = $matches{$2};
428 $matches{$2} = $entry;
432 add_track_metadata($entry, $1, $2);
439 # Insert the given entry into the referenced hash if it represents a
440 # standalone recording (not associated with a release). The recording
441 # MBID is used as the hash key.
442 sub check_standalone {
443 my ($outhash, $entry) = @_;
444 my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID");
446 return if exists $entry->{MUSICBRAINZ_ALBUMID};
447 $outhash->{$mbid} = $entry if ($mbid);
450 # Given an artist MBID, return a list of two hash refererences. The
451 # first contains the associated releases in the MPD database and the
452 # hash keys are release MBIDs. The second contains the artist's
453 # standalone recordings and the hash keys are recording MBIDs.
455 # In scalar context only the release hash is returned.
457 # Since MPD returns results on a per-track basis, each entry in the
458 # hash has the metadata for one unspecified track from that release.
459 sub get_releases_by_artist_mbid {
460 my (%releases, %standalones);
463 foreach my $mbid (@_) {
464 MPD::exec("search", "(MUSICBRAINZ_ARTISTID == \"$mbid\")");
469 if (/^(\w+): (.*)$/) {
471 check_standalone(\%standalones, $entry);
473 } elsif ($1 eq "MUSICBRAINZ_ALBUMID") {
474 $releases{$2} //= $entry;
477 add_track_metadata($entry, $1, $2);
480 check_standalone(\%standalones, $entry);
483 return wantarray ? (\%releases, values %standalones) : \%releases;
486 # Given a filename, return the IDs (if any) for that file in the
487 # current MPD play queue.
488 sub get_ids_by_filename {
492 MPD::exec("playlistfind", "file", $file);
497 if (/^(\w+): (.*)$/) {
498 push @results, $2 if ($1 eq "Id");
505 sub update_entry_ids {
508 foreach my $entry (@_) {
509 unless (exists $entry->{Id}) {
510 my ($id) = get_ids_by_filename($entry->{file});
514 push @notqueued, $entry;
523 # albumsort(matches, a, b)
525 # Sort hash keys (a, b) by disc/track number for album menus.
527 my ($matches, $a, $b) = @_;
529 return $matches->{$a}->{Disc} <=> $matches->{$b}->{Disc}
530 || $matches->{$a}->{Track} <=> $matches->{$b}->{Track}
534 # datesort(matches, a, b)
536 # Sort hash keys (a, b) by release date
538 my ($matches, $a, $b) = @_;
540 return $matches->{$a}->{Date} cmp $matches->{$b}->{Date}
544 # menu_trackname(entry)
546 # Format the track name for display in an FVWM menu, where entry
547 # is a hash reference containing the track metadata.
550 my $fmt = "$entry->{trackfmt}$entry->{Artist} - $entry->{Title}";
551 return "$entry->{thumb}" . fvwm_label_escape($fmt);
557 Copyright © 2021 Nick Bowler
558 License GPLv3+: GNU General Public License version 3 or any later version.
559 This is free software: you are free to change and redistribute it.
560 There is NO WARRANTY, to the extent permitted by law.
565 my ($fh) = (@_, *STDERR);
567 print $fh "Usage: $0 [options]\n";
568 print $fh "Try $0 --help for more information.\n" unless (@_ > 0);
572 print_usage(*STDOUT);
574 This is "mpdmenu": a menu-based MPD client for FVWM.
577 -h, --host=HOST Connect to the MPD server on HOST, overriding defaults.
578 -p, --port=PORT Connect to the MPD server on PORT, overriding defaults.
579 -m, --menu=NAME Set the name of the generated menu.
580 --album-id=MBID Generate a menu for the given release MBID.
582 Generate a menu for standalone tracks with the given
583 "album" NAME. An artist MBID must be supplied.
584 --artist-id=MBID Generate a menu for the given artist MBID.
585 --track-id=MBID Generate a menu for the given track MBID.
587 Generate a menu for the given recording MBID.
588 -V, --version Print a version message and then exit.
589 -H, --help Print this message and then exit.
594 'host|h=s' => \$MPD::host,
595 'port|p=s' => \$MPD::port,
596 'menu|m=s' => \$menu,
598 'artist-id=s' => sub { $artistids{$_[1]} = 1; $mode = "artist"; },
599 'album-id=s' => sub { $albumid = $_[1]; $mode = "album"; },
600 'album-name=s' => sub { $albumname = $_[1]; $mode = "albumname"; },
601 'track-id=s' => sub { $trackid = $_[1]; $mode = "track"; },
602 'recording-id=s' => sub { $recordingid = $_[1]; $mode = "recording"; },
604 'V|version' => sub { print_version(); exit },
605 'H|help' => sub { print_help(); exit },
607 'topmenu=s' => \$topmenu, # top menu name (for submenu generation)
608 ) or do { print_usage; exit 1 };
610 $mode = "albumname" if ($albumname && $mode eq "artist");
612 unless (defined $menu) {
613 $topmenu //= "MenuMPD";
614 $menu = $topmenu . ($mode ne "top" ? $mode : "");
619 $sock = MPD::connect();
620 die("MPD version $MPD::major.$MPD::minor.$MPD::revision insufficient.")
621 unless MPD::min_version(MPD_MJR_MIN, MPD_MNR_MIN, MPD_REV_MIN);
623 MPD::exec("binarylimit", 64);
625 $mpd_have_binarylimit = 1 if /^OK/;
626 last if /^OK/ or /^ACK/;
629 if ($mode eq "top") {
640 if (/^(\w+): (.*)$/) {
645 MPD::exec("currentsong");
650 if (/^(\w+): (.*)$/) {
651 add_track_metadata(\%current, $1, $2);
655 my $playstate = $state{state} eq "play" ? "Playing"
656 : $state{state} eq "stop" ? "Stopped"
657 : $state{state} eq "pause" ? "Paused"
659 fvwm_cmd("AddToMenu", $menu, $playstate, "Title");
661 if (exists($current{file})) {
662 top_track_cover(\%current);
663 top_track_title(\%current);
664 top_track_artist(\%current);
665 top_track_album(\%current);
666 top_track_musicbrainz(\%current);
668 fvwm_cmd("AddToMenu", $menu, "[current track unavailable]");
671 if ($state{state} =~ /^p/) {
672 my $pp = $state{state} eq "pause" ? "lay" : "ause";
674 fvwm_cmd("AddToMenu", $menu, "", "Nop");
675 fvwm_cmd("AddToMenu", $menu, "Next%next.svg:16x16%",
676 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "next");
677 fvwm_cmd("AddToMenu", $menu, "P$pp%p$pp.svg:16x16%",
678 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "p$pp");
679 fvwm_cmd("AddToMenu", $menu, "Stop%stop.svg:16x16%",
680 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "stop");
681 fvwm_cmd("AddToMenu", $menu, "Prev%prev.svg:16x16%",
682 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "previous");
683 } elsif ($state{state} eq "stop") {
684 fvwm_cmd("AddToMenu", $menu, "", "Nop");
685 fvwm_cmd("AddToMenu", $menu, "Play%play.svg:16x16%",
686 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "play");
688 } elsif ($mode eq "album") {
689 my $matches = get_tracks_by_release_mbid($albumid);
692 $menu //= "MenuMPDAlbum";
694 my $track_max = max(map { $_->{Track} } values %$matches);
695 my $disc_max = max(map { $_->{Disc} } values %$matches);
697 # CDs have a max of 99 tracks and I hope 100+-disc-releases
698 # don't exist so this is fine.
699 my $track_digits = $track_max >= 10 ? 2 : 1;
700 my $disc_digits = $disc_max > 1 ? $disc_max >= 10 ? 2 : 1 : 0;
703 fvwm_cmd("AddToMenu", $menu);
704 fvwm_cmd("+", "Release not found", "Title") unless keys %$matches;
705 foreach my $file (sort { albumsort($matches, $a, $b) } keys %$matches) {
706 my $entry = $matches->{$file};
708 # Format disc/track numbers
709 $entry->{trackfmt} = sprintf("%*.*s%.*s%*d\t",
710 $disc_digits, $disc_digits, $entry->{Disc},
712 $track_digits, $entry->{Track});
713 $entry->{trackfmt} =~ s/ /\N{U+2007}/g;
715 unless (exists $entry->{Id}) {
716 my ($id) = get_ids_by_filename($file);
720 push @notqueued, $entry;
725 if (defined $currentdisc && $currentdisc != $entry->{Disc}) {
726 fvwm_cmd("+", "", "Nop");
728 $currentdisc = $entry->{Disc};
730 fvwm_cmd("+", menu_trackname($entry), "Exec",
731 "exec", "$FindBin::Bin/mpdexec.pl",
732 "playid", $entry->{Id});
735 fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
736 foreach my $entry (@notqueued) {
737 fvwm_cmd("+", menu_trackname($entry));
739 } elsif ($mode eq "artist") {
740 # Create an artist menu.
741 my ($matches, @recs) = get_releases_by_artist_mbid(keys %artistids);
743 $menu //= "MenuMPDArtist";
745 my @mbids = sort { datesort($matches, $a, $b) } keys %$matches;
746 my @files = map { $matches->{$_}->{file} } @mbids;
747 my @thumbs = get_item_thumbnails({ small => 1 }, @files);
750 fvwm_cmd("AddToMenu", $menu, "No releases found", "Title")
753 foreach my $mbid (@mbids) {
754 my $entry = $matches->{$mbid};
755 my $thumb = shift @thumbs;
757 my @submenu = make_submenu("$topmenu-$mbid",
759 fvwm_cmd("AddToMenu", $menu,
760 $thumb . fvwm_label_escape($entry->{Album}),
764 my @artists = map { "--artist-id=$_" } keys %artistids;
765 my %nonalbums = map { $_->{Album} => $_ } @recs;
766 foreach my $name (sort keys %nonalbums) {
767 my $mbid = $nonalbums{$name}->{MUSICBRAINZ_TRACKID};
768 my @submenu = make_submenu("$topmenu-$mbid", @artists,
769 "--album-name=$name");
770 fvwm_cmd("AddToMenu", $menu, fvwm_label_escape($name), @submenu);
772 } elsif ($mode eq "albumname") {
773 # Create a standalone recordings menu
774 my ($releases, @recs) = get_releases_by_artist_mbid(keys %artistids);
776 $menu //= "MenuMPDRecordings";
777 my @tracks = sort { $a->{Title} cmp $b->{Title} }
778 grep { $_->{Album} eq $albumname } @recs;
780 # Show thumbnails for standalone recordings
781 my @thumbs = get_item_thumbnails({ small => 1 },
782 map { $_->{file} } @tracks);
783 foreach my $entry (@tracks) {
784 $entry->{thumb} = shift @thumbs;
787 my @notqueued = update_entry_ids(@tracks);
789 fvwm_cmd("AddToMenu", $menu);
790 fvwm_cmd("+", "No tracks found", "Title") unless @tracks;
792 foreach my $entry (@tracks) {
793 next unless exists $entry->{Id};
795 fvwm_cmd("+", menu_trackname($entry), "Exec",
796 "exec", "$FindBin::Bin/mpdexec.pl",
797 "playid", $entry->{Id});
800 fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
801 foreach my $entry (@notqueued) {
802 fvwm_cmd("+", menu_trackname($entry));
804 } elsif ($mode eq "track" || $mode eq "recording") {
805 my ($matches, $mbid);
808 if ($mode eq "track") {
809 $matches = get_tracks_by_track_mbid($trackid)
811 $matches = get_tracks_by_recording_mbid($recordingid)
814 $menu //= "MenuMPDTrack";
815 fvwm_cmd("DestroyMenu", $menu);
817 my @files = sort { datesort($matches, $a, $b) } keys %$matches;
818 my @thumbs = get_item_thumbnails({ small => 1 }, @files);
820 fvwm_cmd("AddToMenu", $menu);
821 fvwm_cmd("+", "No tracks found", "Title") unless @files;
822 foreach my $file (@files) {
823 my $entry = $matches->{$file};
824 $entry->{thumb} = shift @thumbs;
826 unless (exists $entry->{Id}) {
827 my ($id) = get_ids_by_filename($file);
831 push @notqueued, $entry;
836 fvwm_cmd("+", menu_trackname($entry), "Exec",
837 "exec", "$FindBin::Bin/mpdexec.pl",
838 "playid", $entry->{Id});
841 fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
842 foreach my $entry (@notqueued) {
843 fvwm_cmd("+", menu_trackname($entry));
848 print $sock "close\n";