File Coverage

blib/lib/TV/Mediathek.pm
Criterion Covered Total %
statement 11 13 84.6
branch n/a
condition n/a
subroutine 5 5 100.0
pod n/a
total 16 18 88.8


line stmt bran cond sub pod time code
1             package TV::Mediathek;
2 1     1   283718 use Moose;
  1         1129582  
  1         8  
3             with 'MooseX::Log::Log4perl';
4              
5 1     1   7662 BEGIN { $Class::Date::WARNINGS = 0; }
6              
7 1     1   482087 use DBI;
  1         20034  
  1         74  
8 1     1   29688 use WWW::Mechanize;
  1         250031  
  1         44  
9 1     1   395 use XML::Twig;
  0            
  0            
10             use File::Util;
11             use File::Spec::Functions;
12             use YAML::Any qw/Dump/;
13              
14             use Data::Dumper;
15             use Class::Date qw/date/;
16             use Format::Human::Bytes;
17             use Lingua::DE::ASCII;
18              
19             use IO::Uncompress::AnyUncompress qw(anyuncompress $AnyUncompressError);
20              
21             use Video::Flvstreamer 0.03;
22             use TV::Mediathek::LoggerConfig;
23              
24             =head1 NAME
25              
26             TV::Mediathek - Access to Mediathek
27              
28             =head1 VERSION
29              
30             Version 0.03
31              
32             =cut
33              
34             our $VERSION = '0.03';
35              
36             =head1 SYNOPSIS
37              
38             List and download TV programs from German and French public TV Mediathek repositories.
39              
40             Based on (and using some resources from) the original Java MediathekView script:
41             http://zdfmediathk.sourceforge.net/index.html
42              
43             =head1 METHODS
44              
45             =head2 new
46              
47             Create new instance of TV::Mediathek
48              
49             =head3 PARAMS
50              
51             =over 2
52              
53             =item proxy <Str>
54              
55             Address of proxy server to use. e.g. http://proxy.example.com:8001/
56              
57             Default: undef
58              
59             =item socks <Str>
60              
61             Address of socks server to use for download.
62              
63             Default: undef
64              
65             =item timeout <Int>
66              
67             Timeout in seconds while downloading video
68              
69             Default: 10
70              
71             =item agent <Str>
72              
73             User agent string to use.
74              
75             Default: LWP::UserAgent default
76              
77             =item cookie_jar <Str>
78              
79             File to use as a cookie jar
80              
81             Default: undef
82              
83             =item mech <WWW::Mechanize>
84              
85             If you already have a WWW::Mechanize object, you can pass it here, otherwise one will be created for you
86              
87             =item flvstreamer_binary <Str>
88              
89             Path to flvstreamer binary
90              
91             Default: 'flvstreamer'
92              
93             =item cache_time <Int>
94              
95             Time in seconds to read from cached sources before refreshing.
96              
97             Default: 3600
98              
99             =item sql_cache_size <Int>
100              
101             Set memory in bytes which SQLite can use for caching.
102              
103             Default: 80000
104              
105             =item cache_dir <Str>
106              
107             Directory to cache files in
108              
109             Required. No Default.
110              
111             =item target_dir <Str>
112              
113             Directory to which video files should be saved
114              
115             =item date_in_filename <Bool>
116              
117             Should the date of the programme be included in the filename.
118              
119             With: 2011-10-22_Mit_offenen_Karten.avi
120              
121             Without: Mit_offenen_Karten.avi
122              
123             Default: 1
124              
125             =back
126              
127             =cut
128              
129             has 'proxy' => ( is => 'ro', isa => 'Str', );
130             has 'socks' => ( is => 'ro', isa => 'Str', );
131             has 'timeout' => ( is => 'ro', isa => 'Int', required => 1, default => 10 );
132             has 'agent' => ( is => 'ro', isa => 'Str', );
133             has 'cookie_jar' => ( is => 'ro', isa => 'Str', );
134             has 'date_in_filename' => ( is => 'ro', isa => 'Bool', required => 1, default => 1 );
135             has 'mech' => ( is => 'ro', isa => 'WWW::Mechanize', lazy_build => 1 );
136             has 'flvstreamer_binary' => ( is => 'ro', isa => 'Str', required => 1, default => '/usr/bin/flvstreamer', );
137              
138             # TODO: RCL 2011-09-27 Test for executable binary
139              
140             has 'cache_time' => ( is => 'ro', isa => 'Int', required => 1, default => 3600, );
141             has 'sqlite_cache_size' => ( is => 'ro', isa => 'Int', required => 1, default => 80000, ); # Allow sqlite to use 80MB in memory for caching
142             has 'cache_dir' => ( is => 'ro', isa => 'Str', required => 1, );
143              
144             # TODO: RCL 2011-09-27 Test for directory exists
145              
146             has 'target_dir' => ( is => 'ro', isa => 'Str', required => 1, );
147              
148             # TODO: RCL 2011-09-27 Test for directory exists
149              
150             # Some internals - do not need to be in pod documentation
151             has 'flv' => ( is => 'ro', isa => 'Video::Flvstreamer', lazy_build => 1 );
152             has 'cache_files' => ( is => 'ro', isa => 'HashRef', lazy_build => 1 );
153             has 'dbh' => ( is => 'ro', isa => 'DBI::db', lazy_build => 1 );
154             has 'file_util' => (
155             is => 'ro',
156             isa => 'File::Util',
157             required => 1,
158             lazy => 1,
159             default => sub { File::Util->new() },
160             );
161              
162             # Things to be done after the object has been instanciated
163             after 'new' => sub {
164              
165             # In case a logger hasn't been created elsewhere, this will initialise the default logger
166             # for the context
167             # It uses init_once so existing configurations won't be clobbered
168             my $logger_config = TV::Mediathek::LoggerConfig->new();
169             $logger_config->init_logger();
170             };
171              
172             # Build the WWW::Mechanize object
173             sub _build_mech {
174             my $self = shift;
175              
176             my $mech = WWW::Mechanize->new();
177             $mech->proxy( [ 'http', 'ftp' ], $self->proxy ) if ( $self->proxy );
178             $mech->agent( $self->agent ) if ( $self->agent );
179             $mech->cookie_jar( { file => $self->cookie_jar } ) if ( $self->cookie_jar );
180             return $mech;
181             }
182              
183             # Build the Video::Flvstreamer object
184             sub _build_flv {
185             my $self = shift;
186              
187             # TODO: RCL 2011-09-27 Chang to hash rather than hashref when Flvstreamer updated
188             return Video::Flvstreamer->new(
189             {
190             target_dir => $self->target_dir,
191             timeout => $self->timeout,
192             flvstreamer => $self->flvstreamer_binary,
193             socks => $self->socks,
194             debug => $self->log->is_debug(),
195             }
196             );
197              
198             }
199              
200             # Create a hashref of the paths for the various cache files
201             sub _build_cache_files {
202             my $self = shift;
203              
204             my %cache_files = (
205             sources => catfile( $self->cache_dir, 'sources.xml' ),
206             media => catfile( $self->cache_dir, 'media.xml' ),
207             media_zip => catfile( $self->cache_dir, 'media.zip' ),
208             db => catfile( $self->cache_dir, 'mediathek.db' ),
209             );
210             return \%cache_files;
211             }
212              
213             # Create the database handle to the SQLite database
214             sub _build_dbh {
215             my $self = shift;
216              
217             if ( !-f $self->cache_files->{db} ) {
218             $self->init_db();
219             }
220              
221             my $dbh = DBI->connect( "dbi:SQLite:dbname=" . $self->cache_files->{db}, "", "" );
222             if ( !$dbh ) {
223             die( "DB could not be initialised: #!" );
224             }
225              
226             # Make UTF compatible
227             $dbh->{sqlite_unicode} = 1;
228              
229             # turning synchronous off makes SQLite /much/ faster!
230             # It might also be responsible for race conditions where a read doesn't see a write which has just happened...
231             $dbh->do( "PRAGMA synchronous=OFF" );
232             $dbh->do( "PRAGMA cache_size=" . $self->sqlite_cache_size );
233             return $dbh;
234             }
235              
236             =head2 refresh_sources
237              
238             Download the sources into the sources table in the databse. All current entries are deleted from the
239             table, and the news entries are added
240              
241             =cut
242             sub refresh_sources {
243             my $self = shift;
244              
245             my $f = File::Util->new();
246              
247             # Give some debug info about the cache file
248             if ( $self->log->is_debug() && $self->cache_files->{sources} ) {
249             $self->log->debug( "Cached sources file " . ( -f $self->cache_files->{sources} ? 'exists' : 'does not exist' ) );
250             if ( -f $self->cache_files->{sources} ) {
251             $self->log->debug(
252             "Cached sources file is " . ( time() - $self->file_util->created( $self->cache_files->{sources} ) ) . 's old' );
253             }
254             }
255              
256             if ( !-f $self->cache_files->{sources}
257             || ( time() - $self->file_util->created( $self->cache_files->{sources} ) > $self->cache_time ) )
258             {
259             $self->log->debug( "Loading sources from internet" );
260             $self->get_url_to_file( 'http://zdfmediathk.sourceforge.net/update.xml', $self->cache_files->{sources} );
261             }
262             $self->log->debug( "Sources XML file is " . Format::Human::Bytes::base10( $self->file_util->size( $self->cache_files->{sources} ) ) );
263              
264             $self->log->debug( "Deleting sources table in db" );
265             my $sql = 'DELETE FROM sources';
266             my $sth = $self->dbh->prepare( $sql );
267             $sth->execute;
268              
269             # Prepare the Twig handler and graft in the database statement handler for inserting the new values
270             my $t = XML::Twig->new( twig_handlers => { Server => \&_source_to_db, }, );
271             $sql = 'INSERT INTO sources ( url, time, tried ) VALUES( ?, ?, 0 )';
272             $sth = $self->dbh->prepare( $sql );
273             $t->{mediathek_sth} = $sth;
274              
275             $self->log->debug( sprintf "Parsing source XML: %s", $self->cache_files->{sources} );
276             $t->parsefile( $self->cache_files->{sources} );
277             $self->log->debug( "Finished parsing source XML" );
278             $t->purge;
279             $sth->finish;
280             }
281              
282             # Private XML::Twig twig handler method to parse the source XML file and insert the results
283             # into the database
284             sub _source_to_db {
285             my ( $t, $section ) = @_;
286              
287             my %values;
288             ###FIXME - get all children, not just by name
289             foreach my $key ( qw/Download_Filme_1 Datum Zeit/ ) {
290             my $element = $section->first_child( $key );
291             if ( $element ) {
292             $values{$key} = $element->text();
293             }
294             }
295             my ( $day, $month, $year ) = split( /\./, $values{Datum} );
296             my ( $hour, $min, $sec ) = split( /:/, $values{Zeit} );
297             my $date = Class::Date->new( [ $year, $month, $day, $hour, $min, $sec ] );
298             $t->{mediathek_sth}->execute( $values{Download_Filme_1}, $date );
299             }
300              
301             =head2 refresh_media
302              
303             Refresh the media listing.
304             This will try each of the sources from the sources table in the database, ordered by time (youngest first)
305             and if possible download and import the resulting XML into the database.
306             Prior to import into the database, all existing data from the channels, themes, media and map_media tables
307             will be deleted.
308              
309             =cut
310             sub refresh_media {
311             my ( $self ) = @_;
312              
313             $self->refresh_sources();
314              
315             # Give some debug info about the cache file
316             if ( $self->log->is_debug() && $self->cache_files->{media} ) {
317             $self->log->debug(
318             sprintf "Cached media file %s %s",
319             ( $self->cache_files->{media} ),
320             ( -f $self->cache_files->{media} ? 'exists' : 'does not exist' )
321             );
322             if ( -f $self->cache_files->{media} ) {
323             $self->log->debug( sprintf "Cached media file is %us old",
324             ( time() - $self->file_util->created( $self->cache_files->{media} ) ) );
325             }
326             }
327              
328             if ( !-f $self->cache_files->{media}
329             || ( time() - $self->file_util->created( $self->cache_files->{media} ) > $self->cache_time ) )
330             {
331              
332             my $sql = 'SELECT id, url, time FROM sources WHERE tried==0 ORDER BY time DESC LIMIT 1';
333             my $sth_select = $self->dbh->prepare( $sql );
334             $sql = 'UPDATE sources SET tried=1 WHERE url=?';
335             my $sth_update = $self->dbh->prepare( $sql );
336             my $got_media = undef;
337              
338             do {
339             $sth_select->execute();
340             my $row = $sth_select->fetchrow_hashref();
341              
342             if ( !$row ) {
343             die( "No url found in sources table" );
344             }
345              
346             $self->log->debug( "Getting media from internet: $row->{url} ($row->{time})" );
347             $self->get_url_to_file( $row->{url}, $self->cache_files->{media_zip} );
348             $self->log->debug(
349             "Compressed file is " . Format::Human::Bytes::base10( $self->file_util->size( $self->cache_files->{media_zip} ) ) );
350              
351             $self->log->debug( "Uncompressing media..." );
352             my $media_xml;
353              
354             # Uncompress the file to an the XML string
355             if ( !anyuncompress $self->cache_files->{media_zip} => $self->cache_files->{media} ) {
356             $self->log->warn( $AnyUncompressError );
357             $sth_update->execute( $row->{url} );
358              
359             # next does not work in do/while loop...
360             } else {
361             $got_media = 1;
362             }
363             } while ( !$got_media );
364             $sth_select->finish();
365             $sth_update->finish();
366             }
367             $self->log->debug( "Media XML file is " . Format::Human::Bytes::base10( $self->file_util->size( $self->cache_files->{media} ) ) );
368              
369             $self->log->debug( "Deleting media tables in db" );
370             $self->dbh->do( 'DELETE FROM channels' );
371             $self->dbh->do( 'DELETE FROM themes' );
372             $self->dbh->do( 'DELETE FROM map_media' );
373             $self->dbh->do( 'DELETE FROM media' );
374              
375             my $t = XML::Twig->new( twig_handlers => { Filme => \&_media_to_db, }, );
376              
377             # Prepare the statement handlers
378             my $sths = {};
379             my $sql =
380             'INSERT OR IGNORE INTO media '
381             . '( nr, filename, title, date, url, url_auth, url_hd, url_org, url_rtmp, url_theme ) '
382             . 'VALUES( ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )';
383             $sths->{ins_media} = $self->dbh->prepare( $sql );
384              
385             $sql = 'INSERT OR IGNORE INTO channels ( channel ) VALUES( ? )';
386             $sths->{ins_channel} = $self->dbh->prepare( $sql );
387              
388             $sql = 'INSERT OR IGNORE INTO themes ( channel_id, theme ) VALUES( ?, ? )';
389             $sths->{ins_theme} = $self->dbh->prepare( $sql );
390              
391             $sql = 'INSERT OR IGNORE INTO map_media ( media_id, theme_id ) VALUES( ?, ? )';
392             $sths->{ins_map_media} = $self->dbh->prepare( $sql );
393              
394             $sql = 'SELECT id AS channel_id FROM channels WHERE channel=?';
395             $sths->{sel_channel_id} = $self->dbh->prepare( $sql );
396              
397             $sql = 'SELECT id AS theme_id FROM themes WHERE channel_id=? AND theme=?';
398             $sths->{sel_theme_id} = $self->dbh->prepare( $sql );
399              
400             $sql = 'SELECT id AS media_id FROM media WHERE url=?';
401             $sths->{sel_media_id} = $self->dbh->prepare( $sql );
402              
403             $t->{mediathek_sths} = $sths;
404             $t->{mediathek_logger} = $self->log;
405             $t->{mediathek_count_inserts} = 0;
406              
407             $self->log->debug( sprintf "Parsing media XML: %s", $self->cache_files->{media} );
408             $t->parsefile( $self->cache_files->{media} );
409             $self->log->debug( "Finished parsing media XML" );
410             $t->purge;
411              
412             # Clean up all of the handlers
413             foreach ( keys( %$sths ) ) {
414             $sths->{$_}->finish;
415             }
416              
417             $t->{mediathek_sths} = undef;
418             $t->{mediathek_logger} = undef;
419             $t->{mediathek_count_inserts} = undef;
420              
421             $self->log->debug( __PACKAGE__ . "->refresh_media end" );
422             }
423              
424             # Local XML::Twig twig handler method for importing media to the database.
425             # Expects to receive a twig with the required statement handlers initialised.
426             # <Filme><Nr>0000</Nr><Sender>3Sat</Sender><Thema>3sat.full</Thema><Titel>Mediathek-Beiträge</Titel><Datum>04.09.2011</Datum><Zeit>19:23:11</Zeit><Url>http://wstreaming.zdf.de/3sat/veryhigh/110103_jazzbaltica2010ceu_musik.asx</Url><UrlOrg>http://wstreaming.zdf.de/3sat/300/110103_jazzbaltica2010ceu_musik.asx</UrlOrg><Datei>110103_jazzbaltica2010ceu_musik.asx</Datei><Film-alt>false</Film-alt></Filme>
427             sub _media_to_db {
428             my ( $t, $section ) = @_;
429              
430             my %values;
431             ###FIXME - get all children, not just by name
432             foreach my $key ( qw/Datei Nr Sender Thema Titel Datum Url UrlOrg UrlAuth UrlHD UrlRTMP UrlThema/ ) {
433             my $element = $section->first_child( $key );
434             if ( $element ) {
435             $values{$key} = $element->text();
436             }
437             }
438              
439             foreach ( qw/Url Sender Thema Titel/ ) {
440             if ( !$values{$_} ) {
441             warn( "$_ not defined for entry $values{Nr}. Skipping.\n" );
442             return undef;
443             }
444             }
445              
446             my ( $row, $sql );
447             my $sths = $t->{mediathek_sths};
448             $sths->{ins_channel}->execute( $values{Sender} );
449              
450             $sths->{sel_channel_id}->execute( $values{Sender} );
451             $row = $sths->{sel_channel_id}->fetchrow_hashref();
452             if ( !$row ) {
453             die( "Could not find channel_id for $values{Sender} at entry number $values{Nr}" );
454             }
455             my $channel_id = $row->{channel_id};
456              
457             $sths->{ins_theme}->execute( $channel_id, $values{Thema} );
458             $sths->{sel_theme_id}->execute( $channel_id, $values{Thema} );
459             $row = $sths->{sel_theme_id}->fetchrow_hashref();
460             if ( !$row ) {
461             die( "Could not find themeid for Theme \"$values{Thema}\" and "
462             . "Channel \"$values{Sender}\" (channel_id $channel_id) at entry number $values{Nr}" );
463             }
464             my $theme_id = $row->{theme_id};
465              
466             local $Class::Date::DATE_FORMAT = "%Y-%m-%d";
467             my $date;
468             if ( $values{Datum} ) {
469             my ( $day, $month, $year ) = split( /\./, $values{Datum} );
470             $date = Class::Date->new( [ $year, $month, $day ] );
471             } else {
472              
473             #using current time as default
474             $date = date( time );
475             }
476              
477             # Add the media data
478             #( filename, title, datum, url, url_auth, url_hd, url_org, url_rtmp, url_theme )
479             $sths->{ins_media}->execute(
480             $values{Nr}, $values{Datei}, $values{Titel}, $date, $values{Url},
481             $values{UrlAuth}, $values{UrlHD}, $values{UrlOrg}, $values{UrlRTMP}, $values{UrlThema}
482             );
483             $sths->{sel_media_id}->execute( $values{Url} );
484             $row = $sths->{sel_media_id}->fetchrow_hashref();
485             if ( !$row ) {
486             die( "Could not find media with url $values{Url}" );
487             }
488             my $media_id = $row->{media_id};
489              
490             # And lastly add the mapping
491             $sths->{ins_map_media}->execute( $media_id, $theme_id );
492              
493             $section->purge;
494             }
495              
496             =head2 count_videos
497              
498             Count the number of videos matching your search criteria.
499              
500             TODO: RCL 2011-10-28 Documentation
501              
502             =cut
503             sub count_videos {
504             my ( $self, $args ) = @_;
505             my $sql =
506             'SELECT COUNT( DISTINCT( m.id ) ) AS count_videos '
507             . 'FROM media m '
508             . 'JOIN map_media mm ON m.id=mm.media_id '
509             . 'JOIN themes t ON t.id=mm.theme_id '
510             . 'JOIN channels c ON c.id=t.channel_id';
511              
512             my ( @where_sql, @where_args );
513             if ( $args->{channel} ) {
514             push( @where_sql, 'c.channel=?' );
515             push( @where_args, $args->{channel} );
516             }
517             if ( $args->{theme} ) {
518             push( @where_sql, 't.theme=?' );
519             push( @where_args, $args->{theme} );
520             }
521             if ( $args->{title} ) {
522             push( @where_sql, 'm.title=?' );
523             push( @where_args, $args->{title} );
524             }
525             if ( $args->{date} ) {
526             my $modifier = substr( $args->{date}, 0, 1 );
527             my $date = substr( $args->{date}, 1 );
528             if ( $modifier =~ m/[<>=]/ ) {
529             push( @where_sql, 'm.date' . $modifier . '?' );
530             push( @where_args, $date );
531             } else {
532             $self->log->warn( "Unsupported date modifier: $modifier" );
533             }
534             }
535             if ( scalar( @where_sql ) > 0 ) {
536             $sql .= ' WHERE ' . join( ' AND ', @where_sql );
537             }
538              
539             $self->log->debug( "SQL: $sql" );
540             $self->log->debug( "SQL Args: " . join( ', ', @where_args ) );
541             my $sth = $self->dbh->prepare( $sql );
542             $sth->execute( @where_args );
543             my $row = $sth->fetchrow_hashref();
544             return $row->{count_videos};
545             }
546              
547             =head2 list
548              
549             List the videos matching your search criteria.
550              
551             TODO: RCL 2011-10-28 Document search options
552              
553             =cut
554             sub list {
555             my ( $self, $args ) = @_;
556              
557             my ( @joins, @selects, @where_sql, @where_args );
558             push( @selects, 'c.channel' );
559             push( @selects, 'c.id AS channel_id' );
560             if ( $args->{channel} ) {
561             if ( $args->{channel} =~ m/\*/ ) {
562             $args->{channel} =~ s/\*/\%/g;
563             push( @where_sql, 'c.channel LIKE ?' );
564             } else {
565             push( @where_sql, 'c.channel=?' );
566             }
567             push( @where_args, $args->{channel} );
568             }
569             if ( $args->{list_all} || $args->{channel} || $args->{theme} || $args->{title} || $args->{media_id} ) {
570             push( @joins, 'JOIN themes t ON c.id=t.channel_id' );
571             push( @selects, 't.theme' );
572             push( @selects, 't.id AS theme_id' );
573             }
574             if ( $args->{theme} ) {
575             if ( $args->{theme} =~ m/\*/ ) {
576             $args->{theme} =~ s/\*/\%/g;
577             push( @where_sql, 't.theme LIKE ?' );
578             } else {
579             push( @where_sql, 't.theme=?' );
580             }
581             push( @where_args, $args->{theme} );
582             }
583             if ( $args->{list_all} || $args->{title} || $args->{theme} || $args->{media_id} ) {
584             push( @selects, 'm.id AS media_id' );
585             push( @selects, 'm.*' );
586             push( @joins, 'JOIN map_media mm ON mm.theme_id=t.id' );
587             push( @joins, 'JOIN media m ON mm.media_id=m.id' );
588             }
589             if ( $args->{title} ) {
590             if ( $args->{title} =~ m/\*/ ) {
591             $args->{title} =~ s/\*/\%/g;
592             push( @where_sql, 'm.title LIKE ?' );
593             } else {
594             push( @where_sql, 'm.title=?' );
595             }
596             push( @where_args, $args->{title} );
597             }
598             if ( $args->{media_id} ) {
599             push( @where_sql, 'm.id=?' );
600             push( @where_args, $args->{media_id} );
601             }
602             if ( $args->{date} ) {
603             my $modifier = substr( $args->{date}, 0, 1 );
604             my $date = substr( $args->{date}, 1 );
605             if ( $modifier =~ m/[<>=]/ ) {
606             push( @where_sql, 'm.date' . $modifier . '?' );
607             push( @where_args, $date );
608             } else {
609             $self->log->warn( "Unsupported date modifier: $modifier" );
610             }
611             }
612              
613             my $sql = 'SELECT ' . join( ', ', @selects ) . ' FROM channels c ' . join( ' ', @joins );
614             if ( scalar( @where_sql ) > 0 ) {
615             $sql .= ' WHERE ' . join( ' AND ', @where_sql );
616             }
617              
618             $self->log->debug( "SQL: $sql" );
619             $self->log->debug( "SQL Args: " . join( ', ', @where_args ) );
620              
621             my $sth = $self->dbh->prepare( $sql );
622             $sth->execute( @where_args );
623             my $row;
624             my $out;
625             while ( $row = $sth->fetchrow_hashref() ) {
626             $out->{channels}->{ $row->{channel_id} } = $row->{channel};
627             if ( $row->{theme_id} ) {
628             $out->{themes}->{ $row->{theme_id} } = {
629             theme => $row->{theme},
630             channel_id => $row->{channel_id}
631             };
632             }
633             if ( $row->{media_id} ) {
634             $out->{media}->{ $row->{media_id} } = {
635             title => $row->{title},
636             date => $row->{date},
637             theme_id => $row->{theme_id},
638             url => $row->{url}
639             };
640             }
641             }
642             return $out;
643             }
644              
645             =head2 get_videos
646              
647             Download (to the target_dir) the videos matching your search criteria.
648              
649             TODO: RCL 2011-10-28 Document search options
650              
651             =cut
652             sub get_videos {
653             my ( $self, $args ) = @_;
654              
655             $args->{list_all} = 1;
656             my $list = $self->list( $args );
657              
658             # TODO: RCL 2011-11-04 -1 is not a safe or intuitively understood value for "no abo"
659             my $abo_id = $args->{abo_id} || -1;
660              
661             if ( !$list->{media} ) {
662             $self->log->warn( "No videos found matching your search..." );
663             }
664              
665             $self->log->info( "Found " . scalar( keys( %{ $list->{media} } ) ) . " videos to download" );
666              
667             my $sth = $self->dbh->prepare( 'INSERT INTO downloads ( abo_id, media_id, path, url, time ) ' . 'VALUES( ?, ?, ?, ?, ? )' );
668              
669             foreach my $media_id ( sort( keys( %{ $list->{media} } ) ) ) {
670             my $video = $list->{media}->{$media_id};
671             my $theme = to_ascii( $list->{themes}->{ $video->{theme_id} }->{theme} );
672             my $channel = to_ascii( $list->{channels}->{ $list->{themes}->{ $video->{theme_id} }->{channel_id} } );
673             my $date = $list->{media}->{$media_id}->{date};
674             my $target_dir = catfile( $self->target_dir, $channel, $theme );
675             $target_dir =~ s/\s/_/g;
676             $self->log->debug( "Target dir: $target_dir" );
677             if ( !-d $target_dir ) {
678             if ( !$self->file_util->make_dir( $target_dir ) ) {
679             die( "Could not make target dir: $target_dir" );
680             }
681             }
682             my $title = to_ascii( $video->{title} );
683              
684             #TODO: find a module which replaces all bad-in-filename characters
685             $title =~ s/\(/_/g;
686             $title =~ s/\)/_/g;
687             $title =~ s/\//_/g;
688             $title =~ s/\W/_/g;
689             if( $self->date_in_filename ){
690             $title = sprintf( '%s_%s', $date, $title );
691             }
692            
693             my $target_path = catfile( $target_dir, $title . '.avi' );
694             # TODO: RCL 2011-11-04 If this is an abo, check if it has already been downloaded downloaded
695             if ( $self->requires_download( { path => $target_path } ) && !$args->{test} ) {
696             $self->log->info(
697             sprintf( "Getting %s%s || %s || %s", ( $args->{test} ? '>>TEST<< ' : '' ), $channel, $theme, $video->{title} ) );
698             if ( $video->{url} =~ /^http/ ) {
699             my @args = ( "/usr/bin/mplayer", "-playlist", to_ascii( $video->{url} ), "-dumpstream", "-dumpfile", $target_path );
700             $self->log->debug( sprintf( "Running: %s", "@args" ) );
701             system( @args ) == 0 or $self->log->warn( sprintf( "%s", $! ) );
702             } else {
703              
704             # Sometimes the url is not just a url, it's a whole load of arguments tailored for a flvstreamer
705             # download.
706             # e.g. --host vod.daserste.de --app ardfs/ --playpath mp4:videoportal/mediathek/W+wie+Wissen/c_150000/156934/format168877.f4v --resume -q -o /tmp/mediathek_target/ARD/W_wie_Wissen/Erblindung_durch_Parasiten_Infektion.avi
707             # These have to be passed as individual arguments, otherwise flvstreamer will receive the whole
708             # string as one argument and will not be able to parse it.
709             my @video_args = split( ' ', $video->{url} );
710             $self->flv->get_raw( \@video_args, $target_path );
711             }
712              
713             if ( -e $target_path ) {
714             if ( !defined $sth->execute( $abo_id, $media_id, $target_path, $video->{url}, date( time ) ) ) {
715             $self->log->error( "Could not insert downloaded media: $DBI::errstr" );
716             }
717             } else {
718             $self->log->warn( sprintf( "Could not download %s", $video->{title} ) );
719             }
720             }
721             }
722             $sth->finish();
723             }
724              
725             =head2 add_abo
726              
727             TODO: RCL 2012-01-26 Document
728              
729             =cut
730             sub add_abo {
731             my ( $self, $args ) = @_;
732              
733             if ( !$args->{channel} && !$args->{theme} && !$args->{title} ) {
734             $self->log->warn( "Abo would download all media. Please specify a filter.\n" );
735             return undef;
736             }
737              
738             my $sth = $self->dbh->prepare( 'INSERT INTO abos ( name, channel, theme, ' . 'title, expires_after) VALUES( ?, ?, ?, ?, ? )' );
739             if ( $sth->execute( $args->{name}, $args->{channel}, $args->{theme}, $args->{title}, $args->{expires} ) ) {
740             $self->log->info( "Abo \"$args->{name}\" successfully added." );
741             } else {
742             $self->log->error( "Abo not added: $DBI::errstr" );
743             }
744             $sth->finish();
745             }
746              
747             =head2 del_abo
748              
749             TODO: RCL 2012-01-26 Documentation
750              
751             =cut
752             sub del_abo {
753             my ( $self, $args ) = @_;
754              
755             my $result = $self->dbh->do( "DELETE FROM abos WHERE name='$args->{name}'" );
756             if ( $result == 1 ) {
757             $self->log->info( "Abo \"$args->{name}\" successfully deleted." );
758             } elsif ( $result == 0 ) {
759             $self->log->warn( "Abo \"$args->{name}\" not found." );
760             } elsif ( !defined $result ) {
761             $self->log->error( "Abo not deleted: $DBI::errstr" );
762             }
763             }
764              
765             =head2 get_abos
766              
767             TODO: RCL 2012-01-26 Documentation
768              
769             =cut
770             sub get_abos {
771             my ( $self ) = @_;
772              
773             my $arr_ref = $self->dbh->selectall_arrayref( "SELECT name FROM abos ORDER BY name" );
774             if ( !defined $arr_ref ) {
775             $self->log->error( "An error occurred while retrieving abos: $DBI::errstr" );
776             return ();
777             }
778              
779             return @{$arr_ref};
780             }
781              
782             =head2 run_abo
783              
784             TODO: RCL 2012-01-26 Documentation
785              
786             =cut
787             sub run_abo {
788             my ( $self, $args ) = @_;
789              
790             my $arr_ref = $self->dbh->selectall_arrayref( "SELECT * FROM abos WHERE name='$args->{name}'", { Slice => {} } );
791             if ( !defined $arr_ref ) {
792             $self->log->error( "An error occurred while retrieving abo \"$args->{name}\": $DBI::errstr" );
793             } elsif ( @{$arr_ref} == 0 ) {
794             $self->log->warn( "Abo \"$args->{name}\" not found." );
795             } else {
796             my $abo = @{$arr_ref}[0];
797             if ( $abo->{expires_after} > 0 ) {
798             $self->log->debug( "Abo \"$abo->{name}\" has expiry date. Checking expired downloads..." );
799             $self->expire_downloads( { abo_id => $abo->{abo_id}, expires_after => $abo->{expires_after} } );
800             }
801             $self->log->debug( "Abo \"$abo->{name}\" has no expiry date. Proceeding with downloads..." );
802             $self->get_videos(
803             {
804             channel => $abo->{channel},
805             theme => $abo->{theme},
806             title => $abo->{title},
807             abo_id => $abo->{abo_id}
808             }
809             );
810             }
811             }
812              
813             =head2 get_downloaded_media
814              
815             TODO: RCL 2012-01-26 Documentation
816              
817             =cut
818             sub get_downloaded_media {
819             my ( $self ) = @_;
820              
821             my $sql =
822             "SELECT abos.name, downloads.media_id, downloads.path, downloads.time "
823             . "FROM downloads LEFT OUTER JOIN abos ON abos.abo_id=downloads.abo_id WHERE "
824             . "downloads.expired=0 ORDER BY downloads.time";
825              
826             my $arr_ref = $self->dbh->selectall_arrayref( $sql, { Slice => {} } );
827             if ( !defined $arr_ref ) {
828             $self->log->error( "An error occurred while retrieving media: $DBI::errstr" );
829             return ();
830             }
831              
832             return @{$arr_ref};
833             }
834              
835             =head2 del_downloaded
836              
837             TODO: RCL 2012-01-26 Documentation
838              
839             =cut
840             sub del_downloaded {
841             my ( $self, $args ) = @_;
842              
843             my $arr_ref = $self->dbh->selectall_arrayref( "SELECT path FROM downloads WHERE " . "media_id=$args->{id}", { Slice => {} } );
844             if ( !defined $arr_ref ) {
845             $self->log->error( "An error occurred while retrieving media: $DBI::errstr" );
846             } elsif ( @{$arr_ref} > 1 ) {
847             $self->log->error( "Database inconsistency: media refers to several downloads." );
848             } elsif ( @{$arr_ref} == 0 ) {
849             $self->log->warn( "Media not found." );
850             } else {
851             my $file = ${$arr_ref}[0]->{path};
852             if ( unlink $file ) {
853             if ( defined $self->dbh->do( "DELETE FROM downloads WHERE media_id=$args->{id}" ) ) {
854             $self->log->info( "Media \"$file\" successfully deleted." );
855             } else {
856             $self->log->error( "Media \"$file\" deleted, but not removed from database: $DBI::errstr" );
857             }
858             } else {
859             $self->log->error( "Could not delete file: $file" );
860             }
861             }
862             }
863              
864             =head2 expire_downloads
865              
866             TODO: RCL 2012-01-26 Documentation
867              
868             =cut
869             sub expire_downloads {
870             my ( $self, $args ) = @_;
871              
872             my $arr_ref =
873             $self->dbh->selectall_arrayref( "SELECT * FROM downloads WHERE " . "abo_id=$args->{abo_id} AND expired=0 ", { Slice => {} } );
874             if ( !defined $arr_ref ) {
875             $self->log->error( "Could not retrieve expired downloads: $DBI::errstr" );
876             } elsif ( @{$arr_ref} > 0 ) {
877             foreach my $download ( @$arr_ref ) {
878             my $now = date( time );
879             my $exp = "$args->{expires_after}D";
880             my $expires_on = date( $download->{time} ) + $exp;
881             if ( $now > $expires_on ) {
882             if ( unlink $download->{path} ) {
883             if ( defined $self->dbh->do( "UPDATE downloads SET expired=1 WHERE path='$download->{path}'" ) ) {
884             $self->log->info( "$download->{path} expired on $expires_on. Deleted." );
885             } else {
886             $self->log->error( "Media \"$download->{path}\" deleted, but not removed from database: $DBI::errstr" );
887             }
888             } else {
889             $self->log->error( "Could not delete file: $download->{path}" );
890             }
891             } else {
892             $self->log->debug( "$download->{path} expires on $expires_on. Not deleting." );
893             }
894             }
895             } else {
896             $self->log->debug( "All downloads already expired." );
897             }
898             }
899              
900             =head2 requires_download
901              
902             TODO: RCL 2012-01-26 Documentation
903              
904             =cut
905             sub requires_download {
906             my ( $self, $args ) = @_;
907              
908             if ( -e $args->{path} ) {
909             $self->log->info( "Media already downloaded: $args->{path}" );
910             return 0;
911             }
912              
913             my $arr_ref = $self->dbh->selectall_arrayref( "SELECT expired FROM downloads WHERE " . "path='$args->{path}'" );
914             if ( defined $arr_ref ) {
915             if ( @{$arr_ref} == 0 ) {
916             return 1;
917             }
918              
919             my $expired = @{$arr_ref}[0];
920             if ( @{$expired}[0] == 1 ) {
921             $self->log->info( "Media $args->{path} expired. Not downloading." );
922             return 0;
923             }
924             } else {
925             $self->log->error( "Could not identify required downloads: $DBI::errstr" );
926             }
927              
928             return 1;
929             }
930              
931             =head2 get_url_to_file
932              
933             TODO: RCL 2012-01-26 Documentation
934              
935             =cut
936             sub get_url_to_file {
937             my ( $self, $url, $filename ) = @_;
938             $self->log->debug( "Saving $url to $filename" );
939             my $response = $self->mech->get( $url );
940             if ( !$response->is_success ) {
941             die( "get failed: " . $response->status_line . "\n" );
942             }
943              
944             my $write_mode = '>';
945             my $binmode = 1;
946             if ( $filename =~ m/\.xml$/ ) {
947             $write_mode .= ':encoding(UTF-8)';
948             $binmode = undef;
949             }
950              
951             if ( !open( FH, $write_mode, $filename ) ) {
952             die( "Could not open file: $filename\n$!\n" );
953             }
954             if ( $binmode ) {
955             binmode( FH );
956             }
957             print FH $response->decoded_content;
958             close FH;
959             }
960              
961             =head2 init_db
962              
963             TODO: RCL 2012-01-26 Documentation
964              
965             =cut
966             sub init_db {
967             my ( $self ) = @_;
968             $self->log->debug( sprintf "got cache file for db: %s\n", $self->cache_files->{db} );
969              
970             if ( -f $self->cache_files->{db} ) {
971             $self->log->debug( "Deleting old database" );
972             unlink( $self->cache_files->{db} );
973             }
974             my $dbh = DBI->connect( "dbi:SQLite:dbname=" . $self->cache_files->{db}, "", "" );
975             if ( !$dbh ) {
976             die( "Could not connect to DB during init_db: $!" );
977             }
978             $self->log->debug( "Reading SQL file in" );
979              
980             require 'TV/Mediathek/CreateDB.pm';
981             my $sql_generator = TV::Mediathek::CreateDB->new( dbh => $dbh );
982             my $sql = $sql_generator->create_sql;
983              
984             my @commands = split( /;/, $sql );
985             foreach ( @commands ) {
986             $self->log->debug( "SQL: $_\n" );
987             $dbh->do( $_ );
988             }
989             $dbh->disconnect;
990             }
991              
992             =head1 AUTHOR
993              
994             Robin Clarke, C<< <perl at robinclarke.net> >>
995              
996             =head1 BUGS
997              
998             Please report any bugs or feature requests to L<https://github.com/robin13/mediathekp>
999              
1000             =head1 SUPPORT
1001              
1002             You can find documentation for this module with the perldoc command.
1003              
1004             perldoc TV::Mediathek
1005              
1006              
1007             You can also look for information at:
1008              
1009             =over 4
1010              
1011             =item * Github
1012              
1013             L<https://github.com/robin13/mediathekp>
1014              
1015             =item * Search CPAN
1016              
1017             L<http://search.cpan.org/dist/TV/Mediathek/>
1018              
1019             =back
1020              
1021              
1022             =head1 ACKNOWLEDGEMENTS
1023              
1024             Thanks to Michael Unterkalmsteiner for added functionality!
1025              
1026             =head1 LICENSE AND COPYRIGHT
1027              
1028             Copyright 2011 Robin Clarke.
1029              
1030             This program is free software; you can redistribute it and/or modify it
1031             under the terms of either: the GNU General Public License as published
1032             by the Free Software Foundation; or the Artistic License.
1033              
1034             See http://dev.perl.org/licenses/ for more information.
1035              
1036              
1037             =cut
1038              
1039             1;