]> git.ktnx.net Git - mpd-feeder.git/blob - bin/mpd-feeder
initial source import
[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 = DBI->connect( "dbi:" . $opt->db_path,
163             $opt->db_user, $opt->db_password,
164             { RaiseError => 1, AutoCommit => 1 } );
165
166         $db_generation = $self->db_get_option('generation');
167     }
168
169     method db_get_option($name) {
170         my $sth = $db->prepare_cached("select $name from options");
171         $sth->execute;
172         my @result = $sth->fetchrow_array;
173
174         return $result[0];
175     }
176
177     method db_set_option( $name, $value ) {
178         my $sth = $db->prepare_cached("update options set $name = ?");
179         $sth->execute($value);
180     }
181
182     method db_store_song($song, $artist, $album) {
183         return unless length($song) and length($artist) and length($album);
184
185         $db->prepare_cached(
186             <<'SQL')->execute( $song, $artist, $album, $db_generation );
187 INSERT INTO songs(path, artist, album, generation)
188 VALUES($1, $2, $3, $3)
189 ON CONFLICT ON CONSTRAINT songs_pkey DO
190 UPDATE SET artist = $2
191          , album = $3
192          , generation = $4
193 SQL
194         $db->prepare_cached(<<'SQL')->execute( $artist, $album, $db_generation );
195 INSERT INTO albums(artist, album, generation)
196 VALUES($1, $2, $3)
197 ON CONFLICT ON CONSTRAINT albums_pkey DO
198 UPDATE SET generation = $3
199 SQL
200         $db->prepare_cached(<<'SQL')->execute( $artist, $db_generation );
201 INSERT INTO artists(artist, generation)
202 VALUES($1, $2)
203 ON CONFLICT ON CONSTRAINT artists_pkey DO
204 UPDATE SET generation = $2
205 SQL
206     }
207
208     method db_remove_stale_entries {
209         $db->prepare_cached('DELETE FROM songs WHERE generation <> ?')
210             ->execute($db_generation);
211         $db->prepare_cached('DELETE FROM albums WHERE generation <> ?')
212             ->execute($db_generation);
213         $db->prepare_cached('DELETE FROM artists WHERE generation <> ?')
214             ->execute($db_generation);
215     }
216
217     method db_note_song_qeued($item) {
218         $db->prepare_cached(
219             'UPDATE songs SET last_queued=current_timestamp WHERE path=?')
220             ->execute( $item->{song} );
221         $db->prepare_cached(
222             'UPDATE artists SET last_queued=CURRENT_TIMESTAMP WHERE artist=?')
223             ->execute( $item->{artist} );
224         $db->prepare_cached(
225             'UPDATE albums SET last_queued=CURRENT_TIMESTAMP WHERE artist=? AND album=?'
226         )->execute( $item->{artist}, $item->{album} );
227     }
228
229     method update_db() {
230         $mpd->send('listallinfo')->on_done(
231             sub {
232                 try {
233                     $db->begin;
234
235                     $db_generation++;
236
237                     my ($song, $artist, $album);
238
239                     foreach my $row (@_) {
240                         chomp($row);
241
242                         if ($row =~ s/^file:\s*//) {
243                             $self->db_store_song( $song, $artist, $album );
244                             $song = $row;
245                             $artist = $album = undef;
246                         }
247                         elsif ( $row =~ s/^Artist:\s*// ) {
248                             $artist = $row;
249                         }
250                         elsif ( $row =~ s/^Album:\s*// ) {
251                             $album = $row;
252                         }
253                     }
254
255                     $self->db_store_song($song, $artist, $album);
256
257                     $self->db_remove_stale_entries;
258
259                     $self->db_set_option( generation => $db_generation );
260
261                     $db->commit;
262                 }
263                 catch {
264                     my $err = $@;
265
266                     $db_generation--;
267
268                     $db->rollback;
269
270                     die $err;
271                 }
272             }
273         );
274     }
275
276     method db_find_suitable_songs($num) {
277         my @result;
278         my $sth = $self->db->prepare_cached(<<SQL);
279 SELECT s.path, s.artist, s.album
280 FROM songs s
281 JOIN artists ar ON ar.artist=s.artist
282 JOIN albums al ON al.album=s.album
283 WHERE (s.last_queued IS NULL OR s.last_queued < CURRENT_TIMESTAMP - CAST(? AS float))
284   AND (ar.last_queued IS NULL OR ar.last_queued < CURRENT_TIMESTAMP - CAST(? AS float))
285   AND (al.last_queued IS NULL OR al.last_queued < CURRENT_TIMESTAMP - CAST(? AS float))
286   AND NOT EXISTS (SELECT 1 FROM blacklisted_artists bar WHERE bar.artist = s.artist)
287   AND NOT EXISTS (SELECT 1 FROM blacklisted_albums  bal WHERE bal.album  = s.album)
288 ORDER BY random()
289 LIMIT ?
290 SQL
291         $sth->execute(
292             $self->opt->min_song_interval / 3600.0 / 24.0,
293             $self->opt->min_artist_interval / 3600.0 / 24.0,
294             $self->opt->min_album_interval / 3600.0 / 24.0,
295             $num,
296         );
297         while ( my @row = $sth->fetchrow_array ) {
298             push @result,
299                 { song => $row[0], artist => $row[1], album => $row[2] };
300         }
301
302         return @result;
303     }
304
305     method queue_songs($num = undef) {
306         if (!defined $num) {
307             $mpd->send('playlist')->on_done( sub {
308                     my $present = scalar @_;
309
310                     $self->queue_songs( $opt->target_queue_length - $present )
311                         if $present < $opt->target_queue_length;
312                 } );
313         }
314         else {
315             my @list = $self->db_find_suitable_songs($num);
316
317             if (@list < $num) {
318                 $mpd->loop->add(
319                     IO::Async::Timer::Countdown->new(
320                         delay     => 15,
321                         on_expire => sub { $self->queue_songs($num) },
322                     )
323                 );
324             }
325             else {
326                 $mpd->send( [ map {"add $_->{song}"} @list ] );
327                 $self->db_note_song_qeued($_) for @list;
328             }
329         }
330     }
331 }
332
333 my $feeder = Feeder->new();
334
335 $feeder->opt->dump, exit if $feeder->opt->dump_config;
336
337 $feeder->queue_songs(1), exit if $feeder->opt->single;
338
339 # FIXME: handle blacklist manipulation
340
341 $feeder->queue_songs;
342
343 exit if $feeder->opt->one_shot;
344
345 $feeder->mpd->on(
346     database => sub {
347         $feeder->update_db;
348     }
349 );
350
351 $feeder->mpd->on(
352     playlist => sub {
353         $feeder->queue_songs;
354     }
355 );
356
357 $feeder->mpd->idle(qw(database playlist));
358 $feeder->mpd->get;