TVDB: better handling of first run
[nonametv.git] / lib / NonameTV / Exporter / Json.pm
blob260c428260b22eead9535221636c38afde63b363
1 package NonameTV::Exporter::Json;
3 use strict;
4 use warnings;
6 use utf8;
8 use IO::File;
9 use DateTime;
10 use File::Copy;
11 use JSON::XS;
13 use NonameTV::Exporter;
14 use NonameTV::Language qw/LoadLanguage/;
15 use NonameTV qw/norm/;
17 use NonameTV::Log qw/d p w StartLogSection EndLogSection SetVerbosity/;
19 use base 'NonameTV::Exporter';
21 =pod
23 Export data in json format.
25 Options:
27 --verbose
28 Show which datafiles are created.
30 --quiet
31 Show only fatal errors.
33 --export-channels
34 Print a list of all channels in xml-format to stdout.
36 --remove-old
37 Remove any old xmltv files from the output directory.
39 --force-export
40 Recreate all output files, not only the ones where data has
41 changed.
43 =cut
45 sub new {
46 my $proto = shift;
47 my $class = ref($proto) || $proto;
48 my $self = $class->SUPER::new( @_ );
49 bless ($self, $class);
51 defined( $self->{Root} ) or die "You must specify Root";
52 defined( $self->{Language} ) or die "You must specify Language";
54 $self->{MaxDays} = 365 unless defined $self->{MaxDays};
55 $self->{MinDays} = $self->{MaxDays} unless defined $self->{MinDays};
57 $self->{LastRequiredDate} =
58 DateTime->today->add( days => $self->{MinDays}-1 )->ymd("-");
60 $self->{OptionSpec} = [ qw/export-channels remove-old force-export
61 verbose+ quiet+ help/ ];
63 $self->{OptionDefaults} = {
64 'export-channels' => 0,
65 'remove-old' => 0,
66 'force-export' => 0,
67 'help' => 0,
68 'verbose' => 0,
69 'quiet' => 0,
72 my $ds = $self->{datastore};
74 # Load language strings
75 $self->{lngstr} = LoadLanguage( $self->{Language},
76 "exporter-xmltv", $ds );
78 return $self;
81 sub Export
83 my( $self, $p ) = @_;
85 if( $p->{'help'} )
87 print << 'EOH';
88 Export data in json-format with one file per day and channel.
90 Options:
92 --export-channels
93 Generate an json-file listing all channels and their corresponding
94 base url.
96 --remove-old
97 Remove all data-files for dates that have already passed.
99 --force-export
100 Export all data. Default is to only export data for batches that
101 have changed since the last export.
105 return;
108 SetVerbosity( $p->{verbose}, $p->{quiet} );
110 StartLogSection( "Json", 0 );
112 if( $p->{'export-channels'} )
114 $self->ExportChannelList();
115 return;
118 if( $p->{'remove-old'} )
120 $self->RemoveOld();
121 return;
124 my $todo = {};
125 my $update_started = time();
126 my $last_update = $self->ReadState();
128 if( $p->{'force-export'} ) {
129 $self->FindAll( $todo );
131 else {
132 $self->FindUpdated( $todo, $last_update );
133 $self->FindUnexportedDays( $todo, $last_update );
136 $self->ExportData( $todo );
138 $self->WriteState( $update_started );
139 EndLogSection( "Json" );
143 # Find all dates for each channel
144 sub FindAll {
145 my $self = shift;
146 my( $todo ) = @_;
148 my $ds = $self->{datastore};
150 my ( $res, $channels ) = $ds->sa->Sql(
151 "select id from channels where export=1");
153 my $last_date = DateTime->today->add( days => $self->{MaxDays} -1 );
154 my $first_date = DateTime->today;
156 while( my $data = $channels->fetchrow_hashref() ) {
157 add_dates( $todo, $data->{id},
158 '1970-01-01 00:00:00', '2100-12-31 23:59:59',
159 $first_date, $last_date );
162 $channels->finish();
165 # Find all dates that may have new data for each channel.
166 sub FindUpdated {
167 my $self = shift;
168 my( $todo, $last_update ) = @_;
170 my $ds = $self->{datastore};
172 my ( $res, $update_batches ) = $ds->sa->Sql( << 'EOSQL'
173 select channel_id, batch_id,
174 min(start_time)as min_start, max(start_time) as max_start
175 from programs
176 where batch_id in (
177 select id from batches where last_update > ?
179 group by channel_id, batch_id
181 EOSQL
182 , [$last_update] );
184 my $last_date = DateTime->today->add( days => $self->{MaxDays} -1 );
185 my $first_date = DateTime->today;
187 while( my $data = $update_batches->fetchrow_hashref() ) {
188 add_dates( $todo, $data->{channel_id},
189 $data->{min_start}, $data->{max_start},
190 $first_date, $last_date );
193 $update_batches->finish();
196 # Find all dates that should be exported but haven't been exported
197 # yet.
198 sub FindUnexportedDays {
199 my $self = shift;
200 my( $todo, $last_update ) = @_;
202 my $ds = $self->{datastore};
204 my $days = int( time()/(24*60*60) ) - int( $last_update/(24*60*60) );
205 $days = $self->{MaxDays} if $days > $self->{MaxDays};
207 if( $days > 0 ) {
208 # The previous export was done $days ago.
210 my $last_date = DateTime->today->add( days => $self->{MaxDays} -1 );
211 my $first_date = $last_date->clone->subtract( days => $days-1 );
213 my ( $res, $channels ) = $ds->sa->Sql(
214 "select id from channels where export=1");
216 while( my $data = $channels->fetchrow_hashref() ) {
217 add_dates( $todo, $data->{id},
218 '1970-01-01 00:00:00', '2100-12-31 23:59:59',
219 $first_date, $last_date );
222 $channels->finish();
226 sub ExportData {
227 my $self = shift;
228 my( $todo ) = @_;
230 my $ds = $self->{datastore};
232 foreach my $channel (keys %{$todo}) {
233 my $chd = $ds->sa->Lookup( "channels", { id => $channel } );
235 foreach my $date (sort keys %{$todo->{$channel}}) {
236 $self->ExportFile( $chd, $date );
241 sub ReadState {
242 my $self = shift;
244 my $ds = $self->{datastore};
246 my $last_update = $ds->sa->Lookup( 'state', { name => "json_last_update" },
247 'value' );
249 if( not defined( $last_update ) )
251 $ds->sa->Add( 'state', { name => "json_last_update", value => 0 } );
252 $last_update = 0;
255 return $last_update;
258 sub WriteState {
259 my $self = shift;
260 my( $update_started ) = @_;
262 my $ds = $self->{datastore};
264 $ds->sa->Update( 'state', { name => "json_last_update" },
265 { value => $update_started } );
268 #######################################################
270 # Utility functions
272 sub add_dates {
273 my( $h, $chid, $from, $to, $first, $last ) = @_;
275 my $from_dt = create_dt( $from, 'UTC' )->truncate( to => 'day' );
276 my $to_dt = create_dt( $to, 'UTC' )->truncate( to => 'day' );
278 $to_dt = $last->clone() if $last < $to_dt;
279 $from_dt = $first->clone() if $first > $from_dt;
281 my $first_dt = $from_dt->clone()->subtract( days => 1 );
283 for( my $dt = $first_dt->clone();
284 $dt <= $to_dt; $dt->add( days => 1 ) ) {
285 $h->{$chid}->{$dt->ymd('-')} = 1;
289 sub create_dt
291 my( $str, $tz ) = @_;
293 my( $year, $month, $day, $hour, $minute, $second ) =
294 ( $str =~ /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/ );
296 if( defined( $second ) ) {
297 return DateTime->new(
298 year => $year,
299 month => $month,
300 day => $day,
301 hour => $hour,
302 minute => $minute,
303 second => $second,
304 time_zone => $tz );
307 ( $year, $month, $day ) =
308 ( $str =~ /^(\d{4})-(\d{2})-(\d{2})$/ );
310 die( "Xmltv: Unknown time format $str" )
311 unless defined $day;
313 return DateTime->new(
314 year => $year,
315 month => $month,
316 day => $day,
317 time_zone => $tz );
320 #######################################################
322 # Json-specific methods.
325 sub ExportFile {
326 my $self = shift;
327 my( $chd, $date ) = @_;
329 my $section = "Json $chd->{xmltvid}_$date";
331 StartLogSection( $section, 0 );
333 d "Generating";
335 my $startdate = $date;
336 my $enddate = create_dt( $date, 'UTC' )->add( days => 1 )->ymd('-');
338 my( $res, $sth ) = $self->{datastore}->sa->Sql( "
339 SELECT * from programs
340 WHERE (channel_id = ?)
341 and (start_time >= ?)
342 and (start_time < ?)
343 ORDER BY start_time",
344 [$chd->{id}, "$startdate 00:00:00", "$enddate 23:59:59"] );
346 my $w = $self->CreateWriter( $chd, $date );
348 my $done = 0;
350 my $d1 = $sth->fetchrow_hashref();
352 if( (not defined $d1) or ($d1->{start_time} gt "$startdate 23:59:59") ) {
353 $self->CloseWriter( $w );
354 $sth->finish();
355 EndLogSection( $section );
356 return;
359 while( my $d2 = $sth->fetchrow_hashref() )
361 if( (not defined( $d1->{end_time})) or
362 ($d1->{end_time} eq "0000-00-00 00:00:00") )
364 # Fill in missing end_time on the previous entry with the start-time
365 # of the current entry
366 $d1->{end_time} = $d2->{start_time}
368 elsif( $d1->{end_time} gt $d2->{start_time} )
370 # The previous programme ends after the current programme starts.
371 # Adjust the end_time of the previous programme.
372 w "Adjusted endtime $d1->{end_time} => $d2->{start_time}";
374 $d1->{end_time} = $d2->{start_time}
378 $self->WriteEntry( $w, $d1, $chd )
379 unless $d1->{title} eq "end-of-transmission";
381 if( $d2->{start_time} gt "$startdate 23:59:59" ) {
382 $done = 1;
383 last;
385 $d1 = $d2;
388 if( not $done )
390 # The loop exited because we ran out of data. This means that
391 # there is no data for the day after the day that we
392 # wanted to export. Make sure that we write out the last entry
393 # if we know the end-time for it.
394 if( (defined( $d1->{end_time})) and
395 ($d1->{end_time} ne "0000-00-00 00:00:00") )
397 $self->WriteEntry( $w, $d1, $chd )
398 unless $d1->{title} eq "end-of-transmission";
400 else
402 w "Missing end-time for last entry"
403 unless $date gt $self->{LastRequiredDate};
407 $self->CloseWriter( $w );
408 $sth->finish();
410 EndLogSection( $section );
413 sub CreateWriter
415 my $self = shift;
416 my( $chd, $date ) = @_;
418 my $xmltvid = $chd->{xmltvid};
420 my $path = $self->{Root};
421 my $filename = $xmltvid . "_" . $date . ".js";
423 $self->{writer_filename} = $filename;
424 $self->{writer_entries} = 0;
425 # Make sure that writer_entries is always true if we don't require data
426 # for this date.
427 $self->{writer_entries} = "0 but true"
428 if( ($date gt $self->{LastRequiredDate}) or $chd->{empty_ok} );
430 my $data = [];
432 return $data;
435 sub CloseWriter
437 my $self = shift;
438 my( $data ) = @_;
440 my $path = $self->{Root};
441 my $filename = $self->{writer_filename};
442 delete $self->{writer_filename};
444 open( my $fh, ">$path$filename.new")
445 or die( "Json: cannot write to $path$filename.new" );
447 my $odata = {
448 jsontv => {
449 programme => $data,
453 my $js = JSON::XS->new;
454 $js->ascii( 1 );
455 $js->pretty( 1 );
456 $fh->print( $js->encode( $odata ) );
457 $fh->close();
459 system("gzip -f -n $path$filename.new");
460 if( -f "$path$filename.gz" )
462 system("diff $path$filename.new.gz $path$filename.gz > /dev/null");
463 if( $? )
465 move( "$path$filename.new.gz", "$path$filename.gz" );
466 p "Exported";
467 if( not $self->{writer_entries} )
469 w "Created empty file";
472 else
474 unlink( "$path$filename.new.gz" );
477 else
479 move( "$path$filename.new.gz", "$path$filename.gz" );
480 p "Generated";
481 if( not $self->{writer_entries} )
483 w "Empty file";
488 sub WriteEntry
490 my $self = shift;
491 my( $data, $entry, $chd ) = @_;
493 $self->{writer_entries}++;
495 my $start_time = create_dt( $entry->{start_time}, "UTC" );
496 my $end_time = create_dt( $entry->{end_time}, "UTC" );
498 my $d = {
499 channel => $chd->{xmltvid},
500 start => $start_time->strftime( "%s" ),
501 stop => $end_time->strftime( "%s" ),
502 title => { $chd->{sched_lang}, $entry->{title} }
505 $d->{desc} = { $chd->{sched_lang} => $entry->{description} }
506 if defined( $entry->{description} ) and $entry->{description} ne "";
508 $d->{'subTitle'} = { $chd->{sched_lang} => $entry->{subtitle} }
509 if defined( $entry->{subtitle} ) and $entry->{subtitle} ne "";
511 if( defined( $entry->{episode} ) and ($entry->{episode} =~ /\S/) )
513 my( $season, $ep, $part );
515 if( $entry->{episode} =~ /\./ )
517 ( $season, $ep, $part ) = split( /\s*\.\s*/, $entry->{episode} );
518 if( $season =~ /\S/ )
520 $season++;
523 else
525 print "Simple episode '$entry->{episode}'\n";
526 $ep = $entry->{episode};
529 if( $ep =~ /\S/ ) {
530 my( $ep_nr, $ep_max ) = split( "/", $ep );
531 $ep_nr++;
533 my $ep_text = $self->{lngstr}->{episode_number} . " $ep_nr";
534 $ep_text .= " " . $self->{lngstr}->{of} . " $ep_max"
535 if defined $ep_max;
536 $ep_text .= " " . $self->{lngstr}->{episode_season} . " $season"
537 if( $season );
539 $d->{'episodeNum'} = { xmltv_ns => norm($entry->{episode}),
540 onscreen => $ep_text };
542 else {
543 # This episode is only a segment and not a real episode.
544 # I.e. " . . 0/2".
545 $d->{'episodeNum'} = { xmltv_ns => norm($entry->{episode}) };
549 if( defined( $entry->{program_type} ) and ($entry->{program_type} =~ /\S/) )
551 push @{$d->{category}->{en}}, $entry->{program_type};
553 elsif( defined( $chd->{def_pty} ) and ($chd->{def_pty} =~ /\S/) )
555 push @{$d->{category}->{en}}, $chd->{def_pty};
558 if( defined( $entry->{category} ) and ($entry->{category} =~ /\S/) )
560 push @{$d->{category}->{en}}, $entry->{category};
562 elsif( defined( $chd->{def_cat} ) and ($chd->{def_cat} =~ /\S/) )
564 push @{$d->{category}->{en}}, $chd->{def_cat};
567 if( defined( $entry->{production_date} ) and
568 ($entry->{production_date} =~ /\S/) )
570 $d->{date} = substr( $entry->{production_date}, 0, 4 );
573 if( $entry->{aspect} ne "unknown" )
575 $d->{video} = { aspect => $entry->{aspect} };
578 if( $entry->{directors} =~ /\S/ )
580 $d->{credits}->{director} = [split( ", ", $entry->{directors})];
583 if( $entry->{actors} =~ /\S/ )
585 $d->{credits}->{actor} = [split( ", ", $entry->{actors})];
588 if( $entry->{writers} =~ /\S/ )
590 $d->{credits}->{writer} = [split( ", ", $entry->{writers})];
593 if( $entry->{adapters} =~ /\S/ )
595 $d->{credits}->{adapter} = [split( ", ", $entry->{adapters})];
598 if( $entry->{producers} =~ /\S/ )
600 $d->{credits}->{producer} = [split( ", ", $entry->{producers})];
603 if( $entry->{presenters} =~ /\S/ )
605 $d->{credits}->{presenter} = [split( ", ", $entry->{presenters})];
608 if( $entry->{commentators} =~ /\S/ )
610 $d->{credits}->{commentator} = [split( ", ", $entry->{commentators})];
613 if( $entry->{guests} =~ /\S/ )
615 $d->{credits}->{guest} = [split( ", ", $entry->{guests})];
618 push @{$data}, $d;
622 # Write description of all channels to channels.js.gz.
624 sub ExportChannelList
626 my( $self ) = @_;
627 my $ds = $self->{datastore};
629 my $channels = {};
631 my( $res, $sth ) = $ds->sa->Sql( "
632 SELECT * from channels
633 WHERE export=1
634 ORDER BY xmltvid" );
636 while( my $data = $sth->fetchrow_hashref() )
638 $channels->{$data->{xmltvid}} = {
639 "displayName" => {
640 $self->{Language} => $data->{display_name},
642 "baseUrl" => $self->{RootUrl},
645 if( $data->{logo} )
647 $channels->{$data->{xmltvid}}->{icon} = $self->{IconRootUrl} . $data->{xmltvid} . ".png";
651 my $fh = new IO::File("> $self->{Root}channels.js");
652 my $js = JSON::XS->new;
653 $js->ascii( 1 );
654 $js->pretty( 1 );
655 $fh->print( $js->encode( { jsontv => { channels => $channels } } ) );
656 $fh->close();
658 system("gzip -f -n $self->{Root}channels.js");
662 # Remove old js-files and js.gz-files.
664 sub RemoveOld
666 my( $self ) = @_;
668 my $ds = $self->{datastore};
670 # Keep files for the last week.
671 my $keep_date = DateTime->today->subtract( days => 8 )->ymd("-");
673 my @files = glob( $self->{Root} . "*" );
674 my $removed = 0;
676 foreach my $file (@files)
678 my($date) =
679 ($file =~ /(\d\d\d\d-\d\d-\d\d)\.js(\.gz){0,1}/);
681 if( defined( $date ) )
683 # Compare date-strings.
684 if( $date lt $keep_date )
686 unlink( $file );
687 $removed++;
692 p "Removed $removed files"
693 if( $removed > 0 );
698 ### Setup coding system
699 ## Local Variables:
700 ## coding: utf-8
701 ## End: