#!/usr/bin/perl
#
# Copyright © 2008,2010,2012,2019 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 utf8;

use Encode qw(decode encode);
use Encode::Locale qw(decode_argv);
decode_argv(Encode::FB_CROAK);
binmode(STDOUT, ":utf8");

use IO::Socket::INET6;
use Getopt::Long qw(:config gnu_getopt);
use Scalar::Util qw(reftype);
use List::Util qw(any max);
use FindBin;

use constant {
	MPD_MJR_MIN => 0,
	MPD_MNR_MIN => 21,
	MPD_REV_MIN => 0,
};

my $SELF = "$FindBin::Bin/$FindBin::Script";

my $MUSIC = $ENV{MUSIC}    // "/srv/music";
my $host  = $ENV{MPD_HOST} // "localhost";
my $port  = $ENV{MPD_PORT} // "6600";
my $sock;

my ($albumid, $trackid);
my ($topmenu, $menu);
my $mode = "top";
my %artistids;

# Quotes the argument so that it is presented as a single argument to MPD
# at the protocol level.  This also works OK for most FVWM arguments.
sub escape {
	my $s = @_[0] // $_;

	# No way to encode literal newlines in the protocol, so we
	# convert any newlines in the arguments into a space, which
	# can help with quoting.
	$s =~ s/\n/ /g;

	if (/[ \t\\"]/) {
		$s =~ s/[\\"]/\\$&/g;
		return "\"$s\"";
	}

	$s =~ s/^\s*$/"$&"/;
	return $s;
}

# Submit a command to the MPD server; each argument to this function
# is quoted and sent as a single argument to MPD.
sub mpd_exec {
	my $cmd = join(' ', map { escape } @_);

	print $sock "$cmd\n";
}

sub fvwm_cmd_unquoted {
	print join(' ', @_), "\n";
}

sub fvwm_cmd {
	fvwm_cmd_unquoted(map { escape } @_);
}

# 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";
}

# 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;
}

# 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);
}

# 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 = "%";
	}

	open THUMB, "-|", "$FindBin::Bin/mpdthumb.sh", @opts, "--", @_;
	foreach (@_) {
		my $thumb = <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", escape($menu),
		                  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");
	@submenu = make_submenu("$menu-$mbid", "--track-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) = 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);
}

# 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 = ();

	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 <<EOF
mpdmenu.pl 0.8
Copyright © 2019 Nick Bowler
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.
EOF
}

sub print_usage {
	my $fh = $_[1] // *STDERR;

	print $fh "Usage: $0 [options]\n";
	print "Try $0 --help for more information.\n" unless (@_ > 0);
}

sub print_help {
	print_usage(*STDOUT);
	print <<EOF
This is "mpdmenu": a menu-based MPD client for FVWM.

Options:
  -h, --host=HOST   Connect to the MPD server on HOST, overriding defaults.
  -p, --port=PORT   Connect to the MPD server on PORT, overriding defaults.
  -m, --menu=NAME   Set the name of the generated menu.
  --album-id=MBID   Generate a menu for the given release MBID.
  --artist-id=MBID  Generate a menu for the given artist MBID.
  --track-id=MBID   Generate a menu for the given track MBID.
  -V, --version     Print a version message and then exit.
  -H, --help        Print this message and then exit.
EOF
}

GetOptions(
	'host|h=s'    => \$host,
	'port|p=s'    => \$port,
	'menu|m=s'    => \$menu,

	'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 },

	'topmenu=s'   => \$topmenu, # top menu name (for submenu generation)
) or do { print_usage; exit 1 };

unless (defined $menu) {
	$topmenu //= "MenuMPD";
	$menu = $topmenu . ($mode ne "top" ? $mode : "");
}
$topmenu //= $menu;

# Connect to MPD.
$sock = new IO::Socket::INET6(
	PeerAddr => $host,
	PeerPort => $port,
	Proto => 'tcp',
	Timeout => 2
) 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 ($mode eq "top") {
	my %current;
	my %state;

	$menu //= "MenuMPD";

	mpd_exec("status");
	while (<$sock>) {
		last if (/^OK/);
		die($_) if (/^ACK/);

		if (/^(\w+): (.*)$/) {
			$state{$1} = $2;
		}
	}

	mpd_exec("currentsong");
	while (<$sock>) {
		last if (/^OK/);
		die($_) if (/^ACK/);

		if (/^(\w+): (.*)$/) {
			add_track_metadata(\%current, $1, $2);
		}
	}

	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);
	} else {
		fvwm_cmd("AddToMenu", $menu, "[current track unavailable]");
	}

	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;
			}
		}

		if (defined $currentdisc && $currentdisc != $entry->{Disc}) {
			fvwm_cmd("+", "", "Nop");
		}
		$currentdisc = $entry->{Disc};

		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 "artist") {
	# Create an artist menu.
	my $matches = get_releases_by_artist_mbid(keys %artistids);
	my $entry;

	$menu //= "MenuMPDArtist";

	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 $mbid (@mbids) {
		my $entry = $matches->{$mbid};
		my $thumb = shift @thumbs;

		my @submenu = make_submenu("$topmenu-$mbid",
		                           "--album-id=$mbid");
		fvwm_cmd("AddToMenu", $menu,
		         $thumb . fvwm_label_escape($entry->{Album}),
			 @submenu);
	}
} elsif ($mode eq "track") {
	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;
			}
		}

		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));
	}
}

# Finished.
print $sock "close\n";
