3 import time
, datetime
, os
, re
4 import rtv_favourite
, rtv_utils
, rtv_programmeinfo
5 import rtv_propertiesfile
, rtv_selection
7 # TODO: delete old files:
8 # - scheduled recordings
12 MESSAGE_TIME_FORMAT
= "%H:%M on %a"
14 at_output_re
= re
.compile( "job (\d+) at .*\n" )
16 def priority_time_compare( pi1
, pi2
):
17 pi1Pri
= pi1
.get_priority()
18 pi2Pri
= pi2
.get_priority()
24 elif pi1
.startTime
< pi2
.startTime
:
26 elif pi1
.startTime
> pi2
.startTime
:
33 def __init__( self
, config
):
36 def sub_title_matters( self
, proginfo
):
37 return ( proginfo
.sub_title
is not None
38 and proginfo
.unique_subtitles
== True )
40 def add_to_old_progs_map( self
, dr
, fn
, ret
, delete_unused
):
41 if not fn
.endswith( ".rtvinfo" ):
44 full_path
= os
.path
.join( dr
, fn
)
45 proginfo
= rtv_programmeinfo
.ProgrammeInfo()
46 proginfo
.load( full_path
)
48 if not self
.sub_title_matters( proginfo
):
50 os
.remove( full_path
)
53 if proginfo
.title
not in ret
:
54 ret
[proginfo
.title
] = {}
56 if proginfo
.sub_title
not in ret
[proginfo
.title
]:
57 ret
[proginfo
.title
][proginfo
.sub_title
] = 1
59 def find_old_programmes_map( self
, old_dir
, converted_dir
):
62 dr_list
= os
.listdir( old_dir
)
64 self
.add_to_old_progs_map( old_dir
, fn
, ret
, True )
66 for (dirpath
, dirnames
, filenames
) in os
.walk( converted_dir
):
68 self
.add_to_old_progs_map( dirpath
, fn
, ret
, False )
72 def get_at_job( self
, at_output
):
74 m
= at_output_re
.match( ln
)
77 print ( "Unable to understand at command output '%s' - "
78 + "can't create a scheduled_events entry" ) % at_output
81 def print_already_recorded( self
, prog
):
82 print ( "Not recording '%s : %s' - we have recorded it in the past."
83 % ( prog
.title
, prog
.sub_title
) )
85 def print_recording_today( self
, prog
):
86 print ( "Not recording '%s : %s' - we are recording it today already."
87 % ( prog
.title
, prog
.sub_title
) )
89 def remove_already_recorded( self
, scheduler
):
90 old_dir
= os
.path
.join( self
.config
.recorded_progs_dir
, "old" )
91 converted_dir
= self
.config
.converted_progs_dir
92 old_progs_map
= scheduler
.find_old_programmes_map( old_dir
,
96 new_queue_sub_titles_map
= {}
98 for prog
in self
.record_queue
:
99 if ( prog
.title
in old_progs_map
and
100 prog
.sub_title
in old_progs_map
[prog
.title
] ):
101 self
.print_already_recorded( prog
)
102 elif ( prog
.title
in new_queue_sub_titles_map
and
103 prog
.sub_title
in new_queue_sub_titles_map
[prog
.title
] ):
104 self
.print_recording_today( prog
)
106 new_queue
.append( prog
)
107 if self
.sub_title_matters( prog
):
108 new_queue_sub_titles_map
[prog
.title
] = prog
.sub_title
110 self
.record_queue
= new_queue
113 def print_clash_priority_error( self
, losePi
, keepPi
):
114 print ( ("Not recording '%s' at %s - it clashes with '%s',"
115 + " which is higher priority (%d > %d).")
117 losePi
.startTime
.strftime(
118 MESSAGE_TIME_FORMAT
),
119 keepPi
.title
, keepPi
.get_priority(),
120 losePi
.get_priority() ) )
122 def print_clash_time_error( self
, losePi
, keepPi
):
123 print ( ("Not recording '%s' at %s - it clashes with '%s',"
124 + " which has the same priority (%d), but starts earlier"
127 losePi
.startTime
.strftime(
128 MESSAGE_TIME_FORMAT
),
129 keepPi
.title
, keepPi
.get_priority(),
130 keepPi
.startTime
.strftime(
131 MESSAGE_TIME_FORMAT
) ) )
133 def print_clash_same_time( self
, losePi
, keepPi
):
134 print ( ("Not recording '%s' at %s - it clashes with '%s',"
135 + " which has the same priority (%d). They start at"
136 + " the same time, so the one to record was chosen"
139 losePi
.startTime
.strftime(
140 MESSAGE_TIME_FORMAT
),
141 keepPi
.title
, keepPi
.get_priority() ) )
144 def remove_clashes( self
):
145 self
.record_queue
.sort( priority_time_compare
)
148 for pi1Num
in range( len( self
.record_queue
) ):
149 pi1
= self
.record_queue
[pi1Num
]
153 for pi2
in new_queue
:
155 if pi1
.clashes_with( pi2
):
160 new_queue
.append( pi1
)
162 if pi1
.get_priority() < clashed_with
.get_priority():
163 self
.print_clash_priority_error( pi1
, clashed_with
)
164 elif pi1
.startTime
> clashed_with
.startTime
:
165 self
.print_clash_time_error( pi1
, clashed_with
)
167 self
.print_clash_same_time( pi1
, clashed_with
)
169 self
.record_queue
= new_queue
171 def schedule_recordings( self
, queue
):
173 if len( queue
) == 0:
174 print "No programmes found to record."
177 rtv_utils
.ensure_dir_exists( self
.config
.recorded_progs_dir
)
178 rtv_utils
.ensure_dir_exists( self
.config
.scheduled_events_dir
)
179 rtv_utils
.ensure_dir_exists( self
.config
.recording_log_dir
)
181 for programmeInfo
in queue
:
183 if programmeInfo
.sub_title
is None:
186 st
= ": " + programmeInfo
.sub_title
188 print "Recording '%s%s' at '%s'" % ( programmeInfo
.title
,
189 st
, programmeInfo
.startTime
.strftime( MESSAGE_TIME_FORMAT
) )
191 filename
= rtv_utils
.prepare_filename( programmeInfo
.title
)
192 filename
+= programmeInfo
.startTime
.strftime( "-%Y-%m-%d_%H_%M" )
194 length_timedelta
= programmeInfo
.endTime
- programmeInfo
.startTime
195 length_in_seconds
= ( ( length_timedelta
.days
196 * rtv_utils
.SECS_IN_DAY
) + length_timedelta
.seconds
)
197 length_in_seconds
+= 60 * self
.config
.extra_recording_time_mins
199 sched_filename
= os
.path
.join( self
.config
.scheduled_events_dir
,
200 filename
+ ".rtvinfo" )
202 outfilename
= os
.path
.join( self
.config
.recorded_progs_dir
,
205 programmeInfo
.startTime
.strftime( "%H:%M %d.%m.%Y" ) )
206 at_output
= rtv_utils
.run_command_feed_input( cmds_array
,
207 self
.config
.record_start_command
% (
208 self
.channel_xmltv2tzap
.get_value( programmeInfo
.channel
),
209 outfilename
, length_in_seconds
, sched_filename
,
210 os
.path
.join( self
.config
.recording_log_dir
,
211 filename
+ ".log" ) ) )
212 at_job_start
= self
.get_at_job( at_output
)
214 if at_job_start
!= None:
215 programmeInfo
.atJob
= at_job_start
216 programmeInfo
.save( sched_filename
)
218 def sax_callback( self
, pi
):
219 for fav
in self
.favs_and_sels
:
220 if fav
.matches( pi
):
221 dtnow
= datetime
.datetime
.today()
222 if( pi
.startTime
> dtnow
223 and (pi
.startTime
- dtnow
).days
< 1 ):
225 if fav
.deleteAfterDays
:
226 pi
.deleteTime
= pi
.endTime
+ datetime
.timedelta(
227 float( fav
.deleteAfterDays
), 0 )
229 pi
.channel_pretty
= self
.channel_xmltv2tzap
.get_value(
231 if not pi
.channel_pretty
:
233 "Pretty channel name not found for channel %s"
236 pi
.priority
= fav
.priority
237 pi
.destination
= fav
.destination
238 pi
.unique_subtitles
= fav
.unique_subtitles
241 pi
.title
= fav
.real_title
243 self
.record_queue
.append( pi
)
246 def remove_scheduled_events( self
):
248 rtv_utils
.ensure_dir_exists( self
.config
.scheduled_events_dir
)
250 for fn
in os
.listdir( self
.config
.scheduled_events_dir
):
251 full_fn
= os
.path
.join( self
.config
.scheduled_events_dir
, fn
)
253 pi
= rtv_programmeinfo
.ProgrammeInfo()
258 at_job_start
= int( pi
.atJob
)
259 rtv_utils
.run_command( ( "atrm", str( at_job_start
) ) )
267 def delete_pending_recordings( self
):
268 rtv_utils
.ensure_dir_exists( self
.config
.recorded_progs_dir
)
270 dttoday
= datetime
.datetime
.today()
272 for fn
in os
.listdir( self
.config
.recorded_progs_dir
):
273 if fn
[-4:] == ".flv":
275 infofn
= os
.path
.join( self
.config
.recorded_progs_dir
,
276 fn
[:-4] + ".rtvinfo" )
278 if os
.path
.isfile( infofn
):
280 pi
= rtv_programmeinfo
.ProgrammeInfo()
283 if pi
.deleteTime
and pi
.deleteTime
< dttoday
:
284 full_fn
= os
.path
.join(
285 self
.config
.recorded_progs_dir
, fn
)
287 print "Deleting file '%s'" % full_fn
294 def schedule( self
, xmltv_parser
= rtv_utils
, fav_reader
= rtv_selection
,
297 if scheduler
== None:
300 # TODO: if we are generating a TV guide as well as scheduling,
301 # we should share the same XML parser.
303 self
.delete_pending_recordings()
305 self
.favs_and_sels
= fav_reader
.read_favs_and_selections(
306 self
.config
, record_only
= True )
308 self
.channel_xmltv2tzap
= rtv_propertiesfile
.PropertiesFile()
309 self
.channel_xmltv2tzap
.load( self
.config
.channel_xmltv2tzap_file
)
311 scheduler
.remove_scheduled_events()
313 self
.record_queue
= []
314 xmltv_parser
.parse_xmltv_files( self
.config
, self
.sax_callback
)
315 self
.remove_already_recorded( scheduler
)
316 self
.remove_clashes()
317 scheduler
.schedule_recordings( self
.record_queue
)
318 self
.record_queue
= []
321 def schedule( config
):
322 sch
= Schedule( config
)
331 def format_title_subtitle( pi
):
338 def format_pi_list( qu
):
341 ret
+= format_title_subtitle( pi
)
344 ret
+= format_title_subtitle( qu
[-1] )
349 class FakeScheduler( object ):
351 def __init__( self
, schedule
):
353 self
.schedule
= schedule
354 self
.remove_scheduled_events_called
= False
356 dtnow
= datetime
.datetime
.today()
358 favHeroes
= rtv_favourite
.Favourite()
359 favHeroes
.title_re
= "Heroes"
361 favNewsnight
= rtv_favourite
.Favourite()
362 favNewsnight
.title_re
= "Newsnight.*"
363 favNewsnight
.priority
= -50
365 favLost
= rtv_favourite
.Favourite()
366 favLost
.title_re
= "Lost"
367 favLost
.priority
= -50
369 favPocoyo
= rtv_favourite
.Favourite()
370 favPocoyo
.title_re
= "Pocoyo"
372 self
.test_favs
= [ favHeroes
, favNewsnight
, favLost
, favPocoyo
]
374 self
.piHeroes
= rtv_programmeinfo
.ProgrammeInfo()
375 self
.piHeroes
.title
= "Heroes"
376 self
.piHeroes
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1 )
377 self
.piHeroes
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
378 self
.piHeroes
.channel
= "south-east.bbc2.bbc.co.uk"
380 self
.piNewsnight
= rtv_programmeinfo
.ProgrammeInfo()
381 self
.piNewsnight
.title
= "Newsnight"
382 self
.piNewsnight
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1,
384 self
.piNewsnight
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
385 self
.piNewsnight
.channel
= "south-east.bbc1.bbc.co.uk"
387 self
.piNR
= rtv_programmeinfo
.ProgrammeInfo()
388 self
.piNR
.title
= "Newsnight Review"
389 self
.piNR
.startTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
390 self
.piNR
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2,
392 self
.piNR
.channel
= "south-east.bbc2.bbc.co.uk"
394 self
.piLost
= rtv_programmeinfo
.ProgrammeInfo()
395 self
.piLost
.title
= "Lost"
396 self
.piLost
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1,
398 self
.piLost
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2,
400 self
.piLost
.channel
= "channel4.com"
402 self
.piTurnip
= rtv_programmeinfo
.ProgrammeInfo()
403 self
.piTurnip
.title
= "Newsnight Turnip"
404 self
.piTurnip
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1,
406 self
.piTurnip
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2,
408 self
.piTurnip
.channel
= "channel5.co.uk"
410 self
.piPocoyo1
= rtv_programmeinfo
.ProgrammeInfo()
411 self
.piPocoyo1
.title
= "Pocoyo"
412 self
.piPocoyo1
.sub_title
= "Subtitle already seen"
413 self
.piPocoyo1
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1 )
414 self
.piPocoyo1
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
415 self
.piPocoyo1
.channel
= "south-east.bbc2.bbc.co.uk"
417 self
.piPocoyo2
= rtv_programmeinfo
.ProgrammeInfo()
418 self
.piPocoyo2
.title
= "Pocoyo"
419 self
.piPocoyo2
.sub_title
= "Subtitle not seen"
420 self
.piPocoyo2
.startTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
421 self
.piPocoyo2
.endTime
= dtnow
+ datetime
.timedelta( hours
= 3 )
422 self
.piPocoyo2
.channel
= "south-east.bbc2.bbc.co.uk"
424 self
.which_test
= None
426 def find_old_programmes_map( self
, old_dir
, converted_dir
):
427 return { self
.piPocoyo1
.title
: { self
.piPocoyo1
.sub_title
: 1 } }
429 def parse_xmltv_files( self
, config
, callback
):
430 if self
.which_test
== "no_clash1":
431 callback( self
.piNewsnight
)
432 callback( self
.piNR
)
433 elif self
.which_test
== "priority_clash1":
434 callback( self
.piHeroes
)
435 callback( self
.piNewsnight
)
436 elif self
.which_test
== "time_clash1":
437 callback( self
.piNewsnight
)
438 callback( self
.piLost
)
439 elif self
.which_test
== "same_clash1":
440 callback( self
.piNewsnight
)
441 callback( self
.piTurnip
)
442 elif self
.which_test
== "norerecord":
443 callback( self
.piPocoyo1
)
444 callback( self
.piPocoyo2
)
446 def remove_scheduled_events( self
):
447 if self
.remove_scheduled_events_called
:
448 raise Exception( "remove_scheduled_events called twice." )
449 self
.remove_scheduled_events_called
= True
451 def read_favs_and_selections( self
, config
, record_only
):
452 return self
.test_favs
454 def schedule_recordings( self
, queue
):
458 self
.which_test
= "priority_clash1"
459 self
.schedule
.schedule( self
, self
, self
)
460 if not self
.remove_scheduled_events_called
:
461 raise Exception( "remove_scheduled_events never called" )
462 if self
.queue
!= [self
.piHeroes
]:
463 raise Exception( "queue should look like %s, but it looks like %s"
464 % ( [self
.piHeroes
], self
.queue
) )
467 self
.which_test
= "no_clash1"
468 self
.remove_scheduled_events_called
= False
469 self
.schedule
.schedule( self
, self
, self
)
470 if not self
.remove_scheduled_events_called
:
471 raise Exception( "remove_scheduled_events never called" )
472 if self
.queue
!= [self
.piNewsnight
, self
.piNR
]:
473 raise Exception( "queue should look like %s, but it looks like %s"
474 % ( [self
.piNewsnight
, self
.piNR
], self
.queue
) )
476 self
.which_test
= "time_clash1"
477 self
.remove_scheduled_events_called
= False
478 self
.schedule
.schedule( self
, self
, self
)
479 if not self
.remove_scheduled_events_called
:
480 raise Exception( "remove_scheduled_events never called" )
481 if self
.queue
!= [self
.piLost
]:
482 raise Exception( "queue should look like %s, but it looks like %s"
483 % ( [self
.piLost
], self
.queue
) )
485 self
.which_test
= "same_clash1"
486 self
.remove_scheduled_events_called
= False
487 self
.schedule
.schedule( self
, self
, self
)
488 if not self
.remove_scheduled_events_called
:
489 raise Exception( "remove_scheduled_events never called" )
490 if self
.queue
!= [self
.piNewsnight
] and self
.queue
!= [self
.piTurnip
]:
491 raise Exception( ("queue should look like %s or %s, but it"
493 % ( format_pi_list( [self
.piNewsnight
] ),
494 format_pi_list( [self
.piTurnip
] ),
495 format_pi_list( self
.queue
) ) )
497 def test_norerecord( self
):
498 self
.which_test
= "norerecord"
499 self
.remove_scheduled_events_called
= False
500 self
.schedule
.schedule( self
, self
, self
)
502 if not self
.remove_scheduled_events_called
:
503 raise Exception( "remove_scheduled_events never called" )
504 if self
.queue
!= [self
.piPocoyo2
]:
505 raise Exception( ("queue should look like %s, but it"
507 % ( format_pi_list( [self
.piPocoyo2
] ),
508 format_pi_list( self
.queue
) ) )
512 class FakeScheduler_SameTitleSameDay( FakeScheduler
):
514 def __init__( self
, schedule
):
515 FakeScheduler
.__init
__( self
, schedule
)
517 def find_old_programmes_map( self
, old_dir
, converted_dir
):
520 def parse_xmltv_files( self
, config
, callback
):
521 dtnow
= datetime
.datetime
.today()
524 self
.piPocoyo1
= rtv_programmeinfo
.ProgrammeInfo()
525 self
.piPocoyo1
.title
= "Pocoyo"
526 self
.piPocoyo1
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1 )
527 self
.piPocoyo1
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
528 self
.piPocoyo1
.channel
= "south-east.bbc2.bbc.co.uk"
530 self
.piPocoyo2
= rtv_programmeinfo
.ProgrammeInfo()
531 self
.piPocoyo2
.title
= "Pocoyo"
532 self
.piPocoyo2
.startTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
533 self
.piPocoyo2
.endTime
= dtnow
+ datetime
.timedelta( hours
= 3 )
534 self
.piPocoyo2
.channel
= "south-east.bbc2.bbc.co.uk"
536 # 2 with identical subtitle
537 self
.piPocoyo3
= rtv_programmeinfo
.ProgrammeInfo()
538 self
.piPocoyo3
.title
= "Pocoyo"
539 self
.piPocoyo3
.sub_title
= "Subtitle we will see twice"
540 self
.piPocoyo3
.startTime
= dtnow
+ datetime
.timedelta( hours
= 3 )
541 self
.piPocoyo3
.endTime
= dtnow
+ datetime
.timedelta( hours
= 4 )
542 self
.piPocoyo3
.channel
= "south-east.bbc2.bbc.co.uk"
544 self
.piPocoyo4
= rtv_programmeinfo
.ProgrammeInfo()
545 self
.piPocoyo4
.title
= "Pocoyo"
546 self
.piPocoyo4
.sub_title
= "Subtitle we will see twice"
547 self
.piPocoyo4
.startTime
= dtnow
+ datetime
.timedelta( hours
= 4 )
548 self
.piPocoyo4
.endTime
= dtnow
+ datetime
.timedelta( hours
= 5 )
549 self
.piPocoyo4
.channel
= "south-east.bbc2.bbc.co.uk"
551 callback( self
.piPocoyo1
)
552 callback( self
.piPocoyo2
)
553 callback( self
.piPocoyo3
)
554 callback( self
.piPocoyo4
)
556 def read_favs_and_selections( self
, config
, record_only
):
557 favPocoyo
= rtv_favourite
.Favourite()
558 favPocoyo
.title_re
= "Pocoyo"
562 self
.schedule
.schedule( self
, self
, self
)
564 if not self
.remove_scheduled_events_called
:
565 raise Exception( "remove_scheduled_events never called" )
567 expected_queue
= [ self
.piPocoyo1
, self
.piPocoyo2
, self
.piPocoyo3
]
569 if self
.queue
!= expected_queue
:
570 raise Exception( ("queue should look like %s, but it"
572 % ( format_pi_list( [self
.piPocoyo3
] ),
573 format_pi_list( self
.queue
) ) )
577 p
= FakeScheduler( Schedule( config
) )
582 FakeScheduler_SameTitleSameDay( Schedule( config
) ).test()