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 self
.pretty_title( prog
)
84 def print_recording_today( self
, prog
):
85 print ( "%s (recording another showing today)"
86 % self
.pretty_title( prog
) )
88 def remove_already_recorded( self
, scheduler
):
90 print "Skipped (already recorded):"
92 old_dir
= os
.path
.join( self
.config
.recorded_progs_dir
, "old" )
93 converted_dir
= self
.config
.converted_progs_dir
94 old_progs_map
= scheduler
.find_old_programmes_map( old_dir
,
98 new_queue_sub_titles_map
= {}
100 for prog
in self
.record_queue
:
101 if ( prog
.title
in old_progs_map
and
102 prog
.sub_title
in old_progs_map
[prog
.title
] ):
103 self
.print_already_recorded( prog
)
104 elif ( prog
.title
in new_queue_sub_titles_map
and
105 prog
.sub_title
is not None and
106 prog
.sub_title
in new_queue_sub_titles_map
[prog
.title
] ):
107 self
.print_recording_today( prog
)
109 new_queue
.append( prog
)
110 if self
.sub_title_matters( prog
):
111 new_queue_sub_titles_map
[prog
.title
] = prog
.sub_title
113 self
.record_queue
= new_queue
116 def print_clash_priority_error( self
, losePi
, keepPi
):
117 print ( ("%s (priority %d) < (priority %d) %s")
118 % ( self
.pretty_title( losePi
), losePi
.get_priority(),
119 keepPi
.get_priority(), self
.pretty_title( keepPi
) ) )
121 def print_clash_time_error( self
, losePi
, keepPi
):
122 print ( ("%s (%s) is later than (%s) %s")
123 % ( self
.pretty_title( losePi
),
124 self
.pretty_time( losePi
),
125 self
.pretty_time( keepPi
),
126 self
.pretty_title( keepPi
) ) )
128 def print_clash_same_time( self
, losePi
, keepPi
):
129 print ( ("%s lost randomly to %s")
130 % ( self
.pretty_title( losePi
),
131 self
.pretty_title( keepPi
) ) )
133 def remove_clashes( self
):
135 print "Skipped (clashes):"
137 self
.record_queue
.sort( priority_time_compare
)
140 for pi1Num
in range( len( self
.record_queue
) ):
141 pi1
= self
.record_queue
[pi1Num
]
145 for pi2
in new_queue
:
147 if pi1
.clashes_with( pi2
):
152 new_queue
.append( pi1
)
154 if pi1
.get_priority() < clashed_with
.get_priority():
155 self
.print_clash_priority_error( pi1
, clashed_with
)
156 elif pi1
.startTime
> clashed_with
.startTime
:
157 self
.print_clash_time_error( pi1
, clashed_with
)
159 self
.print_clash_same_time( pi1
, clashed_with
)
161 self
.record_queue
= new_queue
163 def pretty_title( self
, programmeInfo
):
164 if programmeInfo
.sub_title
is None:
165 return programmeInfo
.title
167 return "%s: %s" % ( programmeInfo
.title
, programmeInfo
.sub_title
)
169 def pretty_time( self
, programmeInfo
):
170 return programmeInfo
.startTime
.strftime( MESSAGE_TIME_FORMAT
)
172 def schedule_recordings( self
, queue
):
174 if len( queue
) == 0:
176 print "No programmes found to record."
179 rtv_utils
.ensure_dir_exists( self
.config
.recorded_progs_dir
)
180 rtv_utils
.ensure_dir_exists( self
.config
.scheduled_events_dir
)
181 rtv_utils
.ensure_dir_exists( self
.config
.recording_log_dir
)
186 for programmeInfo
in queue
:
189 % ( self
.pretty_title( programmeInfo
),
190 self
.pretty_time( programmeInfo
) ) )
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" ) )
208 if programmeInfo
.filetype
is not None:
209 filetype
= programmeInfo
.filetype
213 at_output
= rtv_utils
.run_command_feed_input( cmds_array
,
214 self
.config
.record_start_command
% (
215 self
.channel_xmltv2tzap
.get_value( programmeInfo
.channel
),
216 outfilename
, length_in_seconds
, sched_filename
, filetype
,
217 os
.path
.join( self
.config
.recording_log_dir
,
218 filename
+ ".log" ) ) )
219 at_job_start
= self
.get_at_job( at_output
)
221 if at_job_start
!= None:
222 programmeInfo
.atJob
= at_job_start
223 programmeInfo
.save( sched_filename
)
225 def sax_callback( self
, pi
):
226 for fav
in self
.favs_and_sels
:
227 if fav
.matches( pi
):
228 dtnow
= datetime
.datetime
.today()
229 if( pi
.startTime
> dtnow
230 and (pi
.startTime
- dtnow
).days
231 < self
.config
.options
.days
):
233 if fav
.deleteAfterDays
:
234 pi
.deleteTime
= pi
.endTime
+ datetime
.timedelta(
235 float( fav
.deleteAfterDays
), 0 )
237 pi
.channel_pretty
= self
.channel_xmltv2tzap
.get_value(
239 if not pi
.channel_pretty
:
241 "** Pretty channel name not found for channel %s **"
244 pi
.priority
= fav
.priority
245 pi
.destination
= fav
.destination
246 pi
.unique_subtitles
= fav
.unique_subtitles
247 pi
.filetype
= fav
.filetype
250 pi
.title
= fav
.real_title
252 self
.record_queue
.append( pi
)
255 def remove_scheduled_events( self
):
257 rtv_utils
.ensure_dir_exists( self
.config
.scheduled_events_dir
)
259 for fn
in os
.listdir( self
.config
.scheduled_events_dir
):
260 full_fn
= os
.path
.join( self
.config
.scheduled_events_dir
, fn
)
262 pi
= rtv_programmeinfo
.ProgrammeInfo()
267 at_job_start
= int( pi
.atJob
)
268 rtv_utils
.run_command( ( "atrm", str( at_job_start
) ) )
276 def delete_pending_recordings( self
):
277 rtv_utils
.ensure_dir_exists( self
.config
.recorded_progs_dir
)
279 dttoday
= datetime
.datetime
.today()
281 for fn
in os
.listdir( self
.config
.recorded_progs_dir
):
282 if fn
[-4:] in ( ".flv", ".avi" ):
284 infofn
= os
.path
.join( self
.config
.recorded_progs_dir
,
285 fn
[:-4] + ".rtvinfo" )
287 if os
.path
.isfile( infofn
):
289 pi
= rtv_programmeinfo
.ProgrammeInfo()
292 if pi
.deleteTime
and pi
.deleteTime
< dttoday
:
293 full_fn
= os
.path
.join(
294 self
.config
.recorded_progs_dir
, fn
)
296 print "Deleting file '%s'" % full_fn
303 def schedule( self
, xmltv_parser
= rtv_utils
, fav_reader
= rtv_selection
,
306 if scheduler
== None:
309 # TODO: if we are generating a TV guide as well as scheduling,
310 # we should share the same XML parser.
312 self
.delete_pending_recordings()
314 self
.favs_and_sels
= fav_reader
.read_favs_and_selections(
315 self
.config
, record_only
= True )
317 self
.channel_xmltv2tzap
= rtv_propertiesfile
.PropertiesFile()
318 self
.channel_xmltv2tzap
.load( self
.config
.channel_xmltv2tzap_file
)
320 scheduler
.remove_scheduled_events()
322 self
.record_queue
= []
323 xmltv_parser
.parse_xmltv_files( self
.config
, self
.sax_callback
)
324 self
.remove_already_recorded( scheduler
)
325 self
.remove_clashes()
326 scheduler
.schedule_recordings( self
.record_queue
)
327 self
.record_queue
= []
330 def schedule( config
):
331 sch
= Schedule( config
)
340 def format_title_subtitle( pi
):
347 def format_pi_list( qu
):
350 ret
+= format_title_subtitle( pi
)
353 ret
+= format_title_subtitle( qu
[-1] )
358 class FakeScheduler( object ):
360 def __init__( self
, schedule
):
362 self
.schedule
= schedule
363 self
.remove_scheduled_events_called
= False
365 dtnow
= datetime
.datetime
.today()
367 favHeroes
= rtv_favourite
.Favourite()
368 favHeroes
.title_re
= "Heroes"
370 favNewsnight
= rtv_favourite
.Favourite()
371 favNewsnight
.title_re
= "Newsnight.*"
372 favNewsnight
.priority
= -50
374 favLost
= rtv_favourite
.Favourite()
375 favLost
.title_re
= "Lost"
376 favLost
.priority
= -50
378 favPocoyo
= rtv_favourite
.Favourite()
379 favPocoyo
.title_re
= "Pocoyo"
381 self
.test_favs
= [ favHeroes
, favNewsnight
, favLost
, favPocoyo
]
383 self
.piHeroes
= rtv_programmeinfo
.ProgrammeInfo()
384 self
.piHeroes
.title
= "Heroes"
385 self
.piHeroes
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1 )
386 self
.piHeroes
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
387 self
.piHeroes
.channel
= "south-east.bbc2.bbc.co.uk"
389 self
.piNewsnight
= rtv_programmeinfo
.ProgrammeInfo()
390 self
.piNewsnight
.title
= "Newsnight"
391 self
.piNewsnight
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1,
393 self
.piNewsnight
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
394 self
.piNewsnight
.channel
= "south-east.bbc1.bbc.co.uk"
396 self
.piNR
= rtv_programmeinfo
.ProgrammeInfo()
397 self
.piNR
.title
= "Newsnight Review"
398 self
.piNR
.startTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
399 self
.piNR
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2,
401 self
.piNR
.channel
= "south-east.bbc2.bbc.co.uk"
403 self
.piLost
= rtv_programmeinfo
.ProgrammeInfo()
404 self
.piLost
.title
= "Lost"
405 self
.piLost
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1,
407 self
.piLost
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2,
409 self
.piLost
.channel
= "channel4.com"
411 self
.piTurnip
= rtv_programmeinfo
.ProgrammeInfo()
412 self
.piTurnip
.title
= "Newsnight Turnip"
413 self
.piTurnip
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1,
415 self
.piTurnip
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2,
417 self
.piTurnip
.channel
= "channel5.co.uk"
419 self
.piPocoyo1
= rtv_programmeinfo
.ProgrammeInfo()
420 self
.piPocoyo1
.title
= "Pocoyo"
421 self
.piPocoyo1
.sub_title
= "Subtitle already seen"
422 self
.piPocoyo1
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1 )
423 self
.piPocoyo1
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
424 self
.piPocoyo1
.channel
= "south-east.bbc2.bbc.co.uk"
426 self
.piPocoyo2
= rtv_programmeinfo
.ProgrammeInfo()
427 self
.piPocoyo2
.title
= "Pocoyo"
428 self
.piPocoyo2
.sub_title
= "Subtitle not seen"
429 self
.piPocoyo2
.startTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
430 self
.piPocoyo2
.endTime
= dtnow
+ datetime
.timedelta( hours
= 3 )
431 self
.piPocoyo2
.channel
= "south-east.bbc2.bbc.co.uk"
433 self
.which_test
= None
435 def find_old_programmes_map( self
, old_dir
, converted_dir
):
436 return { self
.piPocoyo1
.title
: { self
.piPocoyo1
.sub_title
: 1 } }
438 def parse_xmltv_files( self
, config
, callback
):
439 if self
.which_test
== "no_clash1":
440 callback( self
.piNewsnight
)
441 callback( self
.piNR
)
442 elif self
.which_test
== "priority_clash1":
443 callback( self
.piHeroes
)
444 callback( self
.piNewsnight
)
445 elif self
.which_test
== "time_clash1":
446 callback( self
.piNewsnight
)
447 callback( self
.piLost
)
448 elif self
.which_test
== "same_clash1":
449 callback( self
.piNewsnight
)
450 callback( self
.piTurnip
)
451 elif self
.which_test
== "norerecord":
452 callback( self
.piPocoyo1
)
453 callback( self
.piPocoyo2
)
455 def remove_scheduled_events( self
):
456 if self
.remove_scheduled_events_called
:
457 raise Exception( "remove_scheduled_events called twice." )
458 self
.remove_scheduled_events_called
= True
460 def read_favs_and_selections( self
, config
, record_only
):
461 return self
.test_favs
463 def schedule_recordings( self
, queue
):
467 self
.which_test
= "priority_clash1"
468 self
.schedule
.schedule( self
, self
, self
)
469 if not self
.remove_scheduled_events_called
:
470 raise Exception( "remove_scheduled_events never called" )
471 if self
.queue
!= [self
.piHeroes
]:
472 raise Exception( "queue should look like %s, but it looks like %s"
473 % ( [self
.piHeroes
], self
.queue
) )
476 self
.which_test
= "no_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
.piNewsnight
, self
.piNR
]:
482 raise Exception( "queue should look like %s, but it looks like %s"
483 % ( [self
.piNewsnight
, self
.piNR
], self
.queue
) )
485 self
.which_test
= "time_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
.piLost
]:
491 raise Exception( "queue should look like %s, but it looks like %s"
492 % ( [self
.piLost
], self
.queue
) )
494 self
.which_test
= "same_clash1"
495 self
.remove_scheduled_events_called
= False
496 self
.schedule
.schedule( self
, self
, self
)
497 if not self
.remove_scheduled_events_called
:
498 raise Exception( "remove_scheduled_events never called" )
499 if self
.queue
!= [self
.piNewsnight
] and self
.queue
!= [self
.piTurnip
]:
500 raise Exception( ("queue should look like %s or %s, but it"
502 % ( format_pi_list( [self
.piNewsnight
] ),
503 format_pi_list( [self
.piTurnip
] ),
504 format_pi_list( self
.queue
) ) )
506 def test_norerecord( self
):
507 self
.which_test
= "norerecord"
508 self
.remove_scheduled_events_called
= False
509 self
.schedule
.schedule( self
, self
, self
)
511 if not self
.remove_scheduled_events_called
:
512 raise Exception( "remove_scheduled_events never called" )
513 if self
.queue
!= [self
.piPocoyo2
]:
514 raise Exception( ("queue should look like %s, but it"
516 % ( format_pi_list( [self
.piPocoyo2
] ),
517 format_pi_list( self
.queue
) ) )
521 class FakeScheduler_SameTitleSameDay( FakeScheduler
):
523 def __init__( self
, schedule
):
524 FakeScheduler
.__init
__( self
, schedule
)
526 def find_old_programmes_map( self
, old_dir
, converted_dir
):
529 def parse_xmltv_files( self
, config
, callback
):
530 dtnow
= datetime
.datetime
.today()
533 self
.piPocoyo1
= rtv_programmeinfo
.ProgrammeInfo()
534 self
.piPocoyo1
.title
= "Pocoyo"
535 self
.piPocoyo1
.startTime
= dtnow
+ datetime
.timedelta( hours
= 1 )
536 self
.piPocoyo1
.endTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
537 self
.piPocoyo1
.channel
= "south-east.bbc2.bbc.co.uk"
539 self
.piPocoyo2
= rtv_programmeinfo
.ProgrammeInfo()
540 self
.piPocoyo2
.title
= "Pocoyo"
541 self
.piPocoyo2
.startTime
= dtnow
+ datetime
.timedelta( hours
= 2 )
542 self
.piPocoyo2
.endTime
= dtnow
+ datetime
.timedelta( hours
= 3 )
543 self
.piPocoyo2
.channel
= "south-east.bbc2.bbc.co.uk"
545 # 2 with identical subtitle
546 self
.piPocoyo3
= rtv_programmeinfo
.ProgrammeInfo()
547 self
.piPocoyo3
.title
= "Pocoyo"
548 self
.piPocoyo3
.sub_title
= "Subtitle we will see twice"
549 self
.piPocoyo3
.startTime
= dtnow
+ datetime
.timedelta( hours
= 3 )
550 self
.piPocoyo3
.endTime
= dtnow
+ datetime
.timedelta( hours
= 4 )
551 self
.piPocoyo3
.channel
= "south-east.bbc2.bbc.co.uk"
553 self
.piPocoyo4
= rtv_programmeinfo
.ProgrammeInfo()
554 self
.piPocoyo4
.title
= "Pocoyo"
555 self
.piPocoyo4
.sub_title
= "Subtitle we will see twice"
556 self
.piPocoyo4
.startTime
= dtnow
+ datetime
.timedelta( hours
= 4 )
557 self
.piPocoyo4
.endTime
= dtnow
+ datetime
.timedelta( hours
= 5 )
558 self
.piPocoyo4
.channel
= "south-east.bbc2.bbc.co.uk"
560 callback( self
.piPocoyo1
)
561 callback( self
.piPocoyo2
)
562 callback( self
.piPocoyo3
)
563 callback( self
.piPocoyo4
)
565 def read_favs_and_selections( self
, config
, record_only
):
566 favPocoyo
= rtv_favourite
.Favourite()
567 favPocoyo
.title_re
= "Pocoyo"
571 self
.schedule
.schedule( self
, self
, self
)
573 if not self
.remove_scheduled_events_called
:
574 raise Exception( "remove_scheduled_events never called" )
576 expected_queue
= [ self
.piPocoyo1
, self
.piPocoyo2
, self
.piPocoyo3
]
578 if self
.queue
!= expected_queue
:
579 raise Exception( ("queue should look like %s, but it"
581 % ( format_pi_list( [self
.piPocoyo3
] ),
582 format_pi_list( self
.queue
) ) )
586 p
= FakeScheduler( Schedule( config
) )
591 FakeScheduler_SameTitleSameDay( Schedule( config
) ).test()