The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Net::MythTV::Fuse::Recordings;

=head1 NAME

Net::MythTV::Fuse::Recordings - Manage list of MythTV recordings for MythTV Fuse filesystem

=head1 SYNOPSIS

 $recordings = Net::MythTV::Fuse::Recordings->new({backend   => 'mythbackend.domain.org',
                                                   pattern   => '%C/%T/%S',
                                                   cachetime => 120,
                                                   maxgets   => 6,
                                                   threaded  => 1,
                                                   debug     => 1}
                                                 );
 $recordings->start_update_thread();
 @paths = $recordings->entries('.');
 $recordings->valid_path('Firefly/Serenity.mpg') or die;
 $recordings->is_dir('Firefly')                  or die;
 ($status,$content) = $recordings->download_recorded_file('Firefly/Serenity.mpg',1024,0);

=head1 DESCRIPTION

This is a utility class used by Net::MythTV::Fuse which handles all
interaction with the backend. Using the MythTV 0.25 API, the module
maintains a cache of current recordings, translates them into a series
of virtual directory listings according to a template, and can
download segments of individual recordings from a local or remote
backend.

=head1 METHODS

=cut


use strict;
use POSIX 'strftime';
use LWP::UserAgent;
use JSON qw(encode_json decode_json);
use Date::Parse 'str2time';
use XML::Simple;
use threads;
use threads::shared;
use Thread::Semaphore;
use Config;
use Carp 'croak';

use constant CACHETIME => 60*5;  # 5 minutes
use constant MAXGETS   => 8;     # allow 8 simultaneous http gets 

# This single shared variable caches the recorded list from the backend as a JSONized string.
# Within each thread, the list is then unserialized and temporarily cached in each thread's memory.
my %Cache    :shared;

use constant Templates => {
    T  => '{Title}',
    S  => '{SubTitle}',
    R  => '{Description}',
    C  => '{Category}',
    ST => '{SubTitle}?{SubTitle}:{Title}',   # prefer %S?%S:%T
    TC => '{SubTitle}?{Title}:{Category}',   # prefer %S?%T:%C
    se => '{Season}',
    e  => '{Episode}',
    PI => '{ProgramId}',
    SI => '{SeriesId}',
    st => '{Stars}',
    U  => '{Recording}{RecGroup}',
    hn => '{HostName}',
    c  => '{Channel}{ChanId}',
    cc => '{Channel}{CallSign}',
    cN => '{Channel}{ChannelName}',
    cn => '{Channel}{ChanNum}',

    y  => '%y{StartTime}',
    Y  => '%Y{StartTime}',
    n  => '%m{StartTime}',  # we don't do the non-leading 0 bit
    m  => '%m{StartTime}',
    j  => '%e{StartTime}',
    d  => '%d{StartTime}',
    g  => '%I{StartTime}',
    G  => '%H{StartTime}',
    h  => '%I{StartTime}',
    H  => '%H{StartTime}',
    i  => '%M{StartTime}',
    s  => '%S{StartTime}',
    a  => '%P{StartTime}',
    A  => '%p{StartTime}',
    b  => '%b{StartTime}',
    B  => '%B{StartTime}',

    ey  => '%y{EndTime}',
    eY  => '%Y{EndTime}',
    en  => '%m{EndTime}',
    em  => '%m{EndTime}',
    ej  => '%e{EndTime}',
    ed  => '%d{EndTime}',
    eg  => '%I{EndTime}',
    eG  => '%H{EndTime}',
    eh  => '%I{EndTime}',
    eH  => '%H{EndTime}',
    ei  => '%M{EndTime}',
    es  => '%S{EndTime}',
    ea  => '%P{EndTime}',
    eA  => '%p{EndTime}',
    eb  => '%b{EndTime}',
    eB  => '%B{EndTime}',

    # the API doesn't distinguish between program start time and recording start time
    py  => '%y{StartTime}',
    pY  => '%Y{StartTime}',
    pn  => '%m{StartTime}',
    pm  => '%m{StartTime}',
    pj  => '%e{StartTime}',
    pd  => '%d{StartTime}',
    pg  => '%I{StartTime}',
    pG  => '%H{StartTime}',
    ph  => '%I{StartTime}',
    pH  => '%H{StartTime}',
    pi  => '%M{StartTime}',
    ps  => '%S{StartTime}',
    pa  => '%P{StartTime}',
    pA  => '%p{StartTime}',
    pb  => '%b{StartTime}',
    pB  => '%B{StartTime}',

    pey  => '%y{EndTime}',
    peY  => '%Y{EndTime}',
    pen  => '%m{EndTime}',
    pem  => '%m{EndTime}',
    pej  => '%e{EndTime}',
    ped  => '%d{EndTime}',
    peg  => '%I{EndTime}',
    peG  => '%H{EndTime}',
    peh  => '%I{EndTime}',
    peH  => '%H{EndTime}',
    pei  => '%M{EndTime}',
    pes  => '%S{EndTime}',
    pea  => '%P{EndTime}',
    peA  => '%p{EndTime}',
    peb  => '%b{EndTime}',
    peB  => '%B{EndTime}',

    oy   => '%y{Airdate}',
    oY   => '%Y{Airdate}',
    on   => '%m{Airdate}', # we don't do the non-leading 0 bit
    om   => '%m{Airdate}',
    oj   => '%e{Airdate}',
    od   => '%d{Airdate}',
    ob   => '%b{Airdate}',
    oB   => '%B{Airdate}',

    '%'  => '%',
    };

