├── 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 | 
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 | | Artist |
107 | Album |
108 | Year |
109 | Modified |
110 | Rating |
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("| "+artist+" | ", 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(""+x['album']+" | ", file=f)
136 | print(""+x['date']+" | ", file=f)
137 | print(""+x['added']+" | ", file=f)
138 | print(" | ", file=f)
139 | print("
", file=f)
140 | album=x['album']
141 | print('''
142 |
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 |
--------------------------------------------------------------------------------