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
is not None and
104 prog
.sub_title
in new_queue_sub_titles_map
[prog
.title
] ):
105 self
.print_recording_today( prog
)
107 new_queue
.append( prog
)
108 if self
.sub_title_matters( prog
):
109 new_queue_sub_titles_map
[prog
.title
] = prog
.sub_title
111 self
.record_queue
= new_queue
114 def print_clash_priority_error( self
, losePi
, keepPi
):
115 print ( ("Not recording '%s' at %s - it clashes with '%s',"
116 + " which is higher priority (%d > %d).")
118 losePi
.startTime
.strftime(
119 MESSAGE_TIME_FORMAT
),
120 keepPi
.title
, keepPi
.get_priority(),
121 losePi
.get_priority() ) )
123 def print_clash_time_error( self
, losePi
, keepPi
):
124 print ( ("Not recording '%s' at %s - it clashes with '%s',"
125 + " which has the same priority (%d), but starts earlier"
128 losePi
.startTime
.strftime(
129 MESSAGE_TIME_FORMAT
),
130 keepPi
.title
, keepPi
.get_priority(),
131 keepPi
.startTime
.strftime(
132 MESSAGE_TIME_FORMAT
) ) )
134 def print_clash_same_time( self
, losePi
, keepPi
):
135 print ( ("Not recording '%s' at %s - it clashes with '%s',"
136 + " which has the same priority (%d). They start at"
137 + " the same time, so the one to record was chosen"
140 losePi
.startTime
.strftime(
141 MESSAGE_TIME_FORMAT
),
142 keepPi
.title
, keepPi
.get_priority() ) )
145 def remove_clashes( self
):
146 self
.record_queue
.sort( priority_time_compare
)
149 for pi1Num
in range( len( self
.record_queue
) ):
150 pi1
= self
.record_queue
[pi1Num
]
154 for pi2
in new_queue
:
156 if pi1
.clashes_with( pi2
):
161 new_queue
.append( pi1
)
163 if pi1
.get_priority() < clashed_with
.get_priority():
164 self
.print_clash_priority_error( pi1
, clashed_with
)
165 elif pi1
.startTime
> clashed_with
.startTime
:
166 self
.print_clash_time_error( pi1
, clashed_with
)
168 self
.print_clash_same_time( pi1
, clashed_with
)
170 self
.record_queue
= new_queue
172 def schedule_recordings( self
, queue
):
174 if len( queue
) == 0:
175 print "No programmes found to record."
178 rtv_utils
.ensure_dir_exists( self
.config
.recorded_progs_dir
)
179 rtv_utils
.ensure_dir_exists( self
.config
.scheduled_events_dir
)
180 rtv_utils
.ensure_dir_exists( self
.config
.recording_log_dir
)
182 for programmeInfo
in queue
:
184 if programmeInfo
.sub_title
is None:
187 st
= ": " + programmeInfo
.sub_title
189 print "Recording '%s%s' at '%s'" % ( programmeInfo
.title
,
190 st
, programmeInfo
.startTime
.strftime( MESSAGE_TIME_FORMAT
) )
192 filename
= rtv_utils
.prepare_filename( programmeInfo
.title
)
193 filename
+= programmeInfo
.startTime
.strftime( "-%Y-%m-%d_%H_%M" )
195 length_timedelta
= programmeInfo
.endTime
- programmeInfo
.startTime
196 length_in_seconds
= ( ( length_timedelta
.days
197 * rtv_utils
.SECS_IN_DAY
) + length_timedelta
.seconds
)
198 length_in_seconds
+= 60 * self
.config
.extra_recording_time_mins
200 sched_filename
= os
.path
.join( self
.config
.scheduled_events_dir
,
201 filename
+ ".rtvinfo" )
203 outfilename
= os
.path
.join( self
.config
.recorded_progs_dir
,
206 programmeInfo
.startTime
.strftime( "%H:%M %d.%m.%Y" ) )
207 at_output
= rtv_utils
.run_command_feed_input( cmds_array
,
208 self
.config
.record_start_command
% (
209 self
.channel_xmltv2tzap
.get_value( programmeInfo
.channel
),
210 outfilename
, length_in_seconds
, sched_filename
,
211 os
.path
.join( self
.config
.recording_log_dir
,
212 filename
+ ".log" ) ) )
213 at_job_start
= self
.get_at_job( at_output
)
215 if at_job_start
!= None:
216 programmeInfo
.atJob
= at_job_start
217 programmeInfo
.save( sched_filename
)
219 def sax_callback( self
, pi
):
220 for fav
in self
.favs_and_sels
:
221 if fav
.matches( pi
):
222 dtnow
= datetime
.datetime
.today()
223 if( pi
.startTime
> dtnow
224 and (pi
.startTime
- dtnow
).days
< 1 ):
226 if fav
.deleteAfterDays
:
227 pi
.deleteTime
= pi
.endTime
+ datetime
.timedelta(
228 float( fav
.deleteAfterDays
), 0 )
230 pi
.channel_pretty
= self
.channel_xmltv2tzap
.get_value(
232 if not pi
.channel_pretty
:
234 "Pretty channel name not found for channel %s"
237 pi
.priority
= fav
.priority
238 pi
.destination
= fav
.destination
239 pi
.unique_subtitles
= fav
.unique_subtitles
242 pi
.title
= fav
.real_title
244 self
.record_queue
.append( pi
)
247 def remove_scheduled_events( self
):
249 rtv_utils
.ensure_dir_exists( self
.config
.scheduled_events_dir
)
251 for fn
in os
.listdir( self
.config
.scheduled_events_dir
):
252 full_fn
= os
.path
.join( self
.config
.scheduled_events_dir
, fn
)
254 pi
= rtv_programmeinfo
.ProgrammeInfo()
259 at_job_start
= int( pi
.atJob
)
260 rtv_utils
.run_command( ( "atrm", str( at_job_start
) ) )
268 def delete_pending_recordings( self
):
269 rtv_utils
.ensure_dir_exists( self
.config
.recorded_progs_dir
)
271 dttoday
= datetime
.datetime
.today()
273 for fn
in os
.listdir( self
.config
.recorded_progs_dir
):
274 if fn
[-4:] in ( ".flv", ".avi" ):
276 infofn
= os
.path
.join( self
.config
.recorded_progs_dir
,
277 fn
[:-4] + ".rtvinfo" )
279 if os
.path
.isfile( infofn
):
281 pi
= rtv_programmeinfo
.ProgrammeInfo()
284 if pi
.deleteTime
and pi
.deleteTime
< dttoday
:
285 full_fn
= os
.path
.join(
286 self
.config
.recorded_progs_dir
, fn
)
288 print "Deleting file '%s'" % full_fn
295 def schedule( self
, xmltv_parser
= rtv_utils
, fav_reader
= rtv_selection
,
298 if scheduler
== None:
301 # TODO: if we are generating a TV guide as well as scheduling,
302 # we should share the same XML parser.
304 self
.delete_pending_recordings()
306 self
.favs_and_sels
= fav_reader
.read_favs_and_selections(
307 self
.config
, record_only
= True )
309 self
.channel_xmltv2tzap
= rtv_propertiesfile
.PropertiesFile()
310 self
.channel_xmltv2tzap
.load( self
.config
.channel_xmltv2tzap_file
)
312 scheduler
.remove_scheduled_events()
314 self
.record_queue
= []
315 xmltv_parser
.parse_xmltv_files( self
.config
, self
.sax_callback
)
316 self
.remove_already_recorded( scheduler
)
317 self
.remove_clashes()
318 scheduler
.schedule_recordings( self
.record_queue
)
319 self
.record_queue
= []
322 def schedule( config
):
323 sch
= Schedule( config
)
332 def format_title_subtitle( pi
):
339 def format_pi_list( qu
):
342 ret
+= format_title_subtitle( pi
)
345 ret
+= format_title_subtitle( qu
[-1] )
350 class FakeScheduler( object ):
352 def __init__( self
, schedule
):
354 self
.schedule
= schedule
355 self
.remove_scheduled_events_called
= False
357 dtnow
= datetime
.datetime
.today()
359 favHeroes
= rtv_favourite
.Favourite()
360 favHeroes
.title_re
= "Heroes"
362 favNewsnight
= rtv_favourite
.Favourite()
363 favNewsnight
.title_re
= "Newsnight.*"
364 favNewsnight
.priority
= -50
366 favLost
= rtv_favourite
.Favourite()
367 favLost
.title_re
= "Lost"
368 favLost
.priority
= -50
370 favPocoyo
= rtv_favourite
.Favourite()
371 favPocoyo
.title_re
= "Pocoyo"
373 self
.test_favs
= [ favHeroes
, favNewsnight
, favLost
, favPocoyo
]
375 self
.piHeroes
= rtv_programmeinfo
.ProgrammeInfo()
376 self
.piHeroes
.title
= "Heroes"
377 self
.piHeroes
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1 )
378 self
.piHeroes
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
379 self
.piHeroes
.channel
= "south-east.bbc2.bbc.co.uk"
381 self
.piNewsnight
= rtv_programmeinfo
.ProgrammeInfo()
382 self
.piNewsnight
.title
= "Newsnight"
383 self
.piNewsnight
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1,
385 self
.piNewsnight
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
386 self
.piNewsnight
.channel
= "south-east.bbc1.bbc.co.uk"
388 self
.piNR
= rtv_programmeinfo
.ProgrammeInfo()
389 self
.piNR
.title
= "Newsnight Review"
390 self
.piNR
.startTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
391 self
.piNR
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2,
393 self
.piNR
.channel
= "south-east.bbc2.bbc.co.uk"
395 self
.piLost
= rtv_programmeinfo
.ProgrammeInfo()
396 self
.piLost
.title
= "Lost"
397 self
.piLost
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1,
399 self
.piLost
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2,
401 self
.piLost
.channel
= "channel4.com"
403 self
.piTurnip
= rtv_programmeinfo
.ProgrammeInfo()
404 self
.piTurnip
.title
= "Newsnight Turnip"
405 self
.piTurnip
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1,
407 self
.piTurnip
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2,
409 self
.piTurnip
.channel
= "channel5.co.uk"
411 self
.piPocoyo1
= rtv_programmeinfo
.ProgrammeInfo()
412 self
.piPocoyo1
.title
= "Pocoyo"
413 self
.piPocoyo1
.sub_title
= "Subtitle already seen"
414 self
.piPocoyo1
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1 )
415 self
.piPocoyo1
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
416 self
.piPocoyo1
.channel
= "south-east.bbc2.bbc.co.uk"
418 self
.piPocoyo2
= rtv_programmeinfo
.ProgrammeInfo()
419 self
.piPocoyo2
.title
= "Pocoyo"
420 self
.piPocoyo2
.sub_title
= "Subtitle not seen"
421 self
.piPocoyo2
.startTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
422 self
.piPocoyo2
.endTime
= dtnow
+ datetime
.timedelta( hours
= 3 )
423 self
.piPocoyo2
.channel
= "south-east.bbc2.bbc.co.uk"
425 self
.which_test
= None
427 def find_old_programmes_map( self
, old_dir
, converted_dir
):
428 return { self
.piPocoyo1
.title
: { self
.piPocoyo1
.sub_title
: 1 } }
430 def parse_xmltv_files( self
, config
, callback
):
431 if self
.which_test
== "no_clash1":
432 callback( self
.piNewsnight
)
433 callback( self
.piNR
)
434 elif self
.which_test
== "priority_clash1":
435 callback( self
.piHeroes
)
436 callback( self
.piNewsnight
)
437 elif self
.which_test
== "time_clash1":
438 callback( self
.piNewsnight
)
439 callback( self
.piLost
)
440 elif self
.which_test
== "same_clash1":
441 callback( self
.piNewsnight
)
442 callback( self
.piTurnip
)
443 elif self
.which_test
== "norerecord":
444 callback( self
.piPocoyo1
)
445 callback( self
.piPocoyo2
)
447 def remove_scheduled_events( self
):
448 if self
.remove_scheduled_events_called
:
449 raise Exception( "remove_scheduled_events called twice." )
450 self
.remove_scheduled_events_called
= True
452 def read_favs_and_selections( self
, config
, record_only
):
453 return self
.test_favs
455 def schedule_recordings( self
, queue
):
459 self
.which_test
= "priority_clash1"
460 self
.schedule
.schedule( self
, self
, self
)
461 if not self
.remove_scheduled_events_called
:
462 raise Exception( "remove_scheduled_events never called" )
463 if self
.queue
!= [self
.piHeroes
]:
464 raise Exception( "queue should look like %s, but it looks like %s"
465 % ( [self
.piHeroes
], self
.queue
) )
468 self
.which_test
= "no_clash1"
469 self
.remove_scheduled_events_called
= False
470 self
.schedule
.schedule( self
, self
, self
)
471 if not self
.remove_scheduled_events_called
:
472 raise Exception( "remove_scheduled_events never called" )
473 if self
.queue
!= [self
.piNewsnight
, self
.piNR
]:
474 raise Exception( "queue should look like %s, but it looks like %s"
475 % ( [self
.piNewsnight
, self
.piNR
], self
.queue
) )
477 self
.which_test
= "time_clash1"
478 self
.remove_scheduled_events_called
= False
479 self
.schedule
.schedule( self
, self
, self
)
480 if not self
.remove_scheduled_events_called
:
481 raise Exception( "remove_scheduled_events never called" )
482 if self
.queue
!= [self
.piLost
]:
483 raise Exception( "queue should look like %s, but it looks like %s"
484 % ( [self
.piLost
], self
.queue
) )
486 self
.which_test
= "same_clash1"
487 self
.remove_scheduled_events_called
= False
488 self
.schedule
.schedule( self
, self
, self
)
489 if not self
.remove_scheduled_events_called
:
490 raise Exception( "remove_scheduled_events never called" )
491 if self
.queue
!= [self
.piNewsnight
] and self
.queue
!= [self
.piTurnip
]:
492 raise Exception( ("queue should look like %s or %s, but it"
494 % ( format_pi_list( [self
.piNewsnight
] ),
495 format_pi_list( [self
.piTurnip
] ),
496 format_pi_list( self
.queue
) ) )
498 def test_norerecord( self
):
499 self
.which_test
= "norerecord"
500 self
.remove_scheduled_events_called
= False
501 self
.schedule
.schedule( self
, self
, self
)
503 if not self
.remove_scheduled_events_called
:
504 raise Exception( "remove_scheduled_events never called" )
505 if self
.queue
!= [self
.piPocoyo2
]:
506 raise Exception( ("queue should look like %s, but it"
508 % ( format_pi_list( [self
.piPocoyo2
] ),
509 format_pi_list( self
.queue
) ) )
513 class FakeScheduler_SameTitleSameDay( FakeScheduler
):
515 def __init__( self
, schedule
):
516 FakeScheduler
.__init
__( self
, schedule
)
518 def find_old_programmes_map( self
, old_dir
, converted_dir
):
521 def parse_xmltv_files( self
, config
, callback
):
522 dtnow
= datetime
.datetime
.today()
525 self
.piPocoyo1
= rtv_programmeinfo
.ProgrammeInfo()
526 self
.piPocoyo1
.title
= "Pocoyo"
527 self
.piPocoyo1
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1 )
528 self
.piPocoyo1
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
529 self
.piPocoyo1
.channel
= "south-east.bbc2.bbc.co.uk"
531 self
.piPocoyo2
= rtv_programmeinfo
.ProgrammeInfo()
532 self
.piPocoyo2
.title
= "Pocoyo"
533 self
.piPocoyo2
.startTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
534 self
.piPocoyo2
.endTime
= dtnow
+ datetime
.timedelta( hours
= 3 )
535 self
.piPocoyo2
.channel
= "south-east.bbc2.bbc.co.uk"
537 # 2 with identical subtitle
538 self
.piPocoyo3
= rtv_programmeinfo
.ProgrammeInfo()
539 self
.piPocoyo3
.title
= "Pocoyo"
540 self
.piPocoyo3
.sub_title
= "Subtitle we will see twice"
541 self
.piPocoyo3
.startTime
= dtnow
+ datetime
.timedelta( hours
= 3 )
542 self
.piPocoyo3
.endTime
= dtnow
+ datetime
.timedelta( hours
= 4 )
543 self
.piPocoyo3
.channel
= "south-east.bbc2.bbc.co.uk"
545 self
.piPocoyo4
= rtv_programmeinfo
.ProgrammeInfo()
546 self
.piPocoyo4
.title
= "Pocoyo"
547 self
.piPocoyo4
.sub_title
= "Subtitle we will see twice"
548 self
.piPocoyo4
.startTime
= dtnow
+ datetime
.timedelta( hours
= 4 )
549 self
.piPocoyo4
.endTime
= dtnow
+ datetime
.timedelta( hours
= 5 )
550 self
.piPocoyo4
.channel
= "south-east.bbc2.bbc.co.uk"
552 callback( self
.piPocoyo1
)
553 callback( self
.piPocoyo2
)
554 callback( self
.piPocoyo3
)
555 callback( self
.piPocoyo4
)
557 def read_favs_and_selections( self
, config
, record_only
):
558 favPocoyo
= rtv_favourite
.Favourite()
559 favPocoyo
.title_re
= "Pocoyo"
563 self
.schedule
.schedule( self
, self
, self
)
565 if not self
.remove_scheduled_events_called
:
566 raise Exception( "remove_scheduled_events never called" )
568 expected_queue
= [ self
.piPocoyo1
, self
.piPocoyo2
, self
.piPocoyo3
]
570 if self
.queue
!= expected_queue
:
571 raise Exception( ("queue should look like %s, but it"
573 % ( format_pi_list( [self
.piPocoyo3
] ),
574 format_pi_list( self
.queue
) ) )
578 p
= FakeScheduler( Schedule( config
) )
583 FakeScheduler_SameTitleSameDay( Schedule( config
) ).test()