my $Package = __PACKAGE__;
foreach (qw(debug backend port dummy_data cache cachetime maxgets threaded
            pattern delimiter mtime localmount semaphore)) {
    eval <<END;
sub ${Package}::${_} {
    my \$self = shift;
    \$self->{$_} = shift if \@_;
    return \$self->{$_};
}
END
}

=head2 $r = Net::MythTV::Fuse::Recordings->new(\%options)

Create a new Recordings object. Options are passed as a hashref and
may contain any of the following keys:

 backend       IP address of the backend (localhost)
 port          Control port for the backend (6544)
 pattern       Template for transforming recordings into paths (%T/%S)
 delimiter     Trim this string from the pathname if it is dangling or occurs multiple times (none)
 cachetime     Maximum time to cache recorded list before refreshing from backend (300 sec)
 maxgets       Maximum number of simultaneous file fetches to perform on backend (8)
 threaded      Run the cache fill process as an ithread (true)
 debug         Turn on debugging messages (false)
 dummy_data_path  For debugging, pass path to backend recording XML listing

See the help text for mythfs.pl for more information on these arguments.

=cut

sub new {
    my $class   = shift;
    my $options = shift;
    $options->{backend} or croak "Usage: $class->new({backend=>\$backend_hostname,\@other_options})";
    
    my $self =  bless {
	backend   => 'localhost',
	port      => 6544,
	pattern   => '%T/%S',
	cachetime => CACHETIME,
	maxgets   => MAXGETS,
	threaded  => $Config{useithreads},
	delimiter => undef,
	debug     => 0,
	%$options,           # these will override
	mtime     => 0,
	cache     => undef,
    },ref $class || $class;

    $self->semaphore(Thread::Semaphore->new($self->maxgets)),
    return $self;
}

=head2 Accessors

These methods get or set the correspondingly-named values:

 debug()
 backend()
 port()
 maxgets()
 threaded()
 pattern()
 delimiter()
 mtime()
 localmount()

These methods are used internally:

 dummy_data()
 cache()
 cachetime()
 semaphore()

=cut

=head2 $r->start_update_thread

Start the thread that periodically fetches and caches the recording
data from the server. Will run as a detached thread until the process
terminates.

=cut

sub start_update_thread {
    my $self = shift;

    $self->_refresh_recorded 
	or croak "Could not contact backend at ",$self->backend,':',$self->port;
    return unless $self->threaded;
    my $thr = threads->create(
	sub {
	    while (1) {
		sleep ($self->cachetime);
		$self->_refresh_recorded;
	    }
	}
	);
    $thr->detach();
}

=head2 $recordings = $r->get_recorded

