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