Prevent malicious users from supplying directories containing ".." etc. for path...
[recordtv.git] / src / rtv_schedule.py
blob998c9093dbea5ec20dd492a307f6f788c5f512da
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 ( "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,
93 converted_dir )
95 new_queue = []
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 )
105 else:
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).")
116 % ( losePi.title,
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"
125 + " (%s).")
126 % ( losePi.title,
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"
137 + " randomly.")
138 % ( losePi.title,
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 )
146 new_queue = []
148 for pi1Num in range( len( self.record_queue ) ):
149 pi1 = self.record_queue[pi1Num]
151 clashed_with = None
153 for pi2 in new_queue:
155 if pi1.clashes_with( pi2 ):
156 clashed_with = pi2
157 break
159 if not clashed_with:
160 new_queue.append( pi1 )
161 else:
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 )
166 else:
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."
175 return
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:
184 st = ""
185 else:
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,
203 filename )
204 cmds_array = ( "at",
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(
230 pi.channel )
231 if not pi.channel_pretty:
232 print (
233 "Pretty channel name not found for channel %s"
234 % pi.channel )
236 pi.priority = fav.priority
237 pi.destination = fav.destination
238 pi.unique_subtitles = fav.unique_subtitles
240 if fav.real_title:
241 pi.title = fav.real_title
243 self.record_queue.append( pi )
244 break
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()
254 pi.load( full_fn )
256 done_atrm = False
257 try:
258 at_job_start = int( pi.atJob )
259 rtv_utils.run_command( ( "atrm", str( at_job_start ) ) )
260 done_atrm = True
261 except ValueError:
262 pass
264 if done_atrm:
265 os.unlink( full_fn )
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()
281 pi.load( infofn )
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
289 os.unlink( full_fn )
290 os.unlink( infofn )
294 def schedule( self, xmltv_parser = rtv_utils, fav_reader = rtv_selection,
295 scheduler = None ):
297 if scheduler == None:
298 scheduler = self
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 )
323 sch.schedule()
329 # === Test code ===
331 def format_title_subtitle( pi ):
332 ret = pi.title
333 if pi.sub_title:
334 ret += " : "
335 ret += pi.sub_title
336 return ret
338 def format_pi_list( qu ):
339 ret = "[ "
340 for pi in qu[:-1]:
341 ret += format_title_subtitle( pi )
342 ret += ", "
343 if len( qu ) > 0:
344 ret += format_title_subtitle( qu[-1] )
345 ret += " ]"
347 return ret
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,
383 minutes = 30 )
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,
391 minutes = 30 )
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,
397 minutes = 25 )
398 self.piLost.endTime = dtnow + datetime.timedelta( hours = 2,
399 minutes = 30 )
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,
405 minutes = 30 )
406 self.piTurnip.endTime = dtnow + datetime.timedelta( hours = 2,
407 minutes = 30 )
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 ):
455 self.queue = queue
457 def test( self ):
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"
492 + " looks like %s" )
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"
506 + " looks like %s" )
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 ):
518 return {}
520 def parse_xmltv_files( self, config, callback ):
521 dtnow = datetime.datetime.today()
523 # 2 with no subtitle
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"
559 return [ favPocoyo ]
561 def test( self ):
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"
571 + " looks like %s" )
572 % ( format_pi_list( [self.piPocoyo3] ),
573 format_pi_list( self.queue ) ) )
576 def test( config ):
577 p = FakeScheduler( Schedule( config ) )
579 p.test()
580 p.test_norerecord()
582 FakeScheduler_SameTitleSameDay( Schedule( config ) ).test()