Added more verbose error logging to flv conversion script, and ensured that the ...
[recordtv.git] / src / rtv_schedule.py
blobf7afcbc14e5f3171b6b81505d255017d93c196ba
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 add_to_old_progs_map( self, dr, fn, ret, delete_unused ):
37 if not fn.endswith( ".rtvinfo" ):
38 return
40 full_path = os.path.join( dr, fn )
41 proginfo = rtv_programmeinfo.ProgrammeInfo()
42 proginfo.load( full_path )
44 if proginfo.sub_title is None or proginfo.unique_subtitles == False:
45 if delete_unused:
46 os.remove( full_path )
47 return
49 if proginfo.title not in ret:
50 ret[proginfo.title] = {}
52 if proginfo.sub_title not in ret[proginfo.title]:
53 ret[proginfo.title][proginfo.sub_title] = 1
55 def find_old_programmes_map( self, old_dir, converted_dir ):
56 ret = {}
58 dr_list = os.listdir( old_dir )
59 for fn in dr_list:
60 self.add_to_old_progs_map( old_dir, fn, ret, True )
62 for (dirpath, dirnames, filenames) in os.walk( converted_dir ):
63 for fn in filenames:
64 self.add_to_old_progs_map( dirpath, fn, ret, False )
66 return ret
68 def get_at_job( self, at_output ):
69 for ln in at_output:
70 m = at_output_re.match( ln )
71 if m:
72 return m.group( 1 )
73 print ( "Unable to understand at command output '%s' - "
74 + "can't create a scheduled_events entry" ) % at_output
75 return None
77 def print_already_recorded( self, prog ):
78 print ( "Not recording '%s : %s' - we have recorded it in the past."
79 % ( prog.title, prog.sub_title ) )
81 def remove_already_recorded( self, scheduler ):
82 old_dir = os.path.join( self.config.recorded_progs_dir, "old" )
83 converted_dir = self.config.converted_progs_dir
84 old_progs_map = scheduler.find_old_programmes_map( old_dir,
85 converted_dir )
87 new_queue = []
89 for prog in self.record_queue:
90 if ( prog.title in old_progs_map and
91 prog.sub_title in old_progs_map[prog.title] ):
92 self.print_already_recorded( prog )
93 else:
94 new_queue.append( prog )
96 self.record_queue = new_queue
99 def print_clash_priority_error( self, losePi, keepPi ):
100 print ( ("Not recording '%s' at %s - it clashes with '%s',"
101 + " which is higher priority (%d > %d).")
102 % ( losePi.title,
103 losePi.startTime.strftime(
104 MESSAGE_TIME_FORMAT ),
105 keepPi.title, keepPi.get_priority(),
106 losePi.get_priority() ) )
108 def print_clash_time_error( self, losePi, keepPi ):
109 print ( ("Not recording '%s' at %s - it clashes with '%s',"
110 + " which has the same priority (%d), but starts earlier"
111 + " (%s).")
112 % ( losePi.title,
113 losePi.startTime.strftime(
114 MESSAGE_TIME_FORMAT ),
115 keepPi.title, keepPi.get_priority(),
116 keepPi.startTime.strftime(
117 MESSAGE_TIME_FORMAT ) ) )
119 def print_clash_same_time( self, losePi, keepPi ):
120 print ( ("Not recording '%s' at %s - it clashes with '%s',"
121 + " which has the same priority (%d). They start at"
122 + " the same time, so the one to record was chosen"
123 + " randomly.")
124 % ( losePi.title,
125 losePi.startTime.strftime(
126 MESSAGE_TIME_FORMAT ),
127 keepPi.title, keepPi.get_priority() ) )
130 def remove_clashes( self ):
131 self.record_queue.sort( priority_time_compare )
132 new_queue = []
134 for pi1Num in range( len( self.record_queue ) ):
135 pi1 = self.record_queue[pi1Num]
137 clashed_with = None
139 for pi2 in new_queue:
141 if pi1.clashes_with( pi2 ):
142 clashed_with = pi2
143 break
145 if not clashed_with:
146 new_queue.append( pi1 )
147 else:
148 if pi1.get_priority() < clashed_with.get_priority():
149 self.print_clash_priority_error( pi1, clashed_with )
150 elif pi1.startTime > clashed_with.startTime:
151 self.print_clash_time_error( pi1, clashed_with )
152 else:
153 self.print_clash_same_time( pi1, clashed_with )
155 self.record_queue = new_queue
157 def schedule_recordings( self, queue ):
159 if len( queue ) == 0:
160 print "No programmes found to record."
161 return
163 rtv_utils.ensure_dir_exists( self.config.recorded_progs_dir )
164 rtv_utils.ensure_dir_exists( self.config.scheduled_events_dir )
165 rtv_utils.ensure_dir_exists( self.config.recording_log_dir )
167 for programmeInfo in queue:
169 print "Recording '%s' at '%s'" % ( programmeInfo.title,
170 programmeInfo.startTime.strftime( MESSAGE_TIME_FORMAT ) )
172 filename = rtv_utils.prepare_filename( programmeInfo.title )
173 filename += programmeInfo.startTime.strftime( "-%Y-%m-%d_%H_%M" )
175 length_timedelta = programmeInfo.endTime - programmeInfo.startTime
176 length_in_seconds = ( ( length_timedelta.days
177 * rtv_utils.SECS_IN_DAY ) + length_timedelta.seconds )
178 length_in_seconds += 60 * self.config.extra_recording_time_mins
180 sched_filename = os.path.join( self.config.scheduled_events_dir,
181 filename + ".rtvinfo" )
183 outfilename = os.path.join( self.config.recorded_progs_dir,
184 filename )
185 cmds_array = ( "at",
186 programmeInfo.startTime.strftime( "%H:%M %d.%m.%Y" ) )
187 at_output = rtv_utils.run_command_feed_input( cmds_array,
188 self.config.record_start_command % (
189 self.channel_xmltv2tzap.get_value( programmeInfo.channel ),
190 outfilename, length_in_seconds, sched_filename,
191 os.path.join( self.config.recording_log_dir,
192 filename + ".log" ) ) )
193 at_job_start = self.get_at_job( at_output )
195 if at_job_start != None:
196 programmeInfo.atJob = at_job_start
197 programmeInfo.save( sched_filename )
199 def sax_callback( self, pi ):
200 for fav in self.favs_and_sels:
201 if fav.matches( pi ):
202 dtnow = datetime.datetime.today()
203 if( pi.startTime > dtnow
204 and (pi.startTime - dtnow).days < 1 ):
206 if fav.deleteAfterDays:
207 pi.deleteTime = pi.endTime + datetime.timedelta(
208 float( fav.deleteAfterDays ), 0 )
210 pi.channel_pretty = self.channel_xmltv2tzap.get_value(
211 pi.channel )
212 if not pi.channel_pretty:
213 print (
214 "Pretty channel name not found for channel %s"
215 % pi.channel )
217 pi.priority = fav.priority
218 pi.destination = fav.destination
219 pi.unique_subtitles = fav.unique_subtitles
221 if fav.real_title:
222 pi.title = fav.real_title
224 self.record_queue.append( pi )
225 break
227 def remove_scheduled_events( self ):
229 rtv_utils.ensure_dir_exists( self.config.scheduled_events_dir )
231 for fn in os.listdir( self.config.scheduled_events_dir ):
232 full_fn = os.path.join( self.config.scheduled_events_dir, fn )
234 pi = rtv_programmeinfo.ProgrammeInfo()
235 pi.load( full_fn )
237 done_atrm = False
238 try:
239 at_job_start = int( pi.atJob )
240 rtv_utils.run_command( ( "atrm", str( at_job_start ) ) )
241 done_atrm = True
242 except ValueError:
243 pass
245 if done_atrm:
246 os.unlink( full_fn )
248 def delete_pending_recordings( self ):
249 rtv_utils.ensure_dir_exists( self.config.recorded_progs_dir )
251 dttoday = datetime.datetime.today()
253 for fn in os.listdir( self.config.recorded_progs_dir ):
254 if fn[-4:] == ".flv":
256 infofn = os.path.join( self.config.recorded_progs_dir,
257 fn[:-4] + ".rtvinfo" )
259 if os.path.isfile( infofn ):
261 pi = rtv_programmeinfo.ProgrammeInfo()
262 pi.load( infofn )
264 if pi.deleteTime and pi.deleteTime < dttoday:
265 full_fn = os.path.join(
266 self.config.recorded_progs_dir, fn )
268 print "Deleting file '%s'" % full_fn
270 os.unlink( full_fn )
271 os.unlink( infofn )
275 def schedule( self, xmltv_parser = rtv_utils, fav_reader = rtv_selection,
276 scheduler = None ):
278 if scheduler == None:
279 scheduler = self
281 # TODO: if we are generating a TV guide as well as scheduling,
282 # we should share the same XML parser.
284 self.delete_pending_recordings()
286 self.favs_and_sels = fav_reader.read_favs_and_selections(
287 self.config, record_only = True )
289 self.channel_xmltv2tzap = rtv_propertiesfile.PropertiesFile()
290 self.channel_xmltv2tzap.load( self.config.channel_xmltv2tzap_file )
292 scheduler.remove_scheduled_events()
294 self.record_queue = []
295 xmltv_parser.parse_xmltv_files( self.config, self.sax_callback )
296 self.remove_already_recorded( scheduler )
297 self.remove_clashes()
298 scheduler.schedule_recordings( self.record_queue )
299 self.record_queue = []
302 def schedule( config ):
303 sch = Schedule( config )
304 sch.schedule()
310 # === Test code ===
312 def format_title_subtitle( pi ):
313 ret = pi.title
314 if pi.sub_title:
315 ret += " : "
316 ret += pi.sub_title
317 return ret
319 def format_pi_list( qu ):
320 ret = "[ "
321 for pi in qu[:-1]:
322 ret += format_title_subtitle( pi )
323 ret += ", "
324 if len( qu ) > 0:
325 ret += format_title_subtitle( qu[-1] )
326 ret += " ]"
328 return ret
330 class FakeScheduler:
332 def __init__( self ):
334 self.remove_scheduled_events_called = False
337 dtnow = datetime.datetime.today()
339 favHeroes = rtv_favourite.Favourite()
340 favHeroes.title_re = "Heroes"
342 favNewsnight = rtv_favourite.Favourite()
343 favNewsnight.title_re = "Newsnight.*"
344 favNewsnight.priority = -50
346 favLost = rtv_favourite.Favourite()
347 favLost.title_re = "Lost"
348 favLost.priority = -50
350 favPocoyo = rtv_favourite.Favourite()
351 favPocoyo.title_re = "Pocoyo"
353 self.test_favs = [ favHeroes, favNewsnight, favLost, favPocoyo ]
355 self.piHeroes = rtv_programmeinfo.ProgrammeInfo()
356 self.piHeroes.title = "Heroes"
357 self.piHeroes.startTime = dtnow + datetime.timedelta( hours = 1 )
358 self.piHeroes.endTime = dtnow + datetime.timedelta( hours = 2 )
359 self.piHeroes.channel = "south-east.bbc2.bbc.co.uk"
361 self.piNewsnight = rtv_programmeinfo.ProgrammeInfo()
362 self.piNewsnight.title = "Newsnight"
363 self.piNewsnight.startTime = dtnow + datetime.timedelta( hours = 1,
364 minutes = 30 )
365 self.piNewsnight.endTime = dtnow + datetime.timedelta( hours = 2 )
366 self.piNewsnight.channel = "south-east.bbc1.bbc.co.uk"
368 self.piNR = rtv_programmeinfo.ProgrammeInfo()
369 self.piNR.title = "Newsnight Review"
370 self.piNR.startTime = dtnow + datetime.timedelta( hours = 2 )
371 self.piNR.endTime = dtnow + datetime.timedelta( hours = 2,
372 minutes = 30 )
373 self.piNR.channel = "south-east.bbc2.bbc.co.uk"
375 self.piLost = rtv_programmeinfo.ProgrammeInfo()
376 self.piLost.title = "Lost"
377 self.piLost.startTime = dtnow + datetime.timedelta( hours = 1,
378 minutes = 25 )
379 self.piLost.endTime = dtnow + datetime.timedelta( hours = 2,
380 minutes = 30 )
381 self.piLost.channel = "channel4.com"
383 self.piTurnip = rtv_programmeinfo.ProgrammeInfo()
384 self.piTurnip.title = "Newsnight Turnip"
385 self.piTurnip.startTime = dtnow + datetime.timedelta( hours = 1,
386 minutes = 30 )
387 self.piTurnip.endTime = dtnow + datetime.timedelta( hours = 2,
388 minutes = 30 )
389 self.piTurnip.channel = "channel5.co.uk"
391 self.piPocoyo1 = rtv_programmeinfo.ProgrammeInfo()
392 self.piPocoyo1.title = "Pocoyo"
393 self.piPocoyo1.sub_title = "Subtitle already seen"
394 self.piPocoyo1.startTime = dtnow + datetime.timedelta( hours = 1 )
395 self.piPocoyo1.endTime = dtnow + datetime.timedelta( hours = 2 )
396 self.piPocoyo1.channel = "south-east.bbc2.bbc.co.uk"
398 self.piPocoyo2 = rtv_programmeinfo.ProgrammeInfo()
399 self.piPocoyo2.title = "Pocoyo"
400 self.piPocoyo2.sub_title = "Subtitle not seen"
401 self.piPocoyo2.startTime = dtnow + datetime.timedelta( hours = 2 )
402 self.piPocoyo2.endTime = dtnow + datetime.timedelta( hours = 3 )
403 self.piPocoyo2.channel = "south-east.bbc2.bbc.co.uk"
405 self.which_test = None
407 def find_old_programmes_map( self, old_dir, converted_dir ):
408 return { self.piPocoyo1.title : { self.piPocoyo1.sub_title : 1 } }
410 def parse_xmltv_files( self, config, callback ):
411 if self.which_test == "no_clash1":
412 callback( self.piNewsnight )
413 callback( self.piNR )
414 elif self.which_test == "priority_clash1":
415 callback( self.piHeroes )
416 callback( self.piNewsnight )
417 elif self.which_test == "time_clash1":
418 callback( self.piNewsnight )
419 callback( self.piLost )
420 elif self.which_test == "same_clash1":
421 callback( self.piNewsnight )
422 callback( self.piTurnip )
423 elif self.which_test == "norerecord":
424 callback( self.piPocoyo1 )
425 callback( self.piPocoyo2 )
427 def remove_scheduled_events( self ):
428 if self.remove_scheduled_events_called:
429 raise Exception( "remove_scheduled_events called twice." )
430 self.remove_scheduled_events_called = True
432 def read_favs_and_selections( self, config, record_only ):
433 return self.test_favs
435 def schedule_recordings( self, queue ):
436 self.queue = queue
438 def test( self ):
439 self.which_test = "priority_clash1"
440 self.schedule.schedule( self, self, self )
441 if not self.remove_scheduled_events_called:
442 raise Exception( "remove_scheduled_events never called" )
443 if self.queue != [self.piHeroes]:
444 raise Exception( "queue should look like %s, but it looks like %s"
445 % ( [self.piHeroes], self.queue ) )
448 self.which_test = "no_clash1"
449 self.remove_scheduled_events_called = False
450 self.schedule.schedule( self, self, self )
451 if not self.remove_scheduled_events_called:
452 raise Exception( "remove_scheduled_events never called" )
453 if self.queue != [self.piNewsnight, self.piNR]:
454 raise Exception( "queue should look like %s, but it looks like %s"
455 % ( [self.piNewsnight, self.piNR], self.queue ) )
457 self.which_test = "time_clash1"
458 self.remove_scheduled_events_called = False
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.piLost]:
463 raise Exception( "queue should look like %s, but it looks like %s"
464 % ( [self.piLost], self.queue ) )
466 self.which_test = "same_clash1"
467 self.remove_scheduled_events_called = False
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.piNewsnight] and self.queue != [self.piTurnip]:
472 raise Exception( ("queue should look like %s or %s, but it"
473 + " looks like %s" )
474 % ( format_pi_list( [self.piNewsnight] ),
475 format_pi_list( [self.piTurnip] ),
476 format_pi_list( self.queue ) ) )
478 def test_norerecord( self ):
479 self.which_test = "norerecord"
480 self.remove_scheduled_events_called = False
481 self.schedule.schedule( self, self, self )
483 if not self.remove_scheduled_events_called:
484 raise Exception( "remove_scheduled_events never called" )
485 if self.queue != [self.piPocoyo2]:
486 raise Exception( ("queue should look like %s, but it"
487 + " looks like %s" )
488 % ( format_pi_list( [self.piPocoyo2] ),
489 format_pi_list( self.queue ) ) )
491 def test( config ):
492 p = FakeScheduler()
493 p.schedule = Schedule( config )
495 p.test()
497 p.test_norerecord()