summaryrefslogtreecommitdiffstats
path: root/clerk.pl
diff options
context:
space:
mode:
authorRasmus Steinke <rasi@xssn.at>2017-10-08 10:48:30 +0200
committerRasmus Steinke <rasi@xssn.at>2017-10-08 10:48:30 +0200
commitaf55106f3bb59675e9b525990eff10a12863077d (patch)
treea7249ac676d52e38f9ab0edffe2407946ceeb44b /clerk.pl
parenta4b8b2fcce33323076bac1d9a9667f5254d59f88 (diff)
downloadperl-app-clerk-af55106f3bb59675e9b525990eff10a12863077d.tar.gz
perl-app-clerk-af55106f3bb59675e9b525990eff10a12863077d.tar.xz
try pl extension to fix github not displaying code
Diffstat (limited to 'clerk.pl')
-rwxr-xr-xclerk.pl657
1 files changed, 657 insertions, 0 deletions
diff --git a/clerk.pl b/clerk.pl
new file mode 100755
index 0000000..b6459b2
--- /dev/null
+++ b/clerk.pl
@@ -0,0 +1,657 @@
+#!/usr/bin/perl
+
+binmode(STDOUT, ":utf8");
+use v5.10;
+use warnings;
+use strict;
+use utf8;
+use Config::Simple;
+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 qw($Bin $Script);
+use Getopt::Long qw(:config no_ignore_case bundling);
+use HTTP::Date;
+use Scalar::Util qw(looks_like_number);
+use IPC::Run qw( timeout start );
+use List::Util qw(any max maxstr);
+use Net::MPD;
+use Pod::Usage qw(pod2usage);
+use POSIX qw(tzset);
+use autodie;
+
+my $self="$Bin/$Script";
+my ($cfg, $mpd);
+my %rvar; # runtime variables
+
+sub main {
+ parse_config();
+ parse_options(@ARGV);
+ tmux_prerequisites();
+
+ renew_db() if $rvar{renewdb};
+ do_instaact() if $rvar{instaact};
+ do_instarand() if $rvar{instarand};
+
+ if ($rvar{tmux_ui}) {
+ tmux_ui();
+ } else {
+ my $go = select_action();
+ do {
+ maybe_renew_db();
+ $go->();
+ tmux_jump_to_queue_maybe();
+ } while ($rvar{endless});
+ }
+}
+
+sub parse_config {
+ $rvar{config_file} = $ENV{CLERK_CONF}
+ // $ENV{HOME} . '/.config/clerk/clerk.conf';
+ $cfg //= Config::Simple->new(filename=>$rvar{config_file});
+
+ my $g = $cfg->param(-block=>'General');
+ %rvar = (%rvar,
+ mpd_host => $g->{mpd_host},
+ tmux_config => $g->{tmux_config},
+ songs => $g->{songs},
+ chunksize => $g->{chunksize},
+ player => $g->{player},
+ tagging => $g->{tagging},
+ randomartist => $g->{randomartist},
+ jump_queue => $g->{jump_queue}
+ );
+
+ my $c = $cfg->param(-block=>'Columns');
+ $rvar{max_width} = {
+ album => $c->{album_l},
+ date => $c->{date_l},
+ title => $c->{title_l},
+ track => $c->{track_l},
+ artist => $c->{artist_l},
+ rating => $c->{rating_l},
+ albumartist => $c->{albumartist_l}
+ };
+
+ $rvar{db} = { file => $g->{database}, mtime => 0 };
+}
+
+sub parse_options {
+ local @ARGV = @_;
+ my $parse_act = sub {
+ my ($name, $bool) = @_;
+ if ($bool && defined $rvar{action}) {
+ warn "Will override already set action: $rvar{action}\n";
+ }
+ $rvar{action} = "$name" if $bool;
+ };
+
+ my $choices = sub {
+ my ($rvar, @choices) = @_;
+
+ return sub {
+ my ($name, $value) = @_;
+ if (any { $value eq $_ } @choices) {
+ $$rvar = $value;
+ } else {
+ die "Value: $value none of " . join(', ', @choices) . "\n";
+ }
+ };
+ };
+
+ $rvar{backend} = 'rofi';
+ GetOptions(
+ 'help|h' => sub { pod2usage(1) },
+
+ # general
+ 'renewdb|u' => \$rvar{renewdb},
+ 'tmux-ui!' => \$rvar{tmux_ui},
+ 'endless!' => \$rvar{endless},
+ 'backend=s' => $choices->(\$rvar{backend}, qw/fzf rofi/),
+ 'f' => sub { $rvar{backend} = 'fzf'; },
+
+ # action
+ 'tracks|t' => $parse_act,
+ 'albums|a' => $parse_act,
+ 'playlists|p' => $parse_act,
+ 'randoms|r' => $parse_act,
+ 'latests|l' => $parse_act,
+
+ # instaact
+ 'instaact=s' => $choices->(\$rvar{instaact},
+ qw/help_pane rand_pane tmux_help/),
+
+ # instarand
+ 'instarand=s' => $choices->(\$rvar{instarand}, qw/track album/),
+ 'T' => sub { $rvar{instarand} = 'track'; },
+ 'A' => sub { $rvar{instarand} = 'album'; },
+ ) or pod2usage(2);
+
+ $rvar{tmux_ui} = (
+ $rvar{action} ||
+ $rvar{renewdb} ||
+ $rvar{instaact} ||
+ (defined $rvar{tmux_ui} && !$rvar{tmux_ui})
+ )? 0 : 1;
+
+ # verify combinations if options
+
+ if ($rvar{backend} eq 'fzf' && $rvar{instarand}) {
+ die "Backend $rvar{backend} and instant random for $rvar{instarand} is not possible\n";
+ }
+
+ if (($rvar{action} // '') ne 'randoms' && defined $rvar{instarand}) {
+ die "-T or -A without -r not allowed\n";
+ }
+}
+
+sub do_instaact {
+ local $_ = $rvar{instaact};
+ if (/help_pane/) { tmux_spawn_help_pane() }
+ elsif (/rand_pane/) { tmux_spawn_random_pane() }
+ elsif (/tmux_help/) { help() }
+ exit;
+}
+
+sub do_instarand {
+ local $_ = $rvar{instarand};
+ if (/track/) { random_tracks() }
+ elsif (/album/) { random_album() }
+ exit;
+}
+
+sub select_action {
+ local $_ = $rvar{action} // '';
+ if (/tracks/) { return sub { action_db_tracks(ask_to_pick_tracks()) } }
+ elsif (/albums/) { return sub { action_db_albums(ask_to_pick_albums()) } }
+ elsif (/playlists/) { return sub { action_playlist(ask_to_pick_playlists()) } }
+ elsif (/randoms/) { return sub { action_random(ask_to_pick_random()) } }
+ elsif (/latests/) { return sub { action_db_albums(ask_to_pick_latests()) } }
+
+ return sub {};
+}
+
+sub db_needs_update {
+ mpd_reachable();
+ my $last = $mpd->stats->{db_update};
+ return !-f $rvar{db}{file} || stat($rvar{db}{file})->mtime < $last;
+}
+
+sub renew_db {
+ # 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
+ mpd_reachable();
+ 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 $times = int($songcount / $rvar{chunksize} + 1);
+
+ if ($rvar{backend} eq "rofi") {
+ system('notify-send', '-t', '5', 'clerk', 'Updating Cache File');
+ }
+ elsif ($rvar{backend} eq "fzf") {
+ print STDERR "::: No cache found or cache file outdated\n";
+ print STDERR "::: Chunksize set to $rvar{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 = $rvar{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;
+ pack_msgpack(\@filtered);
+}
+
+sub maybe_renew_db {
+ renew_db() if db_needs_update();
+}
+
+sub help {
+ open (my $fh, '<', $rvar{tmux_config});
+ my @out;
+ while (my $l = <$fh>) {
+ push @out, $l if $l =~ /bind-key/;
+ }
+ print @out;
+ <STDIN>;
+}
+
+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", "> " ]
+ );
+ my $handle = start $backends{$rvar{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 = $rvar{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($rvar{db}{file});
+ my $rdb = $mp->unpack($msgpack);
+ return $rdb;
+}
+
+sub get_rdb {
+ my $mtime = stat($rvar{db}{file})->mtime;
+ if ($rvar{db}{mtime} < $mtime) {
+ $rvar{db}{ref} = unpack_msgpack();
+ $rvar{db}{mtime} = $mtime;
+ }
+ return $rvar{db}{ref};
+}
+
+sub random_album {
+ mpd_reachable();
+ $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();
+ tmux_jump_to_queue_maybe();
+}
+
+sub random_tracks {
+ mpd_reachable();
+ $mpd->clear();
+ for (my $i=1; $i <= $rvar{songs}; $i++) {
+ my @artists = $mpd->list($rvar{randomartist});
+ 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);
+ $mpd->play();
+ }
+ tmux_jump_to_queue_maybe();
+}
+
+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"} ($rvar{max_width}->@{qw/albumartist date album/});
+
+ 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_tracks {
+ my ($rdb) = @_;
+ my $fmtstr = join "", map {"%-${_}.${_}s\t"} ($rvar{max_width}->@{qw/track title artist album rating/});
+ $fmtstr .= "%-s\n";
+ my @tracks = map {
+ sprintf $fmtstr,
+ (map { $_ // "-" } $_->@{qw/Track Title Artist Album/}),
+ "r=" . ($_->{rating} // '0'),
+ $_->{uri};
+ } @{$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 tmux_prerequisites {
+ $ENV{TMUX_TMPDIR} = '/tmp/clerk/tmux';
+ make_path($ENV{TMUX_TMPDIR}) unless(-d $ENV{TMUX_TMPDIR});
+}
+
+sub tmux {
+ my @args = @_;
+ system 'tmux', @args;
+}
+
+sub tmux_jump_to_queue_maybe {
+ tmux qw/findw -t music queue/ if ($rvar{jump_queue} eq "true");
+}
+
+sub tmux_spawn_random_pane {
+ tmux 'splitw', '-d', $self, '--backend=fzf', '--randoms';
+ tmux qw/select-pane -D/;
+}
+
+sub tmux_spawn_help_pane {
+ tmux 'splitw', '-d', $self, '--instaact=tmux_help';
+ tmux qw/select-pane -D/;
+}
+
+sub tmux_has_session {
+ tmux qw/has -t/, @_;
+ return $? == -0;
+}
+
+sub tmux_ui {
+ maybe_renew_db();
+ unless (tmux_has_session('music')) {
+ my @win = qw/neww -t music -n/;
+ my @clerk = ($self, '--backend=fzf', '--endless');
+ tmux '-f', $rvar{tmux_config}, qw/new -s music -n albums -d/, @clerk, '-a';
+ tmux @win, 'tracks', @clerk, '-t';
+ tmux @win, 'latest', @clerk, '-l';
+ tmux @win, 'playlists', @clerk, '-p';
+ tmux @win, 'queue', $rvar{player};
+ tmux qw/set-environment CLERKBIN/, $self;
+ }
+ tmux qw/attach -t music/;
+ tmux qw/selectw -t queue/;
+}
+
+sub ask_to_pick_tracks {
+ return backend_call(formatted_tracks(get_rdb()), "1,2,3,4");
+}
+
+sub ask_to_pick_albums {
+ return backend_call(formatted_albums(get_rdb(), 0), "1,2,3");
+}
+
+sub ask_to_pick_latests {
+ return backend_call(formatted_albums(get_rdb(), 1), "1,2,3");
+}
+
+sub ask_to_pick_playlists {
+ mpd_reachable();
+ my @pls = $mpd->list_playlists;
+ return backend_call(formatted_playlists(\@pls), "1,2,3");
+}
+
+sub ask_to_pick_random {
+ return backend_call(["Tracks\n", "Albums\n", "---\n", "Mode: $rvar{randomartist}\n", "Number of Songs: $rvar{songs}\n"]);
+}
+
+sub ask_to_pick_song_number {
+ return backend_call([map { $_ . "\n" } qw/5 10 15 20 25 30/]);
+}
+
+sub ask_to_pick_track_settings {
+ return backend_call(["artist\n", "albumartist\n"]);
+}
+
+
+sub ask_to_pick_ratings {
+ return backend_call([map { $_ . "\n" } (qw/1 2 3 4 5 6 7 8 9 10 ---/), "Delete Rating"]);
+}
+
+sub action_db_albums {
+ my ($out) = @_;
+
+ my @sel = util_parse_selection($out);
+
+ my $action = backend_call(["Add\n", "Replace\n", "---\n", "Rate Album(s)\n"]);
+ mpd_reachable();
+ {
+ local $_ = $action;
+ if (/^Add/) { mpd_add_items(\@sel) }
+ elsif (/^Replace/) { mpd_replace_with_items(\@sel) }
+ elsif (/^Rate Album\(s\)/) { mpd_rate_with_albums(\@sel) }
+ }
+}
+
+sub action_db_tracks {
+ my ($out) = @_;
+
+ my @sel = util_parse_selection($out);
+
+ my $action = backend_call(["Add\n", "Replace\n", "---\n", "Rate Track(s)\n"]);
+ mpd_reachable();
+ {
+ local $_ = $action;
+ if (/^Add/) { mpd_add_items(\@sel) }
+ elsif (/^Replace/) { mpd_replace_with_items(\@sel) }
+ elsif (/^Rate Track\(s\)/) { mpd_rate_with_tracks(\@sel) }
+ }
+}
+
+sub action_playlist {
+ my ($out) = @_;
+
+ if ($out =~ /^Save current Queue/) {
+ mpd_save_cur_playlist();
+ maybe_renew_db();
+ } else {
+ my @sel = util_parse_selection($out);
+ my $action = backend_call(["Add\n", "Replace\n", "Delete\n"]);
+
+ mpd_reachable();
+ local $_ = $action;
+ if (/^Add/) { mpd_add_playlists(\@sel) }
+ elsif (/^Delete/) { mpd_delete_playlists(\@sel) }
+ elsif (/^Replace/) { mpd_replace_with_playlists(\@sel) }
+ }
+}
+
+sub action_random {
+ my ($out) = @_;
+
+ mpd_reachable();
+ {
+ local $_ = $out;
+ if (/^Track/) { random_tracks() }
+ elsif (/^Album/) { random_album() }
+ elsif (/^Mode: $rvar{randomartist}/) { action_track_mode(ask_to_pick_track_settings()) }
+ elsif (/^Number of Songs: $rvar{songs}/) { action_song_number(ask_to_pick_song_number()) }
+ }
+}
+
+sub action_song_number {
+ my ($out) = @_;
+
+ $rvar{songs} = max map { split /[\t\n]/ } (split /\n/, $out);
+
+ $cfg->param("General.songs", $rvar{songs});
+ $cfg->save();
+}
+
+sub action_track_mode {
+ my ($out) = $_[0];
+ chomp $out;
+ $rvar{randomartist} = $out;
+
+ $cfg->param("General.randomartist", $rvar{randomartist});
+ $cfg->save();
+}
+
+sub util_parse_selection {
+ my ($sel) = @_;
+ map { (split /[\t\n]/, $_)[-1] } (split /\n/, decode('UTF-8', $sel));
+}
+
+sub mpd_add_items {
+ $mpd->add($_) for @{$_[0]};
+}
+
+sub mpd_rate_items {
+ my ($sel, $rating, $mode) = @_;
+ chomp $rating;
+ $rating = undef if $rating =~ /^Delete Rating/;
+ if ($rvar{tagging} eq "true") {
+ $mpd->send_message('rating', "$_\t$mode\t${rating}") for @{$_[0]};;
+ }
+ $mpd->sticker_value("song", encode('UTF-8', $_), $mode, $rating) for @{$_[0]};
+}
+
+sub mpd_replace_with_items {
+ $mpd->clear;
+ mpd_add_items(@_);
+ $mpd->play;
+}
+
+sub mpd_rate_with_albums {
+ my @list_of_files;
+ my $rating = ask_to_pick_ratings();
+ chomp $rating;
+ my @final_list;
+ foreach my $album_rate (@{$_[0]}) {
+ my @files = $mpd->search('filename', $album_rate);
+ my @song_tags = $files[0];
+ my @songs_to_tag = $mpd->search('albumartist', $song_tags[0]->{AlbumArtist}, 'album', $song_tags[0]->{Album}, 'date', $song_tags[0]->{Date});
+ foreach my $songs (@songs_to_tag) {
+ push @list_of_files, $songs->{uri};
+ }
+ }
+ push @final_list, [ @list_of_files ];
+ if ($rating eq "---") {
+ #noop
+ } else {
+ mpd_rate_items(@final_list, $rating, "albumrating");
+ }
+}
+
+sub mpd_rate_with_tracks {
+ my $rating = ask_to_pick_ratings();
+ if ($rating eq "---\n") {
+ #noop
+ } else {
+ mpd_rate_items(@_, $rating, "rating");
+ }
+}
+
+sub mpd_save_cur_playlist {
+ tzset();
+ $mpd->save(scalar localtime);
+}
+
+sub mpd_add_playlists {
+ $mpd->load($_) for @{$_[0]};
+}
+
+sub mpd_replace_with_playlists {
+ $mpd->clear;
+ mpd_add_playlists(@_);
+ $mpd->play;
+}
+
+sub mpd_delete_playlists {
+ $mpd->rm($_) for @{$_[0]};
+}
+
+# quirk to ensure mpd does not croak just because of timeout
+sub mpd_reachable {
+ $mpd //= Net::MPD->connect($ENV{MPD_HOST} // $rvar{mpd_host} // 'localhost');
+ try {
+ $mpd->ping;
+ } catch {
+ $mpd->_connect;
+ };
+}
+
+main;
+
+__END__
+
+=encoding utf8
+
+=head1 NAME
+
+clerk - mpd client, based on rofi
+
+=head1 SYNOPSIS
+
+clerk [command] [-f]
+
+ 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.
+ -p Add stored playlist to queue
+ -r [-A, -T] Replace current playlist with random songs/album
+ -u Update caches
+
+ Options:
+ -f Use fzf interface
+
+ Without further arguments, clerk starts a tabbed tmux interface
+
+clerk version 2.0
+
+=cut