Return a data structure corresponding to the current recording
list. The data structure is a hashref with two top-level keys:
"directories", which list directory names and their contents, and
"paths" which give size and other attributes for each directory,
subdirectory and file. Here is an example:

 {
  'directories' => {
       '.' => {
               '007 Licence To Kill.mpg' => 1,
               'A Funny Thing Happened on the Way to the Forum.mpg' => 1,
               'Alfred Hitchcock Presents' => 5,
               'American Dad' => 9,
                ...
               },
       'Alfred Hitchcock Presents' => {
               'Back for Christmas.mpg' => 1,
               'Dead Weight.mpg' => 1,
               'Rose Garden.mpg' => 1,
              },
       'American Dad' => {
               'Dr. Klaustus.mpg' => 1,
               'Flirting With Disaster.mpg' => 1,
               'Gorillas in the Mist.mpg' => 1,
              },
         ...
     },
  'paths' => {
       '.' => {
               'ctime' => 1368074100,
               'length' => 240,
               'mtime' => 1368076875,
               'type' => 'directory'
              },
       '007 Licence To Kill.mpg' => {
               'basename' => '1111_20121126200000.mpg',
               'ctime' => 1353978000,
               'host' => 'myth',
               'length' => '21262807708',
               'mtime' => 1357927839,
               'storage' => 'Default',
               'type' => 'file'
              },
       'A Funny Thing Happened on the Way to the Forum.mpg' => {
               'basename' => '1191_20121230000000.mpg',
               'ctime' => 1356843600,
               'host' => 'myth',
               'length' => '12298756208',
               'mtime' => 1357927839,
               'storage' => 'Default',
               'type' => 'file'
              },
         'Alfred Hitchcock Presents' => {
               'ctime' => 1362985200,
               'length' => 5,
               'mtime' => 1362987680,
               'type' => 'directory'
              },
          'Alfred Hitchcock Presents/Back for Christmas.mpg' => {
               'basename' => '1022_20121225153000.mpg',
               'ctime' => 1356467400,
               'host' => 'myth',
               'length' => '647625408',
               'mtime' => 1357927839,
               'storage' => 'Default',
               'type' => 'file'
              },
          'Alfred Hitchcock Presents/Dead Weight.mpg' => {
                 'basename' => '1022_20121207000000.mpg',
                 'ctime' => 1354856400,
                 'host' => 'myth',
                 'length' => '647090360',
                 'mtime' => 1357927839,
                 'storage' => 'Default',
                 'type' => 'file'
               },
             ...
     }

=cut

sub get_recorded {
    my $self = shift;
    my $nocache = shift;
    
    my $cache = $self->cache;

    return $cache if $cache && $nocache;

    $self->_refresh_recorded if !$self->threaded && (time() - $Cache{mtime} >= $self->cachetime);
    return $cache            if $cache && $self->mtime >= $Cache{mtime};

    warn scalar localtime()," refreshing thread-level cache, mtime = $Cache{mtime}\n" if $self->debug;
    lock %Cache;
    $self->mtime($Cache{mtime});
    return $self->cache(decode_json($Cache{recorded}||''));
}

=head2 $path = $r->recording_to_path($metadata)

Given the metadata returned from the backend for a single recording,
transform this into a pathname using the provided template.

=cut

sub recording2path {
    my $self = shift;
    my $recording = shift;
    my $path     = $self->apply_pattern($recording);
    my @components = split '/',$path;

    # trimming operation
    if (my $delimiter = $self->delimiter) {
	foreach (@components) {
	    s/${delimiter}{2,}/$delimiter/g;
	    s/${delimiter}(\s+)/$1/g;
	    s/$delimiter$//;
	}
    }

    return grep {length} @components;
}



=head2 $message = $r->status

Returns the last status message.

=cut

sub status {
    my $self  = shift;
    my $r     = $self->get_recorded;
    my $mtime = localtime($self->mtime);
    return "$mtime: $r->{status}\n";
}

=head2 $time = $r->mtime

Return the time that the status was last updated.

=cut

=head2 @entries = $r->entries($path)

Given a path to a directory in the virtual filesystem, return all
subentries within that directory. Use '.' to indicate the top level
directory.

=cut

sub entries {
    my $self = shift;
    my $path = shift;
    my $r = $self->get_recorded;
    return keys %{$r->{directories}{$path}};
}

=head2 $name = $r->basename($path)

Given a path to a file in the virtual filesystem, returns the basename
of the physical file on the backend's storage disk.

=cut

sub basename {
    my $self = shift;
    my $path = shift;
    my $e = $self->entry($path) or return;
    return $e->{basename};
}

=head2 $entry = $r->entry($path)

Given a path to a file on the virtual filesystem, returns a hashref
that provides length, modification time and basename information about
the recording. This is simply the value of the {path}{$path} key in
the data structure described for get_recorded():

        {'Alfred Hitchcock Presents/Back for Christmas.mpg' => {
               'basename' => '1022_20121225153000.mpg',
               'ctime' => 1356467400,
               'host' => 'myth',
               'length' => '647625408',
               'mtime' => 1357927839,
               'storage' => 'Default',
               'type' => 'file'
              }
        }

=cut

sub entry {
    my $self = shift;
    my $path = shift;
    my $r = $self->get_recorded;
    return $r->{paths}{$path};
}

=head2 $boolean = $r->valid_path($path)

Returns true if the provided path is valid.

=cut

sub valid_path {
    my $self = shift;
    my $path = shift;
    my $r = $self->get_recorded;
    return $r->{paths}{$path};
}


=head2 $boolean = $r->is_dir($path)

Returns true if the provided path is a directory in the virtual filesystem.

=cut

sub is_dir {
    my $self = shift;
    my $path = shift;
    my $r    = $self->get_recorded;
    return $r->{paths}{$path}{type} eq 'directory';
}

=head2 ($status,$content) = $r->download_recorded_file($path,$size,$offset)

Attempts to download the recording corresponding to the indicated
path. $size and $offset allow you to fetch the indicated portion of
the recording.

A two-element list is returned. The first element is a status message,
one of "ok", "not found", "invalid offset", or "connection failed". If
successful, the second element will be the requested content,
otherwise undef.

=cut

sub download_recorded_file {
    my $self = shift;
    my ($path,$size,$offset) = @_;

    my $r    = $self->get_recorded('use_cached');
    my $e    = $r->{paths}{$path} or return 'not found';
    $offset <= $e->{length}       or return 'invalid offset';

    my $basename = $e->{basename};
    # I'm unsure of whether we should use the host in the XML or the designated backend
    my $host     = $e->{host} || $self->backend;  
    my $port     = $self->port;
    my $sg       = $e->{storage};
    my $byterange= $offset.'-'.($offset+$size-1);

    $self->{ua} ||= LWP::UserAgent->new(keep_alive=>20);
    $self->semaphore->down();
    my $response = $self->{ua}->get("http://$host:$port/Content/GetFile?StorageGroup=$sg&FileName=$basename",
				    'Range'       => $byterange);
    $self->semaphore->up();
    $response->is_success or return 'connection failed';
    return ('ok',$response->decoded_content);
}

=head2 $path = $r->apply_pattern($entry)

=cut

sub apply_pattern {
    my $self = shift;
    my $recording = shift;
    no warnings;

    my $pat_sub   = $self->_compile_pattern_sub();
    my $template  = $self->{pattern};

    my $Templates = Templates();
    my @codes     = sort {length($b)<=>length($a)} keys %$Templates;
    my $match     = join('|',@codes);

    $template =~ s/%($match)/$pat_sub->($recording,$1)/eg;
    return $template;
}

sub _compile_pattern_sub {
    my $self = shift;
    return $self->{pattern_sub} if $self->{pattern_sub};

    my $template = $self->{pattern};
    my $Templates= Templates();

    my $sub = "sub {\n";
    $sub   .= "my (\$recording,\$code) = \@_;\n";

    while ($template =~ /%([a-zA-Z%]{1,3})/g) {
	my $code = $1;
	my $field = $Templates->{$code} or next;
	if ($field eq '%') {
	    $sub .= "return '%' if \$code eq '$code';\n";
	    next;
	}
	if ($field =~ /(%\w+)(\{\w+\})/) { #datetime specifier
	    $sub .= "return strftime('$1',localtime(str2time(\$recording->$2)||0)) if \$code eq '$code';\n";
	    next;
	}
	if ($field =~ /(.+)\?(.+)\:(.+)/) {  # something like '{SubTitle}?{SubTitle}:{Title}'
	    $sub .= <<END;
	    if (\$code eq '$code') {
		my \$val = \$recording->$1?\$recording->$2:\$recording->$3;
		\$val  ||= '';
		\$val =~ tr!a-zA-Z0-9_.,&\@:* ^\\![]{}(),?#\$=+%-!_!c;
		return \$val;
	    }
END
    next;
	}

	$sub .= <<END;
	if (\$code eq '$code') {
	    my \$val = \$recording->$field || '';
	    \$val =~ tr!a-zA-Z0-9_.,&\@:* ^\\![]{}(),?#\$=+%-!_!c;
	    return \$val;
	}
END
    ;
    }
    $sub .= "}\n";
    my $s = eval $sub;
    die $@ if $@;
    return $self->{pattern_sub} = $s;
}

sub load_dummy_data {
    my $self = shift;
    my $dummy_data_path = shift;
    open my $fh,$dummy_data_path or croak "$dummy_data_path: $!";
    local $/;
    my $dummy_data = <$fh>;
    $self->dummy_data($dummy_data) if $dummy_data;
}

sub _refresh_recorded {
    my $self = shift;

    print  STDERR scalar(localtime())," Refreshing recording list..." if $self->debug;

    lock %Cache;
    my $var    = {};
    my $parser = XML::Simple->new(SuppressEmpty=>1);
    my ($status,$data) = $self->_fetch_recorded_data();
    $var->{status} = $status;
    if ($status eq 'ok') {
	my $rec = $parser->XMLin($data);
	$self->_build_directory_map($rec,$var);
    } else {
	print STDERR "ERROR: $status..." if $self->debug;
	$var->{paths}{'.'} = {ctime  => time(),
			      mtime  => time(),
			      length => 2,
			      type   => 'directory'};
    }
    $Cache{recorded} = encode_json($var);
    $Cache{mtime}    = time();
    print STDERR "mtime set to $Cache{mtime}\n" if $self->debug;

    return 1;
}

sub _fetch_recorded_data {
    my $self = shift;

    return ('ok',$self->dummy_data) if $self->dummy_data;

    my $host = $self->backend;
    my $port = $self->port;

    $self->{ua} ||= LWP::UserAgent->new(keep_alive=>20);
    my $response = $self->{ua}->get("http://$host:$port/Dvr/GetRecordedList");

    my $status;
    if ($response->is_success) {
	$status = 'ok';
    } else {
	$status = "Recording list request failed with ".$response->status_line;
    }

    return ($status,$response->decoded_content);
}

sub _build_directory_map {
    my $self = shift;
    my ($rec,$map) = @_;

    my $count = 0;
    my (%recordings,%paths);
    for my $r (@{$rec->{Programs}{Program}}) {
	$count++;

	my $sg = $r->{Recording}{StorageGroup};
	next if $sg eq 'LiveTV';

 	my (@path)              = $self->recording2path($r);
	my $key                 = join('-',$r->{HostName},$r->{FileName});  # we use this as our unique ID
	my $path                = join('/',@path);
	$recordings{$key}{path}{$path}++;
	$recordings{$key}{meta} = $r;
	$paths{$path}{$key}++;
    }
    
    # paths that need fixing to be unique
    for my $path (keys %paths) {
	my @keys = keys %{$paths{$path}};
	next unless @keys > 1;

	my $count = 0;
	for my $key (@keys) {
            my $start = $recordings{$key}{meta}{StartTime};
	    $start =~ s/:\d+Z$//;
            
	    my $fixed_path = sprintf("%s_%s-%s",$path,$recordings{$key}{meta}{Channel}{ChanNum},$start);
	    delete $recordings{$key}{path};
	    $recordings{$key}{path}{$fixed_path}++;
	}
    }

    # at this point, we actually build the map that is passed to FUSE
    for my $key (keys %recordings) {

	my ($path) = keys %{$recordings{$key}{path}}; # should only be one unique path at this point

	# take care of the extension
	my $meta     = $recordings{$key}{meta};
	my ($suffix) = $meta->{FileName}    =~ /\.(\w+)$/;
	$path       .= ".$suffix" unless $path =~ /\.$suffix$/;

	my @path = split('/',$path);
	my $filename = pop @path;
	unshift @path,'.';

	my $ctime = str2time($meta->{StartTime});
	my $mtime = str2time($meta->{LastModified});
	
	$map->{paths}{$path}{type}     = 'file';
	$map->{paths}{$path}{host}     = $meta->{HostName};
	$map->{paths}{$path}{length}   = $meta->{FileSize};
	$map->{paths}{$path}{basename} = $meta->{FileName};
	$map->{paths}{$path}{storage}  = $meta->{Recording}{StorageGroup};
	$map->{paths}{$path}{ctime}    = $ctime;
	$map->{paths}{$path}{mtime}    = $mtime;
	
	# take care of the directories
	my $dir = '';
	while (my $p = shift @path) {
	    $dir .= length $dir ? "/$p" : $p;
	    $dir =~ s!^\./!!;

	    $map->{paths}{$dir}{type}     = 'directory';
	    $map->{paths}{$dir}{length}++;
	    $map->{paths}{$dir}{ctime}    = $ctime if ($map->{paths}{$p}{ctime}||0) < $ctime;
	    $map->{paths}{$dir}{mtime}    = $mtime if ($map->{paths}{$p}{mtime}||0) < $mtime;

	    # subdirectory entry
	    if (defined $path[0]) {
		$map->{directories}{$dir}{$path[0]}++;
	    }
	}
	$map->{directories}{$dir}{$filename}++;
    }

    print STDERR scalar keys %recordings," recordings retrieved..." if $self->debug;
    return $map;
}

1;

=head1 AUTHOR

Copyright 2013, Lincoln D. Stein <lincoln.stein@gmail.com>

=head1 LICENSE

This package is distributed under the terms of the Perl Artistic
License 2.0. See http://www.perlfoundation.org/artistic_license_2_0.

=cut

__END__