#!/usr/bin/perl binmode(STDOUT, ":utf8"); use v5.10; use warnings; use strict; use utf8; use Config::Simple; use Time::HiRes qw (sleep); use Data::MessagePack; #use DDP; use Encode qw(decode encode); use File::Basename; use File::Path qw(make_path); use File::Slurper 'read_binary'; use File::stat; use Try::Tiny; use FindBin '$Bin'; use FindBin '$Script'; use List::Util qw(min any shuffle); use Getopt::Std; use HTTP::Date; use IPC::Run qw( timeout start ); use Net::MPD; use POSIX qw(tzset); use autodie; my $self="$Bin/$Script"; $ENV{TMUX_TMPDIR}='/tmp/clerk/tmux'; my $minimal = ""; make_path($ENV{TMUX_TMPDIR}) unless(-d $ENV{TMUX_TMPDIR}); my $config_file = $ENV{'HOME'} . "/.config/clerk/clerk.conf"; if ($ENV{CLERK_CONF}) { $config_file = $ENV{CLERK_CONF}; } my $backend="fzf"; # read configuration file my $cfg = new Config::Simple(filename=>"$config_file"); my $general_cfg = $cfg->param(-block=>"General"); my $mpd_host = $general_cfg->{mpd_host}; my $tmux_config = $general_cfg->{tmux_config}; my $db_file = $general_cfg->{database}; my $songs = $general_cfg->{songs}; my $chunksize = $general_cfg->{chunksize}; my $player = $general_cfg->{player}; my $columns_cfg = $cfg->param(-block=>"Columns"); my $albumartist_l = $columns_cfg->{albumartist_l}; my $album_l = $columns_cfg->{album_l}; my $date_l = $columns_cfg->{date_l}; my $rating_l = $columns_cfg->{rating_l}; my $title_l = $columns_cfg->{title_l}; my $track_l = $columns_cfg->{track_l}; my $artist_l = $columns_cfg->{artist_l}; my $random; # open connection to MPD sub main { my %options=(); getopts("talpfrhuyxzATRZN", \%options); unless ($options{f}) { $backend = 'rofi' } else { $backend = 'fzf'; } if ($options{t} // $options{a} // $options{p} // $options{l} // $options{N} // $options{R} // $options{Z} // $options{r} // $options{x} // $options{y} // $options{h} // $options{z} // $options{u}) { if (defined $options{t}) { create_db(); list_db_entries_for("Tracks") } elsif (defined $options{a}) { create_db(); list_db_entries_for("Albums") } elsif (defined $options{p}) { create_db(); list_playlists() } elsif (defined $options{z}) { help() } elsif (defined $options{h}) { help_output() } elsif (defined $options{R}) { rating() } elsif (defined $options{x}) { create_db(); system('tmux', 'split-window', '-d', $self, '-f', '-r'); system('tmux', 'select-pane', '-D'); } elsif (defined $options{y}) { system('tmux', 'split-window', '-d', $self, '-f', '-z'); system('tmux', 'select-pane', '-D'); } elsif (defined $options{Z}) { system('tmux', 'split-window', '-d', $self, '-f', '-R'); system('tmux', 'select-pane', '-D'); } elsif (defined $options{r}) { if (defined $options{T}) { random_tracks(); } elsif (defined $options{A}) { random_album(); } elsif (defined $options{N}) { random_rated_tracks(); } else { random() } } elsif (defined $options{l}) { create_db(); list_db_entries_for("Latest") } elsif (defined $options{u}) { unlink $db_file; create_db(); } } else { create_db(); system('tmux', 'has-session', '-t', 'music'); if ($? != -0) { system('tmux', '-f', $tmux_config, 'new-session', '-s', 'music', '-n', 'albums', '-d', $self, '-a', '-f'); system('tmux', 'new-window', '-t', 'music', '-n', 'tracks', $self, '-t', '-f'); system('tmux', 'new-window', '-t', 'music', '-n', 'latest', $self, '-l', '-f'); system('tmux', 'new-window', '-t', 'music', '-n', 'playlists', $self, '-p', '-f'); system('tmux', 'new-window', '-t', 'music', '-n', 'queue', $player); system('tmux', 'attach', '-t', 'music'); system('tmux', 'select-window', '-t', 'queue') } else { system('tmux', 'attach', '-t', 'music'); system('tmux', 'select-window', '-t', 'queue') } } } sub help_output { my @output = ( "Usage: clerk [command] [-f]", "clerk version 2.0", "", "Commands:", " -a Add/Replace album(s) to queue.", " -l Add/Replace album(s) to queue (sorted by mtime)", " -t Add/Replace track(s) to queue.", " -R Add/Replace rated track(s) to queue.", " -p Add stored playlist to queue", " -r [-A, -T, -N] Replace current playlist with random songs/album", " -u Update caches", "", "Options:", " -f Use fzf interface", " ", "Without further arguments, clerk starts a tabbed tmux interface"); print join("\n", @output), "\n\n"; } sub create_db { my $mpd = Net::MPD->connect($ENV{MPD_HOST} // $mpd_host // 'localhost'); # Get database copy and save as messagepack file, if file is either missing # or older than latest mpd database update. # get number of songs to calculate number of searches needed to copy mpd database my @track_ratings = $mpd->sticker_find("song", "rating"); my %track_ratings = map {$_->{file} => $_->{sticker}} @track_ratings; my $mpd_stats = $mpd->stats(); my $songcount = $mpd_stats->{songs}; my $last_update = $mpd_stats->{db_update}; if (!-f "$db_file" || stat("$db_file")->mtime < $last_update) { my $times = int($songcount / $chunksize + 1); if ($backend eq "rofi") { system('notify-send', '-t', '5', 'clerk', 'Updating Cache File'); } elsif ($backend eq "fzf") { print STDERR "::: No cache found or cache file outdated\n"; print STDERR "::: Chunksize set to $chunksize songs\n"; print STDERR "::: Requesting $times chunks from MPD\n"; } my @db; # since mpd will silently fail, if response is larger than command buffer, let's split the search. my $chunk_size = $chunksize; for (my $i=0;$i<=$songcount;$i+=$chunk_size) { my $endnumber = $i+$chunk_size; my @temp_db = $mpd->search('filename', '', 'window', "$i:$endnumber"); push @db, @temp_db; } # only save relevant tags to keep messagepack file small # note: maybe use a proper database instead? See list_album function. my @filtered = map { $_->{mtime} = str2time($_->{'Last-Modified'}); $_->{rating} = $track_ratings{$_->{uri}}; +{$_->%{qw/Album Artist Date AlbumArtist Title Track rating uri mtime/}} } @db; # my @filtered = map { $_->{mtime} = str2time($_->{'Last-Modified'}); +{$_->%{qw/Album Artist Date AlbumArtist Title Track uri mtime/}} } @db; pack_msgpack(\@filtered); } } sub help { my $help_txt = system('grep', 'bind-key', $tmux_config); print "$help_txt\n"; ; } sub backend_call { my ($in, $fields, $random) = @_; my $input; my $out; $random //= "ignore"; $fields //= "1,2,3,4"; my %backends = ( fzf => [ qw(fzf --reverse --no-sort -m -e --no-hscroll -i -d \t --tabstop=4 +s --ansi), "--bind=esc:$random,alt-a:toggle-all,alt-n:deselect-all", "--with-nth=$fields" ], rofi => [ "rofi", "-width", "1300", "-matching", "regex", "-dmenu", "-kb-row-tab", "", "-kb-move-word-forward", "", "-kb-accept-alt", "Tab", "-multi-select", "-no-levensthein-sort", "-i", "-p", "> " , "-filter", $minimal ] ); my $handle = start $backends{$backend} // die('backend not found'), \$input, \$out; $input = join "", (@{$in}); finish $handle or die "No selection"; return $out; } sub pack_msgpack { my ($filtered_db) = @_; my $msg = Data::MessagePack->pack($filtered_db); my $filename = "$db_file"; open(my $out, '>:raw', $filename) or die "Could not open file '$filename' $!"; print $out $msg; close $out; } sub unpack_msgpack { my $mp = Data::MessagePack->new->utf8(); my $msgpack = read_binary("$db_file"); my $rdb = $mp->unpack($msgpack); return $rdb; } sub random_album { my $mpd = Net::MPD->connect($ENV{MPD_HOST} // $mpd_host // 'localhost'); my @queue_cmd = ('tmux', 'findw', '-t', 'music', 'queue'); $mpd->clear(); my @album_artists = $mpd->list('albumartist'); my $artist_r = $album_artists[rand @album_artists]; my @album = $mpd->list('album', 'albumartist', $artist_r); my $album_r = $album[rand @album]; $mpd->find_add('albumartist', $artist_r, 'album', $album_r); $mpd->play(); system(@queue_cmd); } sub random_tracks { my $mpd = Net::MPD->connect($ENV{MPD_HOST} // $mpd_host // 'localhost'); my @queue_cmd = ('tmux', 'findw', '-t', 'music', 'queue'); $mpd->clear(); for (my $i=0; $i <= $songs; $i++) { my @artists = $mpd->list('artist'); my $artist_r = $artists[rand @artists]; my @albums = $mpd->list('album', 'artist', $artist_r); my $album_r = $albums[rand @albums]; my @tracks = $mpd->find('artist', $artist_r, 'album', $album_r); my $track_r = $tracks[rand @tracks]; my $foo = $track_r->{uri}; $mpd->add($foo); system(@queue_cmd); $mpd->play(); } } sub random_rated_tracks { my $mpd = Net::MPD->connect($ENV{MPD_HOST} // $mpd_host // 'localhost'); my @queue_cmd = ('tmux', 'findw', '-t', 'music', 'queue'); try { $mpd->close(); }; $mpd->{socket}->close; my @result; my @action_items = ("1\n", "2\n", "3\n", "4\n", "5\n", "6\n", "7\n", "8\n", "9\n", "10\n"); my $minimal_rating = backend_call(\@action_items, "1,2,3", "cancel"); chomp $minimal_rating; $minimal = "$minimal_rating"; my $rdb = unpack_msgpack(); my @rating_rdb = grep { ($_->{rating} // 0) >= $minimal } @$rdb; my @random_rated_tracks = shuffle @rating_rdb; $mpd->add($_->{uri}) for (splice(@random_rated_tracks, -min($#random_rated_tracks, $songs))); } sub random { my $mpd = Net::MPD->connect($ENV{MPD_HOST} // $mpd_host // 'localhost'); my @queue_cmd = ('tmux', 'findw', '-t', 'music', 'queue'); try { $mpd->close(); }; $mpd->{socket}->close; my @action_items = ("Album\n", "Tracks\n", "Rated Tracks\n", "Settings\n"); my $action = backend_call(\@action_items, "1,2,3", "cancel"); if ($action eq "Album\n") { random_album(); system(@queue_cmd); $mpd->play(); } if ($action eq "Tracks\n") { random_tracks(); system(@queue_cmd); $mpd->play(); } if ($action eq "Rated Tracks\n") { random_rated_tracks(); system(@queue_cmd); $mpd->play(); } if ($action eq "Settings\n") { my @action_items = ("5\n", "10\n", "15\n", "20\n", "25\n", "30\n"); my $action = backend_call(\@action_items); chomp $action; $cfg->param("General.songs", $action); $cfg->save(); } } sub rating { my $mpd = Net::MPD->connect($ENV{MPD_HOST} // $mpd_host // 'localhost'); my @queue_cmd = ('tmux', 'findw', '-t', 'music', 'queue'); try { $mpd->close(); }; $mpd->{socket}->close; my @action_items = ("1\n", "2\n", "3\n", "4\n", "5\n", "6\n", "7\n", "8\n", "9\n", "10\n"); my $minimal_rating = backend_call(\@action_items, "1,2,3", "cancel"); chomp $minimal_rating; $minimal = "$minimal_rating"; list_db_entries_for("Ratings"); } sub do_action { $minimal = ""; my $mpd = Net::MPD->connect($ENV{MPD_HOST} // $mpd_host // 'localhost'); my @queue_cmd = ('tmux', 'findw', '-t', 'music', 'queue'); my ($in, $context) = @_; my $action; try { $mpd->close(); }; $mpd->{socket}->close; if ($context eq "playlist") { if ($in eq "Save current Queue\n") { tzset(); my $filename = localtime(); $mpd->save($filename); system(@queue_cmd); } else { my @action_items = ("Add\n", "Replace\n", "Delete\n"); $action = backend_call(\@action_items); if ($action eq "Replace\n") { $mpd->clear(); chomp $in; $mpd->load("$in"); $mpd->play(); system(@queue_cmd); } elsif ($action eq "Add\n") { chomp $in; $mpd->load("$in"); } elsif ($action eq "Delete\n") { chomp $in; $mpd->rm("$in"); } } } elsif ($context eq "tracks") { my @action_items = ("Add\n", "Replace\n", "Rate Track\n"); $action = backend_call(\@action_items); if ($action eq "Replace\n") { $mpd->clear(); } if ($action eq "Rate Track\n") { my @rating_value = ("1\n", "2\n", "3\n", "4\n", "5\n", "6\n", "7\n", "8\n", "9\n", "10\n"); my $rating; $rating = backend_call(\@rating_value); chomp $rating; my $input; foreach my $line (split /\n/, $in) { my $uri = (split /[\t\n]/, $line)[-1]; $uri = decode('UTF-8', $uri ); $mpd->sticker_value("song", "$uri", "rating", "$rating") } } elsif ($action eq "Add\n" || $action eq "Replace\n") { my $input; foreach my $line (split /\n/, $in) { my $uri = (split /[\t\n]/, $line)[-1]; $uri = decode('UTF-8', $uri ); $mpd->add($uri); system(@queue_cmd); } } if ($action eq "Replace\n") { $mpd->play(); } } } sub list_playlists { my $mpd = Net::MPD->connect($ENV{MPD_HOST} // $mpd_host // 'localhost'); for (;;) { my @playlists = $mpd->list_playlists(); my $output = formatted_playlists(\@playlists); my $out = backend_call($output); do_action($out, "playlist", "ignore"); } } sub formatted_albums { my ($rdb, $sorted) = @_; my %uniq_albums; for my $i (@$rdb) { my $newkey = join "", $i->@{qw/AlbumArtist Date Album/}; if (!exists $uniq_albums{$newkey}) { my $dir = (dirname($i->{uri}) =~ s/\/CD.*$//r); $uniq_albums{$newkey} = {$i->%{qw/AlbumArtist Album Date mtime/}, Dir => $dir}; } else { if ($uniq_albums{$newkey}->{'mtime'} < $i->{'mtime'}) { $uniq_albums{$newkey}->{'mtime'} = $i->{'mtime'} } } } my @albums; my $fmtstr = join "", map {"%-${_}.${_}s\t"} ($albumartist_l, $date_l, $album_l); my @skeys; if ($sorted) { @skeys = sort { $uniq_albums{$b}->{mtime} <=> $uniq_albums{$a}->{mtime} } keys %uniq_albums; } else { @skeys = sort keys %uniq_albums; } for my $k (@skeys) { my @vals = ((map { $_ // "Unknown" } $uniq_albums{$k}->@{qw/AlbumArtist Date Album/}), $uniq_albums{$k}->{Dir}); my $strval = sprintf $fmtstr."%s\n", @vals; push @albums, $strval; } return \@albums; } sub formatted_ratings { my ($rating) = @_; my $rating_fmt = "r=$rating"; return($rating_fmt); } sub formatted_tracks { my ($rdb) = @_; my $fmtstr = join "", map {"%-${_}.${_}s\t"} ($track_l, $title_l, $artist_l, $date_l, $album_l, $rating_l); my @tracks = map { sprintf $fmtstr."%-s\n", (map { $_ // "-" } $_->@{qw/Track Title Artist Date Album/}), formatted_ratings($_->{rating} // 0), $_->{uri}; # sprintf $fmtstr."%-s\n", $_->@{qw/Track Title Artist Album uri/} } @{$rdb}; return \@tracks; } sub formatted_rating { my ($rdb) = @_; my @rating_rdb = grep { ($_->{rating} // 0) >= $minimal } @$rdb; $minimal = ""; my $fmtstr = join "", map {"%-${_}.${_}s\t"} ($track_l, $title_l, $artist_l, $date_l, $album_l, $rating_l); my @tracks = map { sprintf $fmtstr."%-s\n", (map { $_ // "-" } $_->@{qw/Track Title Artist Date Album/}), formatted_ratings($_->{rating} // 0), $_->{uri}; } @rating_rdb; return \@tracks; } sub formatted_playlists { my ($rdb) = @_; my @save = ("Save"); push @save, $rdb; my @playlists = map { sprintf "%s\n", $_->{playlist} } @{$rdb}; @save = ("Save current Queue\n", "---\n"); @playlists = sort @playlists; unshift @playlists, @save; return \@playlists; } sub list_db_entries_for { my ($kind) = @_; die "Wrong kind" unless any {; $_ eq $kind} qw/Albums Latest Tracks Ratings/; my $rdb = unpack_msgpack(); my %fields = (Albums=> "1,2,3", Latest => "1,2,3", Tracks => "1,2,3,4,5,6", Ratings => "1,2,3,4,5,6"); my %formater = ( Albums => sub {formatted_albums(@_, 0)}, Latest => sub {formatted_albums(@_, 1)}, Tracks => \&formatted_tracks, Ratings => \&formatted_rating ); my $output = $formater{$kind}->($rdb); if ($backend eq "rofi") { my $out = backend_call($output, $fields{$kind}); do_action($out, "tracks", "ignore"); } elsif ($backend eq "fzf") { for (;;) { create_db(); my $out = backend_call($output, $fields{$kind}); try { do_action($out, "tracks", "ignore"); if($kind eq "Ratings") { system('tmux', 'kill-pane'); } } } } } main;