#!/usr/bin/perl =pod =encoding utf8 =head1 NAME tv_grab_pt_vodafone - Grab TV listings for Vodafone in Portugal =head1 SYNOPSIS tv_grab_pt_vodafone --help tv_grab_pt_vodafone --configure [--config-file FILE] tv_grab_pt_vodafone [--config-file FILE] [--days N] [--offset N] [--channel xmltvid,xmltvid,...] [--output FILE] [--quiet | --debug] tv_grab_pt_vodafone --list-channels [--config-file FILE] [--output FILE] [--quiet | --debug] =head1 DESCRIPTION Output TV listings in XMLTV format for many stations available in Portugal. This program consumes the EPG service from L. First you must run B to choose which stations you want to receive. Then running B with no arguments will get listings for the stations you chose for the maximum 7 days, including today. =head1 OPTIONS B<--configure> Prompt for which stations to download and write the configuration file. B<--config-file FILE> Set the name of the configuration file, the default is B<~/.xmltv/tv_grab_pt_vodafone.conf>. This is the file written by B<--configure> and read when grabbing. B<--output FILE> When grabbing, write output to FILE rather than standard output. B<--days N> When grabbing, grab N days rather than everything available. B<--offset N> Start grabbing at today + N days. B<--quiet> Only print error-messages on STDERR. B<--debug> Provide more information on progress to STDERR to help in debugging. B<--list-channels> Output a list of all channels that data is available for. The list is in xmltv-format. B<--capabilities> Show which capabilities the grabber supports. For more information, see L B<--version> Show the version of the grabber. B<--help> Print a help message and exit. =head1 ERROR HANDLING If the grabber fails to download data from Vodafone, it will print an error message to STDERR and then exit with a status code of 1 to indicate that the data is missing. =head1 ENVIRONMENT VARIABLES The environment variable HOME can be set to change where configuration files are stored. All configuration is stored in $HOME/.xmltv/. On Windows, it might be necessary to set HOME to a path without spaces in it. =head1 CREDITS Kevin Groeneveld (kgroeneveld at gmail dot com) This grabber uses code from tv_grab_pt_meo by Karl Dietz, dekarl -at- users -dot- sourceforge -dot- net, and from tv_grab_zz_sdjson by Kevin Groeneveld, kgroeneveld -at- gmail -dot- com. The original idea of this grabber came from higuita's shell script, see L. Special thanks to Vodafone for building a clean, fast, and public access API; much more reliable than Meo's open API (but sadly not as open) and much better than the lack of any API from NOS. =head1 AUTHOR Nuno Sénica, nsenica -at- gmail -dot- com. =head1 BUGS None known. =cut use warnings; use strict; use utf8; use XMLTV; use XMLTV::Version "$XMLTV::VERSION"; use DateTime; use Encode; # used to convert 'perl strings' into 'utf-8 strings' use XML::LibXML; use XMLTV::Configure::Writer; use XMLTV::Get_nice qw/get_nice/; use XMLTV::Options qw/ParseOptions/; use XMLTV::Supplement qw/GetSupplement/; use JSON; use Text::Unidecode; use URI::Escape qw/ uri_escape /; use URI::Encode qw/ uri_encode uri_decode/; # use Data::Dump qw/pp/; # uncomment to debug my $maxdays = 1+6; # data source is limited to 7 days (including today) my $grabber_name = 'tv_grab_pt_vodafone'; my $grabber_version = '3.00'; my $json_baseurl = 'https://cdn.pt.vtv.vodafone.com/epg/'; # Generate with: # jq -r '.channels[]|(.epgId,.title,.logo.color.uri)' channel.list.new | sed 'N;N; s/\n/\t/g' | sort > channel.list my $channel_list_file = 'channel.list'; my $ua = LWP::UserAgent->new(ssl_opts => { SSL_cipher_list => 'DEFAULT:!DH', }); $ua->agent("$grabber_name $grabber_version"); $ua->default_header('accept-encoding' => scalar HTTP::Message::decodable()); my( $opt, $conf ) = ParseOptions( { grabber_name => $grabber_name, capabilities => [qw/apiconfig baseline manualconfig preferredmethod/], listchannels_sub => \&list_channels, stage_sub => \&config_stage, version => "$XMLTV::VERSION", description => "Portugal (Vodafone)", preferredmethod => 'allatonce', defaults => { days => $maxdays, offset => 0, quiet => 0, debug => 0 }, } ); # limit to maxdays in the future if ($opt->{offset} + $opt->{days} > $maxdays) { $opt->{days} = $maxdays - $opt->{offset}; } # Get the actual data and print it to stdout. my $is_success=1; my $startDate = DateTime->from_epoch( epoch => time () ); $startDate->set_time_zone( 'Europe/Lisbon' ); $startDate->truncate( to => 'day' ); $startDate->add( days => $opt->{offset} ); my $endDate=$startDate->clone()->add( days => $opt->{days} ); $endDate->add( seconds => -1 ); my %w_args = ( cutoff => '000000', days => $opt->{days}, encoding => 'UTF-8', offset => $opt->{offset}, ); my $writer = new XMLTV::Writer( %w_args ); $writer->start({ 'generator-info-name' => "XMLTV/".$opt->{version}, 'generator-info-url' => 'http://www.xmltv.org/', 'source-info-name' => 'EPG Service for Vodafone', 'source-info-url' => $json_baseurl, }); if ( ! undef $opt->{days} ) { if( !$opt->{quiet} ) { print( STDERR "fetching data\n" ); } get_epg( $writer, $startDate, $endDate ); } else { if( !$opt->{quiet} ) { print( STDERR "no data available for the requested time period\n" ); } $is_success = 0; } $writer->end(); if( $is_success ) { exit 0; } else { exit 1; } sub config_stage { my( $stage, $conf ) = @_; die "Unknown stage $stage" if $stage ne "start"; my $result; my $writer = new XMLTV::Configure::Writer( OUTPUT => \$result, encoding => 'utf-8' ); $writer->start( { grabber => 'tv_grab_pt_vodafone' } ); $writer->end( 'select-channels' ); return $result; } sub list_channels { my ( $conf, $opt ) = @_; my $channellist = _read_channel_data(); my $output=XML::LibXML::Document->new( '1.0', 'utf-8' ); my $root=XML::LibXML::Element->new( 'tv' ); $output->setDocumentElement( $root ); foreach my $key ( sort keys %$channellist ) { my $channel=$channellist->{$key}; my $sigla=$key; my $name=$channel->{name}; my $icon=$channel->{icon}; my $tmp=XML::LibXML::Element->new( 'channel' ); $tmp->setAttribute( 'id', encode( 'UTF-8', $sigla ) ); $tmp->appendTextChild( 'display-name', encode( 'UTF-8', $name ) ); my $iconElement=XML::LibXML::Element->new( 'icon' ); $iconElement->setAttribute( 'src', $icon ); $tmp->appendChild( $iconElement ); $root->appendChild( $tmp ); } return $output->toString(); } sub _read_channel_data { my $channel_list = {}; my $c = 0; my $channel_list_str = GetSupplement( 'tv_grab_pt_vodafone', $channel_list_file ); foreach my $channel_string (split( /\n/, $channel_list_str )) { chomp($channel_string); # remove commented channels $channel_string =~ s/^#.*//; next if not length $channel_string; my @items = split("\t",$channel_string); $items[1] =~ s/\"//g; $channel_list->{ $items[0] } = { name => decode('UTF-8', $items[1]), icon => $items[2] }; } return $channel_list } sub get_epg { my( $writer, $startDate, $endDate ) = @_; my @channelList = @{$conf->{channel}}; my %xmlchannels; my %xmlprogs; my $curDate = $startDate; my $channelInfo = _read_channel_data(); while ( $curDate <= $endDate ) { for my $channel (@channelList) { my $channelId = make_channelid( $channelInfo->{$channel}->{name} ); my %ch = ( 'id' => $channelId, 'icon' => [ { src => unquote($channelInfo->{$channel}->{icon}) } ], ); # multiple display-names are ok and may be useful to match other tools lists my @displayname = ( [ sanitizeUTF8( $channelInfo->{$channel}->{name} ), 'pt' ] , [ sanitizeUTF8( $channelId ), 'pt' ] , [ sanitizeUTF8( $channel ), 'pt' ] ); push @{ $ch{'display-name'} }, @displayname ; $xmlchannels{ $channelId } = \%ch ; my $date_day = $curDate->strftime('%d'); my $date_month = $curDate->strftime('%m'); my $date_year = $curDate->year; for my $period ("00-06","06-12","12-18","18-00") { print( STDERR "requesting EPG from " . $curDate->ymd() ." [".$period."]". " for " . $channelId . "\n" ) if( !$opt->{quiet} ); print( STDERR " GET ".$json_baseurl.$channel."/".$date_year."/".$date_month."/".$date_day."/".$period."\n" ) if( $opt->{debug} ); my $epgSource = json_request('get', $channel."/".$date_year."/".$date_month."/".$date_day."/".$period); if ( ! $epgSource ){ die("Bad EPG download, probably channel list is outdated, rerun the grabber configure to update the list.\n" ); } elsif ( !$epgSource->{result} || !$epgSource->{result}->{objects} || scalar @{$epgSource->{result}->{objects}} == 0 ){ print( STDERR " Empty EPG download for ".$channel.", probably channel list is outdated or no API data for that channel\n" . " Rerun the grabber configure to update the list or check for the channel EPG in the Vodafone app.\n" ); next; }; my $data = $epgSource->{result}->{objects}; PROGRAMME: for my $programme ( @{ $data }) { my %prog; my ($dtstart, $dtend, $starts_today) = make_dates($programme->{startDate}, $programme->{endDate}, $curDate); $prog{start} = $dtstart; $prog{stop} = $dtend; $prog{channel} = $channelId; $prog{title} = get_title($programme); $prog{desc} = get_desc($programme); $prog{date} = get_date($programme); $prog{'episode-num'} = make_episode_num($programme); my $length = get_length($programme); $prog{length} = $length if $length; my $icon = get_icon($programme); $prog{icon} = $icon if $icon; my $category = get_category($programme); $prog{category} = $category if $category; my $country = get_country($programme); $prog{country} = $country if $country; my $rating = get_rating($programme); $prog{rating} = $rating if $rating; my $image = get_images($programme); $prog{image} = $image if $image; my $actors = get_actors($programme); $prog{credits}{actor} = $actors if $actors; my $directors = get_directors($programme); $prog{credits}{director} = $directors if $directors; # We can get the same programme for two different days if it goes past midnight. # Lets remove duplicates here. $xmlprogs{$channelId}{ $dtstart, $dtend } = \%prog; print( STDERR " Adding programme: " . $prog{title}[0][0] . " [" . $dtstart . " - " . $dtend . "]\n" ) if( $opt->{debug} ); } } } $curDate->add(days => 1); } $writer->write_channel($_) for values %xmlchannels; for my $ch (keys %xmlchannels) { $writer->write_programme($_) for values %{ $xmlprogs{$ch} }; } $is_success=1; } sub sanitizeUTF8 { my ($str) = @_; $str =~ s/[^[:print:]]+//g; return encode('UTF-8', $str, Encode::FB_CROAK); } sub json_request { my ($method, $path, $content) = @_; # TODO(nsenica): Implement proper throttling control. sleep(0.1); my $url = $json_baseurl . $path; print( STDERR "json_request(" . $method . ") url: " . $url . "\n" ) if( $opt->{debug} ); my @params; push(@params, content_type => 'application/x-www-form-urlencoded; charset=UTF-8'); push(@params, content => $content) if defined $content; my $response = $ua->$method($url, @params); if($response->is_success()) { return JSON->new->utf8(1)->decode( $response->decoded_content()); } else { my $msg = $response->decoded_content(); if($response->header('content-type') =~ m{text/html;charset=UTF-8}i) { my $error = decode_json($msg); $msg = "Server (ID=$error->{'serverID'} Time=$error->{'datetime'}) returned an error:\n" ."$error->{'message'} ($error->{'code'}/$error->{'response'})"; } print( STDERR " Error on the remote EPG API call\n" ) if( !$opt->{quiet} ); print( STDERR $msg . "\n" ) if( $opt->{debug} ); return JSON->new->utf8(1)->decode('{"data": [] }'); } } sub make_episode_num { my ($programme) = @_; return unless $programme->{metas}{'season number'}; my $output; my $season; my $episode; if ( $programme->{metas}{'season number'}{value} ) { $season = $programme->{metas}{'season number'}{value} - 1; } if ( $programme->{metas}{'episode num'}{value} ) { $episode = $programme->{metas}{'episode num'}{value} - 1; } $output = [ [ ($season // "") . "." . ($episode // "") . ".", 'xmltv_ns' ] ] if (defined $season || defined $episode); if ( defined $season && defined $episode ) { push @{ $output }, [ ($season+1) ." ". ($episode+1) , 'onscreen' ]; } elsif ( defined $season ) { push @{ $output }, [ ($season+1) , 'onscreen' ]; } elsif ( defined $episode ) { push @{ $output }, [ ($episode+1) , 'onscreen' ]; } return $output; } sub make_dates { my( $startTime, $endTime, $curDate ) = @_; my $dtstart = DateTime->from_epoch(epoch => $startTime); my $starts_today = 0; # does the programme start on the day we want listings for? if ($dtstart->day == $curDate->day) { $starts_today = 1; } my $dtend = DateTime->from_epoch(epoch =>$endTime); # dates look like GMT, we tried UTC but in summer time they fail return ($dtstart->strftime( '%Y%m%d%H%M%S +0000' ), $dtend->strftime( '%Y%m%d%H%M%S +0000' ), $starts_today); } sub make_channelid { my( $id ) = @_; $id = lc( $id ); # turn into lowercase $id =~ s/\s+//g; # remove whitespace $id =~ s/&//g; # remove ampersand $id =~ s/!//g; # remove ! $id =~ s/\"//g; # remove "" $id =~ s/\+/-plus/g; # turn + into -plus $id = unidecode($id); $id .= '.tv.vodafone.pt'; # append domain part return( $id ); } sub unquote { my ($str) = @_; $str =~ s/^"//; $str =~ s/"$//; return $str; } sub get_title { my ($programme) = @_; return [ [ sanitizeUTF8($programme->{name}), 'pt' ] ]; } sub get_desc { my ($programme) = @_; return [ [ sanitizeUTF8($programme->{description}), 'pt' ] ] if ($programme->{description}); } sub get_length { my ($programme) = @_; if ($programme->{metas}{'display duration'}{value}) { my $duration_str = $programme->{metas}{'display duration'}{value}; my @duration_items = split(/[PTHMS]/, $duration_str); return $duration_items[2] * 3600 + $duration_items[3] * 60 + $duration_items[4] if scalar @duration_items; } } sub get_icon { my ($programme) = @_; if ($programme->{images}[0]{url} && $programme->{images}[0]{imageTypeName} eq "ca") { return [ { src => $programme->{images}[0]{url} . "/width/360/height/640/quality/95" } ]; } if ($programme->{images}[0]{url} && $programme->{images}[0]{imageTypeName} eq "cc") { return [ { src => $programme->{images}[0]{url} . "/width/640/height/360/quality/95" } ]; } } sub get_category { my ($programme) = @_; if ($programme->{tags}{genre}) { my @category = map { [ sanitizeUTF8($_->{value}), 'pt' ] } @{$programme->{tags}{genre}{objects}}; return \@category; } } sub get_country { my ($programme) = @_; return [ [ sanitizeUTF8($programme->{tags}{'country of production'}{objects}[0]{value}), 'pt' ] ] if $programme->{tags}{'country of production'}; } sub get_rating { my ($programme) = @_; if ($programme->{tags}{'parental Rating'}) { my $rating; if ($programme->{tags}{'parental Rating'}{objects}[0]{value} == 0) { $rating = [ [ "All Ages", 'Portuguese Movie Rating' ] ]; } else { $rating = [ [ "M/" . $programme->{tags}{'parental Rating'}{objects}[0]{value}, 'Portuguese Movie Rating' ] ]; } return $rating if $rating; } } sub get_images { my ($programme) = @_; my @images; for my $image (@{$programme->{images}}) { next if !$image->{url}; my $type = ""; my $orient = ""; my $size = 3; my $system = "vodafone"; my $width = 640; my $height = 360; if ($image->{imageTypeName} eq "cc") { $orient = "L"; $type = "still"; } elsif ($image->{imageTypeName} eq "ca") { $orient = "P"; $type = "poster"; $width = 360; $height = 640; } elsif ($image->{imageTypeName} eq "bg") { $orient = "L"; $type = "backdrop"; } push @images, [ $image->{url} . "/width/" . $width . "/height/" . $height . "/quality/95", { type => $type, size => $size, orient => $orient, system => $system } ]; } return \@images if scalar @images; } sub get_date { my ($programme) = @_; return sanitizeUTF8($programme->{metas}{year}{value}) if $programme->{metas}{year}{value}; } sub get_actors { my ($programme) = @_; if ($programme->{tags}{actors}) { my @actors = map { sanitizeUTF8($_->{value}) } @{$programme->{tags}{actors}{objects}}; return \@actors; } } sub get_directors { my ($programme) = @_; if ($programme->{tags}{director}) { my @directors = map { sanitizeUTF8($_->{value}) } @{$programme->{tags}{director}{objects}}; return \@directors; } }