├── clerk_rating_client.service ├── cpanfile ├── LICENSE ├── README.md ├── musiclist ├── clerk_rating_client ├── clerk.py └── clerk.pl /clerk_rating_client.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Clerk Rating Daemon 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | ExecStart=/usr/bin/clerk_rating_client 8 | 9 | [Install] 10 | WantedBy=default.target 11 | -------------------------------------------------------------------------------- /cpanfile: -------------------------------------------------------------------------------- 1 | requires 'Config::Simple', '4.5.8'; 2 | requires 'Data::MessagePack', '0.48'; 3 | requires 'Data::Section::Simple'; 4 | requires 'File::Slurper', '0.009'; 5 | requires 'Try::Tiny', '0.28'; 6 | requires 'HTTP::Date', '6.02'; 7 | requires 'IPC::Run', '0.96'; 8 | requires 'Net::MPD', '0.07'; 9 | requires 'Data::Section::Simple', '0.07'; 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright © 2015 Rasmus Steinke 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the “Software”), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 8 | the Software, and to permit persons to whom the Software is furnished to do so, 9 | subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clerk 2 | 3 | MPD client using rofi or fzf 4 | 5 | ## Screenshot (V4) 6 | ![Screenshot](https://pic.53280.de/clerk.png) 7 | 8 | ## Features: 9 | 10 | * Play random album/tracks 11 | * Add/Replace albums/songs 12 | * Filter lists by rating 13 | * Customizable hotkeys 14 | * Rofi and fzf interfaces 15 | * Optional tmux interface for fzf mode 16 | * Rate albums/tracks 17 | * Optionally store ratings in file tags 18 | 19 | ## Dependencies: 20 | 21 | * rofi (https://github.com/DaveDavenport/rofi) 22 | * fzf 23 | * tmux 24 | * perl-net-mpd 25 | * perl-data-messagepack 26 | * perl-data-section-simple 27 | * perl-file-slurper 28 | * perl-config-simple 29 | * perl-try-tiny 30 | * perl-ipc-run 31 | * perl-http-date 32 | 33 | for the tagging_client: 34 | * metaflac (flac) 35 | * vorbiscomment (vorbis-tools) 36 | * mid3v2 (mutagen) 37 | 38 | 39 | ## Installation 40 | 41 | ### Arch Linux 42 | 43 | * install [clerk-git from AUR](https://aur.archlinux.org/packages/clerk-git/) 44 | 45 | ### Debian/Ubuntu 46 | 47 | * install deb package from [release page](https://github.com/carnager/clerk/releases) 48 | 49 | ### Others 50 | 51 | #### For user only: 52 | 53 | * Install tmux, fzf and rofi 54 | * Install local::lib module (Most distributions should have it) 55 | * Install cpanm 56 | * Put `eval $(perl -I ~/perl5/lib/perl5/ -Mlocal::lib=~/perl5)` to your shell config and restart your terminal 57 | * Clone this repository and change to clerk directory 58 | * Run `cpanm --installdeps .` 59 | * Move clerk.pl and clerk_rating_client to PATH 60 | 61 | #### Globally: 62 | 63 | * Install cpanm 64 | * Clone this repository and change to clerk directory 65 | * Run `cpanm --installdeps .` as root 66 | * Move clerk.pl and clerk_rating_client to PATH 67 | 68 | ## Ratings 69 | 70 | Clerk can rate albums and tracks, which will be saved in MPDs sticker database as rating or albumrating. 71 | Track ratings should be compatible with all other MPD clients that support them. 72 | Albumratings are a unique feature to clerk, as far as I know. 73 | 74 | ### clerk_rating_client 75 | 76 | It's also possible to store ratings in file tags. Currently this is supported for flac, ogg and mp3 files. 77 | For this to work, simply set `tagging=true` in clerk.conf file and set your music_path. 78 | 79 | It’s even possible to tag files not on the same machine (On MPD setups with remote clients). 80 | Simply copy your `clerk.conf` and `clerk_rating_client` to the machine hosting your audio files, edit `music_path` 81 | in config and start `clerk_rating_client`. A systemd user service is available. 82 | 83 | For the moment I use metaflac, mid3v2 and vorbiscomment to tag files, because I haven't found a good perl library 84 | for this task. 85 | 86 | ### Filtering 87 | 88 | clerk integrates ratings fully into its database and exposes the ratings in track and album lists. 89 | To filter by a specific rating use `r=n` as part of your input. Sadly filtering for `r=1` will also show `r=10`. 90 | in rofi interface you can work around this by filtering for `r=1\s`. in fzf interface `r=1$` works. 91 | 92 | If you don't like to see ratings in your track/album listings, simply increase the album_l setting in config. 93 | 94 | ## Usage 95 | 96 | ``` 97 | Usage: 98 | clerk [command] [-f] 99 | 100 | Commands: 101 | -a Add/Replace album(s) to queue. 102 | -l Add/Replace album(s) to queue (sorted by mtime) 103 | -t Add/Replace track(s) to queue. 104 | -p Add stored playlist to queue 105 | -r [-A, -T] Replace current playlist with random songs/album 106 | -u Update caches 107 | 108 | Options: 109 | -f Use fzf interface 110 | 111 | Without further arguments, clerk starts a tabbed tmux interface 112 | Hotkeys for tmux interface can be set in $HOME/.config/clerk/clerk.tmux 113 | 114 | clerk version 4.0 115 | ``` 116 | 117 | ## Hotkeys 118 | 119 | ### Global 120 | 121 | ``` 122 | Tab: select item(s) 123 | Enter: perform action on item 124 | ``` 125 | 126 | ### Hotkeys for tmux interface 127 | 128 | ``` 129 | F1: albums view 130 | F2: tracks view 131 | F3: albums view (sorted by mtime) 132 | F4: playlist view 133 | F5: queue view (uses ncmpcpp by default, can be changed in clerk.conf) 134 | F10: random pane 135 | C-F5: previous song 136 | C-F6: toggle playback 137 | C-F7: stop playback 138 | C-F8: next song 139 | C-F1: show hotkeys 140 | C-q: quit clerk tmux interface 141 | ``` 142 | 143 | All tmux hotkeys can be changed in `clerk.tmux` file. 144 | 145 | ## Files and Variables 146 | 147 | clerk uses `$XDG_CONFIG_HOME` and `$XDG_DATA_HOME` for its files. Both variables are usually unset 148 | and default to `$HOME/.config` and `$HOME/.local/share`. These files are stored by clerk: 149 | 150 | ### XDG_CONFIG_HOME 151 | 152 | * `clerk.conf`: config file. 153 | * `clerk.tmux`: clerk's tmux config 154 | 155 | ### XDG_DATA_HOME 156 | 157 | * `database.mpk`: clerk's local database 158 | 159 | Use `CLERK_CONF`, `CLERK_TMUX` and `CLERK_DATABASE` variables to override file locations. 160 | -------------------------------------------------------------------------------- /musiclist: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from os import environ as environ 4 | from pprint import pprint as pp 5 | from itertools import groupby 6 | import numpy as np 7 | import mpd 8 | import dateutil.parser 9 | import datetime 10 | import subprocess 11 | 12 | client = mpd.MPDClient() 13 | 14 | # Configuration 15 | mpd_host = 'localhost' 16 | mpd_port = '6600' 17 | mpd_pass = '' 18 | 19 | # which path of mpd database to include 20 | relative_path = '/' 21 | 22 | # sync target and css file to use 23 | musiclistcss = '/PATH/TO/musicstyle.css' 24 | target = 'SSHSERVER:/PATH/' 25 | 26 | if 'MPD_HOST' in environ: 27 | mpd_connection = environ['MPD_HOST'].split('@') 28 | if len(mpd_connection) == 1: 29 | mpd_host = mpd_connection[0] 30 | elif len(mpd_connection) == 2: 31 | mpd_host = mpd_connection[1] 32 | mpd_pass = mpd_connection[0] 33 | else: 34 | print('Unable to parse MPD_HOST, using defaults') 35 | 36 | if 'MPD_PORT' in environ: 37 | mpd_port = environ['MPD_PORT'] 38 | 39 | client.connect(mpd_host, mpd_port) 40 | if mpd_pass: 41 | client.password(mpd_pass) 42 | 43 | def reduceToFstElm(maybeList): 44 | return maybeList[0] if isinstance(maybeList, list) else maybeList 45 | 46 | def createAlbumsList(tracks): 47 | ks = ['date', 'albumartist', 'album'] 48 | for t in tracks: 49 | t.update([(k, reduceToFstElm(v)) for (k, v) in t.items() if k in ks]) 50 | return tracks 51 | 52 | alist=client.search('filename', relative_path) 53 | albumlist=createAlbumsList(alist) 54 | newlist = [] 55 | for album in albumlist: 56 | if album['track'] == '1': 57 | try: 58 | rating = client.sticker_get('song', album['file'], 'albumrating') 59 | except mpd.CommandError: 60 | rating = "-" 61 | album['rating'] = rating 62 | mtime_date = str(dateutil.parser.parse(album['last-modified'])) 63 | mtime_list = mtime_date.split(" ") 64 | mtime = mtime_list[0] 65 | entry={'artist': album['albumartist'], 'album': album['album'], 'date': album['date'], 'added': mtime, 'rating': album['rating']} # albumartist, date 66 | newlist.append(entry) 67 | 68 | with open("/tmp/index.html", "a") as f: 69 | print(''' 70 | 71 | 72 | 73 | 74 | music player daemon library 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | ''', file=f) 89 | 90 | print(''' 91 |

