]> git.ktnx.net Git - mpd-feeder.git/blob - bin/mpd-feeder
ddc4a923a9f9fda295010c9fc6aba97ca72b2599
[mpd-feeder.git] / bin / mpd-feeder
1 #!/usr/bin/perl
2
3 use v5.32;
4
5 use Getopt::Long ();
6 use Object::Pad;
7 use Syntax::Keyword::Try;
8
9 class Options {
10     use Time::Duration qw(duration_exact);
11     use Time::Duration::Parse qw(parse_duration);
12     has $target_queue_length :reader = 10;
13     has $mpd_host            :reader = undef;
14     has $mpd_port            :reader = undef;
15     has $db_path             :reader = 'mpd-feeder';
16     has $db_user             :reader = undef;
17     has $db_password         :reader = undef;
18     has $min_album_interval  :reader = parse_duration('5h');
19     has $min_song_interval   :reader = parse_duration('13d');
20     has $min_artist_interval :reader = parse_duration('1h 15m');
21     has $verbose             :reader = 0;
22     has $single              :reader = 0;
23     has $one_shot            :reader = 0;
24     has $dump_config         :reader = 0;
25
26     method verb($message) {
27         return unless $self->opt->verbose;
28         warn "$message\n";
29     }
30     method dbg($message) {
31         return unless $self->opt->verbose > 1;
32         warn "$message\n";
33     }
34
35     method parse_command_line {
36         Getopt::Long::GetOptions(
37             'v|verbose+'                => \$verbose,
38             'dump-config!'              => \$dump_config,
39             's|single!'                 => \$single,
40             'one-shot!'                 => \$one_shot,
41             'tql|target-queue-length=n' => \$target_queue_length,
42             'mpd-host=s'                => \$mpd_host,
43             'mpd-port=s'                => \$mpd_port,
44             'db-path=s'                 => \$db_path,
45             'db-user=s'                 => \$db_user,
46             'min-album-interval=s'        => sub {
47                 $min_album_interval = parse_duration(pop);
48             },
49             'min-sing-interval=s' => sub {
50                 $min_song_interval = parse_duration(pop);
51             },
52             'min-artist-interval=s' => sub {
53                 $min_artist_interval = parse_duration(pop);
54             },
55         ) or exit 1;
56     }
57
58     sub handle_config_option( $ini, $section, $option, $target_ref,
59         $converter = undef )
60     {
61         return undef unless exists $ini->{$section}{$option};
62
63         my $value = $ini->{$section}{$option};
64
65         $value = $converter->($value) if $converter;
66
67         $$target_ref = $value;
68     }
69
70     method dump {
71         say "[mpd-feeder]";
72         say "verbose = $verbose";
73         say "";
74         say "[mpd]";
75         say "host = " . ( $mpd_host // '' );
76         say "port = " . ( $mpd_port // '' );
77         say "target-queue-length = $target_queue_length";
78         say "";
79         say "[queue]";
80         say "target-length = $target_queue_length";
81         say "min-song-interval = " . duration_exact($min_song_interval);
82         say "min-album-interval = " . duration_exact($min_album_interval);
83         say "min-artist-interval = " . duration_exact($min_artist_interval);
84         say "";
85         say "[db]";
86         say "path = " .     ( $db_path     // '' );
87         say "user = " .     ( $db_user     // '' );
88         say "password = " . ( $db_password // '' );
89     }
90
91     method parse_config_file($path) {
92         use Config::INI::Reader;
93         my $ini = Config::INI::Reader->read_file($path);
94
95         handle_config_option( $ini => mpd => host => \$mpd_host );
96         handle_config_option( $ini => mpd => port => \$mpd_port );
97
98         handle_config_option( $ini => 'mpd-feeder' => verbose => \$verbose );
99
100         handle_config_option(
101             $ini => queue => 'target-length' => \$target_queue_length );
102         handle_config_option(
103             $ini => queue => 'min-song-interval' => \$min_song_interval,
104             \&parse_duration
105         );
106         handle_config_option(
107             $ini => queue => 'min-album-interval' => \$min_album_interval,
108             \&parse_duration
109         );
110         handle_config_option(
111             $ini => queue => 'min-artist-interval' => \$min_artist_interval,
112             \&parse_duration
113         );
114
115         handle_config_option( $ini => db => path     => \$db_path );
116         handle_config_option( $ini => db => user     => \$db_user );
117         handle_config_option( $ini => db => password => \$db_password );
118
119         # FIXME: complain about unknown sections/parameters
120     }
121 }
122
123 class Feeder {
124     has $opt :reader;
125     has $db;
126     has $db_generation;
127     has $mpd;
128
129 use constant DEFAULT_CONFIG_FILE => '/etc/mpd-feeder/mpd-feeder.conf';
130
131     ADJUST {
132         $opt = Options->new;
133
134         {
135             my $cfg_file;
136             Getopt::Long::Configure('pass_through');
137             Getopt::Long::GetOptions('cfg|config=s' => \$cfg_file);
138             Getopt::Long::Configure('no_pass_through');
139
140             $cfg_file //= DEFAULT_CONFIG_FILE if -e DEFAULT_CONFIG_FILE;
141
142             $opt->parse_config_file($cfg_file) if $cfg_file;
143         }
144
145         $opt->parse_command_line;
146
147         unless ($opt->dump_config) {
148             $mpd = Net::Async::MPD->new(
149                 host         => $opt->mpd_host,
150                 port         => $opt->mpd_port,
151                 auto_connect => 1,
152             );
153
154             $self->connect_db;
155             $self->update_db;
156         }
157     }
158
159     method connect_db {
160         return if $db;
161
162         $db =
163             DBI->connect( "dbi:Pg:dbname=" . $opt->db_path,
164             $opt->db_user, $opt->db_password,
165             { RaiseError => 1, AutoCommit => 1 } );
166
167         $db_generation = $self->db_get_option('generation');
168     }
169
170     method db_get_option($name) {
171         my $sth = $db->prepare_cached("select $name from options");
172         $sth->execute;
173         my @result = $sth->fetchrow_array;
174
175         return $result[0];
176     }
177
178     method db_set_option( $name, $value ) {
179         my $sth = $db->prepare_cached("update options set $name = ?");
180         $sth->execute($value);
181     }
182
183     method db_store_song($song, $artist, $album) {
184         return unless length($song) and length($artist) and length($album);
185
186         $db->prepare_cached(
187             <<'SQL')->execute( $song, $artist, $album, $db_generation );
188 INSERT INTO songs(path, artist, album, generation)
189 VALUES($1, $2, $3, $4)
190 ON CONFLICT ON CONSTRAINT songs_pkey DO
191 UPDATE SET artist = $2
192          , album = $3
193          , generation = $4
194 SQL
195         $db->prepare_cached(<<'SQL')->execute( $artist, $album, $db_generation );
196 INSERT INTO albums(artist, album, generation)
197 VALUES($1, $2, $3)
198 ON CONFLICT ON CONSTRAINT albums_pkey DO
199 UPDATE SET generation = $3
200 SQL
201         $db->prepare_cached(<<'SQL')->execute( $artist, $db_generation );
202 INSERT INTO artists(artist, generation)
203 VALUES($1, $2)
204 ON CONFLICT ON CONSTRAINT artists_pkey DO
205 UPDATE SET generation = $2
206 SQL
207     }
208
209     method db_remove_stale_entries {
210         $db->prepare_cached('DELETE FROM songs WHERE generation <> ?')
211             ->execute($db_generation);
212         $db->prepare_cached('DELETE FROM albums WHERE generation <> ?')
213             ->execute($db_generation);
214         $db->prepare_cached('DELETE FROM artists WHERE generation <> ?')
215             ->execute($db_generation);
216     }
217
218     method db_note_song_qeued($item) {
219         $db->prepare_cached(
220             'UPDATE songs SET last_queued=current_timestamp WHERE path=?')
221             ->execute( $item->{song} );
222         $db->prepare_cached(
223             'UPDATE artists SET last_queued=CURRENT_TIMESTAMP WHERE artist=?')
224             ->execute( $item->{artist} );
225         $db->prepare_cached(
226             'UPDATE albums SET last_queued=CURRENT_TIMESTAMP WHERE artist=? AND album=?'
227         )->execute( $item->{artist}, $item->{album} );
228     }
229
230     method update_db() {
231         $mpd->send('listallinfo')->on_done(
232             sub {
233                 try {
234                     $db->begin;
235
236                     $db_generation++;
237
238                     my ($song, $artist, $album);
239
240                     foreach my $row (@_) {
241                         chomp($row);
242
243                         if ($row =~ s/^file:\s*//) {
244                             $self->db_store_song( $song, $artist, $album );
245                             $song = $row;
246                             $artist = $album = undef;
247                         }
248                         elsif ( $row =~ s/^Artist:\s*// ) {
249                             $artist = $row;
250                         }
251                         elsif ( $row =~ s/^Album:\s*// ) {
252                             $album = $row;
253                         }
254                     }
255
256                     $self->db_store_song($song, $artist, $album);
257
258                     $self->db_remove_stale_entries;
259
260                     $self->db_set_option( generation => $db_generation );
261
262                     $db->commit;
263                 }
264                 catch {
265                     my $err = $@;
266
267                     $db_generation--;
268
269                     $db->rollback;
270
271                     die $err;
272                 }
273             }
274         );
275     }
276
277     method db_find_suitable_songs($num) {
278         my @result;
279         my $sth = $self->db->prepare_cached(<<SQL);
280 SELECT s.path, s.artist, s.album
281 FROM songs s
282 JOIN artists ar ON ar.artist=s.artist
283 JOIN albums al ON al.album=s.album
284 WHERE (s.last_queued IS NULL OR s.last_queued < CURRENT_TIMESTAMP - CAST(? AS float))
285   AND (ar.last_queued IS NULL OR ar.last_queued < CURRENT_TIMESTAMP - CAST(? AS float))
286   AND (al.last_queued IS NULL OR al.last_queued < CURRENT_TIMESTAMP - CAST(? AS float))
287   AND NOT EXISTS (SELECT 1 FROM blacklisted_artists bar WHERE bar.artist = s.artist)
288   AND NOT EXISTS (SELECT 1 FROM blacklisted_albums  bal WHERE bal.album  = s.album)
289 ORDER BY random()
290 LIMIT ?
291 SQL
292         $sth->execute(
293             $self->opt->min_song_interval / 3600.0 / 24.0,
294             $self->opt->min_artist_interval / 3600.0 / 24.0,
295             $self->opt->min_album_interval / 3600.0 / 24.0,
296             $num,
297         );
298         while ( my @row = $sth->fetchrow_array ) {
299             push @result,
300                 { song => $row[0], artist => $row[1], album => $row[2] };
301         }
302
303         return @result;
304     }
305
306     method queue_songs($num = undef) {
307         if (!defined $num) {
308             $mpd->send('playlist')->on_done( sub {
309                     my $present = scalar @_;
310
311                     $self->queue_songs( $opt->target_queue_length - $present )
312                         if $present < $opt->target_queue_length;
313                 } );
314         }
315         else {
316             my @list = $self->db_find_suitable_songs($num);
317
318             if (@list < $num) {
319                 $mpd->loop->add(
320                     IO::Async::Timer::Countdown->new(
321                         delay     => 15,
322                         on_expire => sub { $self->queue_songs($num) },
323                     )
324                 );
325             }
326             else {
327                 $mpd->send( [ map {"add $_->{song}"} @list ] );
328                 $self->db_note_song_qeued($_) for @list;
329             }
330         }
331     }
332 }
333
334 my $feeder = Feeder->new();
335
336 $feeder->opt->dump, exit if $feeder->opt->dump_config;
337
338 $feeder->queue_songs(1), exit if $feeder->opt->single;
339
340 # FIXME: handle blacklist manipulation
341
342 $feeder->queue_songs;
343
344 exit if $feeder->opt->one_shot;
345
346 $feeder->mpd->on(
347     database => sub {
348         $feeder->update_db;
349     }
350 );
351
352 $feeder->mpd->on(
353     playlist => sub {
354         $feeder->queue_songs;
355     }
356 );
357
358 $feeder->mpd->idle(qw(database playlist));
359 $feeder->mpd->get;