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