3 # Copyright © 2008,2010,2012,2020 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 IO::Socket::INET6;
22 use Getopt::Long qw(:config gnu_getopt);
23 use Scalar::Util qw(reftype);
24 use List::Util qw(any max);
27 use lib "$FindBin::Bin";
36 my $SELF = "$FindBin::Bin/$FindBin::Script";
38 my $MUSIC = $ENV{MUSIC} // "/srv/music";
39 my $host = $ENV{MPD_HOST} // "localhost";
40 my $port = $ENV{MPD_PORT} // "6600";
43 my ($albumid, $trackid);
48 # Submit a command to the MPD server; each argument to this function
49 # is quoted and sent as a single argument to MPD.
51 my $cmd = join(' ', map { MPD::escape } @_);
56 sub fvwm_cmd_unquoted {
57 print join(' ', @_), "\n";
61 fvwm_cmd_unquoted(map { MPD::escape } @_);
64 # Quotes the argument in such a way that it is passed unadulterated by
65 # both FVWM and the shell to a command as a single argument (for use as
66 # an # argument for e.g., the Exec or PipeRead FVWM commands).
68 # The result must be used with fvwm_cmd_unquoted;
69 sub fvwm_shell_literal {
81 # Escapes metacharacters in the argument used in FVWM menu labels. The
82 # string must still be quoted (e.g., by using fvwm_cmd).
83 sub fvwm_label_escape {
84 my @tokens = split /\t/, $_[0];
85 @tokens[0] =~ s/&/&&/g;
86 my $ret = join "\t", @tokens;
87 $ret =~ s/[\$@%^*]/$&$&/g;
91 # make_submenu(name, [args ...])
93 # Creates a submenu (with the specified name) constructed by invoking this
94 # script with the given arguments. Returns a list that can be passed to
95 # fvwm_cmd to display the menu.
99 unshift @_, ("exec", $SELF, "--topmenu=$topmenu", "--menu=$name");
101 fvwm_cmd("DestroyFunc", "Make$name");
102 fvwm_cmd("AddToFunc", "Make$name");
103 fvwm_cmd("+", "I", "DestroyMenu", $name);
105 fvwm_cmd("DestroyMenu", $name);
106 fvwm_cmd("AddToMenu", $name, "DynamicPopupAction", "Make$name");
107 fvwm_cmd("AddToFunc", "Kill$topmenu", "I", "DestroyMenu", $name);
109 fvwm_cmd("DestroyFunc", "Make$name");
110 fvwm_cmd("AddToFunc", "Make$name");
111 fvwm_cmd("+", "I", "DestroyMenu", $name);
112 fvwm_cmd("+", "I", "-PipeRead",
113 join(' ', map { fvwm_shell_literal } @_));
114 fvwm_cmd("AddToFunc", "Kill$topmenu", "I", "DestroyFunc", "Make$name");
116 return ("Popup", $name);
119 # get_item_thumbnails({ options }, file, ...)
120 # get_item_thumbnails(file, ...)
122 # For each music file listed, obtain a thumbnail (if any) for the cover art.
124 # The first argument is a hash reference to control the mode of operation;
125 # it may be omitted for default options.
127 # get_item_thumbnails({ small => 1 }, ...) - smaller thumbnails
129 # The returned list consists of strings (in the same order as the filename
130 # arguments) suitable for use directly in FVWM menus; by default the filename
131 # is bracketed by asterisks (e.g., "*thumbnail.png*"); in small mode it is
132 # surrounded by % (e.g., "%thumbnail.png%"). If no cover art was found, the
133 # empty string is returned for that file.
134 sub get_item_thumbnails {
139 $flags = shift if (reftype($_[0]) eq "HASH");
140 return @results unless @_;
143 if ($flags->{small}) {
144 push @opts, "--small";
148 open THUMB, "-|", "$FindBin::Bin/mpdthumb.sh", @opts, "--", @_;
153 $thumb = "$c$thumb$c" if (-f $thumb);
154 push @results, $thumb;
157 die("mpdthumb failed") if ($?);
162 # add_track_metadata(hashref, key, value)
164 # Inserts the given key into the referenced hash; if the key already exists
165 # in the hash then the hash element is converted to an array reference (if
166 # it isn't already) and the value is appended to that array.
167 sub add_track_metadata {
168 my ($entry, $key, $value) = @_;
170 if (exists($entry->{$key})) {
171 my $ref = $entry->{$key};
173 if (reftype($ref) ne "ARRAY") {
174 return if ($ref eq $value);
177 $entry->{$key} = $ref;
180 push(@$ref, $value) unless (any {$_ eq $value} @$ref);
182 $entry->{$key} = $value;
186 # get_track_metadata(hashref, key)
188 # Return the values associated with the given metadata key as a list.
189 sub get_track_metadata {
190 my ($entry, $key) = @_;
192 return () unless (exists($entry->{$key}));
194 my $ref = $entry->{$key};
195 return @$ref if (reftype($ref) eq "ARRAY");
199 # Given a music filename, search for the cover art in the same directory.
200 sub mpd_cover_filename {
204 $dir =~ s/\/[^\/]*$//;
205 foreach ("cover.png", "cover.jpg", "cover.tiff", "cover.bmp") {
211 return unless defined $file;
213 # Follow one level of symbolic link to get to the scans directory.
214 $file = readlink($file) // $file;
215 $file = "$dir/$file" unless ($file =~ /^\//);
219 # Generate the cover art entry in the top menu.
220 sub top_track_cover {
223 ($entry->{thumb}) = get_item_thumbnails($entry->{file});
224 print "$entry->{thumb}\n";
225 if ($entry->{thumb}) {
226 my $file = "$MUSIC/$entry->{file}";
227 my $cover = mpd_cover_filename($file);
229 $cover = fvwm_shell_literal($cover // $file);
230 fvwm_cmd_unquoted("AddToMenu", MPD::escape($menu),
231 MPD::escape($entry->{thumb}),
232 "Exec", "exec", "geeqie", $cover);
236 # Generate the "Title:" entry in the top menu.
237 sub top_track_title {
241 my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_RELEASETRACKID");
242 @submenu = make_submenu("$menu-$mbid", "--track-id=$mbid") if $mbid;
244 fvwm_cmd("AddToMenu", $menu,
245 fvwm_label_escape("Title:\t$entry->{Title}"),
249 # Generate the "Artist:" entry in the top menu.
250 sub top_track_artist {
254 # TODO: multi-artist tracks should get multiple artist menus; for now
255 # just combine the releases from all artists.
256 my @mbids = get_track_metadata($entry, "MUSICBRAINZ_ARTISTID");
258 @submenu = make_submenu("$menu-TopArtist",
259 map { "--artist-id=$_" } @mbids);
262 fvwm_cmd("AddToMenu", $menu,
263 fvwm_label_escape("Artist:\t$entry->{Artist}"),
267 # Generate the "Album:" entry in the top menu.
268 sub top_track_album {
272 my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID");
273 @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid") if $mbid;
275 fvwm_cmd("AddToMenu", $menu,
276 fvwm_label_escape("Album:\t$entry->{Album}"),
280 # Given a work MBID, return a hash reference containing all tracks
281 # linked to that work. The hash keys are filenames.
282 sub get_tracks_by_work_mbid {
286 foreach my $mbid (@_) {
287 mpd_exec("search", "(MUSICBRAINZ_WORKID == \"$mbid\")");
292 if (/^(\w+): (.*)$/) {
294 if (exists($matches{$2})) {
295 $entry = $matches{$2};
298 $matches{$2} = $entry;
302 add_track_metadata($entry, $1, $2);
310 # Given a track MBID, return a hash reference containing all "related"
311 # tracks in the MPD database. The hash keys are filenames.
313 # Currently tracks are considered "related" if their associated recordings
314 # have at least one work in common.
315 sub get_tracks_by_track_mbid {
321 return \%matches unless ($mbid);
322 mpd_exec("search", "(MUSICBRAINZ_RELEASETRACKID == \"$mbid\")");
327 if (/^(\w+): (.*)$/) {
328 add_track_metadata(\%source, $1, $2);
332 # Always include the current track
333 $matches{$source{file}} = \%source;
335 # Find all tracks related by work
336 foreach my $mbid (get_track_metadata(\%source, "MUSICBRAINZ_WORKID")) {
337 my $related = get_tracks_by_work_mbid($mbid);
338 foreach (keys %$related) {
339 $matches{$_} //= $related->{$_};
346 # Given a release MBID, return a hash reference containing all its
347 # associated tracks in the MPD database. The hash keys are filenames.
348 sub get_tracks_by_release_mbid {
353 return \%matches unless ($mbid);
354 mpd_exec("search", "(MUSICBRAINZ_ALBUMID == \"$mbid\")");
359 if (/^(\w+): (.*)$/) {
361 if (exists($matches{$2})) {
362 $entry = $matches{$2};
365 $matches{$2} = $entry;
369 add_track_metadata($entry, $1, $2);
376 # Given an artist MBID, return a hash reference containing associated
377 # releases in the MPD database. The hash keys are release MBIDs.
379 # Since MPD returns results on a per-track basis, each entry in the
380 # hash has the metadata for one unspecified track from that release.
381 sub get_releases_by_artist_mbid {
385 foreach my $mbid (@_) {
386 mpd_exec("search", "(MUSICBRAINZ_ARTISTID == \"$mbid\")");
391 if (/^(\w+): (.*)$/) {
394 } elsif ($1 eq "MUSICBRAINZ_ALBUMID") {
395 $releases{$2} //= $entry;
398 add_track_metadata($entry, $1, $2);
406 # Given a filename, return the IDs (if any) for that file in the
407 # current MPD play queue.
408 sub get_ids_by_filename {
412 mpd_exec("playlistfind", "file", $file);
417 if (/^(\w+): (.*)$/) {
418 push @results, $2 if ($1 eq "Id");
425 # albumsort(matches, a, b)
427 # Sort hash keys (a, b) by disc/track number for album menus.
429 my ($matches, $a, $b) = @_;
431 return $matches->{$a}->{Disc} <=> $matches->{$b}->{Disc}
432 || $matches->{$a}->{Track} <=> $matches->{$b}->{Track}
436 # datesort(matches, a, b)
438 # Sort hash keys (a, b) by release date
440 my ($matches, $a, $b) = @_;
442 return $matches->{$a}->{Date} cmp $matches->{$b}->{Date}
446 # menu_trackname(entry)
448 # Format the track name for display in an FVWM menu, where entry
449 # is a hash reference containing the track metadata.
452 my $fmt = "$entry->{trackfmt}$entry->{Artist} - $entry->{Title}";
453 return "$entry->{thumb}" . fvwm_label_escape($fmt);
459 Copyright © 2019 Nick Bowler
460 License GPLv3+: GNU General Public License version 3 or any later version.
461 This is free software: you are free to change and redistribute it.
462 There is NO WARRANTY, to the extent permitted by law.
467 my $fh = $_[1] // *STDERR;
469 print $fh "Usage: $0 [options]\n";
470 print "Try $0 --help for more information.\n" unless (@_ > 0);
474 print_usage(*STDOUT);
476 This is "mpdmenu": a menu-based MPD client for FVWM.
479 -h, --host=HOST Connect to the MPD server on HOST, overriding defaults.
480 -p, --port=PORT Connect to the MPD server on PORT, overriding defaults.
481 -m, --menu=NAME Set the name of the generated menu.
482 --album-id=MBID Generate a menu for the given release MBID.
483 --artist-id=MBID Generate a menu for the given artist MBID.
484 --track-id=MBID Generate a menu for the given track MBID.
485 -V, --version Print a version message and then exit.
486 -H, --help Print this message and then exit.
491 'host|h=s' => \$host,
492 'port|p=s' => \$port,
493 'menu|m=s' => \$menu,
495 'artist-id=s' => sub { $artistids{$_[1]} = 1; $mode = "artist"; },
496 'album-id=s' => sub { $albumid = $_[1]; $mode = "album"; },
497 'track-id=s' => sub { $trackid = $_[1]; $mode = "track"; },
499 'V|version' => sub { print_version(); exit },
500 'H|help' => sub { print_help(); exit },
502 'topmenu=s' => \$topmenu, # top menu name (for submenu generation)
503 ) or do { print_usage; exit 1 };
505 unless (defined $menu) {
506 $topmenu //= "MenuMPD";
507 $menu = $topmenu . ($mode ne "top" ? $mode : "");
512 $sock = new IO::Socket::INET6(
517 ) or die("could not open socket: $!.\n");
518 binmode($sock, ":utf8");
520 die("could not connect to MPD: $!.\n")
521 if (!(<$sock> =~ /^OK MPD ([0-9]+)\.([0-9]+)\.([0-9]+)$/));
523 die("MPD version $1.$2.$3 insufficient.\n")
524 if ( ($1 < MPD_MJR_MIN)
525 || ($1 == MPD_MJR_MIN && $2 < MPD_MNR_MIN)
526 || ($1 == MPD_MJR_MIN && $2 == MPD_MNR_MIN && $3 < MPD_REV_MIN));
528 if ($mode eq "top") {
539 if (/^(\w+): (.*)$/) {
544 mpd_exec("currentsong");
549 if (/^(\w+): (.*)$/) {
550 add_track_metadata(\%current, $1, $2);
554 my $playstate = $state{state} eq "play" ? "Playing"
555 : $state{state} eq "stop" ? "Stopped"
556 : $state{state} eq "pause" ? "Paused"
558 fvwm_cmd("AddToMenu", $menu, $playstate, "Title");
560 if (exists($current{file})) {
561 top_track_cover(\%current);
562 top_track_title(\%current);
563 top_track_artist(\%current);
564 top_track_album(\%current);
566 fvwm_cmd("AddToMenu", $menu, "[current track unavailable]");
569 if ($state{state} =~ /^p/) {
570 my $pp = $state{state} eq "pause" ? "lay" : "ause";
572 fvwm_cmd("AddToMenu", $menu, "", "Nop");
573 fvwm_cmd("AddToMenu", $menu, "Next%next.svg:16x16%",
574 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "next");
575 fvwm_cmd("AddToMenu", $menu, "P$pp%p$pp.svg:16x16%",
576 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "p$pp");
577 fvwm_cmd("AddToMenu", $menu, "Stop%stop.svg:16x16%",
578 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "stop");
579 fvwm_cmd("AddToMenu", $menu, "Prev%prev.svg:16x16%",
580 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "previous");
581 } elsif ($state{state} eq "stop") {
582 fvwm_cmd("AddToMenu", $menu, "", "Nop");
583 fvwm_cmd("AddToMenu", $menu, "Play%play.svg:16x16%",
584 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "play");
586 } elsif ($mode eq "album") {
587 my $matches = get_tracks_by_release_mbid($albumid);
590 $menu //= "MenuMPDAlbum";
592 my $track_max = max(map { $_->{Track} } values %$matches);
593 my $disc_max = max(map { $_->{Disc} } values %$matches);
595 # CDs have a max of 99 tracks and I hope 100+-disc-releases
596 # don't exist so this is fine.
597 my $track_digits = $track_max >= 10 ? 2 : 1;
598 my $disc_digits = $disc_max > 1 ? $disc_max >= 10 ? 2 : 1 : 0;
601 fvwm_cmd("AddToMenu", $menu);
602 fvwm_cmd("+", "Release not found", "Title") unless keys %$matches;
603 foreach my $file (sort { albumsort($matches, $a, $b) } keys %$matches) {
604 my $entry = $matches->{$file};
606 # Format disc/track numbers
607 $entry->{trackfmt} = sprintf("%*.*s%.*s%*d\t",
608 $disc_digits, $disc_digits, $entry->{Disc},
610 $track_digits, $entry->{Track});
611 $entry->{trackfmt} =~ s/ /\N{U+2007}/g;
613 unless (exists $entry->{Id}) {
614 my ($id) = get_ids_by_filename($file);
618 push @notqueued, $entry;
623 if (defined $currentdisc && $currentdisc != $entry->{Disc}) {
624 fvwm_cmd("+", "", "Nop");
626 $currentdisc = $entry->{Disc};
628 fvwm_cmd("+", menu_trackname($entry), "Exec",
629 "exec", "$FindBin::Bin/mpdexec.pl",
630 "playid", $entry->{Id});
633 fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
634 foreach my $entry (@notqueued) {
635 fvwm_cmd("+", menu_trackname($entry));
637 } elsif ($mode eq "artist") {
638 # Create an artist menu.
639 my $matches = get_releases_by_artist_mbid(keys %artistids);
642 $menu //= "MenuMPDArtist";
644 my @mbids = sort { datesort($matches, $a, $b) } keys %$matches;
645 my @files = map { $matches->{$_}->{file} } @mbids;
646 my @thumbs = get_item_thumbnails({ small => 1 }, @files);
647 fvwm_cmd("AddToMenu", $menu, "No releases found", "Title") unless @mbids;
649 foreach my $mbid (@mbids) {
650 my $entry = $matches->{$mbid};
651 my $thumb = shift @thumbs;
653 my @submenu = make_submenu("$topmenu-$mbid",
655 fvwm_cmd("AddToMenu", $menu,
656 $thumb . fvwm_label_escape($entry->{Album}),
659 } elsif ($mode eq "track") {
660 my $matches = get_tracks_by_track_mbid($trackid);
663 $menu //= "MenuMPDTrack";
665 my @files = sort { datesort($matches, $a, $b) } keys %$matches;
666 my @thumbs = get_item_thumbnails({ small => 1 }, @files);
668 fvwm_cmd("AddToMenu", $menu);
669 fvwm_cmd("+", "No tracks found", "Title") unless @files;
670 foreach my $file (@files) {
671 my $entry = $matches->{$file};
672 $entry->{thumb} = shift @thumbs;
674 unless (exists $entry->{Id}) {
675 my ($id) = get_ids_by_filename($file);
679 push @notqueued, $entry;
684 fvwm_cmd("+", menu_trackname($entry), "Exec",
685 "exec", "$FindBin::Bin/mpdexec.pl",
686 "playid", $entry->{Id});
689 fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
690 foreach my $entry (@notqueued) {
691 fvwm_cmd("+", menu_trackname($entry));
696 print $sock "close\n";