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.
15 use IO::Socket::INET6;
16 use Scalar::Util qw(reftype);
26 use open qw(:std :utf8);
27 binmode(STDOUT, ":utf8");
30 my $SELF = "$FindBin::Bin/$FindBin::Script";
31 my $MUSIC = $ENV{MUSIC} // "/srv/music";
33 # get_item_thumbnails({ options }, file, ...)
34 # get_item_thumbnails(file, ...)
36 # For each music file listed, obtain a thumbnail (if any) for the cover art.
38 # The first argument is a hash reference to control the mode of operation;
39 # it may be omitted for default options.
41 # get_item_thumbnails({ small => 1 }, ...) - smaller thumbnails
43 # The returned list consists of strings (in the same order as the filename
44 # arguments) suitable for use directly in FVWM menus; by default the filename
45 # is bracketed by asterisks (e.g., "*thumbnail.png*"); in small mode it is
46 # surrounded by % (e.g., "%thumbnail.png%"). If no cover art was found, the
47 # empty string is returned for that file.
48 sub get_item_thumbnails {
53 $flags = shift if (reftype($_[0]) eq "HASH");
54 return @results unless @_;
57 if ($flags->{small}) {
58 push @opts, "--small";
62 open THUMB, "-|", "$FindBin::Bin/mpdthumb.sh", @opts, "--", @_;
67 $thumb = "$c$thumb$c" if (-f $thumb);
68 push @results, $thumb;
71 die("mpdthumb failed") if ($?);
76 # Given a music filename, search for the cover art in the same directory.
77 sub mpd_cover_filename {
81 $dir =~ s/\/[^\/]*$//;
82 foreach ("cover.png", "cover.jpg", "cover.tiff", "cover.bmp") {
88 return unless defined $file;
90 # Follow one level of symbolic link to get to the scans directory.
91 $file = readlink($file) // $file;
92 $file = "$dir/$file" unless ($file =~ /^\//);
101 # Global hash for tracking what is to be "accepted".
104 my $FVWM = (defined $ENV{FVWM_USERDIR}) ? $ENV{FVWM_USERDIR}
105 : $ENV{HOME}."/.fvwm";
106 my $icons = "$FVWM/icons";
108 # Default values for stuff.
109 my ($album, $artist, $title, $menu) = (undef, undef, undef, undef);
110 my $host = (defined $ENV{MPD_HOST}) ? $ENV{MPD_HOST} : "localhost";
111 my $port = (defined $ENV{MPD_PORT}) ? $ENV{MPD_PORT} : "6600";
114 'host|h=s' => \&host, # Host that MPD is running on.
115 'port|p=s' => \&port, # Port that MPD is listening on.
116 'menu|m=s' => \$menu, # Name of the menu to create.
117 'album=s' => \$album, # Album to get tracks from
118 'artist=s' => \$artist, # Artist to limit results to
119 'title=s' => \$title, # Title to create menu for
122 $album = decode_utf8($album) if defined($album);
123 $artist = decode_utf8($artist) if defined($artist);
124 $title = decode_utf8($title) if defined($title);;
127 my $sock = new IO::Socket::INET6(
132 ) or die("could not open socket: $!.\n");
133 binmode($sock, ":utf8");
135 die("could not connect to MPD: $!.\n")
136 if (!(<$sock> =~ /^OK MPD ([0-9]+)\.([0-9]+)\.([0-9]+)$/));
138 die("MPD version $1.$2.$3 insufficient.\n")
139 if ( ($1 < MPD_MJR_MIN)
140 || ($1 == MPD_MJR_MIN && $2 < MPD_MNR_MIN)
141 || ($1 == MPD_MJR_MIN && $2 == MPD_MNR_MIN && $3 < MPD_REV_MIN));
143 if (defined $album) {
144 # Create an album menu.
148 $menu = "MenuMPDAlbum" unless defined $menu;
151 print $sock "playlistfind album \"$album\"\n";
156 if (/^(\w+): (.*)$/) {
158 if (keys(%$entry) > 0) {
159 addalbumentry(\@playlist, $entry)
168 addalbumentry(\@playlist, $entry) if (keys(%$entry) > 0);
170 die("No tracks found.\n") if (!@playlist);
171 foreach (sort albumsort @playlist) {
172 my ($t_file, $t_trackno, $t_artist, $t_title, $t_id) = (
180 next if (defined $artist && !$accept{albumdir($t_file)});
182 $t_artist = sanitise($t_artist, 0);
183 $t_title = sanitise($t_title, 0);
185 my $cmd = sprintf "AddToMenu $menu \"%d\t%s - %s\""
186 ." Exec exec $FindBin::Bin/mpdexec.pl"
188 $t_trackno, $t_artist, $t_title, $t_id;
192 } elsif (defined $artist) {
193 # Create an artist menu.
196 my $quoteartist = $artist;
198 $menu = "MenuMPDArtist" unless defined $menu;
200 $quoteartist =~ s/"/\\"/g;
201 print $sock "playlistfind artist \"$quoteartist\"\n";
206 if (/^(\w+): (.*)$/) {
207 $file = $2 if ($1 eq "file");
208 $albums{$2} = $file if ($1 eq "Album");
212 die("No albums found.\n") if (!keys(%albums));
214 { # work around 'use locale' breaking s///i
218 my @keys = sort keys %albums;
219 my @thumbs = get_item_thumbnails({ small => 1 },
220 map { $albums{$_} } @keys);
222 foreach my $key (@keys) {
223 my $a_album = sanitise($key, 1);
224 my $thumb = shift @thumbs;
226 cmd("AddToMenu $menu \"$thumb$a_album\" Popup MenuMPDArt_$i");
228 cmd("AddToMenu MenuMPDArt_$i DynamicPopUpAction MakeMenuMPDArt_$i");
230 cmd("DestroyFunc MakeMenuMPDArt_$i");
231 cmd("AddToFunc MakeMenuMPDArt_$i
232 + I DestroyMenu MenuMPDArt_$i
233 + I -PipeRead \"exec $SELF "
234 ."--menu MenuMPDArt_$i "
235 ."--album ".shellify($key, 1)." "
236 ."--artist ".shellify($artist, 1)."\"");
238 cmd("AddToFunc KillMenuMPD I DestroyMenu MenuMPDArt_$i");
239 cmd("AddToFunc KillMenuMPD I DestroyFunc MakeMenuMPDArt_$i");
243 } # end use locale workaround
244 } elsif (defined $title) {
245 # Create a title menu.
249 $menu = "MenuMPDTitle" unless defined $menu;
251 # Open and close brackets.
252 my ($ob, $cb) = ("[\[~〜<〈(ー−-]", "[\]~〜>〉)ー−-]");
256 # Deal with specific cases.
257 s/ちいさな(?=ヘミソフィア)//; # ヘミソフィア
258 s/ "mix on air flavor" dear EIKO SHIMAMIYA//; # Spiral wind
259 s/ "So,you need me" Style//; # I need you
260 s/ ::Symphony Second movement:://; # Disintegration
261 s/-\[instrumental\]//; # 青い果実
262 s/ -Practice Track-//; # Fair Heaven
263 s/〜世界で一番アナタが好き〜//; # Pure Heart
265 s/ sora no uta ver.//; # 美しい星
267 s/\s*-remix-$//; # Otherwise "D-THREAD -remix-" doesn't work right.
269 # Deal with titles like "blah (ABC version)".
270 s/\s*$ob.*(style|mix|edit|edition|ver\.?|version|melody|カラオケ)$cb?$//i;
272 # Deal with titles like "blah (without XYZ)".
273 s/\s*$ob\s*((e\.)?piano|english|japanese|inst|tv|without|w\/o|off|back|short|karaoke|game).*//i;
275 # Deal with titles like "blah instrumental".
276 s/\s+(instrumental|off vocal|short|tv)([\s-]+(mix|size|version))?$//i;
277 s/\s+without\s+\w+$//i;
279 # Deal with separate movements in classical pieces.
283 my $_basetitle = $basetitle;
285 $_basetitle =~ s/"/\\"/g;
286 print $sock "playlistsearch title \"$_basetitle\"\n";
291 if (/^(\w+): (.*)$/) {
293 push @titles, $entry if (keys(%$entry) > 0);
300 push @titles, $entry if (keys(%$entry) > 0);
302 { # work around 'use locale' breaking s///i
305 my @thumbs = get_item_thumbnails({ small => 1 },
306 map { $_->{file} } @titles);
307 for (my $i = 0; $i < @titles; $i++) {
308 $titles[$i]->{thumb} = $thumbs[$i];
311 foreach (sort titlesort @titles) {
312 my ($t_file, $t_artist, $t_title, $t_id, $thumb) = (
320 # MPD searches are case-insensitive.
321 next if (!($t_title =~ m/(\P{Latin}|^)\Q$basetitle\E(\P{Latin}|$)/ || $t_title =~ m/\Q$basetitle\E/i));
323 $t_artist = sanitise($t_artist, 1);
324 $t_title = sanitise($t_title, 1);
326 cmd("AddToMenu $menu \"$thumb$t_artist - $t_title\""
327 ." Exec exec $FindBin::Bin/mpdexec.pl"
330 } # end use locale workaround
333 my ($state, $songid) = (undef, undef);
336 $menu = "MenuMPD" unless defined $menu;
338 print $sock "status\n";
343 if (/^(\w+): (.*)$/) {
344 $state = $2 if ($1 eq "state");
345 $songid = $2 if ($1 eq "songid");
348 die("Failed status query\n") unless (defined $state);
350 cmd("AddToMenu $menu Playing Title") if ($state eq "play");
351 cmd("AddToMenu $menu Paused Title") if ($state eq "pause");
352 cmd("AddToMenu $menu Stopped Title") if ($state eq "stop");
354 if (defined $songid) {
355 print $sock "playlistid $songid\n";
360 if (/^(\w+): (.*)$/) {
364 die("Failed data query\n") unless (keys(%entry) > 0);
366 my ($thumb) = get_item_thumbnails($entry{file});
368 my $cover = mpd_cover_filename("$MUSIC/$entry{file}");
370 cmd("AddToMenu $menu \"$thumb\" "
371 ."Exec exec geeqie ".shellify($cover, 0));
374 cmd("AddToMenu $menu \"Title: ".sanitise($entry{Title}, 0)
375 ."\" Popup MenuMPDTitle");
376 cmd("AddToMenu $menu \"Artist: ".sanitise($entry{Artist}, 0)
377 ."\" Popup MenuMPDArtist");
378 cmd("AddToMenu $menu \"Album: ".sanitise($entry{Album}, 0)
379 ."\" Popup MenuMPDAlbum");
380 cmd("AddToMenu $menu \"\" Nop");
382 cmd("AddToMenu $menu \"<Song info unavailable>\"");
383 cmd("AddToMenu $menu \"\" Nop");
386 if ($state eq "play" || $state eq "pause") {
387 cmd("AddToMenu $menu \"\t\tNext%$icons/next.svg:16x16%\" "
388 ."Exec exec $FindBin::Bin/mpdexec.pl next");
389 cmd("AddToMenu $menu \"\t\tPause%$icons/pause.svg:16x16%\" "
390 ."Exec exec $FindBin::Bin/mpdexec.pl pause");
391 cmd("AddToMenu $menu \"\t\tPlay%$icons/play.svg:16x16%\" "
392 ."Exec exec $FindBin::Bin/mpdexec.pl play");
393 cmd("AddToMenu $menu \"\t\tStop%$icons/stop.svg:16x16%\" "
394 ."Exec exec $FindBin::Bin/mpdexec.pl stop");
395 cmd("AddToMenu $menu \"\t\tPrev%$icons/prev.svg:16x16%\" "
396 ."Exec exec $FindBin::Bin/mpdexec.pl previous");
397 } elsif ($state eq "stop") {
398 cmd("AddToMenu $menu \"\t\tPlay%$icons/play.svg:16x16%\" "
399 ."Exec exec $FindBin::Bin/mpdexec.pl play");
401 die("Unknown MPD state!\n");
404 cmd("AddToMenu $menu \"\" Nop");
405 cmd("AddToMenu $menu \"\t\tShuffle%$icons/shuffle.svg:16x16%\" "
406 ."Exec exec $FindBin::Bin/mpdexec.pl shuffle");
408 cmd("DestroyMenu MenuMPDTitle");
409 cmd("AddToMenu MenuMPDTitle DynamicPopUpAction MakeMenuMPDTitle");
410 cmd("DestroyMenu MenuMPDArtist");
411 cmd("AddToMenu MenuMPDArtist DynamicPopUpAction MakeMenuMPDArtist");
412 cmd("DestroyMenu MenuMPDAlbum");
413 cmd("AddToMenu MenuMPDAlbum DynamicPopUpAction MakeMenuMPDAlbum");
415 cmd("DestroyFunc MakeMenuMPDTitle");
416 cmd("AddToFunc MakeMenuMPDTitle
417 + I DestroyMenu MenuMPDTitle
418 + I -PipeRead \"exec $SELF "
419 ."--menu MenuMPDTitle "
420 ."--title ".shellify($entry{Title}, 1)."\"");
422 cmd("DestroyFunc MakeMenuMPDAlbum");
423 cmd("AddToFunc MakeMenuMPDAlbum
424 + I DestroyMenu MenuMPDAlbum
425 + I -PipeRead \"exec $SELF "
426 ."--menu MenuMPDAlbum "
427 ."--album ".shellify($entry{Album}, 1)." "
428 ."--artist ".shellify($entry{Artist}, 1)."\"");
430 cmd("DestroyFunc MakeMenuMPDArtist");
431 cmd("AddToFunc MakeMenuMPDArtist
432 + I DestroyMenu MenuMPDArtist
433 + I -PipeRead \"exec $SELF "
434 ."--menu MenuMPDArtist "
435 ."--artist ".shellify($entry{Artist}, 1)."\"");
437 cmd("DestroyFunc KillMenuMPD");
438 cmd("AddToFunc KillMenuMPD I Nop");
442 print $sock "close\n";
455 my ($playlist, $entry) = @_;
457 push(@$playlist, $entry);
459 if (defined $artist && $artist eq $entry->{Artist}) {
460 my $albumdir = albumdir($entry->{file});
461 $accept{$albumdir} = 1;
469 $file =~ s:(/Disk [0-9]+[^/]*)?/[^/]*$::;
475 return ($a->{Disc} <=> $b->{Disc}) if ($a->{Disc} != $b->{Disc});
476 return ($a->{Track} <=> $b->{Track});
481 return ($a->{Album} cmp $b->{Album}) if($a->{Album} ne $b->{Album});
482 return ($a->{Artist} cmp $b->{Artist}) if($a->{Artist} ne $b->{Artist});
483 return ($a->{Title} cmp $b->{Title});
488 my ($str, $quoted) = @_;