X-Git-Url: https://git.draconx.ca/gitweb/mpdhacks.git/blobdiff_plain/f955e376aa421d6e1c169addf66e120d29415faa..HEAD:/mpdmenu.pl diff --git a/mpdmenu.pl b/mpdmenu.pl index 7a48c20..71613c5 100755 --- a/mpdmenu.pl +++ b/mpdmenu.pl @@ -1,412 +1,848 @@ #!/usr/bin/perl +# +# Copyright © 2008,2010,2012,2020-2021 Nick Bowler +# +# Silly little script to generate an FVWM menu with various bits of MPD +# status information and controls. +# +# License GPLv3+: GNU General Public License version 3 or any later version. +# This is free software: you are free to change and redistribute it. +# There is NO WARRANTY, to the extent permitted by law. use strict; -use Getopt::Long; -use IO::Socket::INET6; +use utf8; + +use Encode qw(decode encode); +use Encode::Locale qw(decode_argv); +decode_argv(Encode::FB_CROAK); +binmode(STDOUT, ":utf8"); + +use Getopt::Long qw(:config gnu_getopt); +use Scalar::Util qw(reftype); +use List::Util qw(any max); +use FindBin; + +use lib "$FindBin::Bin"; +use MPDHacks; use constant { MPD_MJR_MIN => 0, - MPD_MNR_MIN => 13, + MPD_MNR_MIN => 21, MPD_REV_MIN => 0, }; -use utf8; -use encoding 'utf8'; -use Encode; +my $SELF = "$FindBin::Bin/$FindBin::Script"; + +my $MUSIC = $ENV{MUSIC} // "/srv/music"; +my ($sock, $mpd_have_binarylimit); -sub cmd -{ - print "$_[0]\n"; +my ($albumid, $albumname, $trackid, $recordingid); +my ($topmenu, $menu); +my $mode = "top"; +my %artistids; + +sub fvwm_cmd_unquoted { + print join(' ', @_), "\n"; } -# Global hash for tracking what is to be "accepted". -my %accept = (); +sub fvwm_cmd { + fvwm_cmd_unquoted(map { MPD::escape } @_); +} -my $FVWM = (defined $ENV{FVWM_USERDIR}) ? $ENV{FVWM_USERDIR} - : $ENV{HOME}."/.fvwm"; -my $icons = "$FVWM/icons"; +# Quotes the argument in such a way that it is passed unadulterated by +# both FVWM and the shell to a command as a single argument (for use as +# an # argument for e.g., the Exec or PipeRead FVWM commands). +# +# The result must be used with fvwm_cmd_unquoted; +sub fvwm_shell_literal { + my $s = @_[0] // $_; + + $s =~ s/\$/\$\$/g; + if ($s =~ /[' \t]/) { + $s =~ s/'/'\\''/g; + return "'$s'"; + } + $s =~ s/^\s*$/'$&'/; + return "$s"; +} -# Default values for stuff. -my ($album, $artist, $title, $menu) = (undef, undef, undef, undef); -my $host = (defined $ENV{MPD_HOST}) ? $ENV{MPD_HOST} : "localhost"; -my $port = (defined $ENV{MPD_PORT}) ? $ENV{MPD_PORT} : "6600"; +# Escapes metacharacters in the argument used in FVWM menu labels. The +# string must still be quoted (e.g., by using fvwm_cmd). +sub fvwm_label_escape { + my @tokens = split /\t/, $_[0]; + @tokens[0] =~ s/&/&&/g; + my $ret = join "\t", @tokens; + $ret =~ s/[\$@%^*]/$&$&/g; + return $ret; +} -GetOptions( - 'host|h=s' => \&host, # Host that MPD is running on. - 'port|p=s' => \&port, # Port that MPD is listening on. - 'menu|m=s' => \$menu, # Name of the menu to create. - 'album=s' => \$album, # Album to get tracks from - 'artist=s' => \$artist, # Artist to limit results to - 'title=s' => \$title, # Title to create menu for -); - -$album = decode_utf8($album) if defined($album); -$artist = decode_utf8($artist) if defined($artist); -$title = decode_utf8($title) if defined($title);; +# make_submenu(name, [args ...]) +# +# Creates a submenu (with the specified name) constructed by invoking this +# script with the given arguments. Returns a list that can be passed to +# fvwm_cmd to display the menu. +sub make_submenu { + my $name = shift; + $name =~ s/-/_/g; + unshift @_, ("exec", $SELF, "--topmenu=$topmenu", "--menu=$name"); + + fvwm_cmd("DestroyFunc", "Make$name"); + fvwm_cmd("AddToFunc", "Make$name"); + fvwm_cmd("+", "I", "DestroyMenu", $name); + + fvwm_cmd("DestroyMenu", $name); + fvwm_cmd("AddToMenu", $name, "DynamicPopupAction", "Make$name"); + fvwm_cmd("AddToFunc", "Kill$topmenu", "I", "DestroyMenu", $name); + + fvwm_cmd("DestroyFunc", "Make$name"); + fvwm_cmd("AddToFunc", "Make$name"); + fvwm_cmd("+", "I", "DestroyMenu", $name); + fvwm_cmd("+", "I", "-PipeRead", + join(' ', map { fvwm_shell_literal } @_)); + fvwm_cmd("AddToFunc", "Kill$topmenu", "I", "DestroyFunc", "Make$name"); + + return ("Popup", $name); +} -# Connect to MPD. -my $sock = new IO::Socket::INET6( - PeerAddr => $host, - PeerPort => $port, - Proto => 'tcp' -) or die("could not open socket: $!.\n"); -binmode($sock, ":utf8"); - -die("could not connect to MPD: $!.\n") - if (!(<$sock> =~ /^OK MPD ([0-9]+)\.([0-9]+)\.([0-9]+)$/)); - -die("MPD version $1.$2.$3 insufficient.\n") - if ( ($1 < MPD_MJR_MIN) - || ($1 == MPD_MJR_MIN && $2 < MPD_MNR_MIN) - || ($1 == MPD_MJR_MIN && $2 == MPD_MNR_MIN && $3 < MPD_REV_MIN)); - -if (defined $album) { - # Create an album menu. - my @playlist = (); +# get_item_thumbnails({ options }, file, ...) +# get_item_thumbnails(file, ...) +# +# For each music file listed, obtain a thumbnail (if any) for the cover art. +# +# The first argument is a hash reference to control the mode of operation; +# it may be omitted for default options. +# +# get_item_thumbnails({ small => 1 }, ...) - smaller thumbnails +# +# The returned list consists of strings (in the same order as the filename +# arguments) suitable for use directly in FVWM menus; by default the filename +# is bracketed by asterisks (e.g., "*thumbnail.png*"); in small mode it is +# surrounded by % (e.g., "%thumbnail.png%"). If no cover art was found, the +# empty string is returned for that file. +sub get_item_thumbnails { + my @results = (); + my $flags = {}; + my @opts = (); + + $flags = shift if (reftype($_[0]) eq "HASH"); + return @results unless @_; + + my $c = "*"; + if ($flags->{small}) { + push @opts, "--small"; + $c = "%"; + } + + if ($mpd_have_binarylimit) { + # --embedded implies and requires binarylimit support + push @opts, "--embedded"; + } else { + push @opts, "--no-binarylimit"; + } + + open THUMB, "-|", "$FindBin::Bin/mpdthumb.sh", @opts, "--", @_; + foreach (@_) { + my $thumb = ; + chomp $thumb; + + $thumb = "$c$thumb$c" if (-f $thumb); + push @results, $thumb; + } + close THUMB; + die("mpdthumb failed") if ($?); + + return @results; +} + +# add_track_metadata(hashref, key, value) +# +# Inserts the given key into the referenced hash; if the key already exists +# in the hash then the hash element is converted to an array reference (if +# it isn't already) and the value is appended to that array. +sub add_track_metadata { + my ($entry, $key, $value) = @_; + + if (exists($entry->{$key})) { + my $ref = $entry->{$key}; + + if (reftype($ref) ne "ARRAY") { + return if ($ref eq $value); + + $ref = [$ref]; + $entry->{$key} = $ref; + } + + push(@$ref, $value) unless (any {$_ eq $value} @$ref); + } else { + $entry->{$key} = $value; + } +} + +# get_track_metadata(hashref, key) +# +# Return the values associated with the given metadata key as a list. +sub get_track_metadata { + my ($entry, $key) = @_; + + return () unless (exists($entry->{$key})); + + my $ref = $entry->{$key}; + return @$ref if (reftype($ref) eq "ARRAY"); + return $ref +} + +# Given a music filename, search for the cover art in the same directory. +sub mpd_cover_filename { + my ($dir) = @_; + my $file; + + $dir =~ s/\/[^\/]*$//; + foreach ("cover.png", "cover.jpg", "cover.tiff", "cover.bmp") { + if (-f "$dir/$_") { + $file = "$dir/$_"; + last; + } + } + return unless defined $file; + + # Follow one level of symbolic link to get to the scans directory. + $file = readlink($file) // $file; + $file = "$dir/$file" unless ($file =~ /^\//); + return $file; +} + +# Generate the cover art entry in the top menu. +sub top_track_cover { + my ($entry) = @_; + + ($entry->{thumb}) = get_item_thumbnails($entry->{file}); + print "$entry->{thumb}\n"; + if ($entry->{thumb}) { + my $file = "$MUSIC/$entry->{file}"; + my $cover = mpd_cover_filename($file); + + $cover = fvwm_shell_literal($cover // $file); + fvwm_cmd_unquoted("AddToMenu", MPD::escape($menu), + MPD::escape($entry->{thumb}), + "Exec", "exec", "geeqie", $cover); + } +} + +# Generate the "Title:" entry in the top menu. +sub top_track_title { + my ($entry) = @_; + my @submenu; + + my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_RELEASETRACKID"); + if ($mbid) { + @submenu = make_submenu("$menu-$mbid", "--track-id=$mbid") + } else { + ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID"); + @submenu = make_submenu("$menu-track-$mbid", "--recording-id=$mbid") + if ($mbid); + } + + fvwm_cmd("AddToMenu", $menu, + fvwm_label_escape("Title:\t$entry->{Title}"), + @submenu); +} + +# Generate the "Artist:" entry in the top menu. +sub top_track_artist { + my ($entry) = @_; + my @submenu; + + # TODO: multi-artist tracks should get multiple artist menus; for now + # just combine the releases from all artists. + my @mbids = get_track_metadata($entry, "MUSICBRAINZ_ARTISTID"); + if (@mbids) { + @submenu = make_submenu("$menu-TopArtist", + map { "--artist-id=$_" } @mbids); + } + + fvwm_cmd("AddToMenu", $menu, + fvwm_label_escape("Artist:\t$entry->{Artist}"), + @submenu); +} + +# Generate the "Album:" entry in the top menu. +sub top_track_album { + my ($entry) = @_; + my @submenu; + my $mbid; + + if (($mbid) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID")) { + @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid"); + } elsif (($mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID")) { + # Standalone recording + my @a = get_track_metadata($entry, "MUSICBRAINZ_ARTISTID"); + my ($album) = get_track_metadata($entry, "Album"); + + @submenu = make_submenu("$menu-$mbid", "--album-name=$album", + map { "--artist-id=$_" } @a); + + } + + fvwm_cmd("AddToMenu", $menu, + fvwm_label_escape("Album:\t$entry->{Album}"), + @submenu); +} + +# Generate the "MusicBrainz:" entry in the top menu. +sub top_track_musicbrainz { + my ($entry) = @_; + my ($track_mbid, $recording_mbid, $release_mbid); + my @artist_mbids; + my $label = "MB:"; + my %idmap; + + ($track_mbid) = get_track_metadata($entry, "MUSICBRAINZ_RELEASETRACKID"); + ($recording_mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID"); + ($release_mbid) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID"); + @artist_mbids = get_track_metadata($entry, "MUSICBRAINZ_ARTISTID"); + return unless $track_mbid // $recording_mbid + // $release_mbid // @artist_mbids; + + foreach (get_track_metadata($entry, "Comment")) { + $idmap{$1} = $2 if /^([^=]*)=(.*) \(idmap\)$/; + } + + fvwm_cmd("AddToMenu", $menu, "", "Nop"); + if ($track_mbid) { + fvwm_cmd("AddToMenu", $menu, "$label\tShow track", + "Exec", "exec", "xdg-open", + "https://musicbrainz.org/track/$track_mbid"); + $label = ""; + } elsif ($recording_mbid) { + fvwm_cmd("AddToMenu", $menu, "$label\tShow recording", + "Exec", "exec", "xdg-open", + "https://musicbrainz.org/recording/$recording_mbid"); + $label = ""; + } elsif ($release_mbid) { + fvwm_cmd("AddToMenu", $menu, "$label\tShow", + "Exec", "exec", "xdg-open", + "https://musicbrainz.org/release/$release_mbid"); + $label = ""; + } + + foreach my $mbid (@artist_mbids) { + my $name = " $idmap{$mbid}" if $idmap{$mbid}; + + fvwm_cmd("AddToMenu", $menu, "$label\tShow artist$name", + "Exec", "exec", "xdg-open", + "https://musicbrainz.org/artist/$mbid"); + $label = ""; + } +} + +# Given a work MBID, return a hash reference containing all tracks +# linked to that work. The hash keys are filenames. +sub get_tracks_by_work_mbid { + my %matches; my $entry; - $menu = "MenuMPDAlbum" unless defined $menu; - - $album =~ s/"/\\"/g; - print $sock "playlistfind album \"$album\"\n"; - while (<$sock>) { - last if (/^OK/); - die($_) if (/^ACK/); - - if (/^(\w+): (.*)$/) { - if ($1 eq "file") { - if (keys(%$entry) > 0) { - addalbumentry(\@playlist, $entry) + foreach my $mbid (@_) { + MPD::exec("search", "(MUSICBRAINZ_WORKID == \"$mbid\")"); + while (<$sock>) { + last if (/^OK/); + die($_) if (/^ACK/); + + if (/^(\w+): (.*)$/) { + if ($1 eq "file") { + if (exists($matches{$2})) { + $entry = $matches{$2}; + } else { + $entry = {}; + $matches{$2} = $entry; + } } - - $entry = {}; + + add_track_metadata($entry, $1, $2); } - - $entry->{$1} = $2; } } - addalbumentry(\@playlist, $entry) if (keys(%$entry) > 0); - - die("No tracks found.\n") if (!@playlist); - foreach (sort albumsort @playlist) { - my ($t_file, $t_trackno, $t_artist, $t_title, $t_id) = ( - $_->{file}, - $_->{Track}, - $_->{Artist}, - $_->{Title}, - $_->{Id}, - ); - - next if (defined $artist && !$accept{albumdir($t_file)}); - - $t_artist = sanitise($t_artist); - $t_title = sanitise($t_title); - - my $cmd = sprintf "AddToMenu $menu \"%d\t%s - %s\"" - ." Exec mpc playid %d", - $t_trackno, $t_artist, $t_title, $t_id; - - cmd($cmd); - } -} elsif (defined $artist) { - # Create an artist menu. - my %albums = (); - my $file; - my $quoteartist = $artist; - $menu = "MenuMPDArtist" unless defined $menu; + return \%matches; +} + +# Given a track MBID, return a hash reference containing all "related" +# tracks in the MPD database. The hash keys are filenames. +# +# Currently tracks are considered "related" if their associated recordings +# have at least one work in common. +sub get_tracks_by_track_mbid { + my ($mbid, $tagname) = (@_, "MUSICBRAINZ_RELEASETRACKID"); + my %source; + my %matches; + my $entry; - $quoteartist =~ s/"/\\"/g; - print $sock "playlistfind artist \"$quoteartist\"\n"; + return \%matches unless ($mbid); + MPD::exec("search", "($tagname == \"$mbid\")"); while (<$sock>) { last if (/^OK/); die($_) if (/^ACK/); - + if (/^(\w+): (.*)$/) { - $file = $2 if ($1 eq "file"); - $albums{$2} = $file if ($1 eq "Album"); + add_track_metadata(\%source, $1, $2); } } - die("No albums found.\n") if (!keys(%albums)); - -{ # work around 'use locale' breaking s///i - my $i = 0; - use locale; - foreach (sort keys(%albums)) { - my $key = $_; - my $a_album = sanitise($key); + # Always include the current track + $matches{$source{file}} = \%source; - open THUMB, "-|", "$FVWM/scripts/thumbnail.sh", - "--small", "--music", $albums{$key}; - my $thumb = ; - close THUMB; - die("Incompetent use of thumbnail.sh") if ($?); + # Find all tracks related by work + foreach my $mbid (get_track_metadata(\%source, "MUSICBRAINZ_WORKID")) { + my $related = get_tracks_by_work_mbid($mbid); + foreach (keys %$related) { + $matches{$_} //= $related->{$_}; + } + } - $thumb =~ s/\n//sg; - $thumb = "%$thumb%" if (-f $thumb); + return \%matches; +} - cmd("AddToMenu $menu \"$thumb$a_album\" Popup MenuMPDArt_$i"); +sub get_tracks_by_recording_mbid { + return get_tracks_by_track_mbid($_[0], "MUSICBRAINZ_TRACKID"); +} - cmd("AddToMenu MenuMPDArt_$i DynamicPopUpAction MakeMenuMPDArt_$i"); +# Given a release MBID, return a hash reference containing all its +# associated tracks in the MPD database. The hash keys are filenames. +sub get_tracks_by_release_mbid { + my ($mbid) = @_; + my %matches; + my $entry; - cmd("DestroyFunc MakeMenuMPDArt_$i"); - cmd("AddToFunc MakeMenuMPDArt_$i - + I DestroyMenu MenuMPDArt_$i - + I -PipeRead \"exec $FVWM/scripts/mpdmenu.pl " - ."--menu MenuMPDArt_$i " - ."--album ".shellify($key, 1)." " - ."--artist ".shellify($artist, 1)."\""); + return \%matches unless ($mbid); + MPD::exec("search", "(MUSICBRAINZ_ALBUMID == \"$mbid\")"); + while (<$sock>) { + last if (/^OK/); + die($_) if (/^ACK/); - cmd("AddToFunc KillMenuMPD I DestroyMenu MenuMPDArt_$i"); - cmd("AddToFunc KillMenuMPD I DestroyFunc MakeMenuMPDArt_$i"); + if (/^(\w+): (.*)$/) { + if ($1 eq "file") { + if (exists($matches{$2})) { + $entry = $matches{$2}; + } else { + $entry = {}; + $matches{$2} = $entry; + } + } - $i++; + add_track_metadata($entry, $1, $2); + } } -} # end use locale workaround -} elsif (defined $title) { - # Create a title menu. - my @titles; - my $entry; - $menu = "MenuMPDTitle" unless defined $menu; - - # Open and close brackets. - my ($ob, $cb) = ("[\[~〜<(ー−-]", "[\]~〜>)ー−-]"); + return \%matches; +} - $_ = $title; +# Insert the given entry into the referenced hash if it represents a +# standalone recording (not associated with a release). The recording +# MBID is used as the hash key. +sub check_standalone { + my ($outhash, $entry) = @_; + my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_TRACKID"); - # Deal with specific cases. - s/ちいさな(?=ヘミソフィア)//; # ヘミソフィア - s/ "mix on air flavor" dear EIKO SHIMAMIYA//; # Spiral wind - s/ "So,you need me" Style//; # I need you - s/ ::Symphony Second movement:://; # Disintegration - s/-\[instrumental\]//; # 青い果実 - s/ -Practice Track-//; # Fair Heaven - s/〜世界で一番アナタが好き〜//; # Pure Heart - s/〜彼方への哀歌//; # 十二幻夢 + return if exists $entry->{MUSICBRAINZ_ALBUMID}; + $outhash->{$mbid} = $entry if ($mbid); +} - s/\s*-remix-$//; # Otherwise "D-THREAD -remix-" doesn't work right. +# Given an artist MBID, return a list of two hash refererences. The +# first contains the associated releases in the MPD database and the +# hash keys are release MBIDs. The second contains the artist's +# standalone recordings and the hash keys are recording MBIDs. +# +# In scalar context only the release hash is returned. +# +# Since MPD returns results on a per-track basis, each entry in the +# hash has the metadata for one unspecified track from that release. +sub get_releases_by_artist_mbid { + my (%releases, %standalones); + my $entry; - # Deal with titles like "blah (ABC version)". - s/\s*$ob.*(style|mix|edit|edition|ver\.?|version|カラオケ)$cb?$//i; + foreach my $mbid (@_) { + MPD::exec("search", "(MUSICBRAINZ_ARTISTID == \"$mbid\")"); + while (<$sock>) { + last if (/^OK/); + die($_) if (/^ACK/); + + if (/^(\w+): (.*)$/) { + if ($1 eq "file") { + check_standalone(\%standalones, $entry); + $entry = {}; + } elsif ($1 eq "MUSICBRAINZ_ALBUMID") { + $releases{$2} //= $entry; + } - # Deal with titles like "blah (without XYZ)". - s/\s*$ob((e\.)?piano|english|japanese|inst|tv|without|w\/o|off|back|short|karaoke|game).*//i; + add_track_metadata($entry, $1, $2); + } + } + check_standalone(\%standalones, $entry); + } - # Deal with titles like "blah instrumental". - s/\s+(instrumental|off vocal|short)(\s+(size|version|s))?$//i; - s/\s+without\s+\w+$//i; + return wantarray ? (\%releases, values %standalones) : \%releases; +} - my $basetitle = $_; - my $_basetitle = $basetitle; +# Given a filename, return the IDs (if any) for that file in the +# current MPD play queue. +sub get_ids_by_filename { + my ($file) = @_; + my @results = (); - $_basetitle =~ s/"/\\"/g; - print $sock "playlistsearch title \"$_basetitle\"\n"; + MPD::exec("playlistfind", "file", $file); while (<$sock>) { last if (/^OK/); die($_) if (/^ACK/); - + if (/^(\w+): (.*)$/) { - if ($1 eq "file") { - push @titles, $entry if (keys(%$entry) > 0); - $entry = {}; - } - - $entry->{$1} = $2; + push @results, $2 if ($1 eq "Id"); } } - push @titles, $entry if (keys(%$entry) > 0); - -{ # work around 'use locale' breaking s///i - use locale; - foreach (sort titlesort @titles) { - my ($t_file, $t_artist, $t_title, $t_id) = ( - $_->{file}, - $_->{Artist}, - $_->{Title}, - $_->{Id}, - ); - - # MPD searches are case-insensitive. - next if (!($t_title =~ m/(\P{Latin}|^)\Q$basetitle\E(\P{Latin}|$)/)); - $t_artist = sanitise($t_artist); - $t_title = sanitise($t_title); - - open THUMB, "-|", "$FVWM/scripts/thumbnail.sh", - "--small", "--music", $t_file; - my $thumb = ; - close(THUMB); - die("Incompetent use of thumbnail.sh") if ($?); - $thumb =~ s/\n//sg; - $thumb = "%$thumb%" if (-f $thumb); + return @results; +} - cmd("AddToMenu $menu \"$thumb$t_artist - $t_title\" Exec mpc playid $t_id"); +sub update_entry_ids { + my @notqueued = (); + + foreach my $entry (@_) { + unless (exists $entry->{Id}) { + my ($id) = get_ids_by_filename($entry->{file}); + if (defined $id) { + $entry->{Id} = $id; + } else { + push @notqueued, $entry; + next; + } + } } -} # end use locale workaround -} else { - # Make MPD base menu - my ($state, $songid) = (undef, undef); - my %entry = (); - $menu = "MenuMPD" unless defined $menu; + return @notqueued; +} + +# albumsort(matches, a, b) +# +# Sort hash keys (a, b) by disc/track number for album menus. +sub albumsort { + my ($matches, $a, $b) = @_; + + return $matches->{$a}->{Disc} <=> $matches->{$b}->{Disc} + || $matches->{$a}->{Track} <=> $matches->{$b}->{Track} + || $a cmp $b; +} + +# datesort(matches, a, b) +# +# Sort hash keys (a, b) by release date +sub datesort { + my ($matches, $a, $b) = @_; + + return $matches->{$a}->{Date} cmp $matches->{$b}->{Date} + || $a cmp $b; +} + +# menu_trackname(entry) +# +# Format the track name for display in an FVWM menu, where entry +# is a hash reference containing the track metadata. +sub menu_trackname { + my ($entry) = @_; + my $fmt = "$entry->{trackfmt}$entry->{Artist} - $entry->{Title}"; + return "$entry->{thumb}" . fvwm_label_escape($fmt); +} + +sub print_version { + print < 0); +} + +sub print_help { + print_usage(*STDOUT); + print < \$MPD::host, + 'port|p=s' => \$MPD::port, + 'menu|m=s' => \$menu, + + 'artist-id=s' => sub { $artistids{$_[1]} = 1; $mode = "artist"; }, + 'album-id=s' => sub { $albumid = $_[1]; $mode = "album"; }, + 'album-name=s' => sub { $albumname = $_[1]; $mode = "albumname"; }, + 'track-id=s' => sub { $trackid = $_[1]; $mode = "track"; }, + 'recording-id=s' => sub { $recordingid = $_[1]; $mode = "recording"; }, + + 'V|version' => sub { print_version(); exit }, + 'H|help' => sub { print_help(); exit }, + + 'topmenu=s' => \$topmenu, # top menu name (for submenu generation) +) or do { print_usage; exit 1 }; + +$mode = "albumname" if ($albumname && $mode eq "artist"); - print $sock "status\n"; +unless (defined $menu) { + $topmenu //= "MenuMPD"; + $menu = $topmenu . ($mode ne "top" ? $mode : ""); +} +$topmenu //= $menu; + +# Connect to MPD. +$sock = MPD::connect(); +die("MPD version $MPD::major.$MPD::minor.$MPD::revision insufficient.") + unless MPD::min_version(MPD_MJR_MIN, MPD_MNR_MIN, MPD_REV_MIN); + +MPD::exec("binarylimit", 64); +while (<$sock>) { + $mpd_have_binarylimit = 1 if /^OK/; + last if /^OK/ or /^ACK/; +} + +if ($mode eq "top") { + my %current; + my %state; + + $menu //= "MenuMPD"; + + MPD::exec("status"); while (<$sock>) { last if (/^OK/); die($_) if (/^ACK/); if (/^(\w+): (.*)$/) { - $state = $2 if ($1 eq "state"); - $songid = $2 if ($1 eq "songid"); + $state{$1} = $2; } } - die("Failed status query\n") unless (defined $state && defined $songid); - print $sock "playlistid $songid\n"; + MPD::exec("currentsong"); while (<$sock>) { last if (/^OK/); die($_) if (/^ACK/); if (/^(\w+): (.*)$/) { - $entry{$1} = $2; + add_track_metadata(\%current, $1, $2); } } - die("Failed data query\n") unless (keys(%entry) > 0); - - open THUMB, "-|", "$FVWM/scripts/thumbnail.sh", - "--image", "--music", $entry{file}; - my $thumb = ; - my $scan = ; - close(THUMB); - die("Incompetent use of thumbnail.sh") if ($?); - - $thumb =~ s/\n//sg; - $scan =~ s/\n//sg; - - cmd("AddToMenu $menu Playing Title") if ($state eq "play"); - cmd("AddToMenu $menu Paused Title") if ($state eq "pause"); - cmd("AddToMenu $menu Stopped Title") if ($state eq "stop"); - if (-f $thumb) { - cmd("AddToMenu $menu \"*$thumb*\" " - ."Exec exec gqview ".shellify($scan, 0)); - } - cmd("AddToMenu $menu \"Title: ".sanitise($entry{Title})."\" " - ."Popup MenuMPDTitle"); - cmd("AddToMenu $menu \"Artist: ".sanitise($entry{Artist})."\" " - ."Popup MenuMPDArtist"); - cmd("AddToMenu $menu \"Album: ".sanitise($entry{Album})."\" " - ."Popup MenuMPDAlbum"); - cmd("AddToMenu $menu \"\" Nop"); - - if ($state eq "play" || $state eq "pause") { - cmd("AddToMenu $menu \"\t\tNext%$icons/next.svg:16x16%\" " - ."Exec exec mpc next"); - cmd("AddToMenu $menu \"\t\tPause%$icons/pause.svg:16x16%\" " - ."Exec exec mpc pause") if ($state eq "play"); - cmd("AddToMenu $menu \"\t\tPlay%$icons/play.svg:16x16%\" " - ."Exec exec mpc play") if ($state eq "pause"); - cmd("AddToMenu $menu \"\t\tStop%$icons/stop.svg:16x16%\" " - ."Exec exec mpc stop"); - cmd("AddToMenu $menu \"\t\tPrev%$icons/prev.svg:16x16%\" " - ."Exec exec mpc prev"); - } elsif ($state eq "stop") { - cmd("AddToMenu $menu \"\t\tPlay%$icons/play.svg:16x16%\" " - ."Exec exec mpc play"); + + my $playstate = $state{state} eq "play" ? "Playing" + : $state{state} eq "stop" ? "Stopped" + : $state{state} eq "pause" ? "Paused" + : "Unknown"; + fvwm_cmd("AddToMenu", $menu, $playstate, "Title"); + + if (exists($current{file})) { + top_track_cover(\%current); + top_track_title(\%current); + top_track_artist(\%current); + top_track_album(\%current); + top_track_musicbrainz(\%current); } else { - die("Unknown MPD state!\n"); - } - - cmd("AddToMenu $menu \"\" Nop"); - cmd("AddToMenu $menu \"\t\tShuffle%$icons/shuffle.svg:16x16%\" " - ."Exec exec mpc shuffle"); - - cmd("DestroyMenu MenuMPDTitle"); - cmd("AddToMenu MenuMPDTitle DynamicPopUpAction MakeMenuMPDTitle"); - cmd("DestroyMenu MenuMPDArtist"); - cmd("AddToMenu MenuMPDArtist DynamicPopUpAction MakeMenuMPDArtist"); - cmd("DestroyMenu MenuMPDAlbum"); - cmd("AddToMenu MenuMPDAlbum DynamicPopUpAction MakeMenuMPDAlbum"); - - cmd("DestroyFunc MakeMenuMPDTitle"); - cmd("AddToFunc MakeMenuMPDTitle - + I DestroyMenu MenuMPDTitle - + I -PipeRead \"exec $FVWM/scripts/mpdmenu.pl " - ."--menu MenuMPDTitle " - ."--title ".shellify($entry{Title}, 1)."\""); - - cmd("DestroyFunc MakeMenuMPDAlbum"); - cmd("AddToFunc MakeMenuMPDAlbum - + I DestroyMenu MenuMPDAlbum - + I -PipeRead \"exec $FVWM/scripts/mpdmenu.pl " - ."--menu MenuMPDAlbum " - ."--album ".shellify($entry{Album}, 1)." " - ."--artist ".shellify($entry{Artist}, 1)."\""); - - cmd("DestroyFunc MakeMenuMPDArtist"); - cmd("AddToFunc MakeMenuMPDArtist - + I DestroyMenu MenuMPDArtist - + I -PipeRead \"exec $FVWM/scripts/mpdmenu.pl " - ."--menu MenuMPDArtist " - ."--artist ".shellify($entry{Artist}, 1)."\""); - - cmd("DestroyFunc KillMenuMPD"); - cmd("AddToFunc KillMenuMPD I Nop"); -} + fvwm_cmd("AddToMenu", $menu, "[current track unavailable]"); + } -# Finished. -print $sock "close\n"; + if ($state{state} =~ /^p/) { + my $pp = $state{state} eq "pause" ? "lay" : "ause"; + + fvwm_cmd("AddToMenu", $menu, "", "Nop"); + fvwm_cmd("AddToMenu", $menu, "Next%next.svg:16x16%", + "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "next"); + fvwm_cmd("AddToMenu", $menu, "P$pp%p$pp.svg:16x16%", + "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "p$pp"); + fvwm_cmd("AddToMenu", $menu, "Stop%stop.svg:16x16%", + "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "stop"); + fvwm_cmd("AddToMenu", $menu, "Prev%prev.svg:16x16%", + "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "previous"); + } elsif ($state{state} eq "stop") { + fvwm_cmd("AddToMenu", $menu, "", "Nop"); + fvwm_cmd("AddToMenu", $menu, "Play%play.svg:16x16%", + "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "play"); + } +} elsif ($mode eq "album") { + my $matches = get_tracks_by_release_mbid($albumid); + my @notqueued = (); + + $menu //= "MenuMPDAlbum"; + + my $track_max = max(map { $_->{Track} } values %$matches); + my $disc_max = max(map { $_->{Disc} } values %$matches); + + # CDs have a max of 99 tracks and I hope 100+-disc-releases + # don't exist so this is fine. + my $track_digits = $track_max >= 10 ? 2 : 1; + my $disc_digits = $disc_max > 1 ? $disc_max >= 10 ? 2 : 1 : 0; + my $currentdisc; + + fvwm_cmd("AddToMenu", $menu); + fvwm_cmd("+", "Release not found", "Title") unless keys %$matches; + foreach my $file (sort { albumsort($matches, $a, $b) } keys %$matches) { + my $entry = $matches->{$file}; + + # Format disc/track numbers + $entry->{trackfmt} = sprintf("%*.*s%.*s%*d\t", + $disc_digits, $disc_digits, $entry->{Disc}, + $disc_digits, "-", + $track_digits, $entry->{Track}); + $entry->{trackfmt} =~ s/ /\N{U+2007}/g; + + unless (exists $entry->{Id}) { + my ($id) = get_ids_by_filename($file); + if (defined $id) { + $entry->{Id} = $id; + } else { + push @notqueued, $entry; + next; + } + } -sub sanitise -{ - $_ = $_[0]; - s/([\$&@%^*])/\1\1/g; - s/"/\\"/g; - return $_; -} + if (defined $currentdisc && $currentdisc != $entry->{Disc}) { + fvwm_cmd("+", "", "Nop"); + } + $currentdisc = $entry->{Disc}; -sub addalbumentry -{ - my ($playlist, $entry) = @_; + fvwm_cmd("+", menu_trackname($entry), "Exec", + "exec", "$FindBin::Bin/mpdexec.pl", + "playid", $entry->{Id}); + } - push(@$playlist, $entry); + fvwm_cmd("+", "Not in play queue", "Title") if @notqueued; + foreach my $entry (@notqueued) { + fvwm_cmd("+", menu_trackname($entry)); + } +} elsif ($mode eq "artist") { + # Create an artist menu. + my ($matches, @recs) = get_releases_by_artist_mbid(keys %artistids); + + $menu //= "MenuMPDArtist"; - if (defined $artist && $artist eq $entry->{Artist}) { - my $albumdir = albumdir($entry->{file}); - $accept{$albumdir} = 1; + my @mbids = sort { datesort($matches, $a, $b) } keys %$matches; + my @files = map { $matches->{$_}->{file} } @mbids; + my @thumbs = get_item_thumbnails({ small => 1 }, @files); + + unless (@mbids) { + fvwm_cmd("AddToMenu", $menu, "No releases found", "Title") } -} -sub albumdir -{ - my $file = $_[0]; + foreach my $mbid (@mbids) { + my $entry = $matches->{$mbid}; + my $thumb = shift @thumbs; - $file =~ s:(/Disk [0-9]+[^/]*)?/[^/]*$::; - return $file -} + my @submenu = make_submenu("$topmenu-$mbid", + "--album-id=$mbid"); + fvwm_cmd("AddToMenu", $menu, + $thumb . fvwm_label_escape($entry->{Album}), + @submenu); + } -sub albumsort -{ - return ($a->{Disc} <=> $b->{Disc}) if ($a->{Disc} != $b->{Disc}); - return ($a->{Track} <=> $b->{Track}); -} + my @artists = map { "--artist-id=$_" } keys %artistids; + my %nonalbums = map { $_->{Album} => $_ } @recs; + foreach my $name (sort keys %nonalbums) { + my $mbid = $nonalbums{$name}->{MUSICBRAINZ_TRACKID}; + my @submenu = make_submenu("$topmenu-$mbid", @artists, + "--album-name=$name"); + fvwm_cmd("AddToMenu", $menu, fvwm_label_escape($name), @submenu); + } +} elsif ($mode eq "albumname") { + # Create a standalone recordings menu + my ($releases, @recs) = get_releases_by_artist_mbid(keys %artistids); + + $menu //= "MenuMPDRecordings"; + my @tracks = sort { $a->{Title} cmp $b->{Title} } + grep { $_->{Album} eq $albumname } @recs; + + # Show thumbnails for standalone recordings + my @thumbs = get_item_thumbnails({ small => 1 }, + map { $_->{file} } @tracks); + foreach my $entry (@tracks) { + $entry->{thumb} = shift @thumbs; + } -sub titlesort -{ - return ($a->{Album} cmp $b->{Album}) if($a->{Album} ne $b->{Album}); - return ($a->{Artist} cmp $b->{Artist}) if($a->{Artist} ne $b->{Artist}); - return ($a->{Title} cmp $b->{Title}); -} + my @notqueued = update_entry_ids(@tracks); -sub shellify -{ - my ($str, $quoted) = @_; - $str =~ s/'/'\\''/g; - if ($quoted) { - $str =~ s/\\/\\\\/g; - $str =~ s/"/\\"/g; + fvwm_cmd("AddToMenu", $menu); + fvwm_cmd("+", "No tracks found", "Title") unless @tracks; + + foreach my $entry (@tracks) { + next unless exists $entry->{Id}; + + fvwm_cmd("+", menu_trackname($entry), "Exec", + "exec", "$FindBin::Bin/mpdexec.pl", + "playid", $entry->{Id}); + } + + fvwm_cmd("+", "Not in play queue", "Title") if @notqueued; + foreach my $entry (@notqueued) { + fvwm_cmd("+", menu_trackname($entry)); + } +} elsif ($mode eq "track" || $mode eq "recording") { + my ($matches, $mbid); + my @notqueued; + + if ($mode eq "track") { + $matches = get_tracks_by_track_mbid($trackid) + } else { + $matches = get_tracks_by_recording_mbid($recordingid) + } + + $menu //= "MenuMPDTrack"; + fvwm_cmd("DestroyMenu", $menu); + + my @files = sort { datesort($matches, $a, $b) } keys %$matches; + my @thumbs = get_item_thumbnails({ small => 1 }, @files); + + fvwm_cmd("AddToMenu", $menu); + fvwm_cmd("+", "No tracks found", "Title") unless @files; + foreach my $file (@files) { + my $entry = $matches->{$file}; + $entry->{thumb} = shift @thumbs; + + unless (exists $entry->{Id}) { + my ($id) = get_ids_by_filename($file); + if (defined $id) { + $entry->{Id} = $id; + } else { + push @notqueued, $entry; + next; + } + } + + fvwm_cmd("+", menu_trackname($entry), "Exec", + "exec", "$FindBin::Bin/mpdexec.pl", + "playid", $entry->{Id}); + } + + fvwm_cmd("+", "Not in play queue", "Title") if @notqueued; + foreach my $entry (@notqueued) { + fvwm_cmd("+", menu_trackname($entry)); } - return "'$str'"; } + +# Finished. +print $sock "close\n";