music player daemon library

92 |

generated on 93 | ''', file=f) 94 | now = datetime.datetime.now() 95 | print(now, file=f) 96 | 97 | 98 | print(''' 99 |

100 |
101 |
102 |
103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | ''', file=f) 115 | 116 | byAlbum = lambda t: t['album'] 117 | byArtist = lambda t: t['artist'] 118 | for artist, albs in groupby(sorted(newlist, key=byArtist), key=byArtist): 119 | albs = [list(albs)[0] for alb, albs in groupby(albs, key=byAlbum)] 120 | albums = list(albs) 121 | rowspan=len(albums) 122 | 123 | for x in albums: 124 | print("", file=f) 125 | print("", file=f) 126 | 127 | rating = 0 128 | 129 | if x['rating'] != None and x['rating'] != '-': 130 | rating = x['rating'] 131 | 132 | ratePercent = int(rating) * 10 133 | ratePercentStr = str(ratePercent) 134 | 135 | print("", file=f) 136 | print("", file=f) 137 | print("", file=f) 138 | print("", file=f) 139 | print("", file=f) 140 | album=x['album'] 141 | print(''' 142 |
ArtistAlbumYearModifiedRating
"+artist+""+x['album']+""+x['date']+""+x['added']+"
"+ratePercentStr+"%
143 |
144 | 145 | 146 | 147 | 148 | ''', file=f) 149 | 150 | f.close() 151 | subprocess.Popen(['scp', '/tmp/index.html', target]).communicate() 152 | subprocess.Popen(['scp', musiclistcss, target]).communicate() 153 | subprocess.Popen(['rm', '/tmp/index.html']) 154 | subprocess.Popen(['notify-send', 'Musiclist Sync', 'Syncing of Musiclist done']) 155 | -------------------------------------------------------------------------------- /clerk_rating_client: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | binmode(STDOUT, ":utf8"); 4 | use v5.10; 5 | use warnings; 6 | use Array::Utils qw(:all); 7 | #use DDP { show_unicode => 1 }; 8 | use Data::Dumper; 9 | use File::stat; 10 | use Try::Tiny; 11 | use Config::Simple; 12 | use File::Spec; 13 | use IPC::Run; 14 | use strict; 15 | use utf8; 16 | use Encode qw(decode encode); 17 | use File::Find; 18 | use Getopt::Std; 19 | use Net::MPD; 20 | 21 | my $config_file = $ENV{'HOME'} . "/.config/clerk/clerk.conf"; 22 | 23 | if ($ENV{CLERK_CONF}) { 24 | $config_file = $ENV{CLERK_CONF}; 25 | } 26 | 27 | my $cfg = new Config::Simple(filename=>"$config_file"); 28 | my $general_cfg = $cfg->param(-block=>"General"); 29 | my $mpd_host = $general_cfg->{mpd_host}; 30 | my $music_root = $general_cfg->{music_root}; 31 | 32 | my $mpd = Net::MPD->connect($ENV{MPD_HOST} // $mpd_host // 'localhost'); 33 | 34 | sub main { 35 | my %options=(); 36 | getopts("rst", \%options); 37 | if ($options{r} // $options{s} // $options{t}) { 38 | if (defined $options{r}) { subscribe_ratings_channel(); idle_loop(); } 39 | elsif (defined $options{s}) { subscribe_ratings_channel(); sync_ratings(); } 40 | elsif (defined $options{t}) { tag_from_sticker(); } 41 | } else { subscribe_ratings_channel(); idle_loop(); }; 42 | } 43 | 44 | sub subscribe_ratings_channel { 45 | $mpd->subscribe('rating'); 46 | } 47 | 48 | sub idle_loop { 49 | while(1) { 50 | $mpd->idle('message'); 51 | song_handler(); 52 | } 53 | } 54 | 55 | sub song_handler { 56 | my @messages = $mpd->read_messages; 57 | for my $msg (@messages) { 58 | my ($uri, $mode, $rating) = get_info_from_message($msg->{message}); 59 | my ($albumartist, $artist, $title, $album) = get_track_tags($uri); 60 | my ($stats) = get_timestamp($uri); 61 | my ($file_atime, $file_mtime) = ($stats->atime, $stats->mtime); 62 | 63 | if ($uri =~ /\.flac$/) { 64 | tag_flacs($uri, $mode, $rating, $artist, $albumartist, $title, $album, $file_atime, $file_mtime); 65 | } 66 | elsif ($uri =~ /\.mp3$/) { 67 | tag_mp3s($uri, $mode, $rating, $artist, $albumartist, $title, $album, $file_atime, $file_mtime); 68 | } 69 | elsif ($uri =~ /\.ogg$/) { 70 | tag_oggs($uri, $mode, $rating, $artist, $albumartist, $title, $album, $file_atime, $file_mtime); 71 | } 72 | } 73 | } 74 | 75 | sub get_info_from_message { 76 | my ($string) = @_; 77 | my @array = split("\t", $string); 78 | my ($uri, $mode, $rating) = (@array[0,1,2]); 79 | $uri = decode('UTF-8', $uri ); 80 | return($uri, $mode, $rating); 81 | } 82 | 83 | sub get_track_tags { 84 | my ($uri) = @_; 85 | my @files = $mpd->search('filename', $uri); 86 | my @song_tags = $files[0]; 87 | my ($albumartist, $artist, $title, $album) = $song_tags[0]->@{qw/AlbumArtist Artist Title Album/}; 88 | return($albumartist, $artist, $title, $album); 89 | } 90 | 91 | sub get_timestamp { 92 | my $file_name = $_[0]; 93 | return my $stats = stat("${music_root}/$file_name"); 94 | } 95 | 96 | sub set_timestamp { 97 | my ($file_name, $atime, $mtime) = (@_); 98 | utime($atime, $mtime, $file_name); 99 | } 100 | 101 | sub tag_flacs { 102 | my ($uri, $mode, $rating, $artist, $albumartist, $title, $album, $atime, $mtime) = @_; 103 | if ($mode eq "rating") { 104 | my $fmps_rating = $rating/10; 105 | print ":: tagging track \"${title}\" by \"${artist}\" with rating of \"${rating}\"\n"; 106 | system('metaflac', '--remove-tag=FMPS_RATING', "${music_root}/${uri}"); 107 | system('metaflac', "--set-tag=FMPS_RATING=${fmps_rating}", "${music_root}/${uri}"); 108 | } elsif ($mode eq "albumrating") { 109 | print ":: tagging track \"${title}\" by \"${albumartist}\" with albumrating of \"${rating}\"\n"; 110 | system('metaflac', '--remove-tag=ALBUMRATING', "${music_root}/${uri}"); 111 | system('metaflac', "--set-tag=ALBUMRATING=${rating}", "${music_root}/${uri}"); 112 | } 113 | set_timestamp("${music_root}/$uri", $atime, $mtime); 114 | } 115 | 116 | sub tag_mp3s { 117 | my ($uri, $mode, $rating, $artist, $albumartist, $title, $album, $atime, $mtime) = @_; 118 | if ($mode eq "rating") { 119 | my $fmps_rating = $rating/10; 120 | print ":: tagging track \"${title}\" by \"${artist}\" with rating of \"${rating}\"\n"; 121 | system('mid3v2', "--TXXX", "FMPS_RATING:${fmps_rating}", "${music_root}/${uri}"); 122 | } elsif ($mode eq "albumrating") { 123 | print ":: tagging track \"${title}\" by \"${albumartist}\" with albumrating of \"${rating}\"\n"; 124 | system('mid3v2', "--TXXX", "ALBUMRATING:${rating}", "${music_root}/${uri}"); 125 | } 126 | set_timestamp("${music_root}/$uri", $atime, $mtime); 127 | } 128 | 129 | sub tag_oggs { 130 | my ($uri, $mode, $rating, $artist, $albumartist, $title, $album, $atime, $mtime) = @_; 131 | my @values = `vorbiscomment "${music_root}/${uri}"`; 132 | if ($mode eq "rating") { 133 | my $fmps_rating = $rating/10; 134 | @values = grep !/^FMPS_RATING=?$/, @values; 135 | print ":: tagging track \"${title}\" by \"${artist}\" with rating of \"${rating}\"\n"; 136 | push (@values, "FMPS_RATING=$fmps_rating"); 137 | } elsif ($mode eq "albumrating") { 138 | @values = grep !/^ALBUMRATING=?$/, @values; 139 | print ":: tagging track \"${title}\" by \"${albumartist}\" with albumrating of \"${rating}\"\n"; 140 | push (@values, "ALBUMRATING=$rating"); 141 | } 142 | open(my $CMD, '|-', 'vorbiscomment', '-a', "$music_root/$uri"); 143 | for my $vorbiscomment (@values) { 144 | print $CMD "${vorbiscomment}"; 145 | } 146 | close($CMD); 147 | set_timestamp("${music_root}/$uri", $atime, $mtime); 148 | } 149 | 150 | sub sync_ratings { 151 | my @sticker_uris; 152 | my @actual_uris; 153 | my @available_stickers = $mpd->sticker_find('song', 'rating', ''); 154 | foreach my $rated_song (@available_stickers) { 155 | push @sticker_uris, "$rated_song->{file}"; 156 | } 157 | 158 | my @absolute; 159 | find({ 160 | wanted => sub { push @absolute, $_ if -f and -r }, 161 | no_chdir => 1, 162 | }, $music_root); 163 | my @relative = map { File::Spec->abs2rel($_, $music_root) } @absolute; 164 | push @actual_uris, $_ for @relative; 165 | 166 | my @diff = array_diff(@sticker_uris, @actual_uris); 167 | foreach my $unrated_song (@diff) { 168 | if ( $unrated_song =~ /.*.flac$/) { 169 | my $fmps_rating = system('metaflac', '--show-tag=FMPS_RATING', "${music_root}/${unrated_song}"); 170 | my $rating = $fmps_rating*10; 171 | print "$rating\n"; 172 | if ($rating ne "0") { 173 | print "rating ${music_root}/${unrated_song} with $rating\n"; 174 | $mpd->sticker_value("song", "$unrated_song", "rating", "$rating"); 175 | } 176 | } 177 | } 178 | } 179 | 180 | sub tag_from_sticker { 181 | my @available_stickers = $mpd->sticker_find('song', 'rating', ''); 182 | foreach my $rated_song (@available_stickers) { 183 | my $uri = $rated_song->{file}; 184 | my $rating = $rated_song->{sticker}; 185 | my $fmps_rating = $rating/10; 186 | if ($uri =~ /\.flac$/) { 187 | system('metaflac', '--remove-tag=FMPS_RATING', "${music_root}/${uri}"); 188 | system('metaflac', "--set-tag=FMPS_RATING=$fmps_rating", "${music_root}/${uri}"); 189 | } 190 | elsif ($uri =~ /\.mp3$/) { 191 | system('mid3v2', "--TXXX", "FMPS_RATING:${fmps_rating}", "${music_root}/${uri}"); 192 | } 193 | elsif ($uri =~ /\.ogg$/) { 194 | print "!! OGG files not supported, yet\n"; 195 | } 196 | } 197 | } 198 | 199 | main(); 200 | -------------------------------------------------------------------------------- /clerk.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # import modules 4 | from mpd import MPDClient; 5 | # msgpack is used for adding unique ids to mpd database. Also improves startup time by using local cache 6 | import msgpack; 7 | import sys; 8 | import os; 9 | import subprocess; 10 | import random; 11 | import toml; 12 | 13 | ### Connect to MPD 14 | # create variables for MPDClient 15 | m = MPDClient() 16 | 17 | parameter = "> " 18 | #### FUNCTIONS 19 | def create_config(): 20 | config_content = """ 21 | [general] 22 | # Important: String for prompt has to be PLACEHOLDER, define the string in menu_prompt 23 | menu_tool = ["rofi", "-dmenu", "-matching", "regex", "-i", "-p", "PLACEHOLDER", "-multi-select", "-kb-element-next", "", "-kb-row-tab", "", "-kb-move-word-forward", "", "-kb-accept-alt", "Tab", "-no-levensthein-sort", "-ballot-unselected-str", " ", "-ballot-selected-str", "·"] 24 | # fzf example 25 | #menu_tool = ["fzf", "--reverse", "--no-sort", "-m", "-e", "--no-hscroll", "-i", "+s", "--ansi", "--prompt", "PLACEHOLDER"] 26 | menu_prompt = "> " 27 | mpd_host = "localhost" 28 | number_of_tracks = "20" 29 | random_artist = "albumartist" 30 | 31 | # clerk can create a html list of all albums and sync it to a ssh location. The musiclist script that can be used is part of this repository 32 | sync_online_list = true 33 | sync_command = ["/path/to/musiclist"] 34 | 35 | [columns] 36 | artist_width = "40" 37 | albumartist_width = "40" 38 | date_width = "6" 39 | album_width = "200" 40 | id_width = "0" 41 | title_width = "40" 42 | track_width = "4" 43 | """ 44 | content_fix = config_content.split("\n",1)[1] 45 | with open(xdg_config+"/clerk/config", 'w') as configfile: 46 | configfile.writelines(content_fix) 47 | 48 | ### chech for XDG directory and create if needed 49 | xdg_data = os.environ.get('XDG_DATA_HOME', os.environ.get('HOME')+"/.local/share") 50 | xdg_config = os.environ.get('XDG_CONFIG_HOME', os.environ.get('HOME')+"/.config") 51 | 52 | if not os.path.exists(xdg_data+"/clerk"): 53 | try: 54 | os.makedirs(xdg_data+"/clerk", exist_ok=True) 55 | except Exception as e: 56 | print(f"Error creating configuration directory: {e}") 57 | sys.exit(1) 58 | if not os.path.exists(xdg_config+"/clerk"): 59 | # This line should probably create the directory in `xdg_config` instead of `xdg_data` 60 | try: 61 | os.makedirs(xdg_config+"/clerk", exist_ok=True) 62 | except Exception as e: 63 | print(f"Error creating configuration directory: {e}") 64 | sys.exit(1) 65 | 66 | ### Configuration 67 | # create config if it doesn't exist 68 | if not os.path.exists(xdg_config+"/clerk/config"): 69 | create_config() 70 | 71 | # Read Configuration 72 | config = toml.load(xdg_config+"/clerk/config") 73 | menu_tool = config['general']['menu_tool'] 74 | menu_prompt = config['general']['menu_prompt'] 75 | menu_tool = [w.replace('PLACEHOLDER', menu_prompt) for w in menu_tool] 76 | mpd_host = config['general']['mpd_host'] 77 | number_of_tracks = config['general']['number_of_tracks'] 78 | number_of_tracks = int(number_of_tracks) 79 | sync_online_list = config['general']['sync_online_list'] 80 | sync_command = config['general']['sync_command'] 81 | artist_width = config['columns']['artist_width'] 82 | albumartist_width = config['columns']['albumartist_width'] 83 | album_width = config['columns']['album_width'] 84 | track_width = config['columns']['track_width'] 85 | title_width = config['columns']['title_width'] 86 | id_width = config['columns']['id_width'] 87 | date_width = config['columns']['date_width'] 88 | random_artist = config['general']['random_artist'] 89 | 90 | ### function to create menus in menu_tool 91 | # trim value if "yes", means only the last element of a line will be returned. 92 | # used for album/track lists, where the last element is the unique ID. 93 | def _menu(input_list, trim, custom_menu = menu_tool): 94 | list_of_albums = input_list 95 | menu = subprocess.Popen(custom_menu, stdin=subprocess.PIPE, stdout=subprocess.PIPE) 96 | for line in list_of_albums: 97 | menu.stdin.write((line + "\n").encode()) 98 | stdout, _ = menu.communicate() 99 | new=stdout.decode().splitlines() 100 | results = [] 101 | for line in new: 102 | match trim: 103 | case "yes": 104 | x = line.rstrip().split(' ')[-1] 105 | case "no": 106 | x = line.rstrip() 107 | return x 108 | results.append(x) 109 | return results 110 | 111 | def get_songs_in_batches(client, batch_size=10000): 112 | # Get the total number of songs 113 | stats = m.stats() 114 | total_songs = int(stats['songs']) 115 | 116 | # Calculate the number of batches 117 | num_batches = (total_songs + batch_size - 1) // batch_size 118 | 119 | all_songs = [] 120 | 121 | for i in range(num_batches): 122 | start = i * batch_size 123 | end = start + batch_size - 1 124 | window = f"{start}:{end}" 125 | batch_songs = m.search('filename', '', 'window', window) 126 | all_songs.extend(batch_songs) 127 | 128 | return all_songs 129 | 130 | 131 | ### create a local album and track cache and add unique id to each entry 132 | def create_cache(): 133 | db = get_songs_in_batches(m) 134 | latest_cache = [] 135 | album_cache = [] 136 | tracks_cache = [] 137 | album_cache_temp = [] 138 | latest_cache_temp = [] 139 | album_set = set() 140 | latest_set = set() 141 | 142 | latest_db = sorted(db, key=lambda d: d['last-modified']) 143 | 144 | for track in db: 145 | if type(track['track']) is list: 146 | trackn=track['track'][0] 147 | else: 148 | trackn=track['track'] 149 | if type(track['artist']) is list: 150 | artist=track['artist'][0]+" and "+track['artist'][1] 151 | else: 152 | artist=track['artist'] 153 | if type(track['date']) is list: 154 | date=track['date'][0] 155 | else: 156 | date=track['date'] 157 | album_cache_temp.append({'albumartist': track['albumartist'], 'date': date, 'album': track['album']}) 158 | tracks_cache.append({'track': trackn, 'title': track['title'], 'artist': artist, 'album': track['album'], 'date': date}) 159 | 160 | for track in latest_db: 161 | if type(track['track']) is list: 162 | trackn=track['track'][0] 163 | else: 164 | trackn=track['track'] 165 | if type(track['artist']) is list: 166 | artist=track['artist'][0]+" and "+track['artist'][1] 167 | else: 168 | artist=track['artist'] 169 | if type(track['date']) is list: 170 | date=track['date'][0] 171 | else: 172 | date=track['date'] 173 | latest_cache_temp.append({'albumartist': track['albumartist'], 'date': date, 'album': track['album']}) 174 | 175 | for d in album_cache_temp: 176 | t = tuple(d.items()) 177 | if t not in album_set: 178 | album_set.add(t) 179 | album_cache.append(d) 180 | for d in latest_cache_temp: 181 | y = tuple(d.items()) 182 | if y not in latest_set: 183 | latest_set.add(y) 184 | latest_cache.append(d) 185 | for i, dictionary in enumerate(album_cache, start=0): 186 | dictionary['id'] = str(i) 187 | for i, dictionary in enumerate(latest_cache, start=0): 188 | dictionary['id'] = str(i) 189 | for i, dictionary in enumerate(tracks_cache, start=0): 190 | dictionary['id'] = str(i) 191 | 192 | with open(xdg_data+"/clerk/album.cache", "wb") as outfile: 193 | packed = msgpack.packb(album_cache) 194 | outfile.write(packed) 195 | 196 | with open(xdg_data+"/clerk/tracks.cache", "wb") as outfile: 197 | packed = msgpack.packb(tracks_cache) 198 | outfile.write(packed) 199 | 200 | with open(xdg_data+"/clerk/latest.cache", "wb") as outfile: 201 | latest_cache.reverse() 202 | packed = msgpack.packb(latest_cache) 203 | outfile.write(packed) 204 | 205 | # read local album cache 206 | def read_album_cache(mode): 207 | match mode: 208 | case "album": 209 | cache_file = "album.cache" 210 | case "latest": 211 | cache_file = "latest.cache" 212 | with open(xdg_data+"/clerk/"+cache_file, "rb") as inputfile: 213 | mpd_msgpack = inputfile.read() 214 | album_cache = msgpack.unpackb(mpd_msgpack) 215 | return album_cache 216 | 217 | 218 | def read_tracks_cache(): 219 | with open(xdg_data+"/clerk/tracks.cache", "rb") as inputfile: 220 | mpd_msgpack = inputfile.read() 221 | tracks_cache = msgpack.unpackb(mpd_msgpack) 222 | return tracks_cache 223 | 224 | def add_album(mode): 225 | album_cache = read_album_cache(mode) 226 | list_of_albums = [] 227 | # generate a columns view of albums with fixed width for each column 228 | for album in album_cache: 229 | a = ' '.join([f'{x:{y}}' for x, y in zip(album.values(), [albumartist_width, date_width, album_width, id_width])]) 230 | list_of_albums.append(a) 231 | 232 | # show a list of albums using menu_tool 233 | album_result = _menu(list_of_albums, "yes") 234 | if album_result == []: 235 | sys.exit() 236 | 237 | # choose what to do with selected album 238 | list_of_options = ['Add', 'Insert', 'Replace', '---', 'Rate'] 239 | action = _menu(list_of_options, "no") 240 | if action == "": 241 | sys.exit() 242 | 243 | # lookup selected album in local cache 244 | match = [] 245 | for album in album_result: 246 | for search in album_cache: 247 | if album == search['id']: 248 | match.append(search) 249 | #break 250 | action_album(match, action) 251 | 252 | def action_album(albums, action): 253 | match action: 254 | case "Replace": 255 | m.clear() 256 | for match in albums: 257 | m.findadd('albumartist', match['albumartist'], 'album', match['album'], 'date', match['date']) 258 | m.play() 259 | case "Add": 260 | for match in albums: 261 | m.findadd('albumartist', match['albumartist'], 'album', match['album'], 'date', match['date']) 262 | case "Insert": 263 | position=int(m.currentsong()['pos']) 264 | pos=position + 1 265 | for match in albums: 266 | results = m.find('albumartist', match['albumartist'], 'album', match['album'], 'date', match['date']) 267 | for x in results: 268 | m.addid(x['file'], pos) 269 | case "Rate": 270 | for match in albums: 271 | value = input_rating(match['albumartist'], match['album']) 272 | results = m.find('albumartist', match['albumartist'], 'album', match['album'], 'date', match['date']) 273 | for track in results: 274 | if str(value) == "Delete": 275 | m.sticker_delete('song', track['file'], 'albumrating') 276 | elif str(value) == "---": 277 | print("Nothing") 278 | else: 279 | m.sticker_set('song', track['file'], 'albumrating', str(value)) 280 | if sync_online_list == True: 281 | subprocess.run(sync_command) 282 | 283 | def add_tracks(): 284 | tracks_cache = read_tracks_cache() 285 | list_of_tracks = [] 286 | for track in tracks_cache: 287 | try: 288 | a = ' '.join([f'{x:{y}}' for x, y in zip(track.values(), [track_width, title_width, artist_width, album_width, date_width, id_width])]) 289 | except: 290 | print("") 291 | list_of_tracks.append(a) 292 | track_result = _menu(list_of_tracks, "yes") 293 | if track_result == []: 294 | sys.exit() 295 | list_of_options = ['Add', 'Insert', 'Replace', '---', 'Rate'] 296 | action = _menu(list_of_options, "no") 297 | if action == "": 298 | sys.exit() 299 | 300 | match = [] 301 | for track in track_result: 302 | for search in tracks_cache: 303 | if search['id'] == track: 304 | match.append(search) 305 | action_tracks(match, action) 306 | 307 | def action_tracks(tracks, action): 308 | match action: 309 | case "Replace": 310 | m.clear() 311 | for match in tracks: 312 | m.findadd('artist', match['artist'], 'album', match['album'], 'date', match['date'], 'track', match['track'], 'title', match['title']) 313 | m.play() 314 | case "Add": 315 | for match in tracks: 316 | m.findadd('artist', match['artist'], 'album', match['album'], 'date', match['date'], 'track', match['track'], 'title', match['title']) 317 | case "Insert": 318 | position=int(m.currentsong()['pos']) 319 | pos=position + 1 320 | for match in tracks: 321 | results = m.find('artist', match['artist'], 'album', match['album'], 'date', match['date'], 'track', match['track'], 'title', match['title']) 322 | for x in results: 323 | m.addid(x['file'], pos) 324 | case "Rate": 325 | for match in tracks: 326 | value = input_rating(match['albumartist'], match['title']) 327 | results = m.find('artist', match['artist'], 'album', match['album'], 'date', match['date'], 'track', match['track'], 'title', match['title']) 328 | for track in results: 329 | if str(value) == "Delete": 330 | m.sticker_delete('song', track['file'], 'rating') 331 | elif str(value) == "---": 332 | print("Nothing") 333 | else: 334 | m.sticker_set('song', track['file'], 'rating', str(value)) 335 | 336 | def input_rating(artist, album): 337 | rating_options = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '---', 'Delete'] 338 | prompt = artist+" - "+album+" "+menu_prompt 339 | custom_menu = [w.replace('> ', prompt) for w in menu_tool] 340 | rating = _menu(rating_options, "no", custom_menu) 341 | if not rating: 342 | sys.exit() 343 | return(rating) 344 | 345 | def current_track(): 346 | currentsong = m.currentsong() 347 | rofi_options = ['Rate Track', 'Rate Album'] 348 | action = _menu(rofi_options, "no") 349 | if action == "": 350 | sys.exit() 351 | if action == "Rate Album": 352 | value = input_rating(currentsong['albumartist'], currentsong['album']) 353 | results = m.find('albumartist', currentsong['albumartist'], 'album', currentsong['album'], 'date', currentsong['date']) 354 | for track in results: 355 | if str(value) == "Delete": 356 | m.sticker_delete('song', track['file'], 'albumrating') 357 | else: 358 | m.sticker_set('song', track['file'], 'albumrating', str(value)) 359 | if sync_online_list == True: 360 | subprocess.run(sync_command) 361 | elif action == "Rate Track": 362 | value = input_rating(currentsong['albumartist'], currentsong['title']) 363 | if str(value) == "Delete": 364 | m.sticker_delete('song', track['file'], 'rating') 365 | else: 366 | m.sticker_set('song', currentsong['file'], 'rating', str(value)) 367 | 368 | def random_album(): 369 | artist = m.list('albumartist') 370 | artist = random.sample(artist, 1) 371 | for x in artist: 372 | result = m.find('albumartist', x['albumartist']) 373 | album = random.sample(result, 1) 374 | m.clear() 375 | for x in album: 376 | m.findadd('albumartist', x['albumartist'], 'album', x['album'], 'date', x['date']) 377 | m.play() 378 | 379 | def random_tracks(): 380 | artist = m.list(random_artist) 381 | artists = random.sample(artist, number_of_tracks) 382 | m.clear() 383 | for x in artists: 384 | result = m.find(random_artist, x[random_artist]) 385 | track = random.sample(result, 1) 386 | for x in track: 387 | m.findadd('file', x['file']) 388 | m.play() 389 | 390 | def check_update(): 391 | if not os.path.exists(xdg_data+"/clerk/tracks.cache"): 392 | create_cache() 393 | 394 | help_text = """ 395 | clerk version: 5.0 396 | 397 | Options: 398 | 399 | -a add Album 400 | -l add Album (sorted by mtime) 401 | -t add Track(s) 402 | -A Play random Album 403 | -T Play random Tracks 404 | -c Rate current Track 405 | -u Update local caches 406 | """ 407 | 408 | 409 | 410 | # check for config option "mpd_host", otherwise set it to "localhost" 411 | if 'mpd_host' in globals(): 412 | mpd_host = mpd_host 413 | else: 414 | mpd_host = "localhost" 415 | 416 | # Check for MPD_HOST environment variable, otherwise use mpd_host variable 417 | mpd_host = os.environ.get('MPD_HOST', mpd_host) 418 | m.connect(mpd_host, 6600) 419 | 420 | # Create cache files if needed 421 | check_update() 422 | 423 | if len(sys.argv) > 1: 424 | match sys.argv[1]: 425 | case "-a": 426 | add_album("album") 427 | case "-l": 428 | add_album("latest") 429 | case "-t": 430 | add_tracks() 431 | case "-A": 432 | random_album() 433 | case "-T": 434 | random_tracks() 435 | case "-c": 436 | current_track() 437 | case "-u": 438 | create_cache() 439 | case "-x": 440 | create_config() 441 | case "-h": 442 | print(help_text) 443 | else: 444 | print(help_text) 445 | -------------------------------------------------------------------------------- /clerk.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | binmode(STDOUT, ":utf8"); 4 | use v5.10; 5 | use warnings; 6 | use strict; 7 | use Data::Dumper; 8 | use utf8; 9 | use Config::Simple; 10 | use Data::MessagePack; 11 | use Data::Section::Simple qw(get_data_section); 12 | #use DDP; 13 | use Encode qw(decode encode); 14 | use File::Basename; 15 | use File::Path qw(make_path); 16 | use File::Slurper 'read_binary'; 17 | use File::stat; 18 | use Try::Tiny; 19 | use FindBin qw($Bin $Script); 20 | use Getopt::Long qw(:config no_ignore_case bundling); 21 | use HTTP::Date; 22 | use Scalar::Util qw(looks_like_number); 23 | use IPC::Run qw( timeout start ); 24 | use List::Util qw(any max maxstr); 25 | use Net::MPD; 26 | use Pod::Usage qw(pod2usage); 27 | use POSIX qw(tzset); 28 | use autodie; 29 | 30 | my $self="$Bin/$Script"; 31 | my ($cfg, $mpd); 32 | my %rvar; # runtime variables 33 | 34 | my $xdg_config_home = $ENV{'XDG_CONFIG_HOME'} || "$ENV{'HOME'}/.config"; 35 | my $xdg_data_home = $ENV{'XDG_DATA_HOME'} || "$ENV{'HOME'}/.local/share"; 36 | 37 | sub main { 38 | create_files_if_needed(); 39 | parse_config(); 40 | parse_options(@ARGV); 41 | tmux_prerequisites(); 42 | 43 | renew_db() if $rvar{renewdb}; 44 | do_instaact() if $rvar{instaact}; 45 | do_instarand() if $rvar{instarand}; 46 | 47 | if ($rvar{tmux_ui}) { 48 | tmux_ui(); 49 | } else { 50 | my $go = select_action(); 51 | do { 52 | maybe_renew_db(); 53 | $go->(); 54 | tmux_jump_to_queue_maybe(); 55 | } while ($rvar{endless}); 56 | } 57 | } 58 | 59 | sub create_files_if_needed { 60 | my $clerk_conf_content = get_data_section('clerk.conf'); 61 | my $clerk_tmux_content = get_data_section('clerk.tmux'); 62 | 63 | my $clerk_conf_file = "$xdg_config_home/clerk/clerk.conf"; 64 | my $clerk_tmux_file = "$xdg_config_home/clerk/clerk.tmux"; 65 | 66 | unless(-e "$xdg_config_home/clerk" or mkdir "$xdg_config_home/clerk") { 67 | die "Unable to create \"$xdg_config_home/clerk\"\n"; 68 | } 69 | 70 | unless(-e "$xdg_data_home/clerk" or mkdir "$xdg_data_home/clerk") { 71 | die "Unable to create \"$xdg_data_home/clerk\"\n"; 72 | } 73 | 74 | if (! -f $clerk_conf_file) { 75 | open my $fh, ">", $clerk_conf_file; 76 | print {$fh} $clerk_conf_content; 77 | close $fh; 78 | } 79 | if (! -f $clerk_conf_file) { 80 | open my $fh, ">", $clerk_conf_file; 81 | print {$fh} $clerk_conf_content; 82 | close $fh; 83 | } 84 | 85 | if (! -f $clerk_tmux_file) { 86 | open my $fh, ">", $clerk_tmux_file; 87 | print {$fh} $clerk_tmux_content; 88 | close $fh; 89 | } 90 | } 91 | 92 | sub parse_config { 93 | $rvar{config_file} = $ENV{CLERK_CONF} 94 | // "$xdg_config_home/clerk/clerk.conf"; 95 | $cfg //= Config::Simple->new(filename=>$rvar{config_file}); 96 | 97 | $rvar{tmux_config} = $ENV{CLERK_TMUX} 98 | // "$xdg_config_home/clerk/clerk.tmux"; 99 | 100 | $rvar{db} = $ENV{CLERK_DATABASE} 101 | // "$xdg_data_home/clerk/database.mpk"; 102 | 103 | $cfg //= Config::Simple->new(filename=>$rvar{config_file}); 104 | 105 | 106 | my $g = $cfg->param(-block=>'General'); 107 | my $r = $cfg->param(-block=>'Rofi'); 108 | %rvar = (%rvar, 109 | mpd_host => $g->{mpd_host}, 110 | songs => $g->{songs}, 111 | chunksize => $g->{chunksize}, 112 | player => $g->{player}, 113 | tagging => $g->{tagging}, 114 | randomartist => $g->{randomartist}, 115 | jump_queue => $g->{jump_queue}, 116 | backend => $g->{backend}, 117 | rofi_width => $r->{width} // 'default', 118 | rofi_theme => $r->{theme} // 'default' 119 | ); 120 | 121 | my $c = $cfg->param(-block=>'Columns'); 122 | $rvar{max_width} = { 123 | album => $c->{album_l}, 124 | date => $c->{date_l}, 125 | title => $c->{title_l}, 126 | track => $c->{track_l}, 127 | artist => $c->{artist_l}, 128 | rating => $c->{rating_l}, 129 | albumartist => $c->{albumartist_l} 130 | }; 131 | 132 | %rvar = (%rvar, 133 | ); 134 | 135 | $rvar{db} = { file => $rvar{db}, mtime => 0 }; 136 | } 137 | 138 | sub parse_options { 139 | local @ARGV = @_; 140 | my $parse_act = sub { 141 | my ($name, $bool) = @_; 142 | if ($bool && defined $rvar{action}) { 143 | warn "Will override already set action: $rvar{action}\n"; 144 | } 145 | $rvar{action} = "$name" if $bool; 146 | }; 147 | 148 | my $choices = sub { 149 | my ($rvar, @choices) = @_; 150 | 151 | return sub { 152 | my ($name, $value) = @_; 153 | if (any { $value eq $_ } @choices) { 154 | $$rvar = $value; 155 | } else { 156 | die "Value: $value none of " . join(', ', @choices) . "\n"; 157 | } 158 | }; 159 | }; 160 | 161 | #$rvar{backend} = 'rofi'; 162 | GetOptions( 163 | 'help|h' => sub { pod2usage(1) }, 164 | 165 | # general 166 | 'renewdb|u' => \$rvar{renewdb}, 167 | 'tmux-ui!' => \$rvar{tmux_ui}, 168 | 'endless!' => \$rvar{endless}, 169 | 'backend=s' => $choices->(\$rvar{backend}, qw/fzf rofi fuzzel/), 170 | 'f' => sub { $rvar{backend} = 'fzf'; }, 171 | 172 | # action 173 | 'tracks|t' => $parse_act, 174 | 'albums|a' => $parse_act, 175 | 'playlists|p' => $parse_act, 176 | 'randoms|r' => $parse_act, 177 | 'latests|l' => $parse_act, 178 | 179 | # instaact 180 | 'instaact=s' => $choices->(\$rvar{instaact}, 181 | qw/help_pane rand_pane tmux_help/), 182 | 183 | # instarand 184 | 'instarand=s' => $choices->(\$rvar{instarand}, qw/track album/), 185 | 'T' => sub { $rvar{instarand} = 'track'; }, 186 | 'A' => sub { $rvar{instarand} = 'album'; }, 187 | ) or pod2usage(2); 188 | 189 | $rvar{tmux_ui} = ( 190 | $rvar{action} || 191 | $rvar{renewdb} || 192 | $rvar{instaact} || 193 | (defined $rvar{tmux_ui} && !$rvar{tmux_ui}) 194 | )? 0 : 1; 195 | 196 | # verify combinations if options 197 | 198 | if ($rvar{backend} eq 'fzf' && $rvar{instarand}) { 199 | die "Backend $rvar{backend} and instant random for $rvar{instarand} is not possible\n"; 200 | } 201 | 202 | if (($rvar{action} // '') ne 'randoms' && defined $rvar{instarand}) { 203 | die "-T or -A without -r not allowed\n"; 204 | } 205 | } 206 | 207 | sub do_instaact { 208 | local $_ = $rvar{instaact}; 209 | if (/help_pane/) { tmux_spawn_help_pane() } 210 | elsif (/rand_pane/) { tmux_spawn_random_pane() } 211 | elsif (/tmux_help/) { help() } 212 | exit; 213 | } 214 | 215 | sub do_instarand { 216 | local $_ = $rvar{instarand}; 217 | if (/track/) { random_tracks() } 218 | elsif (/album/) { random_album() } 219 | exit; 220 | } 221 | 222 | sub select_action { 223 | local $_ = $rvar{action} // ''; 224 | if (/tracks/) { return sub { action_db_tracks(ask_to_pick_tracks()) } } 225 | elsif (/albums/) { return sub { action_db_albums(ask_to_pick_albums()) } } 226 | elsif (/playlists/) { return sub { action_playlist(ask_to_pick_playlists()) } } 227 | elsif (/randoms/) { return sub { action_random(ask_to_pick_random()) } } 228 | elsif (/latests/) { return sub { action_db_albums(ask_to_pick_latests()) } } 229 | 230 | return sub {}; 231 | } 232 | 233 | sub db_needs_update { 234 | mpd_reachable(); 235 | my $last = $mpd->stats->{db_update}; 236 | return !-f $rvar{db}{file} || stat($rvar{db}{file})->mtime < $last; 237 | } 238 | 239 | sub renew_db { 240 | # Get database copy and save as messagepack file, if file is either missing 241 | # or older than latest mpd database update. 242 | # get number of songs to calculate number of searches needed to copy mpd database 243 | mpd_reachable(); 244 | my @track_ratings = $mpd->sticker_find("song", "rating"); 245 | my @album_ratings = $mpd->sticker_find("song", "albumrating"); 246 | my %track_ratings = map {$_->{file} => $_->{sticker}} @track_ratings; 247 | my %album_ratings = map {$_->{file} => $_->{sticker}} @album_ratings; 248 | 249 | 250 | my $mpd_stats = $mpd->stats(); 251 | my $songcount = $mpd_stats->{songs}; 252 | my $times = int($songcount / $rvar{chunksize} + 1); 253 | 254 | if ($rvar{backend} eq "rofi") { 255 | system('notify-send', '-t', '5000', 'clerk', 'Updating Cache File'); 256 | } 257 | 258 | elsif ($rvar{backend} eq "fzf") { 259 | print STDERR "::: No cache found or cache file outdated\n"; 260 | print STDERR "::: Chunksize set to $rvar{chunksize} songs\n"; 261 | print STDERR "::: Requesting $times chunks from MPD\n"; 262 | } 263 | 264 | my @db; 265 | # since mpd will silently fail, if response is larger than command buffer, let's split the search. 266 | my $chunk_size = $rvar{chunksize}; 267 | for (my $i=0;$i<=$songcount;$i+=$chunk_size) { 268 | my $endnumber = $i+$chunk_size; 269 | my @temp_db = $mpd->search('filename', '', 'window', "$i:$endnumber"); 270 | push @db, @temp_db; 271 | } 272 | 273 | # only save relevant tags to keep messagepack file small 274 | # note: maybe use a proper database instead? See list_album function. 275 | my @filtered = map { 276 | $_->{mtime} = str2time($_->{'Last-Modified'}); 277 | $_->{rating} = $track_ratings{$_->{uri}}; 278 | $_->{albumrating} = $album_ratings{$_->{uri}}; 279 | +{$_->%{qw/Album Artist Date AlbumArtist Title Track rating albumrating uri mtime/}} 280 | } @db; 281 | pack_msgpack(\@filtered); 282 | if ($rvar{backend} eq "rofi") { 283 | system('notify-send', '-t', '5000', 'clerk', 'DONE: Updating Cache File'); 284 | } 285 | elsif ($rvar{backend} eq "fzf") { 286 | print STDERR "::: Cache files updated\n"; 287 | } 288 | } 289 | 290 | sub maybe_renew_db { 291 | renew_db() if db_needs_update(); 292 | } 293 | 294 | sub help { 295 | open (my $fh, '<', $rvar{tmux_config}); 296 | my @out; 297 | while (my $l = <$fh>) { 298 | push @out, $l if $l =~ /bind-key/; 299 | } 300 | print @out; 301 | ; 302 | } 303 | 304 | sub backend_call { 305 | my ($in, $fields, $random) = @_; 306 | my $input; 307 | my $out; 308 | $random //= "ignore"; 309 | $fields //= "1,2,3,4,5"; 310 | my %backends = ( 311 | fzf => [ "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" ], 312 | rofi => [ "rofi", "-matching", "regex", "-dmenu", "-kb-row-tab", "", "-kb-move-word-forward", "", "-kb-accept-alt", "Tab", "-multi-select", "-no-levensthein-sort", "-i", "-p", "> " ], 313 | fuzzel => [ "fuzzel", "--dmenu" ] 314 | ); 315 | 316 | if ($rvar{backend} eq 'rofi') { 317 | if ($rvar{rofi_width} ne 'default') { 318 | push $backends{rofi}->@*, '-width', $rvar{rofi_width}; 319 | } 320 | 321 | if ($rvar{rofi_theme} ne 'default') { 322 | push $backends{rofi}->@*, '-theme', $rvar{rofi_theme}; 323 | } 324 | } 325 | 326 | my $handle = start $backends{$rvar{backend}} // die('backend not found'), \$input, \$out; 327 | $input = join "", (@{$in}); 328 | finish $handle or die "No selection"; 329 | return $out; 330 | } 331 | 332 | sub pack_msgpack { 333 | my ($filtered_db) = @_; 334 | my $msg = Data::MessagePack->pack($filtered_db); 335 | my $filename = $rvar{db}{file}; 336 | open(my $out, '>:raw', $filename) or die "Could not open file '$filename' $!"; 337 | print $out $msg; 338 | close $out; 339 | } 340 | 341 | sub unpack_msgpack { 342 | my $mp = Data::MessagePack->new->utf8(); 343 | my $msgpack = read_binary($rvar{db}{file}); 344 | my $rdb = $mp->unpack($msgpack); 345 | return $rdb; 346 | } 347 | 348 | sub get_rdb { 349 | my $mtime = stat($rvar{db}{file})->mtime; 350 | if ($rvar{db}{mtime} < $mtime) { 351 | $rvar{db}{ref} = unpack_msgpack(); 352 | $rvar{db}{mtime} = $mtime; 353 | } 354 | return $rvar{db}{ref}; 355 | } 356 | 357 | sub random_album { 358 | mpd_reachable(); 359 | $mpd->clear(); 360 | my @album_artists = $mpd->list('albumartist'); 361 | my $artist_r = $album_artists[rand @album_artists]; 362 | my @album = $mpd->list('album', 'albumartist', $artist_r); 363 | my $album_r = $album[rand @album]; 364 | my @date = $mpd->list('date', 'albumartist', $artist_r, 'album', $album_r); 365 | my $date_r = $date[rand @date]; 366 | $mpd->find_add('albumartist', $artist_r, 'album', $album_r, 'date', $date_r); 367 | $mpd->play(); 368 | tmux_jump_to_queue_maybe(); 369 | } 370 | 371 | sub random_tracks { 372 | mpd_reachable(); 373 | $mpd->clear(); 374 | for (my $i=1; $i <= $rvar{songs}; $i++) { 375 | my @artists = $mpd->list($rvar{randomartist}); 376 | my $artist_r = $artists[rand @artists]; 377 | my @albums = $mpd->list('album', $rvar{randomartist}, $artist_r); 378 | my $album_r = $albums[rand @albums]; 379 | my @tracks = $mpd->find($rvar{randomartist}, $artist_r, 'album', $album_r); 380 | my $track_r = $tracks[rand @tracks]; 381 | my $foo = $track_r->{uri}; 382 | $mpd->add($foo); 383 | $mpd->play(); 384 | } 385 | tmux_jump_to_queue_maybe(); 386 | } 387 | 388 | sub formatted_albums { 389 | my ($rdb, $sorted) = @_; 390 | 391 | my %uniq_albums; 392 | my $index = 0; 393 | for my $i (@$rdb) { 394 | my $newkey = join "", $i->@{qw/AlbumArtist Date Album/}; 395 | if (!exists $uniq_albums{$newkey}) { 396 | $uniq_albums{$newkey} = {$i->%{qw/AlbumArtist Album Date albumrating mtime/}, Index => $index}; 397 | } else { 398 | if ($uniq_albums{$newkey}->{'mtime'} < $i->{'mtime'}) { 399 | $uniq_albums{$newkey}->{'mtime'} = $i->{'mtime'} 400 | } 401 | } 402 | $index++;; 403 | } 404 | 405 | my @albums; 406 | my $fmtstr = join "", map {"%-${_}.${_}s\t"} ($rvar{max_width}->@{qw/albumartist date album rating/}); 407 | 408 | my @skeys; 409 | if ($sorted) { 410 | @skeys = sort { $uniq_albums{$b}->{mtime} <=> $uniq_albums{$a}->{mtime} } keys %uniq_albums; 411 | } else { 412 | @skeys = sort keys %uniq_albums; 413 | } 414 | 415 | for my $k (@skeys) { 416 | my @vals = ((map { $_ // "Unknown" } $uniq_albums{$k}->@{qw/AlbumArtist Date Album/}), "r=" . ($uniq_albums{$k}->{albumrating} // '0'), $uniq_albums{$k}->{Index}); 417 | my $strval = sprintf $fmtstr."%s\n", @vals; 418 | push @albums, $strval; 419 | } 420 | return \@albums; 421 | } 422 | 423 | sub formatted_tracks { 424 | my ($rdb) = @_; 425 | my $fmtstr = join "", map {"%-${_}.${_}s\t"} ($rvar{max_width}->@{qw/track title artist album rating/}); 426 | $fmtstr .= "%-s\n"; 427 | my $i = 0; 428 | my @tracks; 429 | @tracks = map { 430 | sprintf $fmtstr, 431 | (map { $_ // "-" } $_->@{qw/Track Title Artist Album/}), 432 | "r=" . ($_->{rating} // '0'), 433 | $i++; 434 | } @{$rdb}; 435 | 436 | return \@tracks; 437 | } 438 | 439 | sub formatted_playlists { 440 | my ($rdb) = @_; 441 | my @save = ("Save"); 442 | push @save, $rdb; 443 | my @playlists = map { 444 | sprintf "%s\n", $_->{playlist} 445 | } @{$rdb}; 446 | @save = ("Save current Queue\n", "---\n"); 447 | @playlists = sort @playlists; 448 | unshift @playlists, @save; 449 | return \@playlists; 450 | } 451 | 452 | sub tmux_prerequisites { 453 | $ENV{TMUX_TMPDIR} = '/tmp/clerk/tmux'; 454 | make_path($ENV{TMUX_TMPDIR}) unless(-d $ENV{TMUX_TMPDIR}); 455 | } 456 | 457 | sub tmux { 458 | my @args = @_; 459 | system 'tmux', @args; 460 | } 461 | 462 | sub tmux_jump_to_queue_maybe { 463 | tmux qw/selectw -t :=queue/ if $ENV{CLERK_JUMP_QUEUE}; 464 | } 465 | 466 | sub tmux_spawn_random_pane { 467 | tmux 'splitw', '-d', '-l', '10', $self, '--backend=fzf', '--randoms'; 468 | tmux qw/select-pane -D/; 469 | } 470 | 471 | sub tmux_spawn_help_pane { 472 | tmux 'splitw', '-d', $self, '--instaact=tmux_help'; 473 | tmux qw/select-pane -D/; 474 | } 475 | 476 | sub tmux_has_session { 477 | tmux qw/has -t/, @_; 478 | return $? == -0; 479 | } 480 | 481 | sub tmux_ui { 482 | maybe_renew_db(); 483 | unless (tmux_has_session('music')) { 484 | my @win = qw/neww -t music -n/; 485 | my @clerk = ($self, '--backend=fzf', '--endless'); 486 | $ENV{CLERK_JUMP_QUEUE} = 1 if ($rvar{jump_queue} // '') eq 'true'; 487 | tmux '-f', $rvar{tmux_config}, qw/new -s music -n albums -d/, @clerk, '-a'; 488 | tmux @win, 'tracks', @clerk, '-t'; 489 | tmux @win, 'latest', @clerk, '-l'; 490 | tmux @win, 'playlists', @clerk, '-p'; 491 | tmux @win, 'queue', $rvar{player}; 492 | tmux qw/set-environment CLERKBIN/, $self; 493 | } 494 | tmux qw/attach -t music/; 495 | tmux qw/selectw -t queue/; 496 | } 497 | 498 | sub ask_to_pick_tracks { 499 | return backend_call(formatted_tracks(get_rdb()), "1,2,3,4,5"); 500 | } 501 | 502 | sub ask_to_pick_albums { 503 | return backend_call(formatted_albums(get_rdb(), 0), "1,2,3,4"); 504 | } 505 | 506 | sub ask_to_pick_latests { 507 | return backend_call(formatted_albums(get_rdb(), 1), "1,2,3,4"); 508 | } 509 | 510 | sub ask_to_pick_playlists { 511 | mpd_reachable(); 512 | my @pls = $mpd->list_playlists; 513 | return backend_call(formatted_playlists(\@pls), "1,2,3"); 514 | } 515 | 516 | sub ask_to_pick_random { 517 | return backend_call(["Tracks\n", "Albums\n", "---\n", "Mode: $rvar{randomartist}\n", "Number of Songs: $rvar{songs}\n", "---\n", "Cancel\n"]); 518 | } 519 | 520 | sub ask_to_pick_song_number { 521 | return backend_call([map { $_ . "\n" } qw/5 10 15 20 25 30/]); 522 | } 523 | 524 | sub ask_to_pick_track_settings { 525 | return backend_call(["artist\n", "albumartist\n"]); 526 | } 527 | 528 | 529 | sub ask_to_pick_ratings { 530 | return backend_call([map { $_ . "\n" } (qw/1 2 3 4 5 6 7 8 9 10 ---/), "Delete Rating"]); 531 | } 532 | 533 | sub action_db_albums { 534 | my ($out) = @_; 535 | 536 | my @sel = util_parse_selection($out); 537 | @sel = map { $rvar{db}{ref}->[$_] } @sel; 538 | my (@uris, @tracks); 539 | for my $album (@sel) { 540 | push @tracks, lookup_album_tags($album->{AlbumArtist}, $album->{Album}, $album->{Date}); 541 | } 542 | foreach (@tracks) { 543 | push @uris, $_->{uri}; 544 | } 545 | 546 | my $action = backend_call(["Add\n", "Insert\n", "Replace\n", "---\n", "Rate Album(s)\n"]); 547 | mpd_reachable(); 548 | { 549 | local $_ = $action; 550 | if (/^Add/) { mpd_add_items(\@uris) } 551 | elsif (/^Insert/) { mpd_insert_albums(\@uris) } 552 | elsif (/^Replace/) { mpd_replace_with_items(\@uris) } 553 | elsif (/^Rate Album\(s\)/) { mpd_rate_with_albums(\@uris) } 554 | } 555 | } 556 | 557 | sub lookup_album_tags { 558 | my ($albumartist, $album, $date) = @_; 559 | return grep { $albumartist eq $_->{AlbumArtist} && $album eq $_->{Album} && $date eq $_->{Date} } $rvar{db}{ref}->@*; 560 | } 561 | 562 | sub get_tags_from_rdb { 563 | } 564 | 565 | sub action_db_tracks { 566 | my ($out) = @_; 567 | 568 | my @sel = util_parse_selection($out); 569 | @sel = map { $rvar{db}{ref}->[$_] } @sel; 570 | my (@uris); 571 | 572 | foreach (@sel) { 573 | push @uris, $_->{uri}; 574 | } 575 | 576 | my $action = backend_call(["Add\n", "Insert\n", "Replace\n", "---\n", "Rate Track(s)\n"]); 577 | mpd_reachable(); 578 | { 579 | local $_ = $action; 580 | if (/^Add/) { mpd_add_items(\@uris) } 581 | elsif (/^Insert/) { mpd_insert_tracks(\@uris) } 582 | elsif (/^Replace/) { mpd_replace_with_items(\@uris) } 583 | elsif (/^Rate Track\(s\)/) { mpd_rate_with_tracks(\@uris) } 584 | } 585 | } 586 | 587 | sub action_playlist { 588 | my ($out) = @_; 589 | 590 | if ($out =~ /^Save current Queue/) { 591 | mpd_save_cur_playlist(); 592 | maybe_renew_db(); 593 | } else { 594 | my @sel = util_parse_selection($out); 595 | my $action = backend_call(["Add\n", "Replace\n", "Delete\n"]); 596 | 597 | mpd_reachable(); 598 | local $_ = $action; 599 | if (/^Add/) { mpd_add_playlists(\@sel) } 600 | elsif (/^Delete/) { mpd_delete_playlists(\@sel) } 601 | elsif (/^Replace/) { mpd_replace_with_playlists(\@sel) } 602 | } 603 | } 604 | 605 | sub action_random { 606 | my ($out) = @_; 607 | 608 | mpd_reachable(); 609 | { 610 | local $_ = $out; 611 | if (/^Track/) { random_tracks() } 612 | elsif (/^Album/) { random_album() } 613 | elsif (/^Mode: $rvar{randomartist}/) { action_track_mode(ask_to_pick_track_settings()) } 614 | elsif (/^Number of Songs: $rvar{songs}/) { action_song_number(ask_to_pick_song_number()) } 615 | } 616 | } 617 | 618 | sub action_song_number { 619 | my ($out) = @_; 620 | 621 | $rvar{songs} = max map { split /[\t\n]/ } (split /\n/, $out); 622 | 623 | $cfg->param("General.songs", $rvar{songs}); 624 | $cfg->save(); 625 | } 626 | 627 | sub action_track_mode { 628 | my ($out) = $_[0]; 629 | chomp $out; 630 | $rvar{randomartist} = $out; 631 | 632 | $cfg->param("General.randomartist", $rvar{randomartist}); 633 | $cfg->save(); 634 | } 635 | 636 | sub util_parse_selection { 637 | my ($sel) = @_; 638 | map { (split /[\t\n]/, $_)[-1] } (split /\n/, $sel); 639 | } 640 | 641 | sub mpd_add_items { 642 | $mpd->add($_) for @{$_[0]}; 643 | } 644 | 645 | sub mpd_insert_tracks { 646 | my $song; 647 | my $bla = $mpd->playlist_info(); 648 | my $pos = ($mpd->current_song->{Pos} +1); 649 | my $prio = "255"; 650 | foreach $song (reverse(@{$_[0]})) { 651 | $mpd->prio_id($prio, $mpd->add_id($song, $pos)); 652 | $prio--; 653 | $pos++; 654 | } 655 | } 656 | 657 | sub mpd_insert_albums { 658 | my $song; 659 | my $bla = $mpd->playlist_info(); 660 | my $pos = ($mpd->current_song->{Pos} +1); 661 | my $prio = "255"; 662 | foreach $song (@{$_[0]}) { 663 | $mpd->prio_id($prio, $mpd->add_id($song, $pos)); 664 | $prio--; 665 | $pos++; 666 | } 667 | } 668 | 669 | sub mpd_rate_items { 670 | my ($sel, $rating, $mode) = @_; 671 | chomp $rating; 672 | $rating = undef if $rating =~ /^Delete Rating/; 673 | if ($rvar{tagging} eq "true") { 674 | $mpd->send_message('rating', "$_\t$mode\t${rating}") for @{$_[0]};; 675 | } 676 | $mpd->sticker_value("song", encode('UTF-8', $_), $mode, $rating) for @{$_[0]}; 677 | } 678 | 679 | sub mpd_replace_with_items { 680 | $mpd->clear; 681 | mpd_add_items(@_); 682 | $mpd->play; 683 | } 684 | 685 | sub mpd_rate_with_albums { 686 | my @list_of_files = @_; 687 | my $rating = ask_to_pick_ratings(); 688 | chomp $rating; 689 | 690 | if ($rating eq "---") { 691 | #noop 692 | } else { 693 | mpd_rate_items(@list_of_files, $rating, "albumrating"); 694 | } 695 | } 696 | 697 | sub mpd_rate_with_tracks { 698 | my $rating = ask_to_pick_ratings(); 699 | if ($rating eq "---\n") { 700 | #noop 701 | } else { 702 | mpd_rate_items(@_, $rating, "rating"); 703 | } 704 | } 705 | 706 | sub mpd_save_cur_playlist { 707 | tzset(); 708 | $mpd->save(scalar localtime); 709 | } 710 | 711 | sub mpd_add_playlists { 712 | $mpd->load($_) for @{$_[0]}; 713 | } 714 | 715 | sub mpd_replace_with_playlists { 716 | $mpd->clear; 717 | mpd_add_playlists(@_); 718 | $mpd->play; 719 | } 720 | 721 | sub mpd_delete_playlists { 722 | $mpd->rm($_) for @{$_[0]}; 723 | } 724 | 725 | # quirk to ensure mpd does not croak just because of timeout 726 | sub mpd_reachable { 727 | $mpd //= Net::MPD->connect($ENV{MPD_HOST} // $rvar{mpd_host} // 'localhost'); 728 | try { 729 | $mpd->ping; 730 | } catch { 731 | $mpd->_connect; 732 | }; 733 | } 734 | 735 | main; 736 | 737 | __END__ 738 | 739 | =encoding utf8 740 | 741 | =head1 NAME 742 | 743 | clerk - mpd client, based on rofi 744 | 745 | =head1 SYNOPSIS 746 | 747 | clerk [command] [-f] 748 | 749 | Commands: 750 | -a Add/Replace album(s) to queue. 751 | -l Add/Replace album(s) to queue (sorted by mtime) 752 | -t Add/Replace track(s) to queue. 753 | -p Add stored playlist to queue 754 | -r [-A, -T] Replace current playlist with random songs/album 755 | -u Update caches 756 | 757 | Options: 758 | -f Use fzf interface 759 | 760 | Without further arguments, clerk starts a tabbed tmux interface 761 | Hotkeys for tmux interface can be set in $HOME/.config/clerk/clerk.tmux 762 | 763 | clerk version 4.0.5 764 | 765 | =cut 766 | 767 | __DATA__ 768 | 769 | @@ clerk.conf 770 | [General] 771 | # MPD_HOST will override this 772 | mpd_host=localhost 773 | 774 | # music root for rating_client 775 | music_root=/mnt/Music 776 | 777 | # player for queue tab 778 | player=ncmpcpp 779 | 780 | # number of songs clerk will get at once for creating its cache files 781 | songs=20 782 | 783 | # if mpd drops the connection while updating, reduce this. 784 | chunksize=30000 785 | 786 | # enable this to jump to queue after adding songs in tmux ui. 787 | jump_queue=true 788 | 789 | # Use albumartist or artist for random tracks? 790 | randomartist=albumartist 791 | 792 | # write tags to audio files. Needs running clerk_rating_client on machine with audio files 793 | # ratings will always be written to sticker database. 794 | tagging=false 795 | 796 | # define graphical backend. Possible options: rofi, fuzzel 797 | backend=rofi 798 | 799 | 800 | [Columns] 801 | # width of columns 802 | albumartist_l=50 803 | album_l=50 804 | artist_l=50 805 | date_l=6 806 | title_l=50 807 | track_l=2 808 | rating_l=4 809 | 810 | [Rofi] 811 | # to use rofi default values, set "default" here 812 | width=default 813 | theme=default 814 | 815 | @@ clerk.tmux 816 | # !Dont move this section. 817 | ## Key Bindings 818 | bind-key -n F1 selectw -t :=albums # show album list 819 | bind-key -n F2 selectw -t :=tracks # show tracks 820 | bind-key -n F3 selectw -t :=latest # show album list (latest first) 821 | bind-key -n F4 selectw -t :=playlists # load playlist 822 | bind-key -n F5 selectw -t :=queue # show queue 823 | bind-key -n C-F5 run-shell 'mpc prev --quiet' # previous song 824 | bind-key -n C-F6 run-shell 'mpc toggle --quiet' # toggle playback 825 | bind-key -n C-F7 run-shell 'mpc stop > /dev/null' # stop playback 826 | bind-key -n C-F8 run-shell 'mpc next --quiet' # next song 827 | bind-key -n F10 run-shell '$CLERKBIN --instaact=rand_pane' # play random album/songs 828 | bind-key -n C-F1 run-shell '$CLERKBIN --instaact=help_pane' # show help 829 | bind-key -n C-q kill-session -t music # quit clerk 830 | 831 | 832 | # Status bar 833 | set-option -g status-position top 834 | set -g status-interval 30 835 | set -g status-justify centre 836 | set -g status-left-length 40 837 | set -g status-left '' 838 | set -g status-right '' 839 | 840 | 841 | # Colors 842 | set -g status-bg colour235 843 | set -g status-fg default 844 | setw -g window-status-current-bg default 845 | setw -g window-status-current-fg default 846 | setw -g window-status-current-attr dim 847 | setw -g window-status-bg default 848 | setw -g window-status-fg white 849 | setw -g window-status-attr bright 850 | setw -g window-status-format ' #[fg=colour243,bold]#W ' 851 | setw -g window-status-current-format ' #[fg=yellow,bold]#[bg=colour235]#W ' 852 | 853 | 854 | 855 | # tmux options 856 | set -g set-titles on 857 | set -g set-titles-string '#T' 858 | set -g default-terminal "screen-256color" 859 | setw -g mode-keys vi 860 | set -sg escape-time 1 861 | set -g repeat-time 1000 862 | set -g base-index 1 863 | setw -g pane-base-index 1 864 | set -g renumber-windows on 865 | unbind C-b 866 | set -g prefix C-a 867 | unbind C-p 868 | bind C-p paste-buffer 869 | 870 | --------------------------------------------------------------------------------