AutoTimer.py: smaller style change / micro-optimization
[enigma2-plugins.git] / autotimer / src / AutoTimer.py
1 # Plugins Config
2 from xml.etree.cElementTree import parse as cet_parse
3 from os import path as os_path
4 from AutoTimerConfiguration import parseConfig, buildConfig
5
6 # Navigation (RecordTimer)
7 import NavigationInstance
8
9 # Timer
10 from ServiceReference import ServiceReference
11 from RecordTimer import RecordTimerEntry
12 from Components.TimerSanityCheck import TimerSanityCheck
13
14 # Timespan
15 from time import localtime, strftime, time, mktime
16 from datetime import timedelta, date
17
18 # EPGCache & Event
19 from enigma import eEPGCache, eServiceReference, eServiceCenter, iServiceInformation
20
21 # Enigma2 Config
22 from Components.config import config
23
24 # AutoTimer Component
25 from AutoTimerComponent import preferredAutoTimerComponent
26
27 from itertools import chain
28 from collections import defaultdict
29 from difflib import SequenceMatcher
30
31 XML_CONFIG = "/etc/enigma2/autotimer.xml"
32
33 def getTimeDiff(timer, begin, end):
34         if begin <= timer.begin <= end:
35                 return end - timer.begin
36         elif timer.begin <= begin <= timer.end:
37                 return timer.end - begin
38         return 0
39
40 typeMap = {
41         "exact": eEPGCache.EXAKT_TITLE_SEARCH,
42         "partial": eEPGCache.PARTIAL_TITLE_SEARCH
43 }
44
45 caseMap = {
46         "sensitive": eEPGCache.CASE_CHECK,
47         "insensitive": eEPGCache.NO_CASE_CHECK
48 }
49
50 class AutoTimer:
51         """Read and save xml configuration, query EPGCache"""
52
53         def __init__(self):
54                 # Initialize
55                 self.timers = []
56                 self.configMtime = -1
57                 self.uniqueTimerId = 0
58                 self.defaultTimer = preferredAutoTimerComponent(
59                         0,              # Id
60                         "",             # Name
61                         "",             # Match
62                         True    # Enabled
63                 )
64
65 # Configuration
66
67         def readXml(self):
68                 # Abort if no config found
69                 if not os_path.exists(XML_CONFIG):
70                         print "[AutoTimer] No configuration file present"
71                         return
72
73                 # Parse if mtime differs from whats saved
74                 mtime = os_path.getmtime(XML_CONFIG)
75                 if mtime == self.configMtime:
76                         print "[AutoTimer] No changes in configuration, won't parse"
77                         return
78
79                 # Save current mtime
80                 self.configMtime = mtime
81
82                 # Parse Config
83                 configuration = cet_parse(XML_CONFIG).getroot()
84
85                 # Empty out timers and reset Ids
86                 del self.timers[:]
87                 self.defaultTimer.clear(-1, True)
88
89                 parseConfig(
90                         configuration,
91                         self.timers,
92                         configuration.get("version"),
93                         0,
94                         self.defaultTimer
95                 )
96                 self.uniqueTimerId = len(self.timers)
97
98         def getXml(self):
99                 return buildConfig(self.defaultTimer, self.timers, webif = True)
100
101         def writeXml(self):
102                 file = open(XML_CONFIG, 'w')
103                 file.writelines(buildConfig(self.defaultTimer, self.timers))
104                 file.close()
105
106 # Manage List
107
108         def add(self, timer):
109                 self.timers.append(timer)
110
111         def getEnabledTimerList(self):
112                 return (x for x in self.timers if x.enabled)
113
114         def getTimerList(self):
115                 return self.timers
116
117         def getTupleTimerList(self):
118                 lst = self.timers
119                 return [(x,) for x in lst]
120
121         def getSortedTupleTimerList(self):
122                 lst = self.timers[:]
123                 lst.sort()
124                 return [(x,) for x in lst]
125
126         def getUniqueId(self):
127                 self.uniqueTimerId += 1
128                 return self.uniqueTimerId
129
130         def remove(self, uniqueId):
131                 idx = 0
132                 for timer in self.timers:
133                         if timer.id == uniqueId:
134                                 self.timers.pop(idx)
135                                 return
136                         idx += 1
137
138         def set(self, timer):
139                 idx = 0
140                 for stimer in self.timers:
141                         if stimer == timer:
142                                 self.timers[idx] = timer
143                                 return
144                         idx += 1
145                 self.timers.append(timer)
146
147 # Main function
148
149         def parseEPG(self, simulateOnly = False):
150                 if NavigationInstance.instance is None:
151                         print "[AutoTimer] Navigation is not available, can't parse EPG"
152                         return (0, 0, 0, [], [], [])
153
154                 total = 0
155                 new = 0
156                 modified = 0
157                 timers = []
158                 conflicting = []
159                 similar = defaultdict(list)                     # Contains the the marked similar eits and the conflicting strings
160                 similars = []                                                                           # Contains the added similar timers
161
162                 # NOTE: the config option specifies "the next X days" which means today (== 1) + X
163                 delta = timedelta(days = config.plugins.autotimer.maxdaysinfuture.value + 1)
164                 evtLimit = mktime((date.today() + delta).timetuple())
165                 checkEvtLimit = delta.days > 1
166                 del delta
167
168                 # Read AutoTimer configuration
169                 self.readXml()
170
171                 # Get E2 instances
172                 epgcache = eEPGCache.getInstance()
173                 serviceHandler = eServiceCenter.getInstance()
174                 recordHandler = NavigationInstance.instance.RecordTimer
175
176                 # Save Recordings in a dict to speed things up a little
177                 # We include processed timers as we might search for duplicate descriptions
178                 # The recordict is always filled
179                 #Question: It might be better to name it timerdict
180                 #Question: Move to a separate function getTimerDict()
181                 #Note: It is also possible to use RecordTimer isInTimer(), but we won't get the timer itself on a match
182                 recorddict = defaultdict(list)
183                 for timer in ( recordHandler.timer_list + recordHandler.processed_timers ):
184                         if timer and timer.service_ref:
185                                 event = epgcache.lookupEventId(timer.service_ref.ref, timer.eit)
186                                 extdesc = event and event.getExtendedDescription()
187                                 timer.extdesc = extdesc
188                                 recorddict[str(timer.service_ref)].append(timer)
189
190                 # Create dict of all movies in all folders used by an autotimer to compare with recordings
191                 # The moviedict will be filled only if one AutoTimer is configured to avoid duplicate description for any recordings
192                 #Question: It might be better to name it recorddict
193                 moviedict = defaultdict(list)
194
195                 # Iterate Timer
196                 for timer in self.getEnabledTimerList():
197                         # Precompute timer destination dir
198                         dest = timer.destination or config.usage.default_path.value
199
200                         # Workaround to allow search for umlauts if we know the encoding
201                         match = timer.match
202                         if timer.encoding != 'UTF-8':
203                                 try:
204                                         match = match.decode('UTF-8').encode(timer.encoding)
205                                 except UnicodeDecodeError:
206                                         pass
207
208                         # Search EPG, default to empty list
209                         epgmatches = epgcache.search(('RITBDSE', 500, typeMap[timer.searchType], match, caseMap[timer.searchCase])) or []
210                         # Sort list of tuples by begin time 'B'
211                         epgmatches.sort(key = lambda x: x[3])
212
213                         # Reset the the marked similar servicerefs
214                         similar.clear()
215
216                         # Loop over all EPG matches
217                         for idx, ( serviceref, eit, name, begin, duration, shortdesc, extdesc ) in enumerate( epgmatches ):
218
219                                 #Question: Do we need this?
220                                 #Question: Move to separate function getRealService()
221                                 eserviceref = eServiceReference(serviceref)
222                                 evt = epgcache.lookupEventId(eserviceref, eit)
223                                 if not evt:
224                                         print "[AutoTimer] Could not create Event!"
225                                         continue
226                                 # Try to determine real service (we always choose the last one)
227                                 n = evt.getNumOfLinkageServices()
228                                 if n > 0:
229                                         i = evt.getLinkageService(eserviceref, n-1)
230                                         serviceref = i.toString()
231
232                                 evtBegin = begin
233                                 evtEnd = end = begin + duration
234
235                                 # If event starts in less than 60 seconds skip it
236                                 if begin < time() + 60:
237                                         print "[AutoTimer] Skipping an event because it starts in less than 60 seconds"
238                                         continue
239
240                                 # Convert begin time
241                                 timestamp = localtime(begin)
242                                 # Update timer
243                                 timer.update(begin, timestamp)
244
245                                 # Check if eit is in similar matches list
246                                 # NOTE: ignore evtLimit for similar timers as I feel this makes the feature unintuitive
247                                 similarTimer = False
248                                 if eit in similar:
249                                         similarTimer = True
250                                         dayofweek = None # NOTE: ignore day on similar timer
251                                 else:
252                                         # If maximum days in future is set then check time
253                                         if checkEvtLimit:
254                                                 if begin > evtLimit:
255                                                         continue
256
257                                         dayofweek = str(timestamp.tm_wday)
258
259                                 # Check timer conditions
260                                 # NOTE: similar matches to not care about the day/time they are on, so ignore them
261                                 if timer.checkServices(serviceref) \
262                                         or timer.checkDuration(duration) \
263                                         or (not similarTimer and (\
264                                                 timer.checkTimespan(timestamp) \
265                                                 or timer.checkTimeframe(begin) \
266                                         )) or timer.checkFilter(name, shortdesc, extdesc, dayofweek):
267                                         continue
268
269                                 if timer.hasOffset():
270                                         # Apply custom Offset
271                                         begin, end = timer.applyOffset(begin, end)
272                                 else:
273                                         # Apply E2 Offset
274                                         begin -= config.recording.margin_before.value * 60
275                                         end += config.recording.margin_after.value * 60
276
277                                 # Eventually change service to alternative
278                                 if timer.overrideAlternatives:
279                                         serviceref = timer.getAlternative(serviceref)
280
281                                 total += 1
282
283                                 # Append to timerlist and abort if simulating
284                                 timers.append((name, begin, end, serviceref, timer.name))
285                                 if simulateOnly:
286                                         continue
287
288                                 # Check for existing recordings in directory
289                                 if timer.avoidDuplicateDescription == 3:
290                                         # Reset movie Exists
291                                         movieExists = False
292
293                                         # Eventually create cache
294                                         if dest and dest not in moviedict:
295                                                 #Question: Move to a separate function getRecordDict()
296                                                 movielist = serviceHandler.list(eServiceReference("2:0:1:0:0:0:0:0:0:0:" + dest))
297                                                 if movielist is None:
298                                                         print "[AutoTimer] listing of movies in " + dest + " failed"
299                                                 else:
300                                                         append = moviedict[dest].append
301                                                         while 1:
302                                                                 movieref = movielist.getNext()
303                                                                 if not movieref.valid():
304                                                                         break
305                                                                 if movieref.flags & eServiceReference.mustDescent:
306                                                                         continue
307                                                                 info = serviceHandler.info(movieref)
308                                                                 if info is None:
309                                                                         continue
310                                                                 event = info.getEvent(movieref)
311                                                                 if event is None:
312                                                                         continue
313                                                                 append({
314                                                                         "name": info.getName(movieref),
315                                                                         "shortdesc": info.getInfoString(movieref, iServiceInformation.sDescription),
316                                                                         "extdesc": event.getExtendedDescription()
317                                                                 })
318                                                         del append
319
320                                         for movieinfo in moviedict.get(dest, ()):
321                                                 if movieinfo.get("name") == name \
322                                                         and movieinfo.get("shortdesc") == shortdesc:
323                                                         # Some channels indicate replays in the extended descriptions
324                                                         # If the similarity percent is higher then 0.8 it is a very close match
325                                                         extdescM = movieinfo.get("extdesc")
326                                                         if ( len(extdesc) == len(extdescM) and extdesc == extdescM ) \
327                                                                 or ( 0.8 < SequenceMatcher(lambda x: x == " ",extdesc, extdescM).ratio() ):
328                                                                 print "[AutoTimer] We found a matching recorded movie, skipping event:", name
329                                                                 movieExists = True
330                                                                 break
331
332                                         if movieExists:
333                                                 continue
334
335                                 # Initialize
336                                 newEntry = None
337                                 oldExists = False
338
339                                 # Check for double Timers
340                                 # We first check eit and if user wants us to guess event based on time
341                                 # we try this as backup. The allowed diff should be configurable though.
342                                 for rtimer in recorddict.get(serviceref, ()):
343                                         if rtimer.eit == eit or config.plugins.autotimer.try_guessing.value and getTimeDiff(rtimer, evtBegin, evtEnd) > ((duration/10)*8):
344                                                 oldExists = True
345
346                                                 # Abort if we don't want to modify timers or timer is repeated
347                                                 if config.plugins.autotimer.refresh.value == "none" or rtimer.repeated:
348                                                         print "[AutoTimer] Won't modify existing timer because either no modification allowed or repeated timer"
349                                                         break
350
351                                                 if hasattr(rtimer, "isAutoTimer"):
352                                                         rtimer.log(501, "[AutoTimer] AutoTimer %s modified this automatically generated timer." % (timer.name))
353                                                 else:
354                                                         if config.plugins.autotimer.refresh.value != "all":
355                                                                 print "[AutoTimer] Won't modify existing timer because it's no timer set by us"
356                                                                 break
357
358                                                         rtimer.log(501, "[AutoTimer] Warning, AutoTimer %s messed with a timer which might not belong to it." % (timer.name))
359
360                                                 newEntry = rtimer
361                                                 modified += 1
362
363                                                 # Modify values saved in timer
364                                                 newEntry.name = name
365                                                 newEntry.description = shortdesc
366                                                 newEntry.begin = int(begin)
367                                                 newEntry.end = int(end)
368                                                 newEntry.service_ref = ServiceReference(serviceref)
369
370                                                 break
371                                         elif timer.avoidDuplicateDescription >= 1 \
372                                                 and not rtimer.disabled \
373                                                 and rtimer.name == name \
374                                                 and rtimer.description == shortdesc:
375                                                         # Some channels indicate replays in the extended descriptions
376                                                         # If the similarity percent is higher then 0.8 it is a very close match
377                                                         if ( len(extdesc) == len(rtimer.extdesc) and extdesc == rtimer.extdesc ) \
378                                                                 or ( 0.8 < SequenceMatcher(lambda x: x == " ",extdesc, rtimer.extdesc).ratio() ):
379                                                                 oldExists = True
380                                                                 print "[AutoTimer] We found a timer (similar service) with same description, skipping event"
381                                                                 break
382
383                                 # We found no timer we want to edit
384                                 if newEntry is None:
385                                         # But there is a match
386                                         if oldExists:
387                                                 continue
388
389                                         # We want to search for possible doubles
390                                         if timer.avoidDuplicateDescription >= 2:
391                                                 for rtimer in chain.from_iterable( recorddict.values() ):
392                                                         if not rtimer.disabled \
393                                                                 and rtimer.name == name \
394                                                                 and rtimer.description == shortdesc:
395                                                                         # Some channels indicate replays in the extended descriptions
396                                                                         # If the similarity percent is higher then 0.8 it is a very close match
397                                                                         if ( len(extdesc) == len(rtimer.extdesc) and extdesc == rtimer.extdesc ) \
398                                                                                 or ( 0.8 < SequenceMatcher(lambda x: x == " ",extdesc, rtimer.extdesc).ratio() ):
399                                                                                 oldExists = True
400                                                                                 print "[AutoTimer] We found a timer (any service) with same description, skipping event"
401                                                                                 break
402                                                 if oldExists:
403                                                         continue
404
405                                         if timer.checkCounter(timestamp):
406                                                 print "[AutoTimer] Not adding new timer because counter is depleted."
407                                                 continue
408
409                                         newEntry = RecordTimerEntry(ServiceReference(serviceref), begin, end, name, shortdesc, eit)
410                                         newEntry.log(500, "[AutoTimer] Try to add new timer based on AutoTimer %s." % (timer.name))
411
412                                         # Mark this entry as AutoTimer (only AutoTimers will have this Attribute set)
413                                         # It is only temporarily, after a restart it will be lost,
414                                         # because it won't be stored in the timer xml file
415                                         newEntry.isAutoTimer = True
416
417                                 # Apply afterEvent
418                                 if timer.hasAfterEvent():
419                                         afterEvent = timer.getAfterEventTimespan(localtime(end))
420                                         if afterEvent is None:
421                                                 afterEvent = timer.getAfterEvent()
422                                         if afterEvent is not None:
423                                                 newEntry.afterEvent = afterEvent
424
425                                 newEntry.dirname = timer.destination
426                                 newEntry.justplay = timer.justplay
427                                 newEntry.tags = timer.tags
428                                 newEntry.vpsplugin_enabled = timer.vps_enabled
429                                 newEntry.vpsplugin_overwrite = timer.vps_overwrite
430
431                                 if oldExists:
432                                         # XXX: this won't perform a sanity check, but do we actually want to do so?
433                                         recordHandler.timeChanged(newEntry)
434                                 else:
435                                         conflictString = ""
436                                         if similarTimer:
437                                                 conflictString = similar[eit].conflictString
438                                                 newEntry.log(504, "[AutoTimer] Try to add similar Timer because of conflicts with %s." % (conflictString))
439
440                                         # Try to add timer
441                                         conflicts = recordHandler.record(newEntry)
442
443                                         if conflicts:
444                                                 conflictString += ' / '.join(["%s (%s)" % (x.name, strftime("%Y%m%d %H%M", localtime(x.begin))) for x in conflicts])
445                                                 print "[AutoTimer] conflict with %s detected" % (conflictString)
446
447                                         if conflicts and config.plugins.autotimer.addsimilar_on_conflict.value:
448                                                 # We start our search right after our actual index
449                                                 # Attention we have to use a copy of the list, because we have to append the previous older matches
450                                                 lepgm = len(epgmatches)
451                                                 for i in xrange(lepgm):
452                                                         servicerefS, eitS, nameS, beginS, durationS, shortdescS, extdescS = epgmatches[ (i+idx+1)%lepgm ]
453                                                         if shortdesc == shortdescS:
454                                                                 # Some channels indicate replays in the extended descriptions
455                                                                 # If the similarity percent is higher then 0.8 it is a very close match
456                                                                 if ( len(extdesc) == len(extdescS) and extdesc == extdescS ) \
457                                                                         or ( 0.8 < SequenceMatcher(lambda x: x == " ",extdesc, extdescS).ratio() ):
458                                                                         # Check if the similar is already known
459                                                                         if eitS not in similar:
460                                                                                 print "[AutoTimer] Found similar Timer: " + name
461
462                                                                                 # Store the actual and similar eit and conflictString, so it can be handled later
463                                                                                 newEntry.conflictString = conflictString
464                                                                                 similar[eit] = newEntry
465                                                                                 similar[eitS] = newEntry
466                                                                                 similarTimer = True
467                                                                                 if beginS <= evtBegin:
468                                                                                         # Event is before our actual epgmatch so we have to append it to the epgmatches list
469                                                                                         epgmatches.append((servicerefS, eitS, nameS, beginS, durationS, shortdescS, extdescS))
470                                                                                 # If we need a second similar it will be found the next time
471                                                                                 break
472                                                                         else:
473                                                                                 similarTimer = False
474                                                                                 newEntry = similar[eitS]
475                                                                                 break
476
477                                         if conflicts is None:
478                                                 timer.decrementCounter()
479                                                 new += 1
480                                                 timer.extdesc = extdesc
481                                                 recorddict[serviceref].append(newEntry)
482
483                                                 # Similar timers are in new timers list and additionally in similar timers list
484                                                 if similarTimer:
485                                                         similars.append((name, begin, end, serviceref, timer.name))
486                                                         similar.clear()
487
488                                         # Don't care about similar timers
489                                         elif not similarTimer:
490                                                 conflicting.append((name, begin, end, serviceref, timer.name))
491
492                                                 if config.plugins.autotimer.disabled_on_conflict.value:
493                                                         newEntry.log(503, "[AutoTimer] Timer disabled because of conflicts with %s." % (conflictString))
494                                                         newEntry.disabled = True
495                                                         # We might want to do the sanity check locally so we don't run it twice - but I consider this workaround a hack anyway
496                                                         conflicts = recordHandler.record(newEntry)
497
498                 return (total, new, modified, timers, conflicting, similars)