4 class App::MPD::Feeder;
6 use App::MPD::Feeder::DB;
7 use App::MPD::Feeder::Options;
11 use IO::Async::Signal;
12 use IO::Async::Timer::Countdown;
13 use IO::Async::Timer::Periodic;
14 use Log::Any qw($log);
17 use Syntax::Keyword::Try;
18 use Time::Duration qw(duration_exact);
20 use constant UNDER_SYSTEMD => eval { require Linux::Systemd::Daemon };
22 has $cfg_file :reader;
25 has $db_needs_update :writer = 1;
27 has $mpd_connected = 0;
28 has $playlist_needs_filling = 1;
29 has $quit_requested = 0;
30 has $reload_requested = 0;
33 has $reconnect_delay = 5;
35 use constant DEFAULT_CONFIG_FILE => '/etc/mpd-feeder/mpd-feeder.conf';
38 Getopt::Long::Configure('pass_through');
39 Getopt::Long::GetOptions( 'cfg|config=s' => \$cfg_file );
40 Getopt::Long::Configure('no_pass_through');
42 $cfg_file //= DEFAULT_CONFIG_FILE if -e DEFAULT_CONFIG_FILE;
46 $db_needs_update = 0 if $opt->skip_db_update;
50 my $new_opt = App::MPD::Feeder::Options->new;
52 $new_opt->parse_config_file($cfg_file) if $cfg_file;
54 $new_opt->parse_command_line;
56 Log::Any::Adapter->set( Stderr => log_level => $new_opt->log_level );
58 $log->debug( "Systemd integration "
59 . ( UNDER_SYSTEMD ? "available" : "not available" ) );
63 $reconnect_delay = $opt->initial_reconnect_delay;
65 $db = App::MPD::Feeder::DB->new( opt => $opt );
68 method status($text, $log_level = undef) {
69 Linux::Systemd::Daemon::sd_notify( status => $text ) if UNDER_SYSTEMD;
70 $log->$log_level($text) if $log_level;
76 my %conn = ( auto_connect => 0 );
77 $conn{host} = $opt->mpd_host if $opt->mpd_host;
78 $conn{port} = $opt->mpd_port if $opt->mpd_port;
80 $mpd = Net::Async::MPD->new(%conn);
84 $log->warn("Connection to MPD lost");
85 $mpd->loop->stop('disconnected');
87 $idler->cancel if $idler;
94 $playlist_needs_filling = 1;
103 my $loop = $mpd->loop;
105 my $int_signal_handler = sub {
106 state $signal_count = 0;
109 if ( $signal_count > 1 ) {
110 $log->warn("Another signal received (#$signal_count)");
111 $log->warn("Exiting abruptly");
115 $log->debug("Signal received. Stopping loop");
123 IO::Async::Signal->new(
125 on_receipt => $int_signal_handler,
131 IO::Async::Signal->new(
134 $log->debug("SIGHUP received. Scheduling reload");
135 $reload_requested = 1;
136 $loop->stop('reload');
143 IO::Async::Signal->new(
147 "SIGUSR1 received. Dumping configuration to STDERR");
148 my $old = select \*STDERR;
164 method update_db( $force = undef ) {
165 if ( !$db_needs_update and !$force ) {
166 $log->debug("Skipping DB update");
170 $self->status('Updating song database', 'info');
172 my $rows = $mpd->send('listallinfo')->get;
174 $log->trace('got all songs from MPD');
176 $self->status('Updating local song database', 'debug');
181 foreach my $entry (@$rows) {
182 next unless exists $entry->{file};
184 $self->db->store_song( $entry->{file},
185 $entry->{AlbumArtist} // $entry->{Artist},
191 my ($total_songs, $total_artists, $total_albums,
192 $new_songs, $new_artists, $new_albums
193 ) = $self->db->finish_update;
196 "Updated data about $song_count songs (including $new_songs new), "
197 . "$total_artists artists (including $new_artists new) "
199 . "and $total_albums albums (including $new_albums new)"
202 $db_needs_update = 0;
206 $self->db->cancel_update;
211 method queue_songs( $num = undef ) {
212 if ( !defined $num ) {
213 return unless $playlist_needs_filling;
215 $self->status("Requesting playlist", 'trace');
216 my $present = $mpd->send('playlist')->get // [];
217 $present = scalar(@$present);
219 if ( $present < $opt->target_queue_length ) {
220 $log->notice( "Playlist contains $present songs. Wanted: "
221 . $opt->target_queue_length );
222 $self->queue_songs( $opt->target_queue_length - $present );
225 $log->info("Playlist contains $present songs");
226 $playlist_needs_filling = 0;
232 $self->status("Looking for suitable songs to queue", 'debug');
233 my @list = $self->db->find_suitable_songs($num);
235 die "Found no suitable songs" unless @list;
237 if ( @list < $num ) {
240 'Found only %d suitable songs instead of %d',
246 $log->debug("About to add $num songs to the playlist");
249 for my $song (@list) {
250 my $path = $song->{song};
255 $log->notice( "Adding " . join( ', ', map {"«$_»"} @paths ) );
256 $self->status('Adding songs to the playlist');
258 # MPD needs raw bytes
259 utf8::encode($_) for @paths;
262 push @commands, [ add => "\"$_\"" ];
264 my $f = $mpd->send( \@commands );
265 $f->on_fail( sub { die @_ } );
268 $self->db->note_song_qeued($_) for @list;
269 $playlist_needs_filling = 0;
276 $self->status("disconnecting and re-starting", 'info');
277 Linux::Systemd::Daemon::sd_reloading() if UNDER_SYSTEMD;
281 my @exec = ( $0, '--config', $self->cfg_file, '--skip-db-update' );
282 if ( $log->is_trace ) {
283 $log->trace( 'exec ' . join( ' ', map { /\s/ ? "'$_'" : $_ } @exec ) );
289 if ( $idler && !$idler->is_ready ) {
290 if ($mpd_connected) {
291 $log->trace("hand-sending 'noidle'");
293 $mpd->{mpd_handle}->write("noidle\n");
296 $log->trace("not connected to MPD: skipping 'noidle'");
300 $log->trace("no idler found");
304 method sleep_before_reconnection {
307 . duration_exact($reconnect_delay)
308 . " before re-connecting",
313 IO::Async::Timer::Countdown->new(
314 delay => $reconnect_delay,
315 on_expire => sub { $mpd->loop->stop },
319 $reconnect_delay = $reconnect_delay * 1.5;
320 $reconnect_delay = 120 if $reconnect_delay > 120;
325 unless ($mpd_connected) {
326 $self->status("Connecting to MPD", 'trace');
327 my $f = $mpd->connect->await;
331 $playlist_needs_filling = 1;
332 $reconnect_delay = $opt->initial_reconnect_delay;
333 $mpd->loop->later( sub { $self->pulse } );
335 elsif ( $f->is_failed ) {
336 $mpd->loop->stop('disconnected');
337 $log->warn($f->failure);
338 $self->sleep_before_reconnection;
341 die "connect Future neither done nor failed!?";
347 if ($db_needs_update) {
349 $mpd->loop->later( sub { $self->pulse } );
353 if ($playlist_needs_filling) {
355 $mpd->loop->later( sub { $self->pulse } );
359 $self->status("Waiting for playlist/database changes", 'debug');
360 $last_mpd_comm = time;
361 $idler = $mpd->send("idle database playlist");
364 $log->trace('got out of idle');
367 if ( $idler->is_done ) {
368 my $result = $idler->get;
370 if ( ref $result and $result->{changed} ) {
371 my $changed = $result->{changed};
372 $changed = [$changed] unless ref $changed;
374 $mpd->emit($_) for @$changed;
377 elsif ( $idler->is_cancelled ) {
378 $log->trace("idle was cancelled");
381 elsif ( $idler->is_failed ) {
382 $log->warn("idle failed: ".$idler->failure);
395 my $loop = $mpd->loop;
398 IO::Async::Timer::Periodic->new(
401 if (!$mpd_connected) {
402 $log->trace("Not connected to MPD. Skipping alive check.");
403 $loop->stop('disconnected');
407 if ( time - $last_mpd_comm > 300 ) {
410 "no active MPD communication for more that 5 minutes");
411 $self->status("checking connection", 'debug');
416 "contacted MPD less than 5 minutes ago. skipping alive check"
424 if ( $quit_requested ) {
425 $self->status("about to quit", 'debug');
426 Linux::Systemd::Daemon::sd_stopping() if UNDER_SYSTEMD;
431 elsif ( $reload_requested ) {
436 $log->trace('About to run the loop');
438 $mpd->loop->later( sub { $self->pulse } );
440 Linux::Systemd::Daemon::sd_ready() if UNDER_SYSTEMD;