]> git.draconx.ca Git - mpdhacks.git/blob - mpdmenu.pl
mpdmenu: Use MBIDs for artists instead of name matching.
[mpdhacks.git] / mpdmenu.pl
1 #!/usr/bin/perl
2 #
3 # Copyright © 2008,2010,2012,2019 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 IO::Socket::INET6;
22 use Getopt::Long qw(:config gnu_getopt);
23 use Scalar::Util qw(reftype);
24 use List::Util qw(any max);
25 use FindBin;
26
27 use constant {
28         MPD_MJR_MIN => 0,
29         MPD_MNR_MIN => 21,
30         MPD_REV_MIN => 0,
31 };
32
33 my $SELF = "$FindBin::Bin/$FindBin::Script";
34
35 my $MUSIC = $ENV{MUSIC}    // "/srv/music";
36 my $host  = $ENV{MPD_HOST} // "localhost";
37 my $port  = $ENV{MPD_PORT} // "6600";
38 my $sock;
39
40 my ($albumid, $title);
41 my %artistids;
42 my $menu;
43 my $mode = "top";
44
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.
47 sub escape {
48         my $s = @_[0] // $_;
49
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.
53         $s =~ s/\n/ /g;
54
55         if (/[ \t\\"]/) {
56                 $s =~ s/[\\"]/\\$&/g;
57                 return "\"$s\"";
58         }
59
60         $s =~ s/^\s*$/"$&"/;
61         return $s;
62 }
63
64 # Submit a command to the MPD server; each argument to this function
65 # is quoted and sent as a single argument to MPD.
66 sub mpd_exec {
67         my $cmd = join(' ', map { escape } @_);
68
69         print $sock "$cmd\n";
70 }
71
72 sub fvwm_cmd_unquoted {
73         print join(' ', @_), "\n";
74 }
75
76 sub fvwm_cmd {
77         fvwm_cmd_unquoted(map { escape } @_);
78 }
79
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).
83 #
84 # The result must be used with fvwm_cmd_unquoted;
85 sub fvwm_shell_literal {
86         my $s = @_[0] // $_;
87
88         $s =~ s/\$/\$\$/g;
89         if ($s =~ /[' \t]/) {
90                 $s =~ s/'/'\\''/g;
91                 return "'$s'";
92         }
93         $s =~ s/^\s*$/'$&'/;
94         return "$s";
95 }
96
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;
104         return $ret;
105 }
106
107 # make_submenu(name, [args ...])
108 #
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.
112 sub make_submenu {
113         my $name = shift;
114         $name =~ s/-/_/g;
115         unshift @_, ("exec", $SELF, "--menu=$name");
116
117         fvwm_cmd("DestroyFunc", "Make$name");
118         fvwm_cmd("AddToFunc", "Make$name");
119         fvwm_cmd("+", "I", "DestroyMenu", $name);
120
121         fvwm_cmd("DestroyMenu", $name);
122         fvwm_cmd("AddToMenu", $name, "DynamicPopupAction", "Make$name");
123         fvwm_cmd("AddToFunc", "KillMenuMPD", "I", "DestroyMenu", $name);
124
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");
131
132         return ("Popup", $name);
133 }
134
135 # get_item_thumbnails({ options }, file, ...)
136 # get_item_thumbnails(file, ...)
137 #
138 # For each music file listed, obtain a thumbnail (if any) for the cover art.
139 #
140 # The first argument is a hash reference to control the mode of operation;
141 # it may be omitted for default options.
142 #
143 #   get_item_thumbnails({ small => 1 }, ...) - smaller thumbnails
144 #
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 {
151         my @results = ();
152         my $flags = {};
153         my @opts = ();
154
155         $flags = shift if (reftype($_[0]) eq "HASH");
156         return @results unless @_;
157
158         my $c = "*";
159         if ($flags->{small}) {
160                 push @opts, "--small";
161                 $c = "%";
162         }
163
164         open THUMB, "-|", "$FindBin::Bin/mpdthumb.sh", @opts, "--", @_;
165         foreach (@_) {
166                 my $thumb = <THUMB>;
167                 chomp $thumb;
168
169                 $thumb = "$c$thumb$c" if (-f $thumb);
170                 push @results, $thumb;
171         }
172         close THUMB;
173         die("mpdthumb failed") if ($?);
174
175         return @results;
176 }
177
178 # add_track_metadata(hashref, key, value)
179 #
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) = @_;
185
186         if (exists($entry->{$key})) {
187                 my $ref = $entry->{$key};
188
189                 if (reftype($ref) ne "ARRAY") {
190                         return if ($ref eq $value);
191
192                         $ref = [$ref];
193                         $entry->{$key} = $ref;
194                 }
195
196                 push(@$ref, $value) unless (any {$_ eq $value} @$ref);
197         } else {
198                 $entry->{$key} = $value;
199         }
200 }
201
202 # get_track_metadata(hashref, key)
203 #
204 # Return the values associated with the given metadata key as a list.
205 sub get_track_metadata {
206         my ($entry, $key) = @_;
207
208         return () unless (exists($entry->{$key}));
209
210         my $ref = $entry->{$key};
211         return @$ref if (reftype($ref) eq "ARRAY");
212         return $ref
213 }
214
215 # Given a music filename, search for the cover art in the same directory.
216 sub mpd_cover_filename {
217         my ($dir) = @_;
218         my $file;
219
220         $dir =~ s/\/[^\/]*$//;
221         foreach ("cover.png", "cover.jpg", "cover.tiff", "cover.bmp") {
222                 if (-f "$dir/$_") {
223                         $file = "$dir/$_";
224                         last;
225                 }
226         }
227         return unless defined $file;
228
229         # Follow one level of symbolic link to get to the scans directory.
230         $file = readlink($file) // $file;
231         $file = "$dir/$file" unless ($file =~ /^\//);
232         return $file;
233 }
234
235 # Generate the cover art entry in the top menu.
236 sub top_track_cover {
237         my ($entry) = @_;
238
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);
244
245                 $cover = fvwm_shell_literal($cover // $file);
246                 fvwm_cmd_unquoted("AddToMenu", escape($menu),
247                                   escape($entry->{thumb}),
248                                   "Exec", "exec", "geeqie", $cover);
249         }
250 }
251
252 # Generate the "Title:" entry in the top menu.
253 sub top_track_title {
254         my ($entry) = @_;
255
256         my @submenu = make_submenu("$menu-TopTrack",
257                                    "--title=$entry->{Title}");
258
259         fvwm_cmd("AddToMenu", $menu,
260                  fvwm_label_escape("Title:\t$entry->{Title}"),
261                  @submenu);
262 }
263
264 # Generate the "Artist:" entry in the top menu.
265 sub top_track_artist {
266         my ($entry) = @_;
267         my @submenu;
268
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");
272         if (@mbids) {
273                 @submenu = make_submenu("$menu-TopArtist",
274                                         map { "--artist-id=$_" } @mbids);
275         }
276
277         fvwm_cmd("AddToMenu", $menu,
278                  fvwm_label_escape("Artist:\t$entry->{Artist}"),
279                  @submenu);
280 }
281
282 # Generate the "Album:" entry in the top menu.
283 sub top_track_album {
284         my ($entry) = @_;
285         my @submenu;
286
287         my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID");
288         @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid") if $mbid;
289
290         fvwm_cmd("AddToMenu", $menu,
291                  fvwm_label_escape("Album:\t$entry->{Album}"),
292                  @submenu);
293 }
294
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 {
298         my ($mbid) = @_;
299         my %matches;
300         my $entry;
301
302         return \%matches unless ($mbid);
303         mpd_exec("search", "(MUSICBRAINZ_ALBUMID == \"$mbid\")");
304         while (<$sock>) {
305                 last if (/^OK/);
306                 die($_) if (/^ACK/);
307
308                 if (/^(\w+): (.*)$/) {
309                         if ($1 eq "file") {
310                                 if (exists($matches{$2})) {
311                                         $entry = $matches{$2};
312                                 } else {
313                                         $entry = {};
314                                         $matches{$2} = $entry;
315                                 }
316                         }
317
318                         add_track_metadata($entry, $1, $2);
319                 }
320         }
321
322         return \%matches;
323 }
324
325 # Given an artist MBID, return a hash reference containing associated
326 # releases in the MPD database.  The hash keys are release MBIDs.
327 #
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 {
331         my %releases;
332         my $entry;
333
334         foreach my $mbid (@_) {
335                 mpd_exec("search", "(MUSICBRAINZ_ARTISTID == \"$mbid\")");
336                 while (<$sock>) {
337                         last if (/^OK/);
338                         die($_) if (/^ACK/);
339
340                         if (/^(\w+): (.*)$/) {
341                                 if ($1 eq "file") {
342                                         $entry = {};
343                                 } elsif ($1 eq "MUSICBRAINZ_ALBUMID") {
344                                         $releases{$2} //= $entry;
345                                 }
346
347                                 add_track_metadata($entry, $1, $2);
348                         }
349                 }
350         }
351
352         return \%releases;
353 }
354
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 {
358         my ($file) = @_;
359         my @results = ();
360
361         mpd_exec("playlistfind", "file", $file);
362         while (<$sock>) {
363                 last if (/^OK/);
364                 die($_) if (/^ACK/);
365
366                 if (/^(\w+): (.*)$/) {
367                         push @results, $2 if ($1 eq "Id");
368                 }
369         }
370
371         return @results;
372 }
373
374 # albumsort(matches, a, b)
375 #
376 # Sort hash keys (a, b) by disc/track number for album menus.
377 sub albumsort {
378         my ($matches, $a, $b) = @_;
379
380         return $matches->{$a}->{Disc} <=> $matches->{$b}->{Disc}
381             || $matches->{$a}->{Track} <=> $matches->{$b}->{Track}
382             || $a cmp $b;
383 }
384
385 # datesort(matches, a, b)
386 #
387 # Sort hash keys (a, b) by release date
388 sub datesort {
389         my ($matches, $a, $b) = @_;
390
391         return $matches->{$a}->{Date} cmp $matches->{$b}->{Date}
392             || $a cmp $b;
393 }
394
395 # menu_trackname(entry)
396 #
397 # Format the track name for display in an FVWM menu, where entry
398 # is a hash reference containing the track metadata.
399 sub menu_trackname {
400         my ($entry) = @_;
401         my $fmt = "$entry->{trackfmt}$entry->{Artist} - $entry->{Title}";
402         return "$entry->{thumb}" . fvwm_label_escape($fmt);
403 }
404
405 sub print_version {
406         print <<EOF
407 mpdmenu.pl 0.8
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.
412 EOF
413 }
414
415 sub print_usage {
416         my $fh = $_[1] // *STDERR;
417
418         print $fh "Usage: $0 [options]\n";
419         print "Try $0 --help for more information.\n" unless (@_ > 0);
420 }
421
422 sub print_help {
423         print_usage(*STDOUT);
424         print <<EOF
425 This is "mpdmenu": a menu-based MPD client for FVWM.
426
427 Options:
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.
435 EOF
436 }
437
438 GetOptions(
439         'host|h=s'    => \$host,
440         'port|p=s'    => \$port,
441         'menu|m=s'    => \$menu,
442
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"; },
446
447         'V|version'   => sub { print_version(); exit },
448         'H|help'      => sub { print_help(); exit },
449 ) or do { print_usage; exit 1 };
450
451 # Connect to MPD.
452 $sock = new IO::Socket::INET6(
453         PeerAddr => $host,
454         PeerPort => $port,
455         Proto => 'tcp',
456         Timeout => 2
457 ) or die("could not open socket: $!.\n");
458 binmode($sock, ":utf8");
459
460 die("could not connect to MPD: $!.\n")
461         if (!(<$sock> =~ /^OK MPD ([0-9]+)\.([0-9]+)\.([0-9]+)$/));
462
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));
467
468 if ($mode eq "top") {
469         my %current;
470         my %state;
471
472         $menu //= "MenuMPD";
473
474         mpd_exec("status");
475         while (<$sock>) {
476                 last if (/^OK/);
477                 die($_) if (/^ACK/);
478
479                 if (/^(\w+): (.*)$/) {
480                         $state{$1} = $2;
481                 }
482         }
483
484         mpd_exec("currentsong");
485         while (<$sock>) {
486                 last if (/^OK/);
487                 die($_) if (/^ACK/);
488
489                 if (/^(\w+): (.*)$/) {
490                         add_track_metadata(\%current, $1, $2);
491                 }
492         }
493
494         my $playstate = $state{state} eq "play"  ? "Playing"
495                       : $state{state} eq "stop"  ? "Stopped"
496                       : $state{state} eq "pause" ? "Paused"
497                       : "Unknown";
498         fvwm_cmd("AddToMenu", $menu, $playstate, "Title");
499
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);
505         } else {
506                 fvwm_cmd("AddToMenu", $menu, "[current track unavailable]");
507         }
508
509         if ($state{state} =~ /^p/) {
510                 my $pp = $state{state} eq "pause" ? "lay" : "ause";
511
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");
525         }
526 } elsif ($mode eq "album") {
527         my $matches = get_tracks_by_release_mbid($albumid);
528         my @notqueued = ();
529
530         $menu //= "MenuMPDAlbum";
531
532         my $track_max = max(map { $_->{Track} } values %$matches);
533         my $disc_max = max(map { $_->{Disc} } values %$matches);
534
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;
539         my $currentdisc;
540
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};
545
546                 # Format disc/track numbers
547                 $entry->{trackfmt} = sprintf("%*.*s%.*s%*d\t",
548                                   $disc_digits, $disc_digits, $entry->{Disc},
549                                   $disc_digits, "-",
550                                   $track_digits, $entry->{Track});
551                 $entry->{trackfmt} =~ s/ /\N{U+2007}/g;
552
553                 unless (exists $entry->{Id}) {
554                         my ($id) = get_ids_by_filename($file);
555                         if (defined $id) {
556                                 $entry->{Id} = $id;
557                         } else {
558                                 push @notqueued, $entry;
559                                 next;
560                         }
561                 }
562
563                 if (defined $currentdisc && $currentdisc != $entry->{Disc}) {
564                         fvwm_cmd("+", "", "Nop");
565                 }
566                 $currentdisc = $entry->{Disc};
567
568                 fvwm_cmd("+", menu_trackname($entry), "Exec",
569                          "exec", "$FindBin::Bin/mpdexec.pl",
570                          "playid", $entry->{Id});
571         }
572
573         fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
574         foreach my $entry (@notqueued) {
575                 fvwm_cmd("+", menu_trackname($entry));
576         }
577 } elsif ($mode eq "artist") {
578         # Create an artist menu.
579         my $matches = get_releases_by_artist_mbid(keys %artistids);
580         my $entry;
581
582         $menu //= "MenuMPDArtist";
583
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;
588
589         foreach my $mbid (@mbids) {
590                 my $entry = $matches->{$mbid};
591                 my $thumb = shift @thumbs;
592
593                 my @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid");
594                 fvwm_cmd("AddToMenu", $menu,
595                          $thumb . fvwm_label_escape($entry->{Album}),
596                          @submenu);
597         }
598 } elsif ($mode eq "track") {
599         # Create a title menu.
600         my @titles;
601         my $entry;
602
603         $menu = "MenuMPDTitle" unless defined $menu;
604
605         # Open and close brackets.
606         my ($ob, $cb) = ("[\[~〜<〈(ー−-]", "[\]~〜>〉)ー−-]");
607
608         $_ = $title;
609
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
618         s/〜彼方への哀歌//;                           # 十二幻夢
619         s/ sora no uta ver.//;                       # 美しい星
620
621         s/\s*-remix-$//; # Otherwise "D-THREAD -remix-" doesn't work right.
622
623         # Deal with titles like "blah (ABC version)".
624         s/\s*$ob.*(style|mix|edit|edition|ver\.?|version|melody|カラオケ)$cb?$//i;
625
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;
628
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;
632
633         # Deal with separate movements in classical pieces.
634         s/: [IVX]+\..*//;
635
636         my $basetitle  = $_;
637         my $_basetitle = $basetitle;
638
639         $_basetitle =~ s/"/\\"/g;
640         print $sock "playlistsearch title \"$_basetitle\"\n";
641         while (<$sock>) {
642                 last if (/^OK/);
643                 die($_) if (/^ACK/);
644
645                 if (/^(\w+): (.*)$/) {
646                         if ($1 eq "file") {
647                                 push @titles, $entry if (keys(%$entry) > 0);
648                                 $entry = {};
649                         }
650
651                         $entry->{$1} = $2;
652                 }
653         }
654         push @titles, $entry if (keys(%$entry) > 0);
655
656 { # work around 'use locale' breaking s///i
657         use locale;
658
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];
663         }
664
665         foreach (sort titlesort @titles) {
666                 my ($t_file, $t_artist, $t_title, $t_id, $thumb) = (
667                         $_->{file},
668                         $_->{Artist},
669                         $_->{Title},
670                         $_->{Id},
671                         $_->{thumb}
672                 );
673
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));
676
677                 $t_artist = sanitise($t_artist, 1);
678                 $t_title  = sanitise($t_title, 1);
679
680                 cmd("AddToMenu $menu \"$thumb$t_artist - $t_title\""
681                     ." Exec exec $FindBin::Bin/mpdexec.pl"
682                     ." playid $t_id");
683         }
684 } # end use locale workaround
685 }
686
687 # Finished.
688 print $sock "close\n";
689
690 sub sanitise
691 {
692         $_ = $_[0];
693         s/&/&&/g if ($_[1]);
694         s/([\$@%^*])/\1\1/g;
695         s/"/\\"/g;
696         return $_;
697 }
698
699 sub titlesort
700 {
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});
704 }
705
706 sub cmd
707 {
708         print "$_[0]\n";
709 }