2dc99abd88234540f8275367da3a37324f5d0065
[mythtv-status.git] / bin / mythtv-status
1 #!/usr/bin/perl -w
2 # Copyright (c) 2007-2017 Andrew Ruthven <andrew@etc.gen.nz>
3 # This code is hereby licensed for public consumption under the GNU GPL v3.
4 #
5 # You should have received a copy of the GNU General Public License along
6 # with this program; if not, write to the Free Software Foundation, Inc.,
7 # 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
8
9 # Display the current status of a MythTV system.
10
11 # While I would love to enable the 6.xx interface for Date::Manip we may
12 # still need to run on platforms that only have version 5.x.  So we'll
13 # turn on backwards compatible mode for now.
14 {
15   no warnings 'once';
16   $Date::Manip::Backend = 'DM5';
17 }
18
19 use LWP::UserAgent;
20 use XML::LibXML;
21 use Date::Manip;
22 use Date::Manip::Date;
23 use Getopt::Long;
24 use Text::Wrap;
25 use POSIX qw/strftime :sys_wait_h/;
26
27 use MIME::Entity;
28 use Pod::Usage;
29 use Sys::SigAction qw/timeout_call/;
30 use Encode;
31
32 # Try and load a config file first, and then allow the command line
33 # to override what is in the config file.
34 my $c;
35
36 if (eval("{ local \$SIG{__DIE__}; require Config::Auto; }")) {
37   $c = eval {
38     Config::Auto::parse("mythtv-status.yml", format => 'yaml')
39   };
40
41   if ($@) {
42     # Die on any issues loading the config file, apart from it not existing.
43     if ($@ =~ /^No config file found/) {
44       $c->{'config_file_loaded'} = 0;
45     } else {
46       die $@;
47     }
48   } else {
49     $c->{'config_file_loaded'} = 1;
50   }
51 }
52
53 # Some sane defaults.
54 $c->{'host'}    ||= "localhost";
55 $c->{'port'}    ||= "6544";
56 $c->{'colour'}  ||= 0;
57 $c->{'episode'} ||= 0;
58 $c->{'description'} ||= 0;
59 $c->{'encoder_details'}     ||= 0;
60 $c->{'encoder_skip_idle'}   //= 1;
61 $c->{'email_only_on_alert'} ||= 0;
62 my $help = 0;
63 my $verbose = 0;
64 $c->{'disk_space_warn'} ||= 95;  # Percent to warn at.
65 $c->{'guide_days_warn'} ||= 2;   # How many days we require.
66 $c->{'auto_expire_count'} ||= 10;      # How many auto expire shows to display.
67 $c->{'recording_in_warn'} ||= 60 * 60; # When is the next recording considered critical? (seconds)
68 $c->{'save_file'} ||= undef; # File to save the XML from the BE to.
69 $c->{'xml_file'}  ||= undef; # Load the BE XML from this file.
70
71 # We allow a hack for MS Outlook which normally recognises the
72 # oneliners block as a paragraph and wraps it.  If it sees "bullets"
73 # it believes it is a bulleted list and leaves it alone.
74 $c->{'oneliner_bullets'} ||= 0;
75
76 # What units we have available for converting diskspaces.
77 # The threshold is at what level we switch to using that unit.
78 my @size_thresholds = (
79   {
80     'unit' => 'TB',
81     'threshold' => 2 * 1024 * 1024,
82     'conversion' => 1024 * 1024,
83   },
84   {
85     'unit' => 'GB',
86     'threshold' => 50 * 1024,        # 50GB seems like a good threshold.
87     'conversion' => 1024,
88   },
89   {
90     'unit' => 'MB',
91   },
92 );
93
94 my $return_code_only = 0;
95
96 my $VERSION = '0.10.8';
97
98 # Some display blocks are disabled by default:
99 $c->{'display'}{'Shows due to Auto Expire'} = 0;
100
101 GetOptions(
102   'c|colour|color!' => \$c->{'colour'},
103   'd|description!' => \$c->{'description'},
104   'e|episode!'    => \$c->{'episode'},
105   'encoder-details!' => \$c->{'encoder_details'},
106   'h|host=s'     => \$c->{'host'},
107   'p|port=i'     => \$c->{'port'},
108   'v|version'    => \&print_version,
109   'email=s@'     => \@{ $c->{'email'} },
110   'email-only-on-conflict|email-only-on-alert|email-only-on-alerts'
111     => \$c->{'email_only_on_alert'},
112   'disk-space-warn=i'     => \$c->{'disk_space_warn'},
113   'guide-days-warn=i'     => \$c->{'guide_days_warn'},
114   'auto-expire-count=i'   => \$c->{'auto_expire_count'},
115   'recording-in-warn=i'   => \$c->{'recording_in_warn'},
116   'encoder-skip-idle!'    => \$c->{'encoder_skip_idle'},
117   'oneliner-bullets!'     => \$c->{'oneliner_bullets'},
118
119   'status!'               => \$c->{'display'}{'Status'},
120   'encoders!'             => \$c->{'display'}{'Encoders'},
121   'recording-now!'        => \$c->{'display'}{'Recording Now'},
122   'scheduled-recordings!' => \$c->{'display'}{'Scheduled Recordings'},
123   'schedule-conflicts!'   => \$c->{'display'}{'Schedule Conflicts'},
124   'next-recording!'       => \$c->{'display'}{'Next Recording In'},
125   'total-disk-space!'     => \$c->{'display'}{'Total Disk Space'},
126   'disk-space!'           => \$c->{'display'}{'Disk Space'},
127   'guide-data!'           => \$c->{'display'}{'Guide Data'},
128   'auto-expire!'          => \$c->{'display'}{'Shows due to Auto Expire'},
129
130   'return-code-only'      => \$return_code_only,
131
132   'file=s'                => \$c->{'xml_file'},
133   's|save-file=s'         => \$c->{'save_file'},
134
135   'date=s'  => \$c->{'date'},
136   'verbose' => \$verbose,
137   'help|?'  => \$help,
138   ) || pod2usage("\nUse --help for help.\n");
139
140 pod2usage(verbose => 1)
141   if $help;
142
143 $0 = "mythtv-status - parent";
144
145 # Get the email address into a format we can use.
146 @{ $c->{'email'} } = split(',', join(',', @{ $c->{'email'} }));
147
148 # Default to not showing some blocks if we're sending email, but let the
149 # user override us.
150 if (scalar(@{ $c->{'email'} }) > 0) {
151   for my $block ('Encoders', 'Recording Now', 'Next Recording In') {
152     if (! defined $c->{'display'}{$block}) {
153       $c->{'display'}{$block} = 0;
154     }
155   }
156 }
157
158 # Possibly use some colour, but not in emails.
159 my $safe = '';
160 my $warning = '';
161 my $normal = '';
162 if ($c->{'colour'} && scalar(@{ $c->{'email'} }) == 0) {
163   $safe    = "\033[0;32m";
164   $warning = "\033[1;31m";
165   $normal  = "\033[0m";
166 }
167
168 # Is a warning present?
169 my $warn_present = 0;
170
171 # Allow setting some defaults for the output blocks.
172 my %defaults = (
173   'schedule' => {
174     'attrs' => [ qw/title startTime NODE_TEXT subTitle channelName:.\/Channel[@channelName] chanNum:.\/Channel[@chanId] inputId:.\/Channel[@inputId]/ ],
175     'template' => "__startTime__"
176       . " - __title__"
177       . ($c->{'episode'} ? " - __subTitle__" : '')
178       . " (__channelName__)"
179       . ($c->{'encoder_details'} ? " - Enc: __inputId__, Chan: __chanNum__" : '')
180       . ($c->{'description'} ? "\n__NODE_TEXT__" : ''),
181
182     'filter' =>  {
183
184       # Only show recordings for today and tomorrow.
185       'startTime' => sub {
186         my $date = substr(ParseDate($_[0]), 0, 8);
187         return ! (($date cmp $today) == 0
188           || ($date cmp $tomorrow) == 0) }
189       },
190     'rewrite' => {
191       '&startTime' => sub { return process_iso_date($_[0]); }
192     }
193   }
194 );
195
196 # The time of the next scheduled recording.
197 my $next_time = 'Never';
198
199 # Are there any alerts that should be notified via email?
200 my @alerts = ();
201
202 # The blocks of output which we might generate.
203 #
204 # Generally, these blocks of output are generated in the order that they
205 # appear below.  However, if a block has the tag "format", who's value is
206 # "one line" it will appear at the start of the output, on a single line,
207 # all one liners grouped together.
208 my @blocks = (
209
210   # All the one liners together
211   {
212     'name' => 'One Liners',
213     'type' => 'sub',
214     'template' => '',
215     'sub' => sub { return 'Place holder' },
216   },
217
218   # Date/Time from server
219   {
220     'name'  => 'Status',
221     'type'  => 'xpath',
222     'xpath' => "//Status",
223     'attrs' => [ qw/ISODate time date/ ],
224     'template' => "__date__",
225     'format' => 'one line',
226     'rewrite' => {
227       '&date' => sub {
228         my ($value, $vars) = @_;
229
230         if (defined $vars->{ISODate} && $vars->{ISODate} =~ /Z$/) {
231           return process_iso_date($vars->{ISODate});
232         } else {
233           return $vars->{date} . ", " . $vars->{time};
234         }
235       },
236     },
237   },
238
239   # Info about the encoders before TV OSD Declutter (Changeset 20037).
240   {
241     'name'  => 'Encoders',
242     'type'  => 'xpath',
243     'xpath' => "//Status/Encoders/Encoder",
244     'protocol_version' => [ "<= 43" ],
245     'attrs' => [ qw/hostname id state connected/ ],
246     'template' => "__hostname__ (__id__) - __state____connected__",
247     'rewrite' => {
248       '/connected/' => { '1' => '', '0' => "${warning}(Disconnected)${normal}" },
249       '/state/' => {
250         '^0$' => "${safe}Idle${normal}",
251         '^1$' => "${warning}Watching LiveTV${normal}",
252         '^2$' => "${warning}Watching Pre-recorded${normal}",
253         '^3$' => "${warning}Watching Recording${normal}",
254         '^4$' => "${warning}Recording${normal}" },
255     },
256     'filter' => {
257       'state' => sub { return $c->{'encoder_skip_idle'} && $_[0] == 0 },
258     },
259   },
260
261   # Info about the encoders after TV OSD Declutter (Changeset 20037).
262   {
263     'name'  => 'Encoders',
264     'type'  => 'xpath',
265     'xpath' => "//Status/Encoders/Encoder",
266     'protocol_version' => [ ">= 44", "< 58" ],
267     'attrs' => [ qw/hostname id state connected/ ],
268     'template' => "__hostname__ (__id__) - __state____connected__",
269     'rewrite' => {
270       '/connected/' => { '1' => '', '0' => "${warning}(Disconnected)${normal}" },
271       '/state/' => {
272          '^-1$' => "${warning}Error${normal}",
273          '^0$' => "${safe}Idle${normal}",
274          '^1$' => "${warning}Watching LiveTV${normal}",
275          '^2$' => "${warning}Watching Pre-recorded${normal}",
276          '^3$' => "${warning}Watching DVD${normal}",
277          '^4$' => "${warning}Watching Video${normal}",
278          '^5$' => "${warning}Watching Recording${normal}",
279          '^6$' => "${warning}Recording${normal}" },
280     },
281     'filter' => {
282       'state' => sub { return $c->{'encoder_skip_idle'} && $_[0] == 0 },
283     },
284   },
285
286   # Info about the encoders after adding Blu-ray (Changeset 25058).
287   #  The protocol version is from svn commit 25362 but is the closest commit
288   #  for mythtv/libs/libmythdb/mythversion.h.
289   {
290     'name'  => 'Encoders',
291     'type'  => 'xpath',
292     'xpath' => "//Status/Encoders/Encoder",
293     'protocol_version' => [ ">= 58" ],
294     'attrs' => [ qw/hostname id state connected/ ],
295     'template' => "__hostname__ (__id__) - __state____connected__",
296     'rewrite' => {
297       '/connected/' => { '1' => '', '0' => "${warning}(Disconnected)${normal}" },
298       '/state/' => {
299          '^-1$' => "${warning}Error${normal}",
300          '^0$' => "${safe}Idle${normal}",
301          '^1$' => "${warning}Watching LiveTV${normal}",
302          '^2$' => "${warning}Watching Pre-recorded${normal}",
303          '^3$' => "${warning}Watching DVD${normal}",
304          '^4$' => "${warning}Watching Blu-ray${normal}",
305          '^5$' => "${warning}Watching Video${normal}",
306          '^6$' => "${warning}Watching Recording${normal}",
307          '^7$' => "${warning}Recording${normal}" },
308     },
309     'filter' => {
310       'state' => sub { return $c->{'encoder_skip_idle'} && $_[0] == 0 },
311     },
312   },
313
314   # What programs (if any) are being recorded right now?
315   {
316     'name'  => 'Recording Now',
317     'type'  => 'xpath',
318     'xpath' => "//Status/Encoders/Encoder/Program",
319     'hide'  => 'after',
320     'attrs' => [ qw/title endTime channelName:.\/Channel[@channelName]
321                     encoderId:.\/Recording[@encoderId]
322                     chanNum:.\/Channel[@chanNum]/ ],
323     'template' => "__title__ (__channelName__) "
324       . ($c->{'encoder_details'} ? ", Enc: __encoderId__, Chan: __chanNum__ " : '')
325       . "Ends: __endTime__",
326     'rewrite' => {
327       '&endTime' => sub {
328         my ($value, $vars) = @_;
329
330         if ($value =~ /Z$/) {
331           $value = process_iso_date($value, { date => 0 });
332         } else {
333           $value =~ s/.*T//;
334         }
335
336         return $value;
337       },
338     },
339     'subs' => {
340       'find_next' =>
341         sub {
342           $warn_present ||= 1;
343           $next_time    = $c->{'date'} || 'now';
344         }
345       }
346   },
347
348   # The upcoming recordings.
349   {
350     'name'  => 'Scheduled Recordings',
351     'type'  => 'xpath',
352     'xpath' => '//Status/Scheduled/Program',
353     'defaults' => 'schedule',
354     'hide'  => 'after',
355     'subs' => {
356       'find_next' => sub {
357         my $vars = shift;
358         return
359           if defined $next_time && $next_time eq 'now';
360
361         my $date = ParseDate($vars->{'startTime'});
362         if ($next_time eq 'Never' || Date_Cmp($date, $next_time) < 0) {
363           $next_time = $date
364         };
365         }
366       }
367   },
368
369   # Conflicts
370   {
371     'name' => 'Schedule Conflicts',
372     'type' => 'sub',
373     'defaults' => 'schedule',
374     'sub' => \&process_conflicts
375   },
376
377   # Auto Expire
378   {
379     'name' => 'Shows due to Auto Expire',
380     'type' => 'sub',
381     'defaults' => 'schedule',
382     'sub' => \&process_auto_expire,
383     'filter' =>  {},   # Over ride the default filter from 'schedule'.
384   },
385
386   # Diskspace, before storage groups
387   {
388     'name' => 'Total Disk Space',
389     'type' => 'xpath',
390     'xpath' => '//Status/MachineInfo/Storage',
391     'protocol_version' => [ "<= 31" ],
392     'attrs' => [ qw/_total_total _total_used/ ],
393     'commify' => [ qw/_total_total _total_used/ ],
394     'human_readable_sizes' => [ qw/_total_total _total_used/ ],
395     'template' => "Total space is ___total_total__ ___total_total_unit__, with ___total_used__ ___total_used_unit__ used (__percent__)",
396     'format' => 'one line',
397     'optional' => 1,
398     'subs' => {
399       'percent' => sub {
400         calc_disk_space_percentage("$_[0]->{'_total_used'} $_[0]->{'_total_used_unit'}", "$_[0]->{'_total_total'} $_[0]->{'_total_total_unit'}")
401         },
402       }
403   },
404
405   # Diskspace, with storage groups
406   {
407     'name' => 'Total Disk Space',
408     'type' => 'xpath',
409     'xpath' => '//Status/MachineInfo/Storage',
410     'protocol_version' => [ ">= 32" ],
411     'xml_version' => [ "== 0" ],
412     'attrs' => [ qw/drive_total_total drive_total_used/ ],
413     'commify' => [ qw/drive_total_total drive_total_used/ ],
414     'human_readable_sizes' => [ qw/drive_total_total drive_total_used/ ],
415     'template' => "Total space is __drive_total_total__ __drive_total_total_unit__, with __drive_total_used__ __drive_total_used_unit__ used (__percent__)",
416     'format' => 'one line',
417     'optional' => 1,
418     'subs' => {
419       'percent' => sub {
420         calc_disk_space_percentage("$_[0]->{'drive_total_used'} $_[0]->{'drive_total_used_unit'}", "$_[0]->{'drive_total_total'} $_[0]->{'drive_total_total_unit'}")
421         }
422       }
423   },
424
425   # Diskspace, with storage groups and sensible XML layout.
426   {
427     'name' => 'Total Disk Space',
428     'type' => 'xpath',
429     'xpath' => '//Status/MachineInfo/Storage/Group[@id="total"]',
430     'protocol_version' => [ ">= 39" ],
431     'attrs' => [ qw/total used/ ],
432     'commify' => [ qw/total used/ ],
433     'human_readable_sizes' => [ qw/total used/ ],
434     'template' => "Total space is __total__ __total_unit__, with __used__ __used_unit__ used (__percent__)",
435     'format' => 'one line',
436     'optional' => 1,
437     'subs' => {
438       'percent' => sub {
439         calc_disk_space_percentage("$_[0]->{'used'} $_[0]->{'used_unit'}", "$_[0]->{'total'} $_[0]->{'total_unit'}")
440         }
441       }
442   },
443
444   # Diskspace, with storage groups and sensible XML layout.
445   {
446     'name' => 'Disk Space',
447     'type' => 'xpath',
448     'xpath' => '//Status/MachineInfo/Storage/Group',
449     'protocol_version' => [ ">= 39" ],
450     'attrs' => [ qw/id total used/ ],
451     'commify' => [ qw/total used/ ],
452     'human_readable_sizes' => [ qw/total used/ ],
453     'template' => "Total space for group __id__ is __total__ __total_unit__, with __used__ __used_unit__ used (__percent__)",
454     'filter' =>  {
455       'id' => sub { return $_[0] eq 'total' },
456       'used' => sub {
457         return ! (
458           (defined $c->{'display'}{'Disk Space'} && $c->{'display'}{'Disk Space'})
459           || ($_[1]->{'used'} / $_[1]->{'total'}) * 100 > $c->{'disk_space_warn'})
460         }
461       },
462     'subs' => {
463       'percent' => sub {
464         calc_disk_space_percentage("$_[0]->{'used'} $_[0]->{'used_unit'}", "$_[0]->{'total'} $_[0]->{'total_unit'}")
465         }
466       }
467   },
468
469   # How many hours till the next recording.
470   {
471     'name' => 'Next Recording In',
472     'type' => 'sub',
473     'format' => 'one line',
474     'template' => '__next_time__',
475     'rewrite' => {
476       '&next_time' => sub {
477         return $next_time
478           if $next_time eq 'Never' || $next_time eq 'now';
479
480         my $err;
481         my $delta   = DateCalc($c->{'date'} || 'now', $next_time, \$err, 1);
482         my $seconds = Delta_Format($delta, 'approx', 0, '%sh');
483
484         # If the next recording is more than 1 day in the future,
485         # print out the days and hours.
486         my $str;
487         if ($seconds > 24 * 3600) {
488           $str = Delta_Format($delta, 0, '%dh Days, %hv Hours');
489         } else {
490           $str = Delta_Format($delta, 0, '%hh Hours, %mv Minutes');
491         }
492
493         $str =~ s/\b1 (Day|Hour|Minute)s/1 $1/g;
494         $str =~ s/\b0 (Days|Hours)(, )?//;
495         $str =~ s/, 0 Minutes$//;
496
497         if ($seconds <= $c->{'recording_in_warn'}) {
498           $warn_present ||= 1;
499           $str = "$warning$str$normal";
500         }
501
502         return $str;
503         }
504       },
505     'filter' =>  {
506       'next_time' => sub { return $_[0] eq 'now' }
507       },
508     'sub' => sub {
509       return substitute_vars($_[0], { 'next_time' => $next_time });
510       }
511   },
512
513   # Check how much Guide data we have
514   {
515     'name'     => 'Guide Data',
516     'format'   => 'one line',
517     'type'     => 'xpath',
518     'xpath'    => '//Status/MachineInfo/Guide[@guideDays]',
519     'attrs'    => [qw/guideDays status guideThru/],
520     'template' => 'There is __guideDays__ days worth of data, through to __guideThru__',
521     'filter' => {
522       'guideDays' => sub {
523         if ($_[0] > $c->{'guide_days_warn'}) {
524           return
525             (defined $c->{'display'}{'Guide Data'} && ! $c->{'display'}{'Guide Data'}) || 1;
526         } else {
527           $warn_present ||= 1;
528           push @alerts, "GUIDE DATA";
529           return 0;
530         }
531         },
532       },
533     'rewrite'  => {
534       '&guideDays' => sub {
535         if ($_[0] <= $c->{'guide_days_warn'}) {
536           $warn_present ||= 1;
537           return "$warning$_[0]$normal";
538         } else {
539           return "$safe$_[0]$normal";
540         }
541         },
542       '/guideThru/' => { 'T\d+:\d+:\d+' => ' ' },
543       '&guideThru' => sub {
544         if ($_[1]->{'guideDays'} <= $c->{'guide_days_warn'}) {
545           $warn_present ||= 1;
546           return "$warning$_[0]$normal";
547         } else {
548           return "$safe$_[0]$normal";
549         }
550         },
551       },
552   },
553
554   {
555     'name'     => 'Guide Data',
556     'format'   => 'one line',
557     'type'     => 'xpath',
558     'xpath'    => '//Status/MachineInfo/Guide[@status=""]',
559     'template' => "${warning}No guide data!${normal}",
560   },
561   );
562
563 ###
564 ### Set some useful variables
565 ###
566 our $today    = substr(ParseDate('today'), 0, 8);
567 our $tomorrow = substr(ParseDate('tomorrow'), 0, 8);
568
569 if ($c->{'date'}) {
570   $today    = substr(ParseDate($c->{'date'}), 0, 8);
571   $tomorrow = substr(DateCalc($c->{'date'}, ParseDateDelta('1 day')), 0, 8);
572 }
573
574 if ($verbose) {
575   print "Today:               $today\n";
576   print "Tomorrow:            $tomorrow\n";
577   print "Config::Auto module: " . (defined $INC{'Config/Auto.pm'} ? 'Loaded' : 'Not Loaded') . "\n";
578   print "Config file loaded:  " . ($c->{'config_file_loaded'} ? 'Yes' : 'No') . "\n";
579 }
580
581 # If we're in return code only mode then we disable all blocks
582 # except for those explicitly enabled.
583 if ($return_code_only) {
584   warn "In return-code-only mode, disabling all blocks by default.\n"
585     if $verbose;
586
587   for my $block (@blocks) {
588     $c->{'display'}{ $block->{'name'} } ||= 0;
589   }
590 }
591
592 # A couple of global variables
593 my ($xml, $charset, $myth);
594 my %version;
595
596 my $exit_value = 0;
597 my $title =  "MythTV status for $c->{'host'}";
598 my $output = "$title\n";
599 $output .= '=' x length($title) . "\n";
600
601 for my $block (@blocks) {
602   $block->{'format'} ||= 'multi line';
603   $block->{'optional'} ||= 0;
604
605   warn "Considering: $block->{'name'}\n"
606     if $verbose;
607
608   my $hide = undef;
609   if (defined $c->{'display'}{ $block->{'name'} }
610     && $c->{'display'}{ $block->{'name'} } == 0) {
611     if (defined $block->{'hide'} && lc($block->{'hide'}) eq 'after') {
612       $hide = 1;
613     } else {
614       next;
615     }
616   }
617
618   warn "  Going to process: $block->{'name'}\n"
619     if $verbose;
620
621   # We might need to set some defaults.
622   if (defined $block->{'defaults'}) {
623     for my $field (keys %{ $defaults{ $block->{'defaults'} } }) {
624       $block->{$field} ||= $defaults{ $block->{'defaults'} }{$field};
625     }
626   }
627
628   my $result = undef;
629   $warn_present = 0;
630   if ($block->{'type'} eq 'xpath') {
631     ($xml, $charset) = load_xml()
632       unless defined $xml;
633
634     $result = process_xml($block, $xml);
635
636   } elsif ($block->{'type'} eq 'sub') {
637
638     $result = &{ $block->{'sub'} }($block)
639       if defined $block->{'sub'};
640   }
641
642   if (defined $result && $result ne '' && ! defined $hide) {
643     $exit_value ||= $warn_present;
644
645     if ($block->{'format'} eq 'one line') {
646       push @oneliners, [ $block->{'name'}, $result ];
647     } else {
648       $output .= "$block->{'name'}:\n";
649       $output .= $result . "\n\n";
650     }
651   }
652 }
653
654 # Deal with the one liners.
655 if (scalar(@oneliners) > 0) {
656
657   # Find the longest header
658   my $length = 0;
659   for $line (@oneliners) {
660     if (length($line->[0]) > $length) {
661       $length = length($line->[0]);
662     }
663   }
664
665   # Put the one liners together, with leading dots to the colon.
666   # We allow a hack for MS Outlook which normally recognises the
667   # oneliners block as a paragrap and wraps it.  If it sees "bullets"
668   # it believes it is a bulleted list and leaves it alone.
669   my $oneliners = "";
670   for $line (@oneliners) {
671     $oneliners .= ($c->{'oneliner_bullets'} ? '* ' : '' )
672       . "$line->[0]"
673       . ('.' x ($length - length($line->[0]))) . ": $line->[1]\n";
674   }
675
676   # What a hacky way of putting the one liners where I want them...
677   $output =~ s/^One Liners:\nPlace holder\n/$oneliners/m;
678 }
679
680 # Either print the status out, or email it.
681 if ($return_code_only) {
682   exit $exit_value;
683 } elsif (scalar(@{ $c->{'email'} }) == 0) {
684   if ($charset =~ /utf(-)?8/i) {
685     $output = encode('UTF-8', $output);
686   }
687   print "\n$output";
688 } else {
689   if ((! $c->{'email_only_on_alert'}) ||
690     ($c->{'email_only_on_alert'} && scalar(@alerts) > 0)) {
691     my $suffix = undef;
692     if (@alerts == 1) {
693       $suffix = $alerts[0];
694     } elsif (@alerts > 1) {
695       $suffix = "MULTIPLE WARNINGS";
696     }
697
698     my $mail = MIME::Entity->build(
699       To      => $c->{'email'},
700       Subject => encode('UTF-8', $title . (defined $suffix ? " - $suffix" : '')),
701       Charset => $charset,
702       Encoding=> "quoted-printable",
703       Data    => encode('UTF-8', $output),
704       );
705
706     $mail->send('sendmail');
707   }
708 }
709
710 exit $exit_value;
711
712 # Fetch the XML status from the backend.
713 sub load_xml {
714   my $status = '';
715   my $charset = '';
716
717   if (defined $c->{'xml_file'}) {
718     open (IN, "< $c->{'xml_file'}")
719       || die "Failed to open $c->{'xml_file'} for reading: $!\n";
720
721     $status = join("", <IN>);
722     $charset = 'UTF-8';
723
724     close IN;
725   } else {
726     my $content_type;
727     # In MythTV 0.25 the path changed from /xml to /Status/GetStatus
728     for my $path ('Status/GetStatus', 'xml') {
729       my $url = "http://$c->{'host'}:$c->{'port'}/$path";
730       ($content_type, $status) = xml_fetch($url);
731
732       last
733         if defined $status;
734     }
735
736     die "Nothing was received from the MythTV Backend.\n"
737       unless defined $status;
738     ($charset)  = ($content_type =~ /charset="(\S+?)"/);
739   }
740
741   if (defined $c->{'save_file'}) {
742     open(OUT, "> $c->{'save_file'}")
743       || die "Failed to open " . $c->{'save_file'} . " for writing: $!\n";
744     print OUT $status;
745     close OUT;
746   }
747
748   # Parse the XML
749   my $parser = XML::LibXML->new();
750
751   # Some XML data seems to have badness in it, including non-existant
752   # UTF-8 characters.  We'll try and recover.
753   $parser->recover(1);
754   $parser->recover_silently(1)
755     unless $verbose;
756
757   clean_xml(\$status);
758
759   # Try and hide any error messages that XML::LibXML insists on printing out.
760   open my $olderr, ">&STDERR";
761   open(STDERR, "> /dev/null") || die "Can't redirect stderr to /dev/null: $!";
762
763   my $xml = eval { $parser->parse_string( $status ) };
764
765   close (STDERR);
766   open (STDERR, ">&", $olderr);
767
768   if ($@) {
769     die "Failed to parse XML: $@\n";
770   }
771
772   # Pick out the XML version.
773   my $items = $xml->documentElement->find('//Status');
774   $version{'xml'}      = @{ $items }[0]->getAttribute('xmlVer') || 0;
775   $version{'protocol'} = @{ $items }[0]->getAttribute('protoVer');
776
777   warn "Loaded XML from " . ($c->{'xml_file'} || $c->{'host'}) . "\n"
778     if $verbose;
779
780   return ($xml, $charset);
781 }
782
783 # Prep the Perl MythTV API if available.
784 sub load_perl_api {
785   my $myth = undef;
786
787   eval { require MythTV };
788   if ($@) {
789     print $@
790       if $verbose;
791   } else {
792
793     # Surpress warnings from DBI.  I tried unsetting $^W but that is ignored.
794     local($SIG{__WARN__}) = sub { if ($verbose) { print shift } };
795     eval { $myth = new MythTV() };
796
797     if ($@) {
798       if ($verbose) {
799         warn "Failed to load Perl API\n";
800         print $@;
801         return undef;
802       }
803     } elsif ($verbose) {
804       warn "Loaded Perl API\n";
805     }
806   }
807
808   return $myth;
809 }
810
811 # We are sometimes passed dodgy XML from MythTV, make some attempts to clean
812 # it.
813 sub clean_xml {
814   my ($xml) = shift;
815
816   # Deal to invalid Unicode.
817   for my $bad ("&#xdbff;", "&#xdee9;") {
818     if ($$xml =~ s/$bad/?/g) {
819       warn "Found and replaced: $bad\n"
820         if $verbose;
821     }
822   }
823 }
824
825 sub process_xml {
826   my ($block, $xml) = @_;
827
828   # Only work on this block if we have received the appropriate version of
829   # the XML.
830   for my $vers (qw/protocol xml/) {
831     if (defined $block->{"${vers}_version"}) {
832       my $result = undef;
833
834       # All the version checks must pass.
835       for my $check (@{ $block->{"${vers}_version"} }) {
836         my $res = eval ( "$version{$vers} $check" );
837
838         if (! defined $result || $res != 1) {
839           $result = $res;
840         }
841       }
842
843       return
844         unless defined $result && $result ne '';
845
846       warn "We have the correct $vers version for $block->{'name'}\n"
847         if $verbose;
848     }
849   }
850
851   my $items = $xml->documentElement->find($block->{'xpath'});
852
853   # Don't do any work on this block if there is nothing for it.
854   return undef
855     if (scalar(@$items) == 0);
856
857   my @lines;
858   for my $item (@{ $items }) {
859     my %vars;
860     for my $key (@{ $block->{'attrs'} }) {
861       if ($key =~ /(.*?):(.*)/) {
862         my $subitem = $item->findnodes($2);
863         $vars{$1} = @{ $subitem }[0]->getAttribute($1)
864           if defined @{ $subitem }[0];
865       } else {
866         $vars{$key} = $key eq 'NODE_TEXT' ? $item->string_value : $item->getAttribute($key);
867       }
868     }
869
870     my $str = substitute_vars($block, \%vars);
871     push @lines, $str
872       if defined $str;
873   }
874
875   return join("\n", @lines);
876 }
877
878 sub process_conflicts {
879   my ($block) = @_;
880   $myth ||= load_perl_api();
881
882   return "Unable to access MythTV Perl API.  Try with --verbose to find out why."
883     unless defined $myth;
884
885   my @lines;
886
887   # This isn't defined in the 0.20 version of the API.  It is in 0.21svn.
888   my $recstatus_conflict = 7;
889
890   my %rows = $myth->backend_rows('QUERY_GETALLPENDING', 2);
891
892   foreach my $row (@{$rows{'rows'}}) {
893     my $show;
894     {
895
896       # MythTV::Program currently has a slightly broken line with a numeric
897       # comparision.
898       local($^W) = undef;
899       $show = new MythTV::Program(@$row);
900     }
901
902     if ($show->{'recstatus'} == $recstatus_conflict) {
903       my %vars = (
904         'title'     => $show->{'title'},
905         'startTime' => strftime("%FT%T", localtime($show->{'starttime'})),
906         'NODE_TEXT' => $show->{'description'},
907         'subTitle'  => $show->{'subtitle'},
908         'channelName' => $show->{'channame'},
909         'inputId'   => $show->{'inputid'},
910         'chanNum'   => $show->{'channum'},
911         );
912
913       my $str = substitute_vars($block, \%vars);
914       push @lines, decode('UTF-8', $str)
915         if defined $str;
916     }
917   }
918
919   if (scalar(@lines) == 1) {
920     push @alerts, "CONFLICT";
921   } elsif (scalar(@lines) > 1) {
922     push @alerts, "CONFLICTS";
923   }
924
925   return join("\n", @lines);
926 }
927
928 sub process_auto_expire {
929   my ($block) = @_;
930   $myth ||= load_perl_api();
931
932   return "Unable to access MythTV Perl API.  Try with --verbose to find out why."
933     unless defined $myth;
934
935   my @lines;
936
937   # This isn't defined in the 0.20 version of the API.  It is in 0.21svn.
938   my %rows = $myth->backend_rows('QUERY_RECORDINGS Delete', 2);
939
940   # Returned in date order, desc.  So reverse it to make the oldest
941   # ones come first.
942   foreach my $row (reverse @{$rows{'rows'}}) {
943     my $show;
944     {
945
946       # MythTV::Program currently has a slightly broken line with a numeric
947       # comparision.
948       local($^W) = undef;
949       $show = new MythTV::Program(@$row);
950     }
951
952     # Who cares about LiveTV recordings?
953     next if $show->{'progflags'} eq 'LiveTV';
954
955     my %vars = (
956       'title'     => $show->{'parentid'} || 'Unknown',
957       'startTime' => strftime("%FT%T", localtime($show->{'starttime'})),
958       'NODE_TEXT' => $show->{'description'},
959       'subTitle'  => $show->{'subtitle'},
960       'channelName' => $show->{'callsign'},
961       'inputId'   => $show->{'inputid'},
962       'chanNum'   => $show->{'chanid'},
963       );
964
965     my $str = substitute_vars($block, \%vars);
966     push @lines, decode('UTF-8', $str)
967       if defined $str;
968
969     # Don't do more work than is required.
970     last if --$c->{'auto_expire_count'} <= 0;
971   }
972
973   return join("\n", @lines);
974 }
975
976 # If either date or time are set to 0, then we don't display that bit of
977 # info.  For example:
978 #   process_iso_date($date, { date => 0 })
979 # Will only show the time.
980 sub process_iso_date {
981   my $date = shift;
982   my $options = shift;
983   $options->{'date'} //= 1;
984   $options->{'time'} //= 1;
985
986   # 2012-10-17T23:50:08Z
987   my $d = new Date::Manip::Date;
988   $d->parse($date);
989
990   # Work out our local timezone. The Date::Manip::Date
991   # docs say that convert will default to the local timezone,
992   # this appears to be lies.
993   my $dmb = $d->base();
994   my ($tz) = $dmb->_now('tz',1);
995   $d->convert($tz);
996
997   # Sample of what MythTV uses:
998   # Thu 18 Oct 2012, 10:20
999   my $format = '';
1000   $format .= '%Y-%m-%d' if $options->{'date'};
1001   $format .= ' '        if $options->{'date'} && $options->{'time'};
1002   $format .= '%X'       if $options->{'time'};
1003
1004   return $d->printf($format);
1005 }
1006
1007 sub substitute_vars {
1008   my $block = shift;
1009   my $vars  = shift;
1010
1011   my %commify = map { $_ => 1 } @{ $block->{'commify'} }
1012     if defined $block->{'commify'};
1013
1014   my $template = $block->{'template'};
1015   my $skip = undef;
1016   my ($key, $value);
1017
1018   # Convert disk spaces into more suitable units.
1019   if (defined $block->{'human_readable_sizes'}) {
1020     for my $key (@{ $block->{'human_readable_sizes'}}) {
1021       for my $unit (@size_thresholds) {
1022         if (defined $vars->{$key} && defined $unit->{'threshold'}) {
1023           if ($vars->{$key} > $unit->{'threshold'}) {
1024             $vars->{$key} = sprintf("%.1f", $vars->{$key} / $unit->{'conversion'});
1025             $vars->{"${key}_unit"} = $unit->{'unit'};
1026
1027             last;
1028           }
1029         } else {
1030           $vars->{"${key}_unit"} = $unit->{'unit'};
1031         }
1032       }
1033     }
1034   }
1035
1036   while (($key, $value) = (each %{ $vars })) {
1037     if (! defined $value) {
1038       if ($block->{'optional'}) {
1039         warn "Unable to find any value for $key while at $block->{'name'}, marked as optional, skipping block.\n"
1040           if $verbose;
1041         return undef;
1042       } else {
1043         warn "Unable to find any value for $key while looking at $block->{'name'}\n";
1044         next;
1045       }
1046     }
1047
1048     $value = wrap('  ', '  ', $value)
1049       if $key eq 'NODE_TEXT';
1050
1051     $value =~ s/\s+$//;
1052     $value = 'Unknown'
1053       if $value eq '';
1054
1055     $skip = 1
1056       if defined $block->{'filter'}{$key} &&
1057       &{ $block->{'filter'}{$key} }($value, $vars);
1058
1059     if (defined $block->{'rewrite'}{"/$key/"}) {
1060       my ($search, $replace);
1061       while (($search, $replace) = each %{ $block->{'rewrite'}{"/$key/"} } ) {
1062         $value =~ s/$search/$replace/g;
1063       }
1064     }
1065
1066     if (defined $block->{'rewrite'}{"&$key"}) {
1067       $value = &{ $block->{'rewrite'}{"&$key"} }($value, $vars);
1068     }
1069
1070     $value = commify($value)
1071       if defined $commify{$key};
1072
1073     $template =~ s/__${key}__/$value/g;
1074   }
1075
1076   my ($name, $sub);
1077   while (($name, $sub) =  each %{ $block->{'subs'} }) {
1078     $value = &$sub($vars);
1079
1080     $template =~ s/__${name}__/$value/g
1081       if defined $value;
1082   }
1083
1084   return defined $skip ? undef : $template;
1085 }
1086
1087 # Work out the disk space percentage, possibly setting a flag that we should
1088 # raise an alert.
1089 sub calc_disk_space_percentage {
1090   my ($used, $total) = @_;
1091
1092   if (! (defined $used && defined $total) ){
1093     warn "Something is wrong calculating the disk space percentage.\n";
1094     return 'unknown';
1095   }
1096
1097   # Guard against zero disk space.
1098   $total = normalise_disk_space($total);
1099   if ($total == 0) {
1100     warn "Total disk space is 0 MB, I can't use that to calculate a percentage!\n";
1101     return 'unknown';
1102   }
1103
1104   my $percent = sprintf("%.1f",
1105     normalise_disk_space($used) / $total * 100);
1106
1107   if ($percent >= $c->{'disk_space_warn'}) {
1108     $exit_value ||= 1;
1109     push @alerts, "DISK SPACE";
1110     return "$warning$percent\%$normal";
1111   } else {
1112     return "$safe$percent\%$normal";
1113   }
1114 }
1115
1116 # Make sure that the disk space is in a common unit.
1117 # Currently that is MB.
1118 sub normalise_disk_space {
1119   if ($_[0] =~ /^([.0-9]+) (\w+)$/) {
1120     my $space = $1;
1121     my $unit = $2;
1122
1123     if ($unit eq 'B') {
1124       return $space / (1024 * 1024);
1125     } elsif ($unit eq 'KB') {
1126       return $space / 1024;
1127     } elsif ($unit eq 'MB') {
1128       return $space;
1129     } elsif ($unit eq 'GB') {
1130       return $space * 1024;
1131     } elsif ($unit eq 'TB') {
1132       return $space * 1024 * 1024;
1133     }
1134
1135     warn "Unknown unit for disk space: $unit.  Please let the author of mythtv-status know.\n";
1136     return $space;
1137   }
1138
1139   warn "Unrecognised format for disk space: $_[0].  Please let the author of mythtv-status know.\n";
1140   return $_[0];
1141 }
1142
1143 # Perform the fetch from the MythTV Backend in a child process.
1144 sub xml_fetch {
1145   my ($url) = @_;
1146
1147   $| = 1;
1148   my $pid = pipe_from_fork('CHILD');
1149   if ($pid) {
1150     # parent
1151     my $content_type;
1152     my $status;
1153
1154     eval {
1155       local $SIG{ALRM} = sub { die "alarm\n" };
1156       alarm(10);
1157       $content_type = <CHILD>;
1158       while (<CHILD>) {
1159         $status .= $_;
1160       }
1161       alarm(0);
1162     };
1163
1164     # The child didn't get back to us in time, kill them off
1165     # and forget what they sent us.
1166     if ($@) {
1167       $status = undef;
1168       my $result;
1169       warn "Our child has stopped talking to us, kill it off.\n";
1170       do {
1171         kill 9, $pid;
1172         $result = waitpid($pid, WNOHANG);
1173       } while $result > 0;
1174
1175       die "Unknown error during retrieval of status from the MythTV backend.\n";
1176     }
1177     $| = 0;
1178
1179     if (defined $content_type && $content_type =~ /utf(-)?8/i) {
1180       $status = decode('UTF-8', $status);
1181     }
1182     return ($content_type, $status);
1183   } else {
1184     # child
1185     $0 = "mythtv-status - child";
1186     my $ua = LWP::UserAgent->new;
1187     $ua->timeout(30);
1188     $ua->env_proxy;
1189
1190     my $response = ua_request_with_timeout($ua, $url);
1191     die "Sorry, failed to fetch $url: Connection to MythTV timed out.\n"
1192       unless defined $response;
1193
1194     # If we get a page doesn't exist, then just ignore it, we need to try
1195     # fetching the status page from a few different locations.
1196     if ($response->code == 404) {
1197       exit 1;
1198     }
1199
1200     die "Sorry, failed to fetch $url: " . $response->status_line . "\n"
1201       unless $response->is_success;
1202
1203     my $content = $response->decoded_content;
1204     if ($response->header('Content-Type') =~ /utf(-)?8/i) {
1205       $content = encode('UTF-8', $content);
1206     }
1207     print $response->header('Content-Type') . "\n";
1208     print $content . "\n";
1209
1210     exit 0;
1211   }
1212 }
1213
1214 # simulate open(FOO, "-|")
1215 sub pipe_from_fork ($) {
1216   my $parent = shift;
1217
1218   $SIG{CHLD} = 'IGNORE';
1219   pipe $parent, my $child or die;
1220   my $pid = fork();
1221   die "fork() failed: $!" unless defined $pid;
1222
1223   if ($pid) {
1224     close $child;
1225   } else {
1226     close $parent;
1227     open(STDOUT, ">&=" . fileno($child)) or die;
1228   }
1229   $pid;
1230 }
1231
1232 # Takes a LWP::UserAgent, and a HTTP::Request, returns a HTTP::Request
1233 # Based on:
1234 # http://stackoverflow.com/questions/73308/true-timeout-on-lwpuseragent-request-method
1235 sub ua_request_with_timeout {
1236   my ($ua, $url) = @_;
1237
1238   # Get whatever timeout is set for LWP and use that to 
1239   #  enforce a maximum timeout per request in case of server
1240   #  deadlock. (This has happened.)
1241   our $res = undef;
1242   if( timeout_call( $ua->timeout(), sub {$res = $ua->get($url);}) ) {
1243       return undef;
1244   } else {
1245       return $res;
1246   }
1247 }
1248
1249 # Beautify numbers by sticking commas in.
1250 sub commify {
1251   my ($num) = shift;
1252
1253   $num = reverse $num;
1254   $num =~ s<(\d\d\d)(?=\d)(?!\d*\.)><$1,>g;
1255   return reverse $num;
1256 }
1257
1258 sub print_version {
1259   print "mythtv-status, version $VERSION.\n";
1260   print "Written by Andrew Ruthven <andrew\@etc.gen.nz>\n";
1261   print "\n";
1262   exit;
1263 }
1264
1265 =head1 NAME
1266
1267 mythtv-status - Display the status of a MythTV backend
1268
1269 =head1 SYNOPSIS
1270
1271  mythtv-status [options]
1272
1273 =head1 DESCRIPTION
1274
1275 This script queries a MythTV backend and reports on the status of it,
1276 any upcoming recordings and any which are happening right now.
1277
1278 The intention is to warn you if there is a program being recorded or
1279 about to be recorded.
1280
1281 =head1 OPTIONS
1282
1283 =over
1284
1285 =item B<-c, --colour>
1286
1287 Use colour when showing the status of the encoder(s).
1288
1289 =item B<--date>
1290
1291 Set the date to run as, used for debugging purposes.
1292
1293 =item B<-d, --description>
1294
1295 Display the description for the scheduled recordings.
1296
1297 =item B<--disk-space-warn>
1298
1299 The threshold (in percent) of used disk space that we should show
1300 the disk space in red (if using colour) or send an email if we're
1301 in email mode with email only on warnings.
1302
1303 =item B<--encoder-details>
1304
1305 Display the input ID and channel name against the recording details.
1306
1307 =item B<--encoder-skip-idle>
1308
1309 Suppress displaying idle encoders in the Encoders block.
1310
1311 =item B<-e, --episode>
1312
1313 Display the episode (subtitle) for the scheduled recordings.
1314
1315 =item B<< --email <address>[ --email <address> ...] >>
1316
1317 Send the output to the listed email addresses.  By default the encoder status,
1318 currently recording shows and time till next recording is suppressed from
1319 the email.
1320
1321 To turn the additional blocks on you can use B<--encoders>, B<--recording-now>
1322 and/or B<--next-recording>.
1323
1324 =item B<--email-only-on-alert>
1325
1326 Only send an email out (if --email is present) if there is an alert
1327 (i.e., schedule conflict or low disk space).
1328
1329 =item B<-?, --help>
1330
1331 Display help.
1332
1333 =item B<< --file <file> >>
1334
1335 Load XML from the file specified instead of querying a MythTV backend.
1336 Handy for debugging things.
1337
1338 =item B<< --save-file <file> >>
1339
1340 Save the XML we received from the MythTV backend.
1341 Handy for debugging things.
1342
1343 =item B<< --guide-days-warn <days> >>
1344
1345 Warn if the number of days of guide data present is equal to or below
1346 this level.  Default is 2 days.
1347
1348 =item B<-h HOST, --host=HOST>
1349
1350 The host to check, defaults to localhost.
1351
1352 =item B<--nostatus>, B<--noencoders>, B<--norecording-now>, B<--noscheduled-recordings>, B<--noschedule-conflicts>, B<--nonext-recording>, B<--nototal-disk-space>, B<--nodisk-space>, B<--noguide-data>, B<--noauto-expire>
1353
1354 Suppress displaying blocks of the output if they would normally be displayed.
1355
1356 =item B<-p PORT, --port=PORT>
1357
1358 The port to use when connecting to MythTV, defaults to 6544.
1359
1360 =item B<--oneliner-bullets>
1361
1362 Insert asterisks (*) before each of the oneliners to stop some
1363 email clients from thinking the oneliner block is a paragraph and
1364 trying to word wrap them.
1365
1366 =item B<--auto-expire>
1367
1368 Display the shows due to auto expire (output is normally suppressed).
1369
1370 =item B<--auto-expire-count>
1371
1372 How many of the auto expire shows to display, defaults to 10.
1373
1374 =item B<--recording-in-warn>
1375
1376 If the "Next Recording In" time is less than this amount, display it
1377 in red.  This in seconds, and defaults to 3600 (1 hour).
1378
1379 =item B<--verbose>
1380
1381 Have slightly more verbose output.  This includes any warnings that might
1382 be generated while parsing the XML.
1383
1384 =item B<-v, --version>
1385
1386 Show the version of mythtv-status and then exit.
1387
1388 =back
1389
1390 =head1 OUTPUT
1391
1392 The output of this script is broken up into several chunks they are:
1393
1394 =over
1395
1396 =item Status
1397
1398 Some general info about the backend, currently just the timestamp of when
1399 this program was run.
1400
1401 =item Guide Data
1402
1403 The number of days of guide data is present.  By default it is only shown
1404 if the number of days is below the warning level.  To show it regardless
1405 of the warning level use --guide-data.
1406
1407 =item Encoders
1408
1409 Each encoder that the backend knows about are listed, with the hostname
1410 they are on, the encoder ID (in brackets) and the current status.
1411
1412 =item Recording Now
1413
1414 Any programs which are being recorded right now.
1415
1416 =item Scheduled Recordings
1417
1418 Up to 10 programs which are scheduled to be recorded today and tomorrow.
1419
1420 =item Schedule Conflicts
1421
1422 Any upcoming schedule conflicts (not just limited to today or tomorrow).
1423
1424 =item Shows due to Auto Expire
1425
1426 The shows which will be deleted and the order they'll be deleted if the
1427 auto expirer kicks in.
1428
1429 =item Total Disk Space
1430
1431 The amount of disk space in total, and used by MythTV.
1432
1433 =item Next Recording In
1434
1435 If there are no recordings currently happening, then the amount of time until
1436 the next recording is displayed.
1437
1438 =item Disk Space
1439
1440 Details about each storage group that MythTV knows about.  By default this
1441 only shows storage groups that are above the warning level.  Use
1442 B<--disk-space> to turn on display of all storage groups.
1443
1444 =back
1445
1446 =head1 RETURN CODES
1447
1448 mythtv-status provides some return codes.
1449
1450 =over
1451
1452 =item 0Z<>
1453
1454 Standard return code
1455
1456 =item 1Z<>
1457
1458 A warning is generated
1459
1460 =back
1461
1462 =head1 AUTHOR
1463
1464 Andrew Ruthven, andrew@etc.gen.nz
1465
1466 =head1 LICENSE
1467
1468 Copyright (c) 2007-2017 Andrew Ruthven <andrew@etc.gen.nz>
1469 This code is hereby licensed for public consumption under the GNU GPL v3.
1470
1471 =cut
1472