Only remove something that clashes with something we are really going to record ...
[recordtv.git] / src / rtv_schedule.py
blobddec1da534f6d4c4dcccfd2a122c0b27f046c654
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: prevent overlapping recordings
8 # TODO: delete old files:
9 # - scheduled recordings
10 # - logs
11 # - selections
13 MESSAGE_TIME_FORMAT = "%H:%M on %a"
15 at_output_re = re.compile( "job (\d+) at .*\n" )
16 num_re = re.compile( "\d+" )
18 def priority_time_compare( pi1, pi2 ):
19 pi1Pri = pi1.get_priority()
20 pi2Pri = pi2.get_priority()
22 if pi1Pri > pi2Pri:
23 return -1
24 elif pi1Pri < pi2Pri:
25 return 1
26 elif pi1.startTime < pi2.startTime:
27 return -1
28 elif pi1.startTime > pi2.startTime:
29 return 1
31 return 0
33 class Schedule:
35 def __init__( self, config ):
36 self.config = config
38 def get_at_job( self, at_output ):
39 for ln in at_output:
40 m = at_output_re.match( ln )
41 if m:
42 return m.group( 1 )
43 print ( "Unable to understand at command output '%s' - "
44 + "can't create a scheduled_events entry" ) % at_output
45 return None
47 def print_clash_priority_error( self, losePi, keepPi ):
48 print ( ("Not recording '%s' at %s - it clashes with '%s',"
49 + " which is higher priority (%d > %d).")
50 % ( losePi.title,
51 losePi.startTime.strftime(
52 MESSAGE_TIME_FORMAT ),
53 keepPi.title, keepPi.get_priority(),
54 losePi.get_priority() ) )
56 def print_clash_time_error( self, losePi, keepPi ):
57 print ( ("Not recording '%s' at %s - it clashes with '%s',"
58 + " which has the same priority (%d), but starts earlier"
59 + " (%s).")
60 % ( losePi.title,
61 losePi.startTime.strftime(
62 MESSAGE_TIME_FORMAT ),
63 keepPi.title, keepPi.get_priority(),
64 keepPi.startTime.strftime(
65 MESSAGE_TIME_FORMAT ) ) )
67 def print_clash_same_time( self, losePi, keepPi ):
68 print ( ("Not recording '%s' at %s - it clashes with '%s',"
69 + " which has the same priority (%d). They start at"
70 + " the same time, so the one to record was chosen"
71 + " randomly.")
72 % ( losePi.title,
73 losePi.startTime.strftime(
74 MESSAGE_TIME_FORMAT ),
75 keepPi.title, keepPi.get_priority() ) )
78 def remove_clashes( self ):
79 self.record_queue.sort( priority_time_compare )
80 new_queue = []
82 for pi1Num in range( len( self.record_queue ) ):
83 pi1 = self.record_queue[pi1Num]
85 clashed_with = None
87 for pi2 in new_queue:
89 if pi1.clashes_with( pi2 ):
90 clashed_with = pi2
91 break
93 if not clashed_with:
94 new_queue.append( pi1 )
95 else:
96 if pi1.get_priority() < clashed_with.get_priority():
97 self.print_clash_priority_error( pi1, clashed_with )
98 elif pi1.startTime > clashed_with.startTime:
99 self.print_clash_time_error( pi1, clashed_with )
100 else:
101 self.print_clash_same_time( pi1, clashed_with )
103 self.record_queue = new_queue
105 def schedule_recordings( self, queue ):
107 if len( queue ) == 0:
108 print "No programmes found to record."
109 return
111 rtv_utils.ensure_dir_exists( self.config.recorded_progs_dir )
112 rtv_utils.ensure_dir_exists( self.config.scheduled_events_dir )
113 rtv_utils.ensure_dir_exists( self.config.recording_log_dir )
115 for programmeInfo in queue:
117 print "Recording '%s' at '%s'" % ( programmeInfo.title,
118 programmeInfo.startTime.strftime( MESSAGE_TIME_FORMAT ) )
120 filename = rtv_utils.prepare_filename( programmeInfo.title )
121 filename += programmeInfo.startTime.strftime( "-%Y-%m-%d_%H_%M" )
123 length_timedelta = programmeInfo.endTime - programmeInfo.startTime
124 length_in_seconds = ( ( length_timedelta.days
125 * rtv_utils.SECS_IN_DAY ) + length_timedelta.seconds )
126 length_in_seconds += 60 * self.config.extra_recording_time_mins
128 sched_filename = os.path.join( self.config.scheduled_events_dir,
129 filename + ".rtvinfo" )
131 outfilename = os.path.join( self.config.recorded_progs_dir,
132 filename )
133 cmds_array = ( "at",
134 programmeInfo.startTime.strftime( "%H:%M %d.%m.%Y" ) )
135 at_output = rtv_utils.run_command_feed_input( cmds_array,
136 self.config.record_start_command % (
137 self.channel_xmltv2tzap.get_value( programmeInfo.channel ),
138 outfilename, length_in_seconds, sched_filename,
139 os.path.join( self.config.recording_log_dir,
140 filename + ".log" ) ) )
141 at_job_start = self.get_at_job( at_output )
143 if at_job_start != None:
144 programmeInfo.atJob = at_job_start
145 programmeInfo.save( sched_filename )
147 def sax_callback( self, pi ):
148 for fav in self.favs_and_sels:
149 if fav.matches( pi ):
150 dtnow = datetime.datetime.today()
151 if( pi.startTime > dtnow
152 and (pi.startTime - dtnow).days < 1 ):
154 if fav.deleteAfterDays:
155 pi.deleteTime = pi.endTime + datetime.timedelta(
156 float( fav.deleteAfterDays ), 0 )
158 pi.channel_pretty = self.channel_xmltv2tzap.get_value(
159 pi.channel )
160 if not pi.channel_pretty:
161 print (
162 "Pretty channel name not found for channel %s"
163 % pi.channel )
164 pi.priority = fav.priority
165 self.record_queue.append( pi )
166 break
168 def remove_scheduled_events( self ):
170 rtv_utils.ensure_dir_exists( self.config.scheduled_events_dir )
172 for fn in os.listdir( self.config.scheduled_events_dir ):
173 full_fn = os.path.join( self.config.scheduled_events_dir, fn )
175 pi = rtv_programmeinfo.ProgrammeInfo()
176 pi.load( full_fn )
178 at_job_start = pi.atJob
179 done_atrm = False
180 if num_re.match( at_job_start ):
181 rtv_utils.run_command( ( "atrm", at_job_start ) )
182 done_atrm = True
184 if done_atrm:
185 os.unlink( full_fn )
187 def delete_pending_recordings( self ):
188 rtv_utils.ensure_dir_exists( self.config.recorded_progs_dir )
190 dttoday = datetime.datetime.today()
192 for fn in os.listdir( self.config.recorded_progs_dir ):
193 if fn[-4:] == ".flv":
195 infofn = os.path.join( self.config.recorded_progs_dir,
196 fn[:-4] + ".rtvinfo" )
198 if os.path.isfile( infofn ):
200 pi = rtv_programmeinfo.ProgrammeInfo()
201 pi.load( infofn )
203 if pi.deleteTime and pi.deleteTime < dttoday:
204 full_fn = os.path.join(
205 self.config.recorded_progs_dir, fn )
207 print "Deleting file '%s'" % full_fn
209 os.unlink( full_fn )
210 os.unlink( infofn )
214 def schedule( self, xmltv_parser = rtv_utils, fav_reader = rtv_selection,
215 scheduler = None ):
217 if scheduler == None:
218 scheduler = self
220 # TODO: if we are generating a TV guide as well as scheduling,
221 # we should share the same XML parser.
223 self.delete_pending_recordings()
225 self.favs_and_sels = fav_reader.read_favs_and_selections(
226 self.config, record_only = True )
228 self.channel_xmltv2tzap = rtv_propertiesfile.PropertiesFile()
229 self.channel_xmltv2tzap.load( self.config.channel_xmltv2tzap_file )
231 scheduler.remove_scheduled_events()
233 self.record_queue = []
234 xmltv_parser.parse_xmltv_files( self.config, self.sax_callback )
235 self.remove_clashes()
236 scheduler.schedule_recordings( self.record_queue )
237 self.record_queue = []
240 def schedule( config ):
241 sch = Schedule( config )
242 sch.schedule()
248 # === Test code ===
250 def format_pi_list( qu ):
251 ret = "[ "
252 for pi in qu[:-1]:
253 ret += pi.title
254 ret += ", "
255 if len( qu ) > 0:
256 ret += qu[-1].title
257 ret += " ]"
259 return ret
261 class FakeScheduler:
263 def __init__( self ):
265 self.remove_scheduled_events_called = False
267 dtnow = datetime.datetime.today()
269 self.favHeroes = rtv_favourite.Favourite()
270 self.favHeroes.title_re = "Heroes"
272 self.favNewsnight = rtv_favourite.Favourite()
273 self.favNewsnight.title_re = "Newsnight.*"
274 self.favNewsnight.priority = -50
276 self.favLost = rtv_favourite.Favourite()
277 self.favLost.title_re = "Lost"
278 self.favLost.priority = -50
280 self.piHeroes = rtv_programmeinfo.ProgrammeInfo()
281 self.piHeroes.title = "Heroes"
282 self.piHeroes.startTime = dtnow + datetime.timedelta( hours = 1 )
283 self.piHeroes.endTime = dtnow + datetime.timedelta( hours = 2 )
284 self.piHeroes.channel = "south-east.bbc2.bbc.co.uk"
286 self.piNewsnight = rtv_programmeinfo.ProgrammeInfo()
287 self.piNewsnight.title = "Newsnight"
288 self.piNewsnight.startTime = dtnow + datetime.timedelta( hours = 1,
289 minutes = 30 )
290 self.piNewsnight.endTime = dtnow + datetime.timedelta( hours = 2 )
291 self.piNewsnight.channel = "south-east.bbc1.bbc.co.uk"
293 self.piNR = rtv_programmeinfo.ProgrammeInfo()
294 self.piNR.title = "Newsnight Review"
295 self.piNR.startTime = dtnow + datetime.timedelta( hours = 2 )
296 self.piNR.endTime = dtnow + datetime.timedelta( hours = 2,
297 minutes = 30 )
298 self.piNR.channel = "south-east.bbc2.bbc.co.uk"
300 self.piLost = rtv_programmeinfo.ProgrammeInfo()
301 self.piLost.title = "Lost"
302 self.piLost.startTime = dtnow + datetime.timedelta( hours = 1,
303 minutes = 25 )
304 self.piLost.endTime = dtnow + datetime.timedelta( hours = 2,
305 minutes = 30 )
306 self.piLost.channel = "channel4.com"
308 self.piTurnip = rtv_programmeinfo.ProgrammeInfo()
309 self.piTurnip.title = "Newsnight Turnip"
310 self.piTurnip.startTime = dtnow + datetime.timedelta( hours = 1,
311 minutes = 30 )
312 self.piTurnip.endTime = dtnow + datetime.timedelta( hours = 2,
313 minutes = 30 )
314 self.piTurnip.channel = "channel5.co.uk"
316 self.which_test = None
318 def parse_xmltv_files( self, config, callback ):
319 if self.which_test == "no_clash1":
320 callback( self.piNewsnight )
321 callback( self.piNR )
322 elif self.which_test == "priority_clash1":
323 callback( self.piHeroes )
324 callback( self.piNewsnight )
325 elif self.which_test == "time_clash1":
326 callback( self.piNewsnight )
327 callback( self.piLost )
328 elif self.which_test == "same_clash1":
329 callback( self.piNewsnight )
330 callback( self.piTurnip )
332 def remove_scheduled_events( self ):
333 if self.remove_scheduled_events_called:
334 raise Exception( "remove_scheduled_events called twice." )
335 self.remove_scheduled_events_called = True
337 def read_favs_and_selections( self, config ):
338 return [ self.favHeroes, self.favNewsnight, self.favLost ]
340 def schedule_recordings( self, queue ):
341 self.queue = queue
343 def test( self ):
344 self.which_test = "priority_clash1"
345 self.schedule.schedule( self, self, self )
346 if not self.remove_scheduled_events_called:
347 raise Exception( "remove_scheduled_events never called" )
348 if self.queue != [self.piHeroes]:
349 raise Exception( "queue should look like %s, but it looks like %s"
350 % ( [self.piHeroes], self.queue ) )
353 self.which_test = "no_clash1"
354 self.remove_scheduled_events_called = False
355 self.schedule.schedule( self, self, self )
356 if not self.remove_scheduled_events_called:
357 raise Exception( "remove_scheduled_events never called" )
358 if self.queue != [self.piNewsnight, self.piNR]:
359 raise Exception( "queue should look like %s, but it looks like %s"
360 % ( [self.piNewsnight, self.piNR], self.queue ) )
362 self.which_test = "time_clash1"
363 self.remove_scheduled_events_called = False
364 self.schedule.schedule( self, self, self )
365 if not self.remove_scheduled_events_called:
366 raise Exception( "remove_scheduled_events never called" )
367 if self.queue != [self.piLost]:
368 raise Exception( "queue should look like %s, but it looks like %s"
369 % ( [self.piLost], self.queue ) )
371 self.which_test = "same_clash1"
372 self.remove_scheduled_events_called = False
373 self.schedule.schedule( self, self, self )
374 if not self.remove_scheduled_events_called:
375 raise Exception( "remove_scheduled_events never called" )
376 if self.queue != [self.piNewsnight] and self.queue != [self.piTurnip]:
377 raise Exception( ("queue should look like %s or %s, but it"
378 + " looks like %s" )
379 % ( format_pi_list( [self.piNewsnight] ),
380 format_pi_list( [self.piTurnip] ),
381 format_pi_list( self.queue ) ) )
383 print "test_schedule Passed"
385 def test_schedule( config ):
386 p = FakeScheduler()
387 p.schedule = Schedule( config )
389 p.test()