Tidy up messages when scheduling recordings.
[recordtv.git] / src / rtv_schedule.py
blob58ea9b95259dcb6032ed4f5cb821281a44700af8
1 #!/usr/bin/python
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
9 # - logs
10 # - selections
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()
20 if pi1Pri > pi2Pri:
21 return -1
22 elif pi1Pri < pi2Pri:
23 return 1
24 elif pi1.startTime < pi2.startTime:
25 return -1
26 elif pi1.startTime > pi2.startTime:
27 return 1
29 return 0
31 class Schedule:
33 def __init__( self, config ):
34 self.config = 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" ):
42 return
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 ):
49 if delete_unused:
50 os.remove( full_path )
51 return
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 ):
60 ret = {}
62 dr_list = os.listdir( old_dir )
63 for fn in dr_list:
64 self.add_to_old_progs_map( old_dir, fn, ret, True )
66 for (dirpath, dirnames, filenames) in os.walk( converted_dir ):
67 for fn in filenames:
68 self.add_to_old_progs_map( dirpath, fn, ret, False )
70 return ret
72 def get_at_job( self, at_output ):
73 for ln in at_output:
74 m = at_output_re.match( ln )
75 if m:
76 return m.group( 1 )
77 print ( "** Unable to understand at command output '%s' - "
78 + "can't create a scheduled_events entry **" ) % at_output
79 return None
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 ):
89 print
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,
95 converted_dir )
97 new_queue = []
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 )
108 else:
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 (%d) is later than (%d) %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( losePi ) ) )
133 def remove_clashes( self ):
134 print
135 print "Skipped (clashes):"
137 self.record_queue.sort( priority_time_compare )
138 new_queue = []
140 for pi1Num in range( len( self.record_queue ) ):
141 pi1 = self.record_queue[pi1Num]
143 clashed_with = None
145 for pi2 in new_queue:
147 if pi1.clashes_with( pi2 ):
148 clashed_with = pi2
149 break
151 if not clashed_with:
152 new_queue.append( pi1 )
153 else:
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 )
158 else:
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
166 else:
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:
175 print
176 print "No programmes found to record."
177 return
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 )
183 print
184 print "Recording:"
186 for programmeInfo in queue:
188 print ( "%s (%s)"
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,
204 filename )
205 cmds_array = ( "at",
206 programmeInfo.startTime.strftime( "%H:%M %d.%m.%Y" ) )
208 if programmeInfo.filetype is not None:
209 filetype = programmeInfo.filetype
210 else:
211 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(
238 pi.channel )
239 if not pi.channel_pretty:
240 print (
241 "** Pretty channel name not found for channel %s **"
242 % pi.channel )
244 pi.priority = fav.priority
245 pi.destination = fav.destination
246 pi.unique_subtitles = fav.unique_subtitles
247 pi.filetype = fav.filetype
249 if fav.real_title:
250 pi.title = fav.real_title
252 self.record_queue.append( pi )
253 break
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()
263 pi.load( full_fn )
265 done_atrm = False
266 try:
267 at_job_start = int( pi.atJob )
268 rtv_utils.run_command( ( "atrm", str( at_job_start ) ) )
269 done_atrm = True
270 except ValueError:
271 pass
273 if done_atrm:
274 os.unlink( full_fn )
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()
290 pi.load( infofn )
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
298 os.unlink( full_fn )
299 os.unlink( infofn )
303 def schedule( self, xmltv_parser = rtv_utils, fav_reader = rtv_selection,
304 scheduler = None ):
306 if scheduler == None:
307 scheduler = self
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 )
332 sch.schedule()
338 # === Test code ===
340 def format_title_subtitle( pi ):
341 ret = pi.title
342 if pi.sub_title:
343 ret += " : "
344 ret += pi.sub_title
345 return ret
347 def format_pi_list( qu ):
348 ret = "[ "
349 for pi in qu[:-1]:
350 ret += format_title_subtitle( pi )
351 ret += ", "
352 if len( qu ) > 0:
353 ret += format_title_subtitle( qu[-1] )
354 ret += " ]"
356 return ret
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,
392 minutes = 30 )
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,
400 minutes = 30 )
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,
406 minutes = 25 )
407 self.piLost.endTime = dtnow + datetime.timedelta( hours = 2,
408 minutes = 30 )
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,
414 minutes = 30 )
415 self.piTurnip.endTime = dtnow + datetime.timedelta( hours = 2,
416 minutes = 30 )
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 ):
464 self.queue = queue
466 def test( self ):
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"
501 + " looks like %s" )
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"
515 + " looks like %s" )
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 ):
527 return {}
529 def parse_xmltv_files( self, config, callback ):
530 dtnow = datetime.datetime.today()
532 # 2 with no subtitle
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"
568 return [ favPocoyo ]
570 def test( self ):
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"
580 + " looks like %s" )
581 % ( format_pi_list( [self.piPocoyo3] ),
582 format_pi_list( self.queue ) ) )
585 def test( config ):
586 p = FakeScheduler( Schedule( config ) )
588 p.test()
589 p.test_norerecord()
591 FakeScheduler_SameTitleSameDay( Schedule( config ) ).test()