X-Git-Url: https://git.draconx.ca/gitweb/mpdhacks.git/blobdiff_plain/cf96918d75b08d0767efff3ec886bfd931b480fe..6b2e89cb66902820a6b5e31991bad3ae4c70296b:/mpdmenu.pl diff --git a/mpdmenu.pl b/mpdmenu.pl index d22193c..8e597e7 100755 --- a/mpdmenu.pl +++ b/mpdmenu.pl @@ -21,7 +21,7 @@ binmode(STDOUT, ":utf8"); use IO::Socket::INET6; use Getopt::Long qw(:config gnu_getopt); use Scalar::Util qw(reftype); -use List::Util qw(any); +use List::Util qw(any max); use FindBin; use constant { @@ -37,6 +37,8 @@ my $host = $ENV{MPD_HOST} // "localhost"; my $port = $ENV{MPD_PORT} // "6600"; my $sock; +my ($albumid, $trackid); +my %artistids; my $menu; my $mode = "top"; @@ -250,9 +252,10 @@ sub top_track_cover { # Generate the "Title:" entry in the top menu. sub top_track_title { my ($entry) = @_; + my @submenu; - my @submenu = make_submenu("$menu-TopTrack", - "--title=$entry->{Title}"); + my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_RELEASETRACKID"); + @submenu = make_submenu("$menu-$mbid", "--track-id=$mbid") if $mbid; fvwm_cmd("AddToMenu", $menu, fvwm_label_escape("Title:\t$entry->{Title}"), @@ -262,9 +265,15 @@ sub top_track_title { # Generate the "Artist:" entry in the top menu. sub top_track_artist { my ($entry) = @_; + my @submenu; - my @submenu = make_submenu("$menu-TopArtist", - "--artist=$entry->{Artist}"); + # 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}"), @@ -276,20 +285,189 @@ sub top_track_album { my ($entry) = @_; my @submenu; - my @submenu = make_submenu("$menu-TopAlbum", - "--artist=$entry->{Artist}", - "--album=$entry->{Album}"); + my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID"); + @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid") if $mbid; fvwm_cmd("AddToMenu", $menu, fvwm_label_escape("Album:\t$entry->{Album}"), @submenu); } -# Global hash for tracking what is to be "accepted". -my %accept = (); +# 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; + + 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; + } + } + + add_track_metadata($entry, $1, $2); + } + } + } + + 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) = @_; + my %source; + my %matches; + my $entry; + + return \%matches unless ($mbid); + mpd_exec("search", "(MUSICBRAINZ_RELEASETRACKID == \"$mbid\")"); + while (<$sock>) { + last if (/^OK/); + die($_) if (/^ACK/); + + if (/^(\w+): (.*)$/) { + add_track_metadata(\%source, $1, $2); + } + } + + # Always include the current track + $matches{$source{file}} = \%source; + + # 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->{$_}; + } + } + + return \%matches; +} + +# 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; + + return \%matches unless ($mbid); + mpd_exec("search", "(MUSICBRAINZ_ALBUMID == \"$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; + } + } + + add_track_metadata($entry, $1, $2); + } + } + + return \%matches; +} + +# Given an artist MBID, return a hash reference containing associated +# releases in the MPD database. The hash keys are release MBIDs. +# +# 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; + my $entry; + + foreach my $mbid (@_) { + mpd_exec("search", "(MUSICBRAINZ_ARTISTID == \"$mbid\")"); + while (<$sock>) { + last if (/^OK/); + die($_) if (/^ACK/); + + if (/^(\w+): (.*)$/) { + if ($1 eq "file") { + $entry = {}; + } elsif ($1 eq "MUSICBRAINZ_ALBUMID") { + $releases{$2} //= $entry; + } + + add_track_metadata($entry, $1, $2); + } + } + } + + return \%releases; +} + +# 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 = (); -# Default values for stuff. -my ($album, $artist, $title); + mpd_exec("playlistfind", "file", $file); + while (<$sock>) { + last if (/^OK/); + die($_) if (/^ACK/); + + if (/^(\w+): (.*)$/) { + push @results, $2 if ($1 eq "Id"); + } + } + + return @results; +} + +# 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 < \$host, - 'port|p=s' => \$port, - 'menu|m=s' => \$menu, + 'host|h=s' => \$host, + 'port|p=s' => \$port, + 'menu|m=s' => \$menu, - 'album=s' => sub { $album = $_[1]; $mode = "album"; }, - 'artist=s' => sub { $artist = $_[1]; - $mode = "artist" unless $mode eq "album"; }, - 'title=s' => sub { $title = $_[1]; $mode = "track"; }, + 'artist-id=s' => sub { $artistids{$_[1]} = 1; $mode = "artist"; }, + 'album-id=s' => sub { $albumid = $_[1]; $mode = "album"; }, + 'track-id=s' => sub { $trackid = $_[1]; $mode = "track"; }, - 'V|version' => sub { print_version(); exit }, - 'H|help' => sub { print_help(); exit }, + 'V|version' => sub { print_version(); exit }, + 'H|help' => sub { print_help(); exit }, ) or do { print_usage; exit 1 }; # Connect to MPD. @@ -412,252 +592,112 @@ if ($mode eq "top") { "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "play"); } } elsif ($mode eq "album") { - # Create an album menu. - my @playlist = (); - 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) - } - - $entry = {}; + 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; } - - $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, 0); - $t_title = sanitise($t_title, 0); + if (defined $currentdisc && $currentdisc != $entry->{Disc}) { + fvwm_cmd("+", "", "Nop"); + } + $currentdisc = $entry->{Disc}; - my $cmd = sprintf "AddToMenu $menu \"%d\t%s - %s\"" - ." Exec exec $FindBin::Bin/mpdexec.pl" - ." playid %d", - $t_trackno, $t_artist, $t_title, $t_id; + fvwm_cmd("+", menu_trackname($entry), "Exec", + "exec", "$FindBin::Bin/mpdexec.pl", + "playid", $entry->{Id}); + } - cmd($cmd); + 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 %albums = (); - my $file; - my $quoteartist = $artist; - - $menu = "MenuMPDArtist" unless defined $menu; - - $quoteartist =~ s/"/\\"/g; - print $sock "playlistfind artist \"$quoteartist\"\n"; - while (<$sock>) { - last if (/^OK/); - die($_) if (/^ACK/); - - if (/^(\w+): (.*)$/) { - $file = $2 if ($1 eq "file"); - $albums{$2} = $file if ($1 eq "Album"); - } - } - - die("No albums found.\n") if (!keys(%albums)); + my $matches = get_releases_by_artist_mbid(keys %artistids); + my $entry; -{ # work around 'use locale' breaking s///i - my $i = 0; - use locale; + $menu //= "MenuMPDArtist"; - my @keys = sort keys %albums; - my @thumbs = get_item_thumbnails({ small => 1 }, - map { $albums{$_} } @keys); + my @mbids = sort { datesort($matches, $a, $b) } keys %$matches; + my @files = map { $matches->{$_}->{file} } @mbids; + my @thumbs = get_item_thumbnails({ small => 1 }, @files); + fvwm_cmd("AddToMenu", $menu, "No releases found", "Title") unless @mbids; - foreach my $key (@keys) { - my $a_album = sanitise($key, 1); + foreach my $mbid (@mbids) { + my $entry = $matches->{$mbid}; my $thumb = shift @thumbs; - cmd("AddToMenu $menu \"$thumb$a_album\" Popup MenuMPDArt_$i"); - - cmd("AddToMenu MenuMPDArt_$i DynamicPopUpAction MakeMenuMPDArt_$i"); - - cmd("DestroyFunc MakeMenuMPDArt_$i"); - cmd("AddToFunc MakeMenuMPDArt_$i - + I DestroyMenu MenuMPDArt_$i - + I -PipeRead \"exec $SELF " - ."--menu MenuMPDArt_$i " - ."--album ".shellify($key, 1)." " - ."--artist ".shellify($artist, 1)."\""); - - cmd("AddToFunc KillMenuMPD I DestroyMenu MenuMPDArt_$i"); - cmd("AddToFunc KillMenuMPD I DestroyFunc MakeMenuMPDArt_$i"); - - $i++; + my @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid"); + fvwm_cmd("AddToMenu", $menu, + $thumb . fvwm_label_escape($entry->{Album}), + @submenu); } -} # end use locale workaround } elsif ($mode eq "track") { - # Create a title menu. - my @titles; - my $entry; - - $menu = "MenuMPDTitle" unless defined $menu; - - # Open and close brackets. - my ($ob, $cb) = ("[\[~〜<〈(ー−-]", "[\]~〜>〉)ー−-]"); - - $_ = $title; - - # 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/〜彼方への哀歌//; # 十二幻夢 - s/ sora no uta ver.//; # 美しい星 - - s/\s*-remix-$//; # Otherwise "D-THREAD -remix-" doesn't work right. - - # Deal with titles like "blah (ABC version)". - s/\s*$ob.*(style|mix|edit|edition|ver\.?|version|melody|カラオケ)$cb?$//i; - - # Deal with titles like "blah (without XYZ)". - s/\s*$ob\s*((e\.)?piano|english|japanese|inst|tv|without|w\/o|off|back|short|karaoke|game).*//i; - - # Deal with titles like "blah instrumental". - s/\s+(instrumental|off vocal|short|tv)([\s-]+(mix|size|version))?$//i; - s/\s+without\s+\w+$//i; - - # Deal with separate movements in classical pieces. - s/: [IVX]+\..*//; - - my $basetitle = $_; - my $_basetitle = $basetitle; - - $_basetitle =~ s/"/\\"/g; - print $sock "playlistsearch title \"$_basetitle\"\n"; - while (<$sock>) { - last if (/^OK/); - die($_) if (/^ACK/); - - if (/^(\w+): (.*)$/) { - if ($1 eq "file") { - push @titles, $entry if (keys(%$entry) > 0); - $entry = {}; + my $matches = get_tracks_by_track_mbid($trackid); + my @notqueued; + + $menu //= "MenuMPDTrack"; + + 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; } - - $entry->{$1} = $2; } - } - push @titles, $entry if (keys(%$entry) > 0); - -{ # work around 'use locale' breaking s///i - use locale; - my @thumbs = get_item_thumbnails({ small => 1 }, - map { $_->{file} } @titles); - for (my $i = 0; $i < @titles; $i++) { - $titles[$i]->{thumb} = $thumbs[$i]; + fvwm_cmd("+", menu_trackname($entry), "Exec", + "exec", "$FindBin::Bin/mpdexec.pl", + "playid", $entry->{Id}); } - foreach (sort titlesort @titles) { - my ($t_file, $t_artist, $t_title, $t_id, $thumb) = ( - $_->{file}, - $_->{Artist}, - $_->{Title}, - $_->{Id}, - $_->{thumb} - ); - - # MPD searches are case-insensitive. - next if (!($t_title =~ m/(\P{Latin}|^)\Q$basetitle\E(\P{Latin}|$)/ || $t_title =~ m/\Q$basetitle\E/i)); - - $t_artist = sanitise($t_artist, 1); - $t_title = sanitise($t_title, 1); - - cmd("AddToMenu $menu \"$thumb$t_artist - $t_title\"" - ." Exec exec $FindBin::Bin/mpdexec.pl" - ." playid $t_id"); + fvwm_cmd("+", "Not in play queue", "Title") if @notqueued; + foreach my $entry (@notqueued) { + fvwm_cmd("+", menu_trackname($entry)); } -} # end use locale workaround } # Finished. print $sock "close\n"; - -sub sanitise -{ - $_ = $_[0]; - s/&/&&/g if ($_[1]); - s/([\$@%^*])/\1\1/g; - s/"/\\"/g; - return $_; -} - -sub addalbumentry -{ - my ($playlist, $entry) = @_; - - push(@$playlist, $entry); - - if (defined $artist && $artist eq $entry->{Artist}) { - my $albumdir = albumdir($entry->{file}); - $accept{$albumdir} = 1; - } -} - -sub albumdir -{ - my $file = $_[0]; - - $file =~ s:(/Disk [0-9]+[^/]*)?/[^/]*$::; - return $file -} - -sub albumsort -{ - return ($a->{Disc} <=> $b->{Disc}) if ($a->{Disc} != $b->{Disc}); - return ($a->{Track} <=> $b->{Track}); -} - -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}); -} - -sub shellify -{ - my ($str, $quoted) = @_; - $str =~ s/'/'\\''/g; - if ($quoted) { - $str =~ s/\\/\\\\/g; - $str =~ s/"/\\"/g; - } - return "'$str'"; -} - -sub cmd -{ - print "$_[0]\n"; -}