The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package TiVo::Calypso;

use 5.006_001;

our $VERSION = '1.3.5';

## Currently requires these additional modules for full functionality:
##
##  Storable
##  IO::File
##  MP3::Info
##  Digest::MD5
##  Encode
##  LWP::Simple

# Constants for use in QueryServer message
use constant VERSION      => '1';
use constant INTVERSION   => $VERSION;
use constant INTNAME      => 'TiVoServer BC';
use constant ORGANIZATION => 'TiVo, Inc.';
use constant COMMENT      => 'Modifications by Scott Schneider, sschneid at gmail dot com';

# Global expiration time (in hours)
my $expire_hours = 48;

## Generic, overridable interface to dynamic class data
##
##   Autoload will catch any method beginning with an underscore ( _ )
##   and convert the method name to a key value, which is used to
##   access the object's internal DATA hash. Methods written to
##   override interactions with a given key should use lvalue
##   syntax to maintain compatibility with other module internals.

sub AUTOLOAD : lvalue {
    my $self  = shift;
    my $param = $AUTOLOAD;

    $param =~ s/^.*:://;

    return unless $param =~ /^_(.+)$/;

    $self->{'DATA'}->{ uc($1) };
}

## TiVo::Calypso->_uri_unescape( $ )
##
##  Decodes URI strings per RFC 2396

sub _uri_unescape {
    my $self = shift;
    my $str  = shift;

    $str =~ s/\+/ /g;
    $str =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;

    return $str;
}

## TiVo::Calypso->_uri_escape( $ )
##
##  Encodes URI strings per RFC 2396

sub _uri_escape {
    my $self = shift;
    my $str  = shift || return undef;

    $str =~ s/([^A-Za-z0-9\+\-\/_.!~*'() ])/sprintf("%%%02X", ord($1))/eg;
    $str =~ s/ /+/g;

    return $str;
}

## TiVo::Calypso->_servicename( $ )
##
##  Returns the service name (first element of object path) of the object
##  or passed argument

sub _servicename {
    my $self = shift;
    my $path = shift || $self->_Object || return undef;

    $path =~ /^(\/[^\/]*)/;

    return $1;
}

## TiVo::Calypso->_basename( $ )
##
##  Returns the basename (filename) of the object's internal Path
##  or passed argument

sub _basename {
    my $self = shift;
    my $path = shift || $self->_Path || return undef;

    my @path_parts = split( /\//, $path );

    return pop @path_parts;
}

## TiVo::Calypso->_query_container
##
##   Returns a data structure (suitable for use with xml_out) which
##   describes this object in response to a QueryContainer command

sub _query_container {
    my $self   = shift;
    my $params = shift;

    my $script_name = $params->_EnvScriptName || "";

    my $details = {
        'Item' => [
            {
                'Details' => {
                    'Title' => $self->_Title || $self->_basename,
                    'ContentType'  => $self->_ContentType,
                    'SourceFormat' => $self->_SourceFormat
                }
            },
            {
                'Links' =>
                  { 'Content' => { 'Url' => $script_name . $self->_Url } }
            }
        ]
    };

    return $details;
}

##############################################################################
# TiVo::Calypso::Server
#   The core server object for processing requests
##############################################################################
package TiVo::Calypso::Server;
@ISA = ('TiVo::Calypso');

## TiVo::Calypso::Server->new( % )
##
##  Constructor for TiVo::Calypso::Server. Accepts parameters via arguement
##  hash.
##    SERVER_NAME
##    CACHE_DIR

sub new {
    my $class = shift;

    my $self = {};
    bless $self, $class;

    my %params = (@_);

    $self->_Name     = $params{'SERVER_NAME'} || 'TiVo Server';
    $self->_CacheDir = $params{'CACHE_DIR'};

    $self->_Services = {};

    return $self;
}

## TiVo::Calypso::Server->load_cache( $ )
##
##  Loads the requested object from the external cache.

sub load_cache {
    my $self = shift;
    my $path = shift || return undef;

    my $cache_dir = $self->_CacheDir || return undef;

    require Storable;
    require Digest::MD5;

    my $cache_name = Digest::MD5::md5_hex($path);
    my $cache_path = "$cache_dir/$cache_name";
    my $rval       = eval { Storable::retrieve("$cache_path") };

    # Check if it is expired.
    my $expiration = $self->expire( $rval, $path, $cache_path );

    return $rval;
}

## TiVo::Calypso::Server->expire( $ )
##
##  Checks the external cache vs. the source file for the
##  object to determine if the object is expired.  If the
##  object is expired, it marks it as expired.

sub expire {
    my $self       = shift;
    my $item       = shift || return 1;
    my $path       = shift || return 1;
    my $cache_path = shift || return 1;

    # If the file exists, and if it was retrieved, check
    # if it is expired.
    my $rval = 1;    # Assume it's expired
    if ( defined($item) ) {
        my $service = $self->_servicename($path);

        return 1 unless defined $self->_Services->{$service};
        $service = $self->_Services->{$service};

        my $real_path = $service->obj_to_path($path);

        # Check file times.
        my @st_cache = stat($cache_path);
        my $ctime    = $st_cache[9];

        if ( !-r $real_path ) {

            # Path not found.  Expire item.
            $item->_Expired = 1;
        }
        else {

            # Path exists, compare times to see if we need
            # to expire it.
            my @st_orig = stat($real_path);
            my $otime   = $st_orig[9];

            my $timenow = time();
            my $xtime   = $timenow - ( $expire_hours * 60 * 60 );

            # If it's expired, mark it as so.  It's expired if either
            # the actual file is newer than the cached file/directory,
            # or the cache file is older than the allowed expiration
            # duration (the current time - $expire_hours ).
            if ( $otime > $ctime || $ctime < $xtime ) {

                # Expire item from cache and remove.
                $item->_Expired = 1;

                unlink $cache_path;
            }
            else {
                $item->_Expired = 0;
            }
        }

        $rval = $item->_Expired;
    }

    return $rval;
}

## TiVo::Calypso::Server->store_cache( $ )
##
##  Stores the given object in the external cache.

sub store_cache {
    my $self   = shift;
    my $object = shift || return undef;

    my $cache_dir = $self->_CacheDir || return undef;

    require Storable;
    require Digest::MD5;

    my $cache_name = Digest::MD5::md5_hex( $object->_Object );

    my $rval = eval { Storable::store( $object, "$cache_dir/$cache_name" ); };

    return $rval;
}

## TiVo::Calypso::Server->freeze( $ )
##
##  Stores the given Object in memory and passes it to the server's current
##  external cache functions

sub freeze {
    my $self   = shift;
    my $object = shift || return undef;

    return $self->store_cache($object);
}

## TiVo::Calypso::Server->thaw( $ )
##
##  Returns the requested Object from Cache, creating it when necessary

sub thaw {
    my $self = shift;
    my $path = shift || return undef;

    my ($item);

    $item = $self->load_cache($path) unless $path eq '/Shuffle';

    if ( !defined($item) || $item->_Expired == 1 ) {
        $item = $self->create_object($path);

        $self->freeze($item);
    }

    return $item;
}

## TiVo::Calypso::Server->create_object( $ )
##
##  Creates a new Item or Container object using the full filesystem
##  path provided.

sub create_object {
    my $self = shift;
    my $path = shift || return undef;

    my ($item);

    # Check for '/' special condition
    if ( $path eq '/' ) {
        $item = TiVo::Calypso::Container::Server->new(
            SERVICE => "/",
            TITLE   => $self->_Name
          )
          || return undef;

        my @contents =
          map { $self->_Services->{$_} } keys %{ $self->_Services };

        $item->_Contents = \@contents;
    }
    elsif ( $path eq '/Shuffle' ) {
        my $service = '/Music';

        return undef unless defined $self->_Services->{$service};
        $service = $self->_Services->{$service};

        $path = $service->obj_to_path($path);

        $item = TiVo::Calypso::Container->new(
            PATH    => $path,
            SERVICE => $service
          )
          || return undef;
    }
    elsif ( $path =~ /\/Browse\// ) {
        my $service = '/Music';

        return undef unless defined $self->_Services->{$service};
        $service = $self->_Services->{$service};

        $path = $service->obj_to_path($path);

        return undef if grep { /^\.\.$/ } split( /\//, $path );

        my $letter = $1 if $path =~ /.+?\/Browse\/(.*)/;
        $path = $1 if $path =~ /(.+?)\/Browse.*/;

        opendir( DIR, $path ) || return undef;

        while ( defined( my $file = readdir DIR ) ) {
            next if $file =~ /^\./;

            if ( defined $server ) {
                my $object_path = $self->_Object . "/" . $file;
                my $child       = $server->thaw($object_path) || next;

                push( @contents, $child );
            }
            else {
                next unless $file =~ /^$letter/;

                my $full_path = $path . "/" . $file;

                if ( -d $full_path ) {
                    my $child = TiVo::Calypso::Container->new(
                        PATH    => $full_path,
                        SERVICE => $service
                      )
                      || next;

                    push( @contents, $child );
                }
                elsif ( -r $full_path ) {
                    my @parts  = split( /\./, $full_path );
                    my $suffix = uc( pop @parts );

                    my $class = "TiVo::Calypso::Item::$suffix";
                    my $child =
                      eval { $class->new( $full_path, $self->_Service ); }
                      || next;

                    push( @contents, $child );
                }
            }
        }

        closedir(DIR);

        $self->_Contents = \@contents;
        $server->freeze($self) if defined $server;

        $item = TiVo::Calypso::Container->new(
            SERVICE => "/Music/Browse/$letter",
            TITLE   => "/Music/Browse/$letter"
        )
        || return undef;

        $item->_Contents = \@contents;
    }

    # Perform filesystem scan
    else {
        my $service = $self->_servicename($path);

        return undef unless defined $self->_Services->{$service};
        $service = $self->_Services->{$service};

        $path = $service->obj_to_path($path);

        return undef if grep { /^\.\.$/ } split( /\//, $path );

        # Create a directory container
        if ( -d $path ) {
            $item = TiVo::Calypso::Container->new(
                PATH    => $path,
                SERVICE => $service
              )
              || return undef;
        }

        # Create a file item
        elsif ( -r $path ) {
            my @parts  = split( /\./, $path );
            my $suffix = uc( pop @parts );

            my $class = "TiVo::Calypso::Item::$suffix";
            $item = eval { $class->new( $path, $service ); } || return undef;
        }
    }

    return $item || undef;
}

## TiVo::Calypso::Server->add_service( $ )
##
##  Adds a TiVo::Calypso::Container object to the service list for this server.

sub add_service {
    my $self    = shift;
    my $service = shift || return undef;

    $self->_Services->{ $service->_Object } = $service;
    $self->freeze($service);

    return $self->_Services->{ $service->_Object };
}

## TiVo::Calypso::Server->request( $ $ $ )
##
##  Processes a client request and returns the output from the appropriate
##  command method. The return value is a list: first element
##  is a scalar containing the mime-type of the returned data, second
##  element is a reference to the data itself. Both scalar refs and
##  IO::File refs may be returned, so the calling application must check
##  for and support both types.

sub request {
    my $self   = shift;
    my $params = shift || return undef;

    # Use a passed TiVo::Calypso::Request object if given or
    # create a TiVo::Calypso::Request object from arguments if needed
    if ( ( ref $params ) !~ /^TiVo::Calypso::Request/ ) {

        # See TiVo::Calypso::Request for the proper syntax of these arguments
        my $script_name  = $params;
        my $path_info    = shift;
        my $query_string = shift;

        $params = TiVo::Calypso::Request->new( $script_name, $path_info, $query_string );
    }

    # File transfer requested? (binary output)
    if ( defined( $params->_EnvPathInfo ) && $params->_EnvPathInfo ) {
        my $path_info = $self->_uri_unescape( $params->_EnvPathInfo );

        my $item = $self->thaw($path_info) || return undef;

        $self->scrobble($item) if $item->_Service->_Scrobble;

        my ( $headers, $ref ) = $item->send( $params, $self );

        my $isDirty = $item->_Dirty;

        if ( $isDirty == 1 ) {
            $item->_Dirty = 0;
            $self->freeze($item);
        }

        return ( $headers, $ref );
    }

    # Command given? (XML output)
    else {
        my $command = uc( $params->_Command ) || 'QUERYCONTAINER';

        # Create and eval the method name dynamically
        my $method   = "command_$command";
        my $response = eval { $self->$method($params); };

        # Call command_UNKNOWN if the eval failed
        if ( !defined $response ) {
            $response = $self->command_UNKNOWN($@);
        }

        # Set the default mime-type to be returned
        my $mime_type = 'text/xml';

        # Check to see if clint requested a different format
        if ( defined( $params->_Format ) ) {
            $mime_type = $params->_Format;

            # If text/html was requested, simply display the xml as plaintext
            if ( $mime_type eq 'text/html' ) {
                $mime_type = 'text/plain';
            }
        }

        my $xml = $self->xml_out($response) || return undef;

        # Wrap XML with header and footer
        my $return = "<?xml version='1.0' encoding='ISO-8859-1' ?>\n";
        $return .= $xml;
        $return .= "<!-- Copyright (c) 2002 TiVo Inc.-->\n";

        my $headers = {
            'Content-Type'   => $mime_type,
            'Content-Length' => length $return
        };

        return ( $headers, \$return );
    }

    my $response = $self->command_QUERYCONTAINER($params);

    return undef;
}

sub scrobble {
    my $self = shift;
    my $item = shift || return undef;

    require Digest::MD5;

    use Encode;
    use LWP::Simple;

    my ( $sec, $min, $hour, $day, $month, $year ) =
      (localtime)[ 0, 1, 2, 3, 4, 5 ];
    my $utc_date = sprintf(
        "%04d-%02d-%02d %02d:%02d:%02d",
        ( $year + 1900 ),
        ( $month + 1 ),
        $day, ( $hour + 6 ),
        $min, $sec
    );

    my $handshake =
        $item->_Service->_ScrobblePostURL
      . '/?hs=true'
      . '&p=1.1'
      . '&c=tst'
      . '&v=1.0'
      . '&u='
      . $item->_Service->_ScrobbleU;

    my ( $update, $challenge, $post_url, $interval ) = split /\n/,
      get($handshake);

    my $password_md5 = Digest::MD5::md5_hex( $item->_Service->_ScrobbleP );
    my $md5_password_digest =
      Digest::MD5::md5_hex( $password_md5 . $challenge );

    for ( $item->_Service->_ScrobbleU,
        $item->_Artist, $item->_Title, $item->_Album, $item->_Duration,
        $utc_date )
    {
        $_ = encode( 'utf8', $_ );
    }

    my $scrobblepost =
        $post_url
      . '?u='    . $item->_Service->_ScrobbleU
      . '&s='    . $md5_password_digest
      . '&a[0]=' . $item->_Artist
      . '&t[0]=' . $item->_Title
      . '&b[0]=' . $item->_Album
      . '&m[0]=' . ''
      . '&l[0]=' . $item->_Duration
      . '&i[0]=' . $utc_date;

    my @response = split /\n/, get($scrobblepost);

    return;
}

## TiVo::Calypso::Server->xml_out( $ [$] )
##
##  Converts a referenced hash/array data structure to XML. Use array
##  references to pass keys when order of the resulting XML tags
##  is important. Keys passed in a hash reference will have no
##  predictable ordering.

sub xml_out {
    my $self   = shift;
    my $data   = shift || return undef;
    my $indent = shift || 0;

    my $return;

    my @keys;

    my $data_type = ref $data;

    # Process each key if the passed reference was a hash
    if ( $data_type eq 'HASH' ) {
        foreach my $key ( keys %$data ) {

            # Force undef values to empty strings before printing
            $data->{$key} = "" unless defined( $data->{$key} );

            my $key_type = ref( $data->{$key} );

            # Recurse again if the child key is another hash
            if ( $key_type eq 'HASH' ) {
                $return .= ' ' x $indent . "<$key>\n";
                $return .= $self->xml_out( $data->{$key}, $indent + 2 ) || "";
                $return .= ' ' x $indent . "</$key>\n";
            }

            # Recurse on each element if the child key is an array
            elsif ( $key_type eq 'ARRAY' ) {
                $return .= ' ' x $indent . "<$key>\n";
                foreach my $item ( @{ $data->{$key} } ) {
                    $return .= $self->xml_out( $item, $indent + 2 ) || "";
                }
                $return .= ' ' x $indent . "</$key>\n";
            }

            # Assume the child is a text node otherwise, and print
            else {
                $return .=
                  ' ' x $indent . "<$key>" . $data->{$key} . "</$key>\n";
            }
        }
    }

    # Recurse on each element if the passed ref is an array
    elsif ( $data_type eq 'ARRAY' ) {
        foreach my $item (@$data) {
            $return .= $self->xml_out( $item, $indent );
        }
    }

    # What's this? Print it and hope for the best
    else {
        $return .= "$data\n";
    }

    return $return;
}

## TiVo::Calypso::Server->command_QUERYSERVER( $ )
##
##  Generates response to QueryServer command
##  Expects to be passed a TiVo::Calypso::Request object
##  Returns data structure suitable for use with xml_out

sub command_QUERYSERVER {
    my $self   = shift;
    my $params = shift;

    my $return = {
        'TiVoServer' => {
            'Version'         => $self->VERSION,
            'InternalVersion' => $self->INTVERSION,
            'InternalName'    => $self->INTNAME,
            'Organization'    => $self->_Organization || $self->ORGANIZATION,
            'Comment'         => $self->_Comment || $self->COMMENT
        }
    };

    return $return;
}

## TiVo::Calypso::Server->command_QUERYCONTAINER( $ )
##
##  Generates response to QueryContainer command
##  Expects to be passed a TiVo::Calypso::Request object
##  Returns data structure suitable for use with xml_out

sub command_QUERYCONTAINER {
    my $self   = shift;
    my $params = shift;

    my $container = $params->_Container;

    # Return service containers unless otherwise requested
    $container = '/' unless defined $container;

    my ($object);

    $object = $self->thaw($container) || return undef;

    my @list;

    if ( defined( $params->_Recurse ) && uc( $params->_Recurse ) eq 'YES' ) {

        # Explode the content list and get a recursive flat list of objects
        @list = @{ $object->explode($self) };
    }
    else {

        # Take the top-level list of objects and remove any subfolder list refs

        @list = @{ $object->contents($self) };

        @list = grep { ref($_) ne 'ARRAY' } @list;

        # We'll always perform the default Sort of Type,Title
        unless ( $params->_Container eq '/Shuffle' ) {
            @list = sort {
                return -1
                  if ( ref $a ) =~ /^TiVo::Calypso::Container/
                  && ( ref $b ) =~ /^TiVo::Calypso::Item/;
                return 1
                  if ( ref $b ) =~ /^TiVo::Calypso::Container/
                  && ( ref $a ) =~ /^TiVo::Calypso::Item/;

                return uc( $a->_Path ) cmp uc( $b->_Path );
            } @list;
        }
    }

=n/a
    # Filters are, at this time, broken. -ss

    # Apply any requested filters
    if ( defined( $params->_Filter ) ) {
        my %types;
        my @filters;

        if ( $params->_Filter =~ /,/ ) {
            @filters = split( /,/, $params->_Filter );
        }
        else {
            @filters = ( $params->_Filter );
        }

        # Construct a list of every possible matching type instead
        # of matching against each object's SourceFormat individually
        my $possible_types = $object->_Service->_MediaTypes;
        $possible_types->{'FOLDER'} = 'x-container/folder';

        foreach my $filter (@filters) {
            my ( $major, $minor ) = split( /\//, $filter );

            $major = $major || '*';
            $minor = $minor || '*';

            # Compare the filter to each supported MediaType for this service
            foreach my $supported ( keys %$possible_types ) {
                my ( $s_major, $s_minor ) =
                  split( /\//, $possible_types->{$supported} );

                if (   ( $major eq $s_major || $major eq '*' )
                    && ( $minor eq $s_minor || $minor eq '*' ) )
                {
                    $types{"$s_major/$s_minor"} = 1;
                }
            }
        }

        @list = grep { defined( $types{ $_->_SourceFormat } ) } @list;
    }
=cut

    my $total_duration = 0;

    # Check for any audio files that passed the Filter and sum their Duration
    foreach (@list) {
        if ( defined( $_->_Duration ) ) {
            $total_duration += $_->_Duration;
        }
    }

    # Perform any requested sorts. Currently incomplete, only supports Random
    # and Type,Title
    if ( defined( $params->_SortOrder ) ) {
        if ( uc( $params->_SortOrder ) eq 'RANDOM' ) {

            # Remove RandomStart from the object list before sorting
            my $start;
            if ( defined( $params->_RandomStart ) ) {
                my $prefix = $params->_EnvScriptname;

                my $short_start = $params->_RandomStart;
                $short_start =~ s/^$prefix//;

                foreach my $i ( 0 .. $#list ) {
                    next unless defined $list[$i]->_Url;
                    next unless $list[$i]->_Url eq $short_start;

                    $start = splice( @list, $i, 1 );
                    last;
                }

            }

            srand( $params->_RandomSeed ) if defined $params->_RandomSeed;

            my $i;
            for ( $i = @list ; --$i ; ) {
                my $j = int rand( $i + 1 );
                next if $i == $j;
                @list[ $i, $j ] = @list[ $j, $i ];
            }

            # Reattach RandomStart as the first object
            unshift( @list, $start ) if defined $start;
        }
    }

    my $count = scalar @list || 0;

    # Anchor defaults to first item
    my $anchor_pos = 0;

    if ( defined( $params->_AnchorItem ) ) {
        my $prefix = $params->_EnvScriptname;

        my $short_anchor = $params->_AnchorItem;
        $short_anchor =~ s/^$prefix//;

        foreach my $i ( 0 .. $#list ) {
            next unless defined $list[$i]->_Url;
            next unless $list[$i]->_Url eq $short_anchor;

            $anchor_pos = $i + 1;
            last;
        }

        # Adjust the anchor position if a positive or negative offset is given
        if ( defined( $params->_AnchorOffset ) ) {
            my $anchor_offset = $params->_AnchorOffset || 0;
            $anchor_pos += $anchor_offset;
        }

    }

    # Trim return list, if requested
    if ( defined( $params->_ItemCount ) ) {
        my $count = $params->_ItemCount;

        # Wrap the pointer if a negative count is requested
        if ( $count < 0 ) {
            $count *= -1;

            # Jump to end of list if no Anchor is provided
            if ( defined( $params->_AnchorItem ) ) {
                $anchor_pos -= $count + 1;
            }
            else {
                $anchor_pos = $#list - $count + 1;
            }
        }

        # Check for under/overflow
        if ( $anchor_pos >= 0 && $anchor_pos <= $#list ) {
            @list = splice( @list, $anchor_pos, $count );
        }
        else {
            $anchor_pos = 0;
            undef @list;
            undef $params->_AnchorItem;
            undef $params->_AnchorOffset;
            undef $params->_ItemCount;
            return $self->command_QUERYCONTAINER( $params );
        }
    }

    # Build description of each item to be returned
    my @children;
    foreach my $child (@list) {
        push( @children, $child->_query_container($params) );
    }

    my $return = {
        'TiVoContainer' => [
            {
                'Details' => {
                    'Title'       => $object->_Title,
                    'ContentType' => $object->_ContentType
                      || 'x-container/folder',
                    'SourceFormat' => $object->_SourceFormat
                      || 'x-container/folder',
                    'TotalItems'    => $count,
                    'TotalDuration' => $total_duration
                }
            },
            { 'ItemStart' => $anchor_pos },
            { 'ItemCount' => scalar @children || 0 },
            \@children
        ]
    };

    return $return;
}

## TiVo::Calypso::Server->command_UNKNOWN( $ )
##
##  Generates response to Unknown commands
##  Expects to be passed a TiVo::Calypso::Request object
##  Returns data structure suitable for use with xml_out

sub command_UNKNOWN {
    my $self   = shift;
    my $params = shift;

    return {};
}

##############################################################################
# TiVo::Calypso::Container
#   Attaches TiVo methods to a particular directory
##############################################################################
package TiVo::Calypso::Container;
@ISA = ('TiVo::Calypso');

## TiVo::Calypso::Container->new( % )
##
##  Generic TiVo::Calypso::Container constructor
##  Accepts parameters via an argument hash.
##  Expects to be passed a full pathname and either a string describing
##  the service prefix (if this container is to be a service) or another
##  TiVo::Calypso::Container object (if this container is to be a subdirectory
##  of an existing service).

sub new {
    my $class = shift;

    my $self = {};
    bless $self, $class;

    my %params = (@_);

    my $service = $params{'SERVICE'} || return undef;
    $self->_Path = $params{'PATH'};

    # This container is a subdirectory
    if ( ( ref $service ) =~ /^TiVo::Calypso::Container/ ) {
        $self->_Object = $service->path_to_obj( $self->_Path ) || return undef;
        $self->_Service = $service;
    }

    # This container is a service container
    else {
        $self->_Object  = $service;
        $self->_Service = $self;
    }

    # Set folder title, if provided
    $self->_Title = $params{'TITLE'};

    # Defaults common to all Containers
    $self->_SourceFormat = 'x-container/folder';
    $self->_Url          =
      '?Command=QueryContainer&Container='
      . $self->_uri_escape( $self->_Object );

    $self->_Expired = 0;

    # Call class-specific init method
    $self->init(%params) || return undef;

    return $self;
}

## TiVo::Calypso::Container->init( )
##
##  Generic TiVo::Calypso::Container initialization

sub init {
    my $self = shift;

    $self->_ContentType = 'x-container/folder';

    $self->_Title = $self->_Title || $self->_basename;

    return 1;
}

## TiVo::Calypso::Container->path_to_obj( $ )
##
##  Converts the given pathname to an object path relative to the
##  current service

sub path_to_obj {
    my $self = shift;
    my $path = shift || return undef;

    my $service_p = $self->_Path;
    my $service_o = $self->_Object;

    $path =~ s/^$service_p/$service_o/;

    return $path;
}

## TiVo::Calypso::Container->obj_to_path( $ )
##
##  Converts the given object path (relative to the current service) to
##  a full filesystem pathname

sub obj_to_path {
    my $self = shift;
    my $path = shift || return undef;

    my $service_p = $self->_Path;
    my $service_o = $self->_Object;

    $path =~ s/^$service_o/$service_p/;

    return $path;
}

## TiVo::Calypso::Container->contents( $ )
##
##  Returns the contents of a TiVo::Calypso::Container directory as a list ref
##  of Item and Container objects.

sub contents {
    my $self   = shift;
    my $server = shift;

    return $self->_Contents if defined $self->_Contents;

    my @contents;

    local *DIR;
    if ( $self->_Path eq '/Shuffle' ) {
        my ( @artists, @songs );

        opendir( DIR, $server->_Services->{'/Shuffle'}->_Path ) || return undef;

        while ( defined( my $file = readdir DIR ) ) {
            next if $file =~ /^\./;

            push @artists, $file;
        }

        closedir(DIR);

        srand();

        for (1) {
            my ( @albums, @songlist );

            my $artist = $artists[ rand @artists ];

            opendir( DIR,
                $server->_Services->{'/Shuffle'}->_Path . '/' . $artist )
              || return undef;

            while ( defined( my $file = readdir DIR ) ) {
                next if $file =~ /^\./;

                push @albums, $artist . '/' . $file;
            }

            closedir(DIR);

            my $album = $albums[ rand @albums ];

            opendir( DIR,
                $server->_Services->{'/Shuffle'}->_Path . '/' . $album )
              || return undef;

            while ( defined( my $file = readdir DIR ) ) {
                next if $file =~ /^\./;

                push @songlist, $album . '/' . $file;

            }

            closedir(DIR);

            push @songs, $songlist[ rand @songlist ];
        }

        foreach my $song (@songs) {
            my @parts  = split( /\./, $song );
            my $suffix = uc( pop @parts );

            my $class = "TiVo::Calypso::Item::$suffix";
            my $child = eval {
                $class->new(
                    $server->_Services->{'/Shuffle'}->_Path . '/' . $song,
                    $self->_Service );
            } || next;

            push @contents, $child;
        }
    }
    elsif ( $self->_Path eq $server->_Services->{'/Music'}->_Path ) {
        foreach (qw/ * A B C D E F G H I J K L M N O P Q R S T U V W X Y Z /) {
            my $child = TiVo::Calypso::Container->new(
                PATH    => $self->_Path . "/Browse/" . $_,
                SERVICE => $self->_Service
              )
              || next;

            push( @contents, $child );
        }
    }
    else {
        opendir( DIR, $self->_Path ) || return undef;

        while ( defined( my $file = readdir DIR ) ) {
            next if $file =~ /^\./;

            if ( defined $server ) {

                my $object_path = $self->_Object . "/" . $file;
                my $child       = $server->thaw($object_path) || next;

                push( @contents, $child );

            }
            else {

                my $full_path = $self->_Path . "/" . $file;

                if ( -d $full_path ) {

                    my $child = TiVo::Calypso::Container->new(
                        PATH    => $full_path,
                        SERVICE => $self->_Service
                      )
                      || next;

                    push( @contents, $child );

                }
                elsif ( -r $full_path ) {

                    my @parts  = split( /\./, $full_path );
                    my $suffix = uc( pop @parts );

                    my $class = "TiVo::Calypso::Item::$suffix";
                    my $child =
                      eval { $class->new( $full_path, $self->_Service ); }
                      || next;

                    push( @contents, $child );
                }

            }

        }

        closedir(DIR);
    }

    # Cache the new information we just built
    $self->_Contents = \@contents;
    $server->freeze($self) if defined $server;

    return \@contents;
}

## TiVo::Calypso::Container->explode( $ )
##
##  Converts the single-directory Container and Item list format of an
##  object's contents() to a recursive list of all Containers and Items.

sub explode {
    my $self   = shift;
    my $server = shift;

    my $list = $self->contents($server);

    @$list = sort {
        return -1
          if ( ref $a ) =~ /^TiVo::Calypso::Container/ && ( ref $b ) =~ /^TiVo::Calypso::Item/;
        return 1
          if ( ref $b ) =~ /^TiVo::Calypso::Container/ && ( ref $a ) =~ /^TiVo::Calypso::Item/;
        return uc( $a->_Path ) cmp uc( $b->_Path );

        #$        return uc($a->_Album) cmp uc($b->_Album) ||
        #$            $a->_Track <=> $b->_Track ||
        #$            $a->_Path  <=> $b->_Path ||
        #$            uc($a->_Title) cmp uc($b->_Title);
    } @$list;

    my @return;

    foreach my $item (@$list) {

        if ( ( ref $item ) =~ /^TiVo::Calypso::Container/ ) {

            # Fetch the most current copy of this item from Cache
            $item = $server->thaw( $item->_Object ) || next;

            push( @return, $item );
            push( @return, @{ $item->explode($server) } );

        }
        else {

            push( @return, $item );

        }

    }

    return \@return;
}

package TiVo::Calypso::Container::Server;
@ISA = ("TiVo::Calypso::Container");

## TiVo::Calypso::Container::Server->init( )
##
##  Defines a Server psuedo-container which overrides the generic init
##  method. Sets content types unique to a Server container;

sub init {
    my $self = shift;

    $self->_Object  = "/";
    $self->_Service = "/";

    $self->_ContentType = 'x-container/tivo-server';

    $self->_Title = $self->_Title || "TiVo Server";

    return 1;
}

# TiVo::Calypso::Container extension
package TiVo::Calypso::Container::Music;
@ISA = ("TiVo::Calypso::Container");

## TiVo::Calypso::Container::Music->init( )
##
##  Defines a Music container which overrides the generic init
##  method. Sets content and media types unique to a 'Music'
##  container.

sub init {
    my $self = shift;

    my %params = (@_);

    $self->_ContentType = 'x-container/tivo-music';

    # Media types accepted for this container.
    # When creating a handler for a new media type, be sure to
    # register it with the appropriate service via:
    #   $service->_MediaTypes->{'NewSuffix'} = 'mime/type';

    $self->_MediaTypes = { 'MP3' => 'audio/mpeg' };

    $self->_Title = $self->_Title || "Music";

    if ( $params{'SCROBBLER'} ) {
        $self->_Scrobble        = 1;
        $self->_ScrobblePostUrl = $params{'SCROBBLER'}->{'POSTURL'};
        $self->_ScrobbleU       = $params{'SCROBBLER'}->{'USERNAME'};
        $self->_ScrobbleP       = $params{'SCROBBLER'}->{'PASSWORD'};
    }

    return 1;
}

##############################################################################
# TiVo::Calypso::Item #   Attaches TiVo methods to a particular file
##############################################################################
package TiVo::Calypso::Item;
@ISA = ('TiVo::Calypso');

## TiVo::Calypso::Item->new( $ $ )
##
##  Constructor for generic TiVo::Calypso::Item
##  Expects to be passed a full pathname and a TiVo::Calypso::Container service
##  to pull container information from

sub new {
    my $class = shift;

    my $self = {};
    bless $self, $class;

    $self->_Path    = shift || return undef;
    $self->_Service = shift || return undef;

    # use the file suffix to determine file type
    my @parts  = split( /\./, $self->_Path );
    my $suffix = uc( pop @parts );

    # Skip this file if the service doesn't claim to support it
    return undef unless defined $self->_Service->_MediaTypes;

    $self->_SourceFormat = $self->_Service->_MediaTypes->{$suffix}
      || return undef;

    $self->_Object = $self->_Service->path_to_obj( $self->_Path )
      || return undef;
    $self->_Url = $self->_uri_escape( $self->_Object );

    # Contruct ContentType from SourceFormat
    my $content_type = $self->_SourceFormat;
    $content_type =~ s/\/.*$/\/\*/;

    $self->_ContentType = $content_type;

    $self->_Dirty = 0;

    # Call class-specific init method
    $self->init || return undef;

    return $self;
}

##
## TiVo::Calypso::Item->init( )
##
##  Generic TiVo::Calypso::Item initialization
##
sub init {
    my $self = shift;

    return 1;
}

## TiVo::Calypso::Item->send( )
##
##  Generic TiVo::Calypso::Item file transfer

sub send {
    my $self = shift;

    require IO::File;

    my $handle = IO::File->new( $self->_Path );

    my $headers = {
        'Content-Type'   => $self->_SourceFormat,
        'Content-Length' => $self->_SourceSize
    };

    return ( $headers, $handle );
}

# TiVo::Calypso::Item extension
package TiVo::Calypso::Item::MP3;
@ISA = ('TiVo::Calypso::Item');

## TiVo::Calypso::Item::MP3->init( )
##
##  Overrides generic init method for TiVo::Calypso::Item and includes MP3
##  specific fields

sub init {
    my $self = shift;

    # use the file suffix to determine file type
    my @parts  = split( /\./, $self->_Path );
    my $suffix = uc( pop @parts );

    # Assume MP3 for lack of anything better.
    require MP3::Info;

    my $tag  = MP3::Info::get_mp3tag( $self->_Path );
    my $info = MP3::Info::get_mp3info( $self->_Path );

    return undef unless defined $info;

    $self->_SourceBitRate = sprintf( "%d", $info->{'BITRATE'} * 1000 ) || 0;
    $self->_SourceSampleRate = sprintf( "%d", $info->{'FREQUENCY'} * 1000 )
      || 0;
    $self->_Duration = sprintf( "%d", ( $info->{'SECS'} * 1000 ) ) || 0;

    $self->_Genre  = $tag->{'GENRE'}  || "";
    $self->_Artist = $tag->{'ARTIST'} || "";
    $self->_Album  = $tag->{'ALBUM'}  || "";
    $self->_Year   = $tag->{'YEAR'}   || "";
    $self->_Title  = $tag->{'TITLE'}  || $self->_basename;

    # Get timestamps and size if the file referenced by Path exists
    if ( stat( $self->_Path ) ) {
        $self->_SourceSize = -s $self->_Path;

        my $change_date = ( stat(_) )[9];
        my $access_date = ( stat(_) )[8];

        $change_date = sprintf( "0x%x", $change_date );
        $access_date = sprintf( "0x%x", $access_date );

        # *nix does not seem to have a portable "creation date" stamp.
        # Using last change date, instead.
        $self->_CreationDate   = $change_date;
        $self->_LastChangeDate = $change_date;
        $self->_LastAccessDate = $access_date;
    }

    return 1;
}

## TiVo::Calypso::Item::MP3->_query_container
##
##   Returns a data structure suitable for use with xml_out which
##   describes this object in response to a QueryContainer command

sub _query_container {
    my $self   = shift;
    my $params = shift;

    my $script_name = $params->_EnvScriptName || "";

    my $details = {
        'Item' => [
            {
                'Details' => {
                    'Title'        => $self->_Title,
                    'ContentType'  => $self->_ContentType,
                    'SourceFormat' => $self->_SourceFormat,
                    'ArtistName'   => $self->_Artist,
                    'SongTitle'    => $self->_Title,
                    'AlbumTitle'   => $self->_Album,
                    'MusicGenre'   => $self->_Genre,
                    'Duration'     => $self->_Duration
                }
            },
            {
                'Links' => {
                    'Content' => {
                        'Url'      => $script_name . $self->_Url,
                        'Seekable' => 'Yes'
                    }
                }
            }
        ]
    };

    return $details;
}

## TiVo::Calypso::Item::MP3->send( $ )
##
##  TiVo::Calypso::Item send extension supporting MP3 seeking

sub send {
    my $self   = shift;
    my $params = shift;

    require IO::File;

    my $handle = IO::File->new( $self->_Path );
    my $length = $self->_SourceSize;

    if ( defined $params->_Seek ) {
        my $seek_ms     = $params->_Seek;
        my $seek_offset =
          sprintf( "%d", ( $seek_ms / $self->_Duration ) * $self->_SourceSize );

        seek( $handle, $seek_offset, 0 );

        $length = $length - $seek_offset;
    }

    my $headers = {
        'Content-Type'         => $self->_SourceFormat,
        'Content-Length'       => $length,
        'TivoAccurateDuration' => $self->_Duration
    };

    return ( $headers, $handle );
}

##############################################################################
# TiVo::Calypso::Request
#   Stores information about a given command request which needs to be
#   passed from object to object
##############################################################################
package TiVo::Calypso::Request;
@ISA = ('TiVo::Calypso');

## TiVo::Calypso::Request->new( $ $ $ )
##
##  Constructor for TiVo::Calypso::Request.
##  Expects to be passed three strings:
##
##    Script Name:  The path and name of the CGI/server as requested in the URI
##                  This is the same string provided by webserver in the
##                  $SCRIPT_NAME environment variable
##    Path Info:    The path information appended after the CGI/server in
##                  the URI, but before the paramater list.
##                  This is the same string provided by webserver in the
##                  $PATH_INFO environment variable
##    Query String  The key/value query string appended to the end of the URI
##                  This is the same string provided by webserver in the
##                  $QUERY_STRING environment variable

sub new {
    my $class = shift;

    my $self = {};
    bless $self, $class;

    $self->_EnvScriptName  = shift;
    $self->_EnvPathInfo    = shift;
    $self->_EnvQueryString = shift;

    # Parse the query_string, if provided
    if ( defined( $self->_EnvQueryString ) ) {
        $self->parse( $self->_EnvQueryString );
    }

    return $self;
}

## TiVo::Calypso::Request->parse( $ )
##
##   Trim, split, and decode a standard CGI query string. The key/value
##   pairs are stored in the object's internal DATA hash

sub parse {
    my $self  = shift;
    my $query = shift;

    # Skip the query if it doesn't contain anything useful
    if ( defined($query) && $query =~ /[=&]/ ) {

        # remove everything before the '?' and replace '+' with a space
        $query =~ s/.*\?//;
        $query =~ s/\+/ /g;

        my @pairs = split( /&/, $query );

        foreach my $pair (@pairs) {
            my ( $key, $value ) = split( /=/, $pair, 2 );

            if ( defined($key) ) {

                # Escape each key and value before storing
                $key = $self->_uri_unescape($key);
                $self->{'DATA'}->{ uc($key) } = $self->_uri_unescape($value);
            }
        }
    }
}

1;