]> git.draconx.ca Git - mpdhacks.git/blob - mpdmenu.pl
Factor out MPD connection code.
[mpdhacks.git] / mpdmenu.pl
1 #!/usr/bin/perl
2 #
3 # Copyright © 2008,2010,2012,2020 Nick Bowler
4 #
5 # Silly little script to generate an FVWM menu with various bits of MPD
6 # status information and controls.
7 #
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.
11
12 use strict;
13
14 use utf8;
15
16 use Encode qw(decode encode);
17 use Encode::Locale qw(decode_argv);
18 decode_argv(Encode::FB_CROAK);
19 binmode(STDOUT, ":utf8");
20
21 use Getopt::Long qw(:config gnu_getopt);
22 use Scalar::Util qw(reftype);
23 use List::Util qw(any max);
24 use FindBin;
25
26 use lib "$FindBin::Bin";
27 use MPDHacks;
28
29 use constant {
30         MPD_MJR_MIN => 0,
31         MPD_MNR_MIN => 21,
32         MPD_REV_MIN => 0,
33 };
34
35 my $SELF = "$FindBin::Bin/$FindBin::Script";
36
37 my $MUSIC = $ENV{MUSIC}    // "/srv/music";
38 my $sock;
39
40 my ($albumid, $trackid);
41 my ($topmenu, $menu);
42 my $mode = "top";
43 my %artistids;
44
45 # Submit a command to the MPD server; each argument to this function
46 # is quoted and sent as a single argument to MPD.
47 sub mpd_exec {
48         my $cmd = join(' ', map { MPD::escape } @_);
49
50         print $sock "$cmd\n";
51 }
52
53 sub fvwm_cmd_unquoted {
54         print join(' ', @_), "\n";
55 }
56
57 sub fvwm_cmd {
58         fvwm_cmd_unquoted(map { MPD::escape } @_);
59 }
60
61 # Quotes the argument in such a way that it is passed unadulterated by
62 # both FVWM and the shell to a command as a single argument (for use as
63 # an # argument for e.g., the Exec or PipeRead FVWM commands).
64 #
65 # The result must be used with fvwm_cmd_unquoted;
66 sub fvwm_shell_literal {
67         my $s = @_[0] // $_;
68
69         $s =~ s/\$/\$\$/g;
70         if ($s =~ /[' \t]/) {
71                 $s =~ s/'/'\\''/g;
72                 return "'$s'";
73         }
74         $s =~ s/^\s*$/'$&'/;
75         return "$s";
76 }
77
78 # Escapes metacharacters in the argument used in FVWM menu labels.  The
79 # string must still be quoted (e.g., by using fvwm_cmd).
80 sub fvwm_label_escape {
81         my @tokens = split /\t/, $_[0];
82         @tokens[0] =~ s/&/&&/g;
83         my $ret = join "\t", @tokens;
84         $ret =~ s/[\$@%^*]/$&$&/g;
85         return $ret;
86 }
87
88 # make_submenu(name, [args ...])
89 #
90 # Creates a submenu (with the specified name) constructed by invoking this
91 # script with the given arguments.  Returns a list that can be passed to
92 # fvwm_cmd to display the menu.
93 sub make_submenu {
94         my $name = shift;
95         $name =~ s/-/_/g;
96         unshift @_, ("exec", $SELF, "--topmenu=$topmenu", "--menu=$name");
97
98         fvwm_cmd("DestroyFunc", "Make$name");
99         fvwm_cmd("AddToFunc", "Make$name");
100         fvwm_cmd("+", "I", "DestroyMenu", $name);
101
102         fvwm_cmd("DestroyMenu", $name);
103         fvwm_cmd("AddToMenu", $name, "DynamicPopupAction", "Make$name");
104         fvwm_cmd("AddToFunc", "Kill$topmenu", "I", "DestroyMenu", $name);
105
106         fvwm_cmd("DestroyFunc", "Make$name");
107         fvwm_cmd("AddToFunc", "Make$name");
108         fvwm_cmd("+", "I", "DestroyMenu", $name);
109         fvwm_cmd("+", "I", "-PipeRead",
110                  join(' ', map { fvwm_shell_literal } @_));
111         fvwm_cmd("AddToFunc", "Kill$topmenu", "I", "DestroyFunc", "Make$name");
112
113         return ("Popup", $name);
114 }
115
116 # get_item_thumbnails({ options }, file, ...)
117 # get_item_thumbnails(file, ...)
118 #
119 # For each music file listed, obtain a thumbnail (if any) for the cover art.
120 #
121 # The first argument is a hash reference to control the mode of operation;
122 # it may be omitted for default options.
123 #
124 #   get_item_thumbnails({ small => 1 }, ...) - smaller thumbnails
125 #
126 # The returned list consists of strings (in the same order as the filename
127 # arguments) suitable for use directly in FVWM menus; by default the filename
128 # is bracketed by asterisks (e.g., "*thumbnail.png*"); in small mode it is
129 # surrounded by % (e.g., "%thumbnail.png%").  If no cover art was found, the
130 # empty string is returned for that file.
131 sub get_item_thumbnails {
132         my @results = ();
133         my $flags = {};
134         my @opts = ();
135
136         $flags = shift if (reftype($_[0]) eq "HASH");
137         return @results unless @_;
138
139         my $c = "*";
140         if ($flags->{small}) {
141                 push @opts, "--small";
142                 $c = "%";
143         }
144
145         open THUMB, "-|", "$FindBin::Bin/mpdthumb.sh", @opts, "--", @_;
146         foreach (@_) {
147                 my $thumb = <THUMB>;
148                 chomp $thumb;
149
150                 $thumb = "$c$thumb$c" if (-f $thumb);
151                 push @results, $thumb;
152         }
153         close THUMB;
154         die("mpdthumb failed") if ($?);
155
156         return @results;
157 }
158
159 # add_track_metadata(hashref, key, value)
160 #
161 # Inserts the given key into the referenced hash; if the key already exists
162 # in the hash then the hash element is converted to an array reference (if
163 # it isn't already) and the value is appended to that array.
164 sub add_track_metadata {
165         my ($entry, $key, $value) = @_;
166
167         if (exists($entry->{$key})) {
168                 my $ref = $entry->{$key};
169
170                 if (reftype($ref) ne "ARRAY") {
171                         return if ($ref eq $value);
172
173                         $ref = [$ref];
174                         $entry->{$key} = $ref;
175                 }
176
177                 push(@$ref, $value) unless (any {$_ eq $value} @$ref);
178         } else {
179                 $entry->{$key} = $value;
180         }
181 }
182
183 # get_track_metadata(hashref, key)
184 #
185 # Return the values associated with the given metadata key as a list.
186 sub get_track_metadata {
187         my ($entry, $key) = @_;
188
189         return () unless (exists($entry->{$key}));
190
191         my $ref = $entry->{$key};
192         return @$ref if (reftype($ref) eq "ARRAY");
193         return $ref
194 }
195
196 # Given a music filename, search for the cover art in the same directory.
197 sub mpd_cover_filename {
198         my ($dir) = @_;
199         my $file;
200
201         $dir =~ s/\/[^\/]*$//;
202         foreach ("cover.png", "cover.jpg", "cover.tiff", "cover.bmp") {
203                 if (-f "$dir/$_") {
204                         $file = "$dir/$_";
205                         last;
206                 }
207         }
208         return unless defined $file;
209
210         # Follow one level of symbolic link to get to the scans directory.
211         $file = readlink($file) // $file;
212         $file = "$dir/$file" unless ($file =~ /^\//);
213         return $file;
214 }
215
216 # Generate the cover art entry in the top menu.
217 sub top_track_cover {
218         my ($entry) = @_;
219
220         ($entry->{thumb}) = get_item_thumbnails($entry->{file});
221         print "$entry->{thumb}\n";
222         if ($entry->{thumb}) {
223                 my $file = "$MUSIC/$entry->{file}";
224                 my $cover = mpd_cover_filename($file);
225
226                 $cover = fvwm_shell_literal($cover // $file);
227                 fvwm_cmd_unquoted("AddToMenu", MPD::escape($menu),
228                                   MPD::escape($entry->{thumb}),
229                                   "Exec", "exec", "geeqie", $cover);
230         }
231 }
232
233 # Generate the "Title:" entry in the top menu.
234 sub top_track_title {
235         my ($entry) = @_;
236         my @submenu;
237
238         my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_RELEASETRACKID");
239         @submenu = make_submenu("$menu-$mbid", "--track-id=$mbid") if $mbid;
240
241         fvwm_cmd("AddToMenu", $menu,
242                  fvwm_label_escape("Title:\t$entry->{Title}"),
243                  @submenu);
244 }
245
246 # Generate the "Artist:" entry in the top menu.
247 sub top_track_artist {
248         my ($entry) = @_;
249         my @submenu;
250
251         # TODO: multi-artist tracks should get multiple artist menus; for now
252         # just combine the releases from all artists.
253         my @mbids = get_track_metadata($entry, "MUSICBRAINZ_ARTISTID");
254         if (@mbids) {
255                 @submenu = make_submenu("$menu-TopArtist",
256                                         map { "--artist-id=$_" } @mbids);
257         }
258
259         fvwm_cmd("AddToMenu", $menu,
260                  fvwm_label_escape("Artist:\t$entry->{Artist}"),
261                  @submenu);
262 }
263
264 # Generate the "Album:" entry in the top menu.
265 sub top_track_album {
266         my ($entry) = @_;
267         my @submenu;
268
269         my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID");
270         @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid") if $mbid;
271
272         fvwm_cmd("AddToMenu", $menu,
273                  fvwm_label_escape("Album:\t$entry->{Album}"),
274                  @submenu);
275 }
276
277 # Given a work MBID, return a hash reference containing all tracks
278 # linked to that work.  The hash keys are filenames.
279 sub get_tracks_by_work_mbid {
280         my %matches;
281         my $entry;
282
283         foreach my $mbid (@_) {
284                 mpd_exec("search", "(MUSICBRAINZ_WORKID == \"$mbid\")");
285                 while (<$sock>) {
286                         last if (/^OK/);
287                         die($_) if (/^ACK/);
288
289                         if (/^(\w+): (.*)$/) {
290                                 if ($1 eq "file") {
291                                         if (exists($matches{$2})) {
292                                                 $entry = $matches{$2};
293                                         } else {
294                                                 $entry = {};
295                                                 $matches{$2} = $entry;
296                                         }
297                                 }
298
299                                 add_track_metadata($entry, $1, $2);
300                         }
301                 }
302         }
303
304         return \%matches;
305 }
306
307 # Given a track MBID, return a hash reference containing all "related"
308 # tracks in the MPD database.  The hash keys are filenames.
309 #
310 # Currently tracks are considered "related" if their associated recordings
311 # have at least one work in common.
312 sub get_tracks_by_track_mbid {
313         my ($mbid) = @_;
314         my %source;
315         my %matches;
316         my $entry;
317
318         return \%matches unless ($mbid);
319         mpd_exec("search", "(MUSICBRAINZ_RELEASETRACKID == \"$mbid\")");
320         while (<$sock>) {
321                 last if (/^OK/);
322                 die($_) if (/^ACK/);
323
324                 if (/^(\w+): (.*)$/) {
325                         add_track_metadata(\%source, $1, $2);
326                 }
327         }
328
329         # Always include the current track
330         $matches{$source{file}} = \%source;
331
332         # Find all tracks related by work
333         foreach my $mbid (get_track_metadata(\%source, "MUSICBRAINZ_WORKID")) {
334                 my $related = get_tracks_by_work_mbid($mbid);
335                 foreach (keys %$related) {
336                         $matches{$_} //= $related->{$_};
337                 }
338         }
339
340         return \%matches;
341 }
342
343 # Given a release MBID, return a hash reference containing all its
344 # associated tracks in the MPD database.  The hash keys are filenames.
345 sub get_tracks_by_release_mbid {
346         my ($mbid) = @_;
347         my %matches;
348         my $entry;
349
350         return \%matches unless ($mbid);
351         mpd_exec("search", "(MUSICBRAINZ_ALBUMID == \"$mbid\")");
352         while (<$sock>) {
353                 last if (/^OK/);
354                 die($_) if (/^ACK/);
355
356                 if (/^(\w+): (.*)$/) {
357                         if ($1 eq "file") {
358                                 if (exists($matches{$2})) {
359                                         $entry = $matches{$2};
360                                 } else {
361                                         $entry = {};
362                                         $matches{$2} = $entry;
363                                 }
364                         }
365
366                         add_track_metadata($entry, $1, $2);
367                 }
368         }
369
370         return \%matches;
371 }
372
373 # Given an artist MBID, return a hash reference containing associated
374 # releases in the MPD database.  The hash keys are release MBIDs.
375 #
376 # Since MPD returns results on a per-track basis, each entry in the
377 # hash has the metadata for one unspecified track from that release.
378 sub get_releases_by_artist_mbid {
379         my %releases;
380         my $entry;
381
382         foreach my $mbid (@_) {
383                 mpd_exec("search", "(MUSICBRAINZ_ARTISTID == \"$mbid\")");
384                 while (<$sock>) {
385                         last if (/^OK/);
386                         die($_) if (/^ACK/);
387
388                         if (/^(\w+): (.*)$/) {
389                                 if ($1 eq "file") {
390                                         $entry = {};
391                                 } elsif ($1 eq "MUSICBRAINZ_ALBUMID") {
392                                         $releases{$2} //= $entry;
393                                 }
394
395                                 add_track_metadata($entry, $1, $2);
396                         }
397                 }
398         }
399
400         return \%releases;
401 }
402
403 # Given a filename, return the IDs (if any) for that file in the
404 # current MPD play queue.
405 sub get_ids_by_filename {
406         my ($file) = @_;
407         my @results = ();
408
409         mpd_exec("playlistfind", "file", $file);
410         while (<$sock>) {
411                 last if (/^OK/);
412                 die($_) if (/^ACK/);
413
414                 if (/^(\w+): (.*)$/) {
415                         push @results, $2 if ($1 eq "Id");
416                 }
417         }
418
419         return @results;
420 }
421
422 # albumsort(matches, a, b)
423 #
424 # Sort hash keys (a, b) by disc/track number for album menus.
425 sub albumsort {
426         my ($matches, $a, $b) = @_;
427
428         return $matches->{$a}->{Disc} <=> $matches->{$b}->{Disc}
429             || $matches->{$a}->{Track} <=> $matches->{$b}->{Track}
430             || $a cmp $b;
431 }
432
433 # datesort(matches, a, b)
434 #
435 # Sort hash keys (a, b) by release date
436 sub datesort {
437         my ($matches, $a, $b) = @_;
438
439         return $matches->{$a}->{Date} cmp $matches->{$b}->{Date}
440             || $a cmp $b;
441 }
442
443 # menu_trackname(entry)
444 #
445 # Format the track name for display in an FVWM menu, where entry
446 # is a hash reference containing the track metadata.
447 sub menu_trackname {
448         my ($entry) = @_;
449         my $fmt = "$entry->{trackfmt}$entry->{Artist} - $entry->{Title}";
450         return "$entry->{thumb}" . fvwm_label_escape($fmt);
451 }
452
453 sub print_version {
454         print <<EOF
455 mpdmenu.pl 0.8
456 Copyright © 2019 Nick Bowler
457 License GPLv3+: GNU General Public License version 3 or any later version.
458 This is free software: you are free to change and redistribute it.
459 There is NO WARRANTY, to the extent permitted by law.
460 EOF
461 }
462
463 sub print_usage {
464         my $fh = $_[1] // *STDERR;
465
466         print $fh "Usage: $0 [options]\n";
467         print "Try $0 --help for more information.\n" unless (@_ > 0);
468 }
469
470 sub print_help {
471         print_usage(*STDOUT);
472         print <<EOF
473 This is "mpdmenu": a menu-based MPD client for FVWM.
474
475 Options:
476   -h, --host=HOST   Connect to the MPD server on HOST, overriding defaults.
477   -p, --port=PORT   Connect to the MPD server on PORT, overriding defaults.
478   -m, --menu=NAME   Set the name of the generated menu.
479   --album-id=MBID   Generate a menu for the given release MBID.
480   --artist-id=MBID  Generate a menu for the given artist MBID.
481   --track-id=MBID   Generate a menu for the given track MBID.
482   -V, --version     Print a version message and then exit.
483   -H, --help        Print this message and then exit.
484 EOF
485 }
486
487 GetOptions(
488         'host|h=s'    => \$MPD::host,
489         'port|p=s'    => \$MPD::port,
490         'menu|m=s'    => \$menu,
491
492         'artist-id=s' => sub { $artistids{$_[1]} = 1; $mode = "artist"; },
493         'album-id=s'  => sub { $albumid = $_[1]; $mode = "album"; },
494         'track-id=s'  => sub { $trackid = $_[1]; $mode = "track"; },
495
496         'V|version'   => sub { print_version(); exit },
497         'H|help'      => sub { print_help(); exit },
498
499         'topmenu=s'   => \$topmenu, # top menu name (for submenu generation)
500 ) or do { print_usage; exit 1 };
501
502 unless (defined $menu) {
503         $topmenu //= "MenuMPD";
504         $menu = $topmenu . ($mode ne "top" ? $mode : "");
505 }
506 $topmenu //= $menu;
507
508 # Connect to MPD.
509 $sock = MPD::connect();
510 die("MPD version $MPD::major.$MPD::minor.$MPD::revision insufficient.")
511         unless MPD::min_version(MPD_MJR_MIN, MPD_MNR_MIN, MPD_REV_MIN);
512
513 if ($mode eq "top") {
514         my %current;
515         my %state;
516
517         $menu //= "MenuMPD";
518
519         mpd_exec("status");
520         while (<$sock>) {
521                 last if (/^OK/);
522                 die($_) if (/^ACK/);
523
524                 if (/^(\w+): (.*)$/) {
525                         $state{$1} = $2;
526                 }
527         }
528
529         mpd_exec("currentsong");
530         while (<$sock>) {
531                 last if (/^OK/);
532                 die($_) if (/^ACK/);
533
534                 if (/^(\w+): (.*)$/) {
535                         add_track_metadata(\%current, $1, $2);
536                 }
537         }
538
539         my $playstate = $state{state} eq "play"  ? "Playing"
540                       : $state{state} eq "stop"  ? "Stopped"
541                       : $state{state} eq "pause" ? "Paused"
542                       : "Unknown";
543         fvwm_cmd("AddToMenu", $menu, $playstate, "Title");
544
545         if (exists($current{file})) {
546                 top_track_cover(\%current);
547                 top_track_title(\%current);
548                 top_track_artist(\%current);
549                 top_track_album(\%current);
550         } else {
551                 fvwm_cmd("AddToMenu", $menu, "[current track unavailable]");
552         }
553
554         if ($state{state} =~ /^p/) {
555                 my $pp = $state{state} eq "pause" ? "lay" : "ause";
556
557                 fvwm_cmd("AddToMenu", $menu, "", "Nop");
558                 fvwm_cmd("AddToMenu", $menu, "Next%next.svg:16x16%",
559                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "next");
560                 fvwm_cmd("AddToMenu", $menu, "P$pp%p$pp.svg:16x16%",
561                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "p$pp");
562                 fvwm_cmd("AddToMenu", $menu, "Stop%stop.svg:16x16%",
563                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "stop");
564                 fvwm_cmd("AddToMenu", $menu, "Prev%prev.svg:16x16%",
565                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "previous");
566         } elsif ($state{state} eq "stop") {
567                 fvwm_cmd("AddToMenu", $menu, "", "Nop");
568                 fvwm_cmd("AddToMenu", $menu, "Play%play.svg:16x16%",
569                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "play");
570         }
571 } elsif ($mode eq "album") {
572         my $matches = get_tracks_by_release_mbid($albumid);
573         my @notqueued = ();
574
575         $menu //= "MenuMPDAlbum";
576
577         my $track_max = max(map { $_->{Track} } values %$matches);
578         my $disc_max = max(map { $_->{Disc} } values %$matches);
579
580         # CDs have a max of 99 tracks and I hope 100+-disc-releases
581         # don't exist so this is fine.
582         my $track_digits = $track_max >= 10 ? 2 : 1;
583         my $disc_digits = $disc_max > 1 ? $disc_max >= 10 ? 2 : 1 : 0;
584         my $currentdisc;
585
586         fvwm_cmd("AddToMenu", $menu);
587         fvwm_cmd("+", "Release not found", "Title") unless keys %$matches;
588         foreach my $file (sort { albumsort($matches, $a, $b) } keys %$matches) {
589                 my $entry = $matches->{$file};
590
591                 # Format disc/track numbers
592                 $entry->{trackfmt} = sprintf("%*.*s%.*s%*d\t",
593                                   $disc_digits, $disc_digits, $entry->{Disc},
594                                   $disc_digits, "-",
595                                   $track_digits, $entry->{Track});
596                 $entry->{trackfmt} =~ s/ /\N{U+2007}/g;
597
598                 unless (exists $entry->{Id}) {
599                         my ($id) = get_ids_by_filename($file);
600                         if (defined $id) {
601                                 $entry->{Id} = $id;
602                         } else {
603                                 push @notqueued, $entry;
604                                 next;
605                         }
606                 }
607
608                 if (defined $currentdisc && $currentdisc != $entry->{Disc}) {
609                         fvwm_cmd("+", "", "Nop");
610                 }
611                 $currentdisc = $entry->{Disc};
612
613                 fvwm_cmd("+", menu_trackname($entry), "Exec",
614                          "exec", "$FindBin::Bin/mpdexec.pl",
615                          "playid", $entry->{Id});
616         }
617
618         fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
619         foreach my $entry (@notqueued) {
620                 fvwm_cmd("+", menu_trackname($entry));
621         }
622 } elsif ($mode eq "artist") {
623         # Create an artist menu.
624         my $matches = get_releases_by_artist_mbid(keys %artistids);
625         my $entry;
626
627         $menu //= "MenuMPDArtist";
628
629         my @mbids = sort { datesort($matches, $a, $b) } keys %$matches;
630         my @files = map { $matches->{$_}->{file} } @mbids;
631         my @thumbs = get_item_thumbnails({ small => 1 }, @files);
632         fvwm_cmd("AddToMenu", $menu, "No releases found", "Title") unless @mbids;
633
634         foreach my $mbid (@mbids) {
635                 my $entry = $matches->{$mbid};
636                 my $thumb = shift @thumbs;
637
638                 my @submenu = make_submenu("$topmenu-$mbid",
639                                            "--album-id=$mbid");
640                 fvwm_cmd("AddToMenu", $menu,
641                          $thumb . fvwm_label_escape($entry->{Album}),
642                          @submenu);
643         }
644 } elsif ($mode eq "track") {
645         my $matches = get_tracks_by_track_mbid($trackid);
646         my @notqueued;
647
648         $menu //= "MenuMPDTrack";
649
650         my @files = sort { datesort($matches, $a, $b) } keys %$matches;
651         my @thumbs = get_item_thumbnails({ small => 1 }, @files);
652
653         fvwm_cmd("AddToMenu", $menu);
654         fvwm_cmd("+", "No tracks found", "Title") unless @files;
655         foreach my $file (@files) {
656                 my $entry = $matches->{$file};
657                 $entry->{thumb} = shift @thumbs;
658
659                 unless (exists $entry->{Id}) {
660                         my ($id) = get_ids_by_filename($file);
661                         if (defined $id) {
662                                 $entry->{Id} = $id;
663                         } else {
664                                 push @notqueued, $entry;
665                                 next;
666                         }
667                 }
668
669                 fvwm_cmd("+", menu_trackname($entry), "Exec",
670                          "exec", "$FindBin::Bin/mpdexec.pl",
671                          "playid", $entry->{Id});
672         }
673
674         fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
675         foreach my $entry (@notqueued) {
676                 fvwm_cmd("+", menu_trackname($entry));
677         }
678 }
679
680 # Finished.
681 print $sock "close\n";