3 # Copyright © 2008,2010,2012,2019 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);
33 my $SELF = "$FindBin::Bin/$FindBin::Script";
35 my $MUSIC = $ENV{MUSIC} // "/srv/music";
36 my $host = $ENV{MPD_HOST} // "localhost";
37 my $port = $ENV{MPD_PORT} // "6600";
40 my ($albumid, $title);
45 # Quotes the argument so that it is presented as a single argument to MPD
46 # at the protocol level. This also works OK for most FVWM arguments.
50 # No way to encode literal newlines in the protocol, so we
51 # convert any newlines in the arguments into a space, which
52 # can help with quoting.
64 # Submit a command to the MPD server; each argument to this function
65 # is quoted and sent as a single argument to MPD.
67 my $cmd = join(' ', map { escape } @_);
72 sub fvwm_cmd_unquoted {
73 print join(' ', @_), "\n";
77 fvwm_cmd_unquoted(map { escape } @_);
80 # Quotes the argument in such a way that it is passed unadulterated by
81 # both FVWM and the shell to a command as a single argument (for use as
82 # an # argument for e.g., the Exec or PipeRead FVWM commands).
84 # The result must be used with fvwm_cmd_unquoted;
85 sub fvwm_shell_literal {
97 # Escapes metacharacters in the argument used in FVWM menu labels. The
98 # string must still be quoted (e.g., by using fvwm_cmd).
99 sub fvwm_label_escape {
100 my @tokens = split /\t/, $_[0];
101 @tokens[0] =~ s/&/&&/g;
102 my $ret = join "\t", @tokens;
103 $ret =~ s/[\$@%^*]/$&$&/g;
107 # make_submenu(name, [args ...])
109 # Creates a submenu (with the specified name) constructed by invoking this
110 # script with the given arguments. Returns a list that can be passed to
111 # fvwm_cmd to display the menu.
115 unshift @_, ("exec", $SELF, "--menu=$name");
117 fvwm_cmd("DestroyFunc", "Make$name");
118 fvwm_cmd("AddToFunc", "Make$name");
119 fvwm_cmd("+", "I", "DestroyMenu", $name);
121 fvwm_cmd("DestroyMenu", $name);
122 fvwm_cmd("AddToMenu", $name, "DynamicPopupAction", "Make$name");
123 fvwm_cmd("AddToFunc", "KillMenuMPD", "I", "DestroyMenu", $name);
125 fvwm_cmd("DestroyFunc", "Make$name");
126 fvwm_cmd("AddToFunc", "Make$name");
127 fvwm_cmd("+", "I", "DestroyMenu", $name);
128 fvwm_cmd("+", "I", "-PipeRead",
129 join(' ', map { fvwm_shell_literal } @_));
130 fvwm_cmd("AddToFunc", "KillMenuMPD", "I", "DestroyFunc", "Make$name");
132 return ("Popup", $name);
135 # get_item_thumbnails({ options }, file, ...)
136 # get_item_thumbnails(file, ...)
138 # For each music file listed, obtain a thumbnail (if any) for the cover art.
140 # The first argument is a hash reference to control the mode of operation;
141 # it may be omitted for default options.
143 # get_item_thumbnails({ small => 1 }, ...) - smaller thumbnails
145 # The returned list consists of strings (in the same order as the filename
146 # arguments) suitable for use directly in FVWM menus; by default the filename
147 # is bracketed by asterisks (e.g., "*thumbnail.png*"); in small mode it is
148 # surrounded by % (e.g., "%thumbnail.png%"). If no cover art was found, the
149 # empty string is returned for that file.
150 sub get_item_thumbnails {
155 $flags = shift if (reftype($_[0]) eq "HASH");
156 return @results unless @_;
159 if ($flags->{small}) {
160 push @opts, "--small";
164 open THUMB, "-|", "$FindBin::Bin/mpdthumb.sh", @opts, "--", @_;
169 $thumb = "$c$thumb$c" if (-f $thumb);
170 push @results, $thumb;
173 die("mpdthumb failed") if ($?);
178 # add_track_metadata(hashref, key, value)
180 # Inserts the given key into the referenced hash; if the key already exists
181 # in the hash then the hash element is converted to an array reference (if
182 # it isn't already) and the value is appended to that array.
183 sub add_track_metadata {
184 my ($entry, $key, $value) = @_;
186 if (exists($entry->{$key})) {
187 my $ref = $entry->{$key};
189 if (reftype($ref) ne "ARRAY") {
190 return if ($ref eq $value);
193 $entry->{$key} = $ref;
196 push(@$ref, $value) unless (any {$_ eq $value} @$ref);
198 $entry->{$key} = $value;
202 # get_track_metadata(hashref, key)
204 # Return the values associated with the given metadata key as a list.
205 sub get_track_metadata {
206 my ($entry, $key) = @_;
208 return () unless (exists($entry->{$key}));
210 my $ref = $entry->{$key};
211 return @$ref if (reftype($ref) eq "ARRAY");
215 # Given a music filename, search for the cover art in the same directory.
216 sub mpd_cover_filename {
220 $dir =~ s/\/[^\/]*$//;
221 foreach ("cover.png", "cover.jpg", "cover.tiff", "cover.bmp") {
227 return unless defined $file;
229 # Follow one level of symbolic link to get to the scans directory.
230 $file = readlink($file) // $file;
231 $file = "$dir/$file" unless ($file =~ /^\//);
235 # Generate the cover art entry in the top menu.
236 sub top_track_cover {
239 ($entry->{thumb}) = get_item_thumbnails($entry->{file});
240 print "$entry->{thumb}\n";
241 if ($entry->{thumb}) {
242 my $file = "$MUSIC/$entry->{file}";
243 my $cover = mpd_cover_filename($file);
245 $cover = fvwm_shell_literal($cover // $file);
246 fvwm_cmd_unquoted("AddToMenu", escape($menu),
247 escape($entry->{thumb}),
248 "Exec", "exec", "geeqie", $cover);
252 # Generate the "Title:" entry in the top menu.
253 sub top_track_title {
256 my @submenu = make_submenu("$menu-TopTrack",
257 "--title=$entry->{Title}");
259 fvwm_cmd("AddToMenu", $menu,
260 fvwm_label_escape("Title:\t$entry->{Title}"),
264 # Generate the "Artist:" entry in the top menu.
265 sub top_track_artist {
269 # TODO: multi-artist tracks should get multiple artist menus; for now
270 # just combine the releases from all artists.
271 my @mbids = get_track_metadata($entry, "MUSICBRAINZ_ARTISTID");
273 @submenu = make_submenu("$menu-TopArtist",
274 map { "--artist-id=$_" } @mbids);
277 fvwm_cmd("AddToMenu", $menu,
278 fvwm_label_escape("Artist:\t$entry->{Artist}"),
282 # Generate the "Album:" entry in the top menu.
283 sub top_track_album {
287 my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID");
288 @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid") if $mbid;
290 fvwm_cmd("AddToMenu", $menu,
291 fvwm_label_escape("Album:\t$entry->{Album}"),
295 # Given a release MBID, return a hash reference containing all its
296 # associated tracks in the MPD database. The hash keys are filenames.
297 sub get_tracks_by_release_mbid {
302 return \%matches unless ($mbid);
303 mpd_exec("search", "(MUSICBRAINZ_ALBUMID == \"$mbid\")");
308 if (/^(\w+): (.*)$/) {
310 if (exists($matches{$2})) {
311 $entry = $matches{$2};
314 $matches{$2} = $entry;
318 add_track_metadata($entry, $1, $2);
325 # Given an artist MBID, return a hash reference containing associated
326 # releases in the MPD database. The hash keys are release MBIDs.
328 # Since MPD returns results on a per-track basis, each entry in the
329 # hash has the metadata for one unspecified track from that release.
330 sub get_releases_by_artist_mbid {
334 foreach my $mbid (@_) {
335 mpd_exec("search", "(MUSICBRAINZ_ARTISTID == \"$mbid\")");
340 if (/^(\w+): (.*)$/) {
343 } elsif ($1 eq "MUSICBRAINZ_ALBUMID") {
344 $releases{$2} //= $entry;
347 add_track_metadata($entry, $1, $2);
355 # Given a filename, return the IDs (if any) for that file in the
356 # current MPD play queue.
357 sub get_ids_by_filename {
361 mpd_exec("playlistfind", "file", $file);
366 if (/^(\w+): (.*)$/) {
367 push @results, $2 if ($1 eq "Id");
374 # albumsort(matches, a, b)
376 # Sort hash keys (a, b) by disc/track number for album menus.
378 my ($matches, $a, $b) = @_;
380 return $matches->{$a}->{Disc} <=> $matches->{$b}->{Disc}
381 || $matches->{$a}->{Track} <=> $matches->{$b}->{Track}
385 # datesort(matches, a, b)
387 # Sort hash keys (a, b) by release date
389 my ($matches, $a, $b) = @_;
391 return $matches->{$a}->{Date} cmp $matches->{$b}->{Date}
395 # menu_trackname(entry)
397 # Format the track name for display in an FVWM menu, where entry
398 # is a hash reference containing the track metadata.
401 my $fmt = "$entry->{trackfmt}$entry->{Artist} - $entry->{Title}";
402 return "$entry->{thumb}" . fvwm_label_escape($fmt);
408 Copyright © 2019 Nick Bowler
409 License GPLv3+: GNU General Public License version 3 or any later version.
410 This is free software: you are free to change and redistribute it.
411 There is NO WARRANTY, to the extent permitted by law.
416 my $fh = $_[1] // *STDERR;
418 print $fh "Usage: $0 [options]\n";
419 print "Try $0 --help for more information.\n" unless (@_ > 0);
423 print_usage(*STDOUT);
425 This is "mpdmenu": a menu-based MPD client for FVWM.
428 -h, --host=HOST Connect to the MPD server on HOST, overriding defaults.
429 -p, --port=PORT Connect to the MPD server on PORT, overriding defaults.
430 -m, --menu=NAME Set the name of the generated menu.
431 --album-id=MBID Generate a menu for the given release MBID.
432 --artist-id=MBID Generate a menu for the given artist MBID.
433 -V, --version Print a version message and then exit.
434 -H, --help Print this message and then exit.
439 'host|h=s' => \$host,
440 'port|p=s' => \$port,
441 'menu|m=s' => \$menu,
443 'artist-id=s' => sub { $artistids{$_[1]} = 1; $mode = "artist"; },
444 'album-id=s' => sub { $albumid = $_[1]; $mode = "album"; },
445 'title=s' => sub { $title = $_[1]; $mode = "track"; },
447 'V|version' => sub { print_version(); exit },
448 'H|help' => sub { print_help(); exit },
449 ) or do { print_usage; exit 1 };
452 $sock = new IO::Socket::INET6(
457 ) or die("could not open socket: $!.\n");
458 binmode($sock, ":utf8");
460 die("could not connect to MPD: $!.\n")
461 if (!(<$sock> =~ /^OK MPD ([0-9]+)\.([0-9]+)\.([0-9]+)$/));
463 die("MPD version $1.$2.$3 insufficient.\n")
464 if ( ($1 < MPD_MJR_MIN)
465 || ($1 == MPD_MJR_MIN && $2 < MPD_MNR_MIN)
466 || ($1 == MPD_MJR_MIN && $2 == MPD_MNR_MIN && $3 < MPD_REV_MIN));
468 if ($mode eq "top") {
479 if (/^(\w+): (.*)$/) {
484 mpd_exec("currentsong");
489 if (/^(\w+): (.*)$/) {
490 add_track_metadata(\%current, $1, $2);
494 my $playstate = $state{state} eq "play" ? "Playing"
495 : $state{state} eq "stop" ? "Stopped"
496 : $state{state} eq "pause" ? "Paused"
498 fvwm_cmd("AddToMenu", $menu, $playstate, "Title");
500 if (exists($current{file})) {
501 top_track_cover(\%current);
502 top_track_title(\%current);
503 top_track_artist(\%current);
504 top_track_album(\%current);
506 fvwm_cmd("AddToMenu", $menu, "[current track unavailable]");
509 if ($state{state} =~ /^p/) {
510 my $pp = $state{state} eq "pause" ? "lay" : "ause";
512 fvwm_cmd("AddToMenu", $menu, "", "Nop");
513 fvwm_cmd("AddToMenu", $menu, "Next%next.svg:16x16%",
514 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "next");
515 fvwm_cmd("AddToMenu", $menu, "P$pp%p$pp.svg:16x16%",
516 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "p$pp");
517 fvwm_cmd("AddToMenu", $menu, "Stop%stop.svg:16x16%",
518 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "stop");
519 fvwm_cmd("AddToMenu", $menu, "Prev%prev.svg:16x16%",
520 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "previous");
521 } elsif ($state{state} eq "stop") {
522 fvwm_cmd("AddToMenu", $menu, "", "Nop");
523 fvwm_cmd("AddToMenu", $menu, "Play%play.svg:16x16%",
524 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "play");
526 } elsif ($mode eq "album") {
527 my $matches = get_tracks_by_release_mbid($albumid);
530 $menu //= "MenuMPDAlbum";
532 my $track_max = max(map { $_->{Track} } values %$matches);
533 my $disc_max = max(map { $_->{Disc} } values %$matches);
535 # CDs have a max of 99 tracks and I hope 100+-disc-releases
536 # don't exist so this is fine.
537 my $track_digits = $track_max >= 10 ? 2 : 1;
538 my $disc_digits = $disc_max > 1 ? $disc_max >= 10 ? 2 : 1 : 0;
541 fvwm_cmd("AddToMenu", $menu);
542 fvwm_cmd("+", "Release not found", "Title") unless keys %$matches;
543 foreach my $file (sort { albumsort($matches, $a, $b) } keys %$matches) {
544 my $entry = $matches->{$file};
546 # Format disc/track numbers
547 $entry->{trackfmt} = sprintf("%*.*s%.*s%*d\t",
548 $disc_digits, $disc_digits, $entry->{Disc},
550 $track_digits, $entry->{Track});
551 $entry->{trackfmt} =~ s/ /\N{U+2007}/g;
553 unless (exists $entry->{Id}) {
554 my ($id) = get_ids_by_filename($file);
558 push @notqueued, $entry;
563 if (defined $currentdisc && $currentdisc != $entry->{Disc}) {
564 fvwm_cmd("+", "", "Nop");
566 $currentdisc = $entry->{Disc};
568 fvwm_cmd("+", menu_trackname($entry), "Exec",
569 "exec", "$FindBin::Bin/mpdexec.pl",
570 "playid", $entry->{Id});
573 fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
574 foreach my $entry (@notqueued) {
575 fvwm_cmd("+", menu_trackname($entry));
577 } elsif ($mode eq "artist") {
578 # Create an artist menu.
579 my $matches = get_releases_by_artist_mbid(keys %artistids);
582 $menu //= "MenuMPDArtist";
584 my @mbids = sort { datesort($matches, $a, $b) } keys %$matches;
585 my @files = map { $matches->{$_}->{file} } @mbids;
586 my @thumbs = get_item_thumbnails({ small => 1 }, @files);
587 fvwm_cmd("AddToMenu", $menu, "No releases found", "Title") unless @mbids;
589 foreach my $mbid (@mbids) {
590 my $entry = $matches->{$mbid};
591 my $thumb = shift @thumbs;
593 my @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid");
594 fvwm_cmd("AddToMenu", $menu,
595 $thumb . fvwm_label_escape($entry->{Album}),
598 } elsif ($mode eq "track") {
599 # Create a title menu.
603 $menu = "MenuMPDTitle" unless defined $menu;
605 # Open and close brackets.
606 my ($ob, $cb) = ("[\[~〜<〈(ー−-]", "[\]~〜>〉)ー−-]");
610 # Deal with specific cases.
611 s/ちいさな(?=ヘミソフィア)//; # ヘミソフィア
612 s/ "mix on air flavor" dear EIKO SHIMAMIYA//; # Spiral wind
613 s/ "So,you need me" Style//; # I need you
614 s/ ::Symphony Second movement:://; # Disintegration
615 s/-\[instrumental\]//; # 青い果実
616 s/ -Practice Track-//; # Fair Heaven
617 s/〜世界で一番アナタが好き〜//; # Pure Heart
619 s/ sora no uta ver.//; # 美しい星
621 s/\s*-remix-$//; # Otherwise "D-THREAD -remix-" doesn't work right.
623 # Deal with titles like "blah (ABC version)".
624 s/\s*$ob.*(style|mix|edit|edition|ver\.?|version|melody|カラオケ)$cb?$//i;
626 # Deal with titles like "blah (without XYZ)".
627 s/\s*$ob\s*((e\.)?piano|english|japanese|inst|tv|without|w\/o|off|back|short|karaoke|game).*//i;
629 # Deal with titles like "blah instrumental".
630 s/\s+(instrumental|off vocal|short|tv)([\s-]+(mix|size|version))?$//i;
631 s/\s+without\s+\w+$//i;
633 # Deal with separate movements in classical pieces.
637 my $_basetitle = $basetitle;
639 $_basetitle =~ s/"/\\"/g;
640 print $sock "playlistsearch title \"$_basetitle\"\n";
645 if (/^(\w+): (.*)$/) {
647 push @titles, $entry if (keys(%$entry) > 0);
654 push @titles, $entry if (keys(%$entry) > 0);
656 { # work around 'use locale' breaking s///i
659 my @thumbs = get_item_thumbnails({ small => 1 },
660 map { $_->{file} } @titles);
661 for (my $i = 0; $i < @titles; $i++) {
662 $titles[$i]->{thumb} = $thumbs[$i];
665 foreach (sort titlesort @titles) {
666 my ($t_file, $t_artist, $t_title, $t_id, $thumb) = (
674 # MPD searches are case-insensitive.
675 next if (!($t_title =~ m/(\P{Latin}|^)\Q$basetitle\E(\P{Latin}|$)/ || $t_title =~ m/\Q$basetitle\E/i));
677 $t_artist = sanitise($t_artist, 1);
678 $t_title = sanitise($t_title, 1);
680 cmd("AddToMenu $menu \"$thumb$t_artist - $t_title\""
681 ." Exec exec $FindBin::Bin/mpdexec.pl"
684 } # end use locale workaround
688 print $sock "close\n";
701 return ($a->{Album} cmp $b->{Album}) if($a->{Album} ne $b->{Album});
702 return ($a->{Artist} cmp $b->{Artist}) if($a->{Artist} ne $b->{Artist});
703 return ($a->{Title} cmp $b->{Title});