1 from __future__ import print_function
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 from Tools.IO import saveFile
9 # Navigation (RecordTimer)
10 import NavigationInstance
13 from ServiceReference import ServiceReference
14 from RecordTimer import RecordTimerEntry
15 from Components.TimerSanityCheck import TimerSanityCheck
18 from time import localtime, strftime, time, mktime
19 from datetime import timedelta, date
22 from enigma import eEPGCache, eServiceReference, eServiceCenter, iServiceInformation
24 from twisted.internet import reactor, defer
25 from twisted.python import failure
26 from threading import currentThread
30 from AutoTimerComponent import preferredAutoTimerComponent
32 from itertools import chain
33 from collections import defaultdict
34 from difflib import SequenceMatcher
35 from operator import itemgetter
37 from Plugins.SystemPlugins.Toolkit.SimpleThread import SimpleThread
40 from Plugins.Extensions.SeriesPlugin.plugin import renameTimer
41 except ImportError as ie:
45 from Plugins.Extensions.SeriesPlugin.plugin import getSeasonAndEpisode
46 except ImportError as ie:
47 getSeasonAndEpisode = None
49 from . import config, xrange, itervalues
51 XML_CONFIG = "/etc/enigma2/autotimer.xml"
53 def getTimeDiff(timer, begin, end):
54 if begin <= timer.begin <= end:
55 return end - timer.begin
56 elif timer.begin <= begin <= timer.end:
57 return timer.end - begin
60 def blockingCallFromMainThread(f, *a, **kw):
62 Modified version of twisted.internet.threads.blockingCallFromThread
63 which waits 30s for results and otherwise assumes the system to be shut down.
64 This is an ugly workaround for a twisted-internal deadlock.
65 Please keep the look intact in case someone comes up with a way
66 to reliably detect from the outside if twisted is currently shutting
70 def _callFromThread():
71 result = defer.maybeDeferred(f, *a, **kw)
72 result.addBoth(queue.put)
73 reactor.callFromThread(_callFromThread)
78 result = queue.get(True, config.plugins.autotimer.timeout.value*60)
79 except Queue.Empty as qe:
80 if True: #not reactor.running: # reactor.running is only False AFTER shutdown, we are during.
81 raise ValueError("Reactor no longer active, aborting.")
85 if isinstance(result, failure.Failure):
86 result.raiseException()
90 "exact": eEPGCache.EXAKT_TITLE_SEARCH,
91 "partial": eEPGCache.PARTIAL_TITLE_SEARCH,
92 "description": eEPGCache.PARTIAL_DESCRIPTION_SEARCH
96 "sensitive": eEPGCache.CASE_CHECK,
97 "insensitive": eEPGCache.NO_CASE_CHECK
101 """Read and save xml configuration, query EPGCache"""
106 self.configMtime = -1
107 self.uniqueTimerId = 0
108 self.defaultTimer = preferredAutoTimerComponent(
118 # Abort if no config found
119 if not os_path.exists(XML_CONFIG):
120 print("[AutoTimer] No configuration file present")
123 # Parse if mtime differs from whats saved
124 mtime = os_path.getmtime(XML_CONFIG)
125 if mtime == self.configMtime:
126 print("[AutoTimer] No changes in configuration, won't parse")
130 self.configMtime = mtime
133 configuration = cet_parse(XML_CONFIG).getroot()
135 # Empty out timers and reset Ids
137 self.defaultTimer.clear(-1, True)
142 configuration.get("version"),
146 self.uniqueTimerId = len(self.timers)
149 return buildConfig(self.defaultTimer, self.timers, webif = True)
152 # XXX: we probably want to indicate failures in some way :)
153 saveFile(XML_CONFIG, buildConfig(self.defaultTimer, self.timers))
157 def add(self, timer):
158 self.timers.append(timer)
160 def getEnabledTimerList(self):
161 return (x for x in self.timers if x.enabled)
163 def getTimerList(self):
166 def getTupleTimerList(self):
168 return [(x,) for x in lst]
170 def getSortedTupleTimerList(self):
173 return [(x,) for x in lst]
175 def getUniqueId(self):
176 self.uniqueTimerId += 1
177 return self.uniqueTimerId
179 def remove(self, uniqueId):
181 for timer in self.timers:
182 if timer.id == uniqueId:
187 def set(self, timer):
189 for stimer in self.timers:
191 self.timers[idx] = timer
194 self.timers.append(timer)
196 def parseEPGAsync(self, simulateOnly = False):
197 t = SimpleThread(lambda: self.parseEPG(simulateOnly=simulateOnly))
203 def parseTimer(self, timer, epgcache, serviceHandler, recordHandler, checkEvtLimit, evtLimit, timers, conflicting, similars, timerdict, moviedict, simulateOnly=False):
207 # Precompute timer destination dir
208 dest = timer.destination or config.usage.default_path.value
210 # Search EPG, default to empty list
211 epgmatches = epgcache.search( ('RITBDSE', 1000, typeMap[timer.searchType], timer.match, caseMap[timer.searchCase]) ) or []
213 # Sort list of tuples by begin time 'B'
214 epgmatches.sort(key=itemgetter(3))
216 # Contains the the marked similar eits and the conflicting strings
217 similardict = defaultdict(list)
219 # Loop over all EPG matches
220 for idx, ( serviceref, eit, name, begin, duration, shortdesc, extdesc ) in enumerate( epgmatches ):
222 print("[AutoTimer] possible epgmatch %s" % (name))
223 eserviceref = eServiceReference(serviceref)
224 evt = epgcache.lookupEventId(eserviceref, eit)
226 print("[AutoTimer] Could not create Event!")
228 # Try to determine real service (we always choose the last one)
229 n = evt.getNumOfLinkageServices()
231 i = evt.getLinkageService(eserviceref, n-1)
232 serviceref = i.toString()
235 evtEnd = end = begin + duration
237 # If event starts in less than 60 seconds skip it
238 if begin < time() + 60:
239 print("[AutoTimer] Skipping an event because it starts in less than 60 seconds")
243 timestamp = localtime(begin)
245 timer.update(begin, timestamp)
247 # Check if eit is in similar matches list
248 # NOTE: ignore evtLimit for similar timers as I feel this makes the feature unintuitive
250 if eit in similardict:
252 dayofweek = None # NOTE: ignore day on similar timer
254 # If maximum days in future is set then check time
259 dayofweek = str(timestamp.tm_wday)
261 # Check timer conditions
262 # NOTE: similar matches do not care about the day/time they are on, so ignore them
263 if timer.checkServices(serviceref) \
264 or timer.checkDuration(duration) \
265 or (not similarTimer and (\
266 timer.checkTimespan(timestamp) \
267 or timer.checkTimeframe(begin) \
268 )): # or timer.checkFilter(name, shortdesc, extdesc, dayofweek):
271 if timer.hasOffset():
272 # Apply custom Offset
273 begin, end = timer.applyOffset(begin, end)
276 begin -= config.recording.margin_before.value * 60
277 end += config.recording.margin_after.value * 60
279 # Overwrite endtime if requested
280 if timer.justplay and not timer.setEndtime:
283 # Eventually change service to alternative
284 if timer.overrideAlternatives:
285 serviceref = timer.getAlternative(serviceref)
288 # Check for existing recordings in directory
289 if timer.avoidDuplicateDescription == 3:
293 if dest and dest not in moviedict:
294 self.addDirectoryToMovieDict(moviedict, dest, serviceHandler)
295 for movieinfo in moviedict.get(dest, ()):
296 if self.checkDuplicates(timer, name, movieinfo.get("name"), shortdesc, movieinfo.get("shortdesc"), extdesc, movieinfo.get("extdesc") ):
297 print("[AutoTimer] We found a matching recorded movie, skipping event:", name)
307 # Check for double Timers
308 # We first check eit and if user wants us to guess event based on time
309 # we try this as backup. The allowed diff should be configurable though.
310 for rtimer in timerdict.get(serviceref, ()):
311 if rtimer.eit == eit:
313 print("[AutoTimer] We found a timer based on eit")
316 elif config.plugins.autotimer.try_guessing.value and getTimeDiff(rtimer, evtBegin, evtEnd) > ((duration/10)*8):
318 print("[AutoTimer] We found a timer based on time guessing")
321 elif timer.avoidDuplicateDescription >= 1 \
322 and not rtimer.disabled:
323 if self.checkDuplicates(timer, name, rtimer.name, shortdesc, rtimer.description, extdesc, rtimer.extdesc ):
324 # if searchForDuplicateDescription > 1 then check short description
326 print("[AutoTimer] We found a timer (similar service) with same description, skipping event")
329 # We found no timer we want to edit
331 # But there is a match
335 # We want to search for possible doubles
336 if timer.avoidDuplicateDescription >= 2:
337 for rtimer in chain.from_iterable( itervalues(timerdict) ):
338 if not rtimer.disabled:
339 if self.checkDuplicates(timer, name, rtimer.name, shortdesc, rtimer.description, extdesc, rtimer.extdesc ):
341 print("[AutoTimer] We found a timer (any service) with same description, skipping event")
346 if timer.checkCounter(timestamp):
347 print("[AutoTimer] Not adding new timer because counter is depleted.")
351 # Append to timerlist and abort if simulating
352 timers.append((name, begin, end, serviceref, timer.name))
357 if newEntry is not None:
358 # Abort if we don't want to modify timers or timer is repeated
359 if config.plugins.autotimer.refresh.value == "none" or newEntry.repeated:
360 print("[AutoTimer] Won't modify existing timer because either no modification allowed or repeated timer")
363 if hasattr(newEntry, "isAutoTimer"):
364 newEntry.log(501, "[AutoTimer] AutoTimer %s modified this automatically generated timer." % (timer.name))
366 if config.plugins.autotimer.refresh.value != "all":
367 print("[AutoTimer] Won't modify existing timer because it's no timer set by us")
370 newEntry.log(501, "[AutoTimer] Warning, AutoTimer %s messed with a timer which might not belong to it: %s ." % (timer.name, newEntry.name))
374 self.modifyTimer(newEntry, name, shortdesc, begin, end, serviceref, eit)
375 newEntry.log(501, "[AutoTimer] AutoTimer modified timer: %s ." % (newEntry.name))
378 newEntry = RecordTimerEntry(ServiceReference(serviceref), begin, end, name, shortdesc, eit)
379 newEntry.log(500, "[AutoTimer] Try to add new timer based on AutoTimer %s." % (timer.name))
381 # Mark this entry as AutoTimer (only AutoTimers will have this Attribute set)
382 # It is only temporarily, after a restart it will be lost,
383 # because it won't be stored in the timer xml file
384 newEntry.isAutoTimer = True
387 if getSeasonAndEpisode is not None and timer.series_labeling:
388 sp_timer = getSeasonAndEpisode(newEntry, name, evtBegin, evtEnd)
392 print("[AutoTimer SeriesPlugin] Returned name %s" % (name))
393 shortdesc = newEntry.description
394 print("[AutoTimer SeriesPlugin] Returned description %s" % (shortdesc))
396 if timer.checkFilter(name, shortdesc, extdesc, dayofweek):
401 if timer.hasAfterEvent():
402 afterEvent = timer.getAfterEventTimespan(localtime(end))
403 if afterEvent is None:
404 afterEvent = timer.getAfterEvent()
405 if afterEvent is not None:
406 newEntry.afterEvent = afterEvent
408 newEntry.dirname = timer.destination
409 newEntry.justplay = timer.justplay
410 newEntry.vpsplugin_enabled = timer.vps_enabled
411 newEntry.vpsplugin_overwrite = timer.vps_overwrite
413 if config.plugins.autotimer.add_autotimer_to_tags.value:
414 tags.append('AutoTimer')
415 if config.plugins.autotimer.add_name_to_tags.value:
416 tagname = timer.name.strip()
418 tagname = tagname[0].upper() + tagname[1:].replace(" ", "_")
423 # XXX: this won't perform a sanity check, but do we actually want to do so?
424 recordHandler.timeChanged(newEntry)
426 #if renameTimer is not None and timer.series_labeling:
427 # renameTimer(newEntry, name, evtBegin, evtEnd)
432 conflictString = similardict[eit].conflictString
433 newEntry.log(504, "[AutoTimer] Try to add similar Timer because of conflicts with %s." % (conflictString))
436 conflicts = recordHandler.record(newEntry)
439 # Maybe use newEntry.log
440 conflictString += ' / '.join(["%s (%s)" % (x.name, strftime("%Y%m%d %H%M", localtime(x.begin))) for x in conflicts])
441 print("[AutoTimer] conflict with %s detected" % (conflictString))
443 if config.plugins.autotimer.addsimilar_on_conflict.value:
444 # We start our search right after our actual index
445 # Attention we have to use a copy of the list, because we have to append the previous older matches
446 lepgm = len(epgmatches)
447 for i in xrange(lepgm):
448 servicerefS, eitS, nameS, beginS, durationS, shortdescS, extdescS = epgmatches[ (i+idx+1)%lepgm ]
449 if self.checkDuplicates(timer, name, nameS, shortdesc, shortdescS, extdesc, extdescS, force=True ):
450 # Check if the similar is already known
451 if eitS not in similardict:
452 print("[AutoTimer] Found similar Timer: " + name)
454 # Store the actual and similar eit and conflictString, so it can be handled later
455 newEntry.conflictString = conflictString
456 similardict[eit] = newEntry
457 similardict[eitS] = newEntry
459 if beginS <= evtBegin:
460 # Event is before our actual epgmatch so we have to append it to the epgmatches list
461 epgmatches.append((servicerefS, eitS, nameS, beginS, durationS, shortdescS, extdescS))
462 # If we need a second similar it will be found the next time
465 newEntry = similardict[eitS]
468 if conflicts is None:
469 timer.decrementCounter()
471 newEntry.extdesc = extdesc
472 timerdict[serviceref].append(newEntry)
474 #if renameTimer is not None and timer.series_labeling:
475 # renameTimer(newEntry, name, evtBegin, evtEnd)
477 # Similar timers are in new timers list and additionally in similar timers list
479 similars.append((name, begin, end, serviceref, timer.name))
482 # Don't care about similar timers
483 elif not similarTimer:
484 conflicting.append((name, begin, end, serviceref, timer.name))
486 if config.plugins.autotimer.disabled_on_conflict.value:
487 newEntry.log(503, "[AutoTimer] Timer disabled because of conflicts with %s." % (conflictString))
488 newEntry.disabled = True
489 # We might want to do the sanity check locally so we don't run it twice - but I consider this workaround a hack anyway
490 conflicts = recordHandler.record(newEntry)
491 return (new, modified)
493 def parseEPG(self, simulateOnly=False, callback=None):
494 if NavigationInstance.instance is None:
495 print("[AutoTimer] Navigation is not available, can't parse EPG")
496 return (0, 0, 0, [], [], [])
504 if currentThread().getName() == 'MainThread':
505 doBlockingCallFromMainThread = lambda f, *a, **kw: f(*a, **kw)
507 doBlockingCallFromMainThread = blockingCallFromMainThread
509 # NOTE: the config option specifies "the next X days" which means today (== 1) + X
510 delta = timedelta(days = config.plugins.autotimer.maxdaysinfuture.value + 1)
511 evtLimit = mktime((date.today() + delta).timetuple())
512 checkEvtLimit = delta.days > 1
515 # Read AutoTimer configuration
519 epgcache = eEPGCache.getInstance()
520 serviceHandler = eServiceCenter.getInstance()
521 recordHandler = NavigationInstance.instance.RecordTimer
523 # Save Timer in a dict to speed things up a little
524 # We include processed timers as we might search for duplicate descriptions
525 # NOTE: It is also possible to use RecordTimer isInTimer(), but we won't get the timer itself on a match
526 timerdict = defaultdict(list)
527 doBlockingCallFromMainThread(self.populateTimerdict, epgcache, recordHandler, timerdict)
529 # Create dict of all movies in all folders used by an autotimer to compare with recordings
530 # The moviedict will be filled only if one AutoTimer is configured to avoid duplicate description for any recordings
531 moviedict = defaultdict(list)
534 for timer in self.getEnabledTimerList():
535 tup = doBlockingCallFromMainThread(self.parseTimer, timer, epgcache, serviceHandler, recordHandler, checkEvtLimit, evtLimit, timers, conflicting, similars, timerdict, moviedict, simulateOnly=simulateOnly)
537 callback(timers, conflicting, similars)
545 return (len(timers), new, modified, timers, conflicting, similars)
547 # Supporting functions
549 def populateTimerdict(self, epgcache, recordHandler, timerdict):
550 for timer in chain(recordHandler.timer_list, recordHandler.processed_timers):
551 if timer and timer.service_ref:
552 if timer.eit is not None:
553 event = epgcache.lookupEventId(timer.service_ref.ref, timer.eit)
554 extdesc = event and event.getExtendedDescription() or ''
555 timer.extdesc = extdesc
556 elif not hasattr(timer, 'extdesc'):
558 timerdict[str(timer.service_ref)].append(timer)
560 def modifyTimer(self, timer, name, shortdesc, begin, end, serviceref, eit=None):
561 # Don't update the name, it will overwrite the name of the SeriesPlugin
563 timer.description = shortdesc
564 timer.begin = int(begin)
566 timer.service_ref = ServiceReference(serviceref)
570 def addDirectoryToMovieDict(self, moviedict, dest, serviceHandler):
571 movielist = serviceHandler.list(eServiceReference("2:0:1:0:0:0:0:0:0:0:" + dest))
572 if movielist is None:
573 print("[AutoTimer] listing of movies in " + dest + " failed")
575 append = moviedict[dest].append
577 movieref = movielist.getNext()
578 if not movieref.valid():
580 if movieref.flags & eServiceReference.mustDescent:
582 info = serviceHandler.info(movieref)
585 event = info.getEvent(movieref)
589 "name": info.getName(movieref),
590 "shortdesc": info.getInfoString(movieref, iServiceInformation.sDescription),
591 "extdesc": event.getExtendedDescription() or '' # XXX: does event.getExtendedDescription() actually return None on no description or an empty string?
594 def checkDuplicates(self, timer, name1, name2, shortdesc1, shortdesc2, extdesc1, extdesc2, force=False):
596 sequenceMatcher = SequenceMatcher(" ".__eq__, name1, name2)
600 ratio = sequenceMatcher.ratio()
601 print("[AutoTimer] names ratio %f - %s - %d - %s - %d" % (ratio, name1, len(name1), name2, len(name2)))
602 if name1 in name2 or (0.8 < ratio): # this is probably a match
604 if (force or timer.searchForDuplicateDescription > 0) and shortdesc1 and shortdesc2:
605 sequenceMatcher.set_seqs(shortdesc1, shortdesc2)
606 ratio = sequenceMatcher.ratio()
607 print("[AutoTimer] shortdesc ratio %f - %s - %d - %s - %d" % (ratio, shortdesc1, len(shortdesc1), shortdesc2, len(shortdesc2)))
608 foundShort = shortdesc1 in shortdesc2 or (0.8 < ratio)
611 # NOTE: only check extended if short description already is a match because otherwise
612 # it won't evaluate to True anyway
613 if foundShort and (force or timer.searchForDuplicateDescription > 1) and extdesc1 and extdesc2:
614 sequenceMatcher.set_seqs(extdesc1, extdesc2)
615 ratio = sequenceMatcher.ratio()
616 print("[AutoTimer] extdesc ratio %f - %s - %d - %s - %d" % (ratio, extdesc1, len(extdesc1), extdesc2, len(extdesc2)))
617 foundExt = (0.8 < ratio)
618 return foundShort and foundExt