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