]> git.ktnx.net Git - mpd-feeder.git/blob - lib/App/MPD/Feeder.pm
2ce166f6a4b45e1a4a5534534af2a474eafe2bd9
[mpd-feeder.git] / lib / App / MPD / Feeder.pm
1 package App::MPD::Feeder;
2
3 use strict;
4 use warnings;
5 use utf8;
6 use feature 'state';
7
8 use App::MPD::Feeder::Options;
9 use App::MPD::Feeder::DB;
10 use DBD::Pg;
11 use DBI;
12 use Getopt::Long;
13 use IO::Async::Signal;
14 use IO::Async::Timer::Periodic;
15 use Log::Any qw($log);
16 use Net::Async::MPD;
17 use Object::Pad;
18 use Syntax::Keyword::Try;
19
20
21 class App::MPD::Feeder {
22     has $cfg_file :reader;
23     has $opt :reader;
24     has $db :reader;
25     has $db_needs_update :writer = 1;
26     has $mpd :reader;
27     has $idler;
28
29 use constant DEFAULT_CONFIG_FILE => '/etc/mpd-feeder/mpd-feeder.conf';
30
31     ADJUST {
32         Getopt::Long::Configure('pass_through');
33         Getopt::Long::GetOptions('cfg|config=s' => \$cfg_file);
34         Getopt::Long::Configure('no_pass_through');
35
36         $cfg_file //= DEFAULT_CONFIG_FILE if -e DEFAULT_CONFIG_FILE;
37
38         $self->configure;
39
40         $db_needs_update = 0 if $opt->skip_db_update;
41     }
42
43     method configure {
44         my $new_opt = App::MPD::Feeder::Options->new;
45
46         $new_opt->parse_config_file($cfg_file) if $cfg_file;
47
48         $new_opt->parse_command_line;
49
50         Log::Any::Adapter->set( Stderr => log_level => $new_opt->log_level );
51
52         $opt = $new_opt;
53
54         $db = App::MPD::Feeder::DB->new( opt => $opt );
55     }
56
57     method connect_mpd {
58         return if $mpd;
59
60         my %conn = ( auto_connect => 1 );
61         $conn{host} = $opt->mpd_host if $opt->mpd_host;
62         $conn{port} = $opt->mpd_port if $opt->mpd_port;
63
64         $mpd = Net::Async::MPD->new(%conn);
65
66         $mpd->on(
67             close => sub {
68                 die "Connection to MPD lost";
69             }
70         );
71
72         my $int_signal_handler = sub {
73             state $signal_count = 0;
74             $signal_count++;
75             $log->debug("Signal received. Stopping loop");
76             $mpd->loop->stop('quit');
77
78             if ( $signal_count > 1 ) {
79                 $log->warn("Another signal received (#$signal_count)");
80                 $log->warn("Exiting abruptly");
81                 exit 2;
82             }
83         };
84
85         for (qw(TERM INT)) {
86             $mpd->loop->add(
87                 IO::Async::Signal->new(
88                     name       => $_,
89                     on_receipt => $int_signal_handler,
90                 )
91             );
92         }
93
94         $mpd->loop->add(
95             IO::Async::Signal->new(
96                 name       => 'HUP',
97                 on_receipt => sub {
98                     $log->debug("SIGHUP received. Scheduling reload");
99                     $mpd->loop->stop('reload');
100                 },
101             )
102         );
103
104         $mpd->loop->add(
105             IO::Async::Signal->new(
106                 name       => 'USR1',
107                 on_receipt => sub {
108                     $log->debug("SIGUSR1 received. Dumping configuration to STDERR");
109                     my $old = select \*STDERR;
110                     try {
111                         $opt->dump;
112                     }
113                     finally {
114                         select $old;
115                     }
116                 },
117             )
118         );
119     }
120
121     method connect_db {
122         $db->connect($opt);
123         $self->update_db;
124     }
125
126     method update_db($force = undef) {
127         if (!$db_needs_update and !$force) {
128             $log->debug("Skipping DB update");
129             return;
130         }
131
132         $log->info('Updating song database');
133         $self->connect_mpd;
134
135         my $rows = $mpd->send('listallinfo')->get;
136
137         $log->trace('got all songs from MPD');
138
139         $db->start_update;
140         try {
141             my $song_count;
142
143             foreach my $entry (@$rows) {
144                 next unless exists $entry->{file};
145
146                 $self->db->store_song( $entry->{file},
147                     $entry->{AlbumArtist} // $entry->{Artist},
148                     $entry->{Album} );
149
150                 $song_count++;
151             }
152
153             my ($total_songs, $total_artists, $total_albums,
154                 $new_songs,   $new_artists,   $new_albums
155             ) = $self->db->finish_update;
156
157             $log->info(
158                 "Updated data about $song_count songs (including $new_songs new), "
159                     . "$total_artists artists (including $new_artists new) "
160
161                     . "and $total_albums albums (including $new_albums new)"
162             );
163
164             $db_needs_update = 0;
165         }
166         catch {
167             my $err = $@;
168             $self->db->cancel_update;
169             die $err;
170         }
171     }
172
173     method queue_songs($num = undef, $callback = undef) {
174         $self->connect_db;
175         if (!defined $num) {
176             $self->connect_mpd;
177             $mpd->send('playlist')->on_done(
178                 sub {
179                     my $present = scalar @{ $_[0] // [] };
180
181                     $log->notice( "Playlist contains $present songs. Wanted: "
182                             . $opt->target_queue_length );
183                     if ( $present < $opt->target_queue_length ) {
184                         $self->queue_songs(
185                             $opt->target_queue_length - $present, $callback );
186                     }
187                     else {
188                         $callback->() if $callback;
189                     }
190                 }
191             );
192
193             return;
194         }
195
196         my @list = $self->db->find_suitable_songs($num);
197
198         die "Found no suitable songs" unless @list;
199
200         if ( @list < $num ) {
201             $log->warn(
202                 sprintf(
203                     'Found only %d suitable songs instead of %d',
204                     scalar(@list), $num
205                 )
206             );
207         }
208
209         $log->info("About to add $num songs to the playlist");
210
211         my @paths;
212         for my $song (@list) {
213             my $path = $song->{song};
214             $path =~ s/"/\\"/g;
215             push @paths, $path;
216         }
217
218         $log->debug( "Adding " . join( ', ', map {"«$_»"} @paths ) );
219         # MPD needs raw bytes
220         utf8::encode($_) for @paths;
221         my @commands;
222         for (@paths) {
223             push @commands, [ add => "\"$_\"" ];
224         }
225         $self->connect_mpd;
226         my $f = $mpd->send( \@commands );
227         $f->on_fail( sub { die @_ } );
228         $f->on_done(
229             sub {
230                 $self->db->note_song_qeued($_) for @list;
231                 $callback->(@_) if $callback;
232             }
233         );
234     }
235
236     method prepare_to_wait_idle {
237         $log->trace('declaring idle mode');
238         $idler = $mpd->send('idle database playlist')->on_done(
239             sub {
240                 my $result = shift;
241
242                 undef $idler;
243
244                 my $changed = $result->{changed} // '';
245
246                 if ( $changed eq 'database' ) {
247                     $db_needs_update = 1;
248                     $self->prepare_to_wait_idle;
249                 }
250                 elsif ( $changed eq 'playlist' ) {
251                     $self->queue_songs( undef,
252                         sub { $self->prepare_to_wait_idle } );
253                 }
254                 elsif ( $changed eq '' ) {
255                     $log->debug("got no changes from idle");
256                     $self->prepare_to_wait_idle;
257                 }
258                 else {
259                     use JSON;
260                     $log->warn(
261                         "Unknown result from idle: " . to_json($result) );
262                     $self->prepare_to_wait_idle;
263                 }
264             }
265         );
266     }
267
268     method stop {
269         undef $mpd;
270
271         $db->disconnect;
272     }
273
274     method run_loop {
275         $self->connect_mpd;
276         $self->connect_db;
277
278         $mpd->loop->add(
279             IO::Async::Timer::Periodic->new(
280                 interval => 300,
281                 on_tick  => sub {
282                     if ($idler) {
283                         $log->trace('breaking idle to see if MPD is there');
284                         undef $idler;
285                         $log->trace("> noidle (direct)");
286                         $mpd->{mpd_handle}->write("noidle\n");
287                     }
288                 },
289             )->start
290         );
291
292         for ( ;; ) {
293             $self->queue_songs( undef, sub { $self->prepare_to_wait_idle } );
294
295             $log->debug("Entering event loop. PID=$$");
296
297             my $result = $mpd->loop->run;
298             $log->trace( "Got loop result of " . ( $result // 'undef' ) );
299
300             if ( 'reload' eq $result ) {
301                 $log->notice("disconnecting");
302                 $self->stop;
303
304                 my @exec = ( $0, '--config', $self->cfg_file, '--skip-db-update' );
305                 if ( $log->is_trace ) {
306                     $log->trace( 'exec '
307                             . join( ' ', map { /\s/ ? "'$_'" : $_ } @exec ) );
308                 }
309                 exec(@exec);
310             }
311
312             if ( 'quit' eq $result ) {
313                 $log->trace("quitting because of 'quit' loop result");
314                 $self->stop;
315                 exit 0;
316             }
317         }
318     }
319 }
320