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
8 # Navigation (RecordTimer)
9 import NavigationInstance
12 from ServiceReference import ServiceReference
13 from RecordTimer import RecordTimerEntry
14 from Components.TimerSanityCheck import TimerSanityCheck
17 from time import localtime, strftime, time, mktime
18 from datetime import timedelta, date
21 from enigma import eEPGCache, eServiceReference, eServiceCenter, iServiceInformation
23 from twisted.internet import reactor, defer
24 from twisted.python import failure
25 from threading import currentThread
29 from AutoTimerComponent import preferredAutoTimerComponent
31 from itertools import chain
32 from collections import defaultdict
33 from difflib import SequenceMatcher
34 from operator import itemgetter
36 from Plugins.SystemPlugins.Toolkit.SimpleThread import SimpleThread
39 from Plugins.Extensions.SeriesPlugin.plugin import renameTimer
40 except ImportError as ie:
43 from . import config, xrange, itervalues
45 XML_CONFIG = "/etc/enigma2/autotimer.xml"
47 def getTimeDiff(timer, begin, end):
48 if begin <= timer.begin <= end:
49 return end - timer.begin
50 elif timer.begin <= begin <= timer.end:
51 return timer.end - begin
54 def blockingCallFromMainThread(f, *a, **kw):
56 Modified version of twisted.internet.threads.blockingCallFromThread
57 which waits 30s for results and otherwise assumes the system to be shut down.
58 This is an ugly workaround for a twisted-internal deadlock.
59 Please keep the look intact in case someone comes up with a way
60 to reliably detect from the outside if twisted is currently shutting
64 def _callFromThread():
65 result = defer.maybeDeferred(f, *a, **kw)
66 result.addBoth(queue.put)
67 reactor.callFromThread(_callFromThread)
72 result = queue.get(True, 30)
73 except Queue.Empty as qe:
74 if True: #not reactor.running: # reactor.running is only False AFTER shutdown, we are during.
75 raise ValueError("Reactor no longer active, aborting.")
79 if isinstance(result, failure.Failure):
80 result.raiseException()
84 "exact": eEPGCache.EXAKT_TITLE_SEARCH,
85 "partial": eEPGCache.PARTIAL_TITLE_SEARCH,
90 "sensitive": eEPGCache.CASE_CHECK,
91 "insensitive": eEPGCache.NO_CASE_CHECK
95 """Read and save xml configuration, query EPGCache"""
100 self.configMtime = -1
101 self.uniqueTimerId = 0
102 self.defaultTimer = preferredAutoTimerComponent(
112 # Abort if no config found
113 if not os_path.exists(XML_CONFIG):
114 print("[AutoTimer] No configuration file present")
117 # Parse if mtime differs from whats saved
118 mtime = os_path.getmtime(XML_CONFIG)
119 if mtime == self.configMtime:
120 print("[AutoTimer] No changes in configuration, won't parse")
124 self.configMtime = mtime
127 configuration = cet_parse(XML_CONFIG).getroot()
129 # Empty out timers and reset Ids
131 self.defaultTimer.clear(-1, True)
136 configuration.get("version"),
140 self.uniqueTimerId = len(self.timers)
143 return buildConfig(self.defaultTimer, self.timers, webif = True)
146 with open(XML_CONFIG, 'w') as config:
147 config.writelines(buildConfig(self.defaultTimer, self.timers))
151 def add(self, timer):
152 self.timers.append(timer)
154 def getEnabledTimerList(self):
155 return (x for x in self.timers if x.enabled)
157 def getTimerList(self):
160 def getTupleTimerList(self):
162 return [(x,) for x in lst]
164 def getSortedTupleTimerList(self):
167 return [(x,) for x in lst]
169 def getUniqueId(self):
170 self.uniqueTimerId += 1
171 return self.uniqueTimerId
173 def remove(self, uniqueId):
175 for timer in self.timers:
176 if timer.id == uniqueId:
181 def set(self, timer):
183 for stimer in self.timers:
185 self.timers[idx] = timer
188 self.timers.append(timer)
190 def parseEPGAsync(self, simulateOnly = False):
191 t = SimpleThread(lambda: self.parseEPG(simulateOnly=simulateOnly))
197 def parseTimer(self, timer, epgcache, serviceHandler, recordHandler, checkEvtLimit, evtLimit, timers, conflicting, similars, timerdict, moviedict, simulateOnly=False):
201 # Precompute timer destination dir
202 dest = timer.destination or config.usage.default_path.value
204 # Workaround to allow search for umlauts if we know the encoding
206 if timer.encoding != 'UTF-8':
208 match = match.decode('UTF-8').encode(timer.encoding)
209 except UnicodeDecodeError:
212 if timer.searchType == "description":
214 mask = (eServiceReference.isMarker | eServiceReference.isDirectory)
216 casesensitive = timer.searchCase == "sensitive"
217 if not casesensitive:
218 match = match.lower()
220 # Service filter defined
221 # Search only using the specified services
222 test = [(service, 0, -1, -1) for service in timer.services]
224 for bouquet in timer.bouquets:
225 services = serviceHandler.list(eServiceReference(bouquet))
226 if not services is None:
228 service = services.getNext()
229 if not service.valid(): #check end of list
231 if not (service.flags & mask):
232 test.append( (service.toString(), 0, -1, -1 ) )
235 # No service filter defined
236 # Search within all services - could be very slow
240 refstr = '1:134:1:0:0:0:0:0:0:0:FROM BOUQUET \"bouquets.tv\" ORDER BY bouquet'
241 bouquetroot = eServiceReference(refstr)
242 mask = eServiceReference.isDirectory
243 if config.usage.multibouquet.value:
244 bouquets = serviceHandler.list(bouquetroot)
247 s = bouquets.getNext()
251 info = serviceHandler.info(s)
253 bouquetlist.append((info.getName(s), s))
255 info = serviceHandler.info(bouquetroot)
257 bouquetlist.append((info.getName(bouquetroot), bouquetroot))
260 mask = (eServiceReference.isMarker | eServiceReference.isDirectory)
261 for name, bouquet in bouquetlist:
262 if not bouquet.valid(): #check end of list
264 if bouquet.flags & eServiceReference.isDirectory:
265 services = serviceHandler.list(bouquet)
266 if not services is None:
268 service = services.getNext()
269 if not service.valid(): #check end of list
271 if not (service.flags & mask):
272 test.append( (service.toString(), 0, -1, -1 ) )
276 # eEPGCache.lookupEvent( [ format of the returned tuples, ( service, 0 = event intersects given start_time, start_time -1 for now_time), ] )
277 test.insert(0, 'RITBDSE')
278 allevents = epgcache.lookupEvent(test) or []
281 for serviceref, eit, name, begin, duration, shortdesc, extdesc in allevents:
282 if match in (shortdesc if casesensitive else shortdesc.lower()) \
283 or match in (extdesc if casesensitive else extdesc.lower()):
284 epgmatches.append( (serviceref, eit, name, begin, duration, shortdesc, extdesc) )
287 # Search EPG, default to empty list
288 epgmatches = epgcache.search( ('RITBDSE', 1000, typeMap[timer.searchType], match, caseMap[timer.searchCase]) ) or []
290 # Sort list of tuples by begin time 'B'
291 epgmatches.sort(key=itemgetter(3))
293 # Contains the the marked similar eits and the conflicting strings
294 similardict = defaultdict(list)
296 # Loop over all EPG matches
297 for idx, ( serviceref, eit, name, begin, duration, shortdesc, extdesc ) in enumerate( epgmatches ):
299 eserviceref = eServiceReference(serviceref)
300 evt = epgcache.lookupEventId(eserviceref, eit)
302 print("[AutoTimer] Could not create Event!")
304 # Try to determine real service (we always choose the last one)
305 n = evt.getNumOfLinkageServices()
307 i = evt.getLinkageService(eserviceref, n-1)
308 serviceref = i.toString()
311 evtEnd = end = begin + duration
313 # If event starts in less than 60 seconds skip it
314 if begin < time() + 60:
315 print("[AutoTimer] Skipping an event because it starts in less than 60 seconds")
319 timestamp = localtime(begin)
321 timer.update(begin, timestamp)
323 # Check if eit is in similar matches list
324 # NOTE: ignore evtLimit for similar timers as I feel this makes the feature unintuitive
326 if eit in similardict:
328 dayofweek = None # NOTE: ignore day on similar timer
330 # If maximum days in future is set then check time
335 dayofweek = str(timestamp.tm_wday)
337 # Check timer conditions
338 # NOTE: similar matches do not care about the day/time they are on, so ignore them
339 if timer.checkServices(serviceref) \
340 or timer.checkDuration(duration) \
341 or (not similarTimer and (\
342 timer.checkTimespan(timestamp) \
343 or timer.checkTimeframe(begin) \
344 )) or timer.checkFilter(name, shortdesc, extdesc, dayofweek):
347 if timer.hasOffset():
348 # Apply custom Offset
349 begin, end = timer.applyOffset(begin, end)
352 begin -= config.recording.margin_before.value * 60
353 end += config.recording.margin_after.value * 60
355 # Overwrite endtime if requested
356 if timer.justplay and not timer.setEndtime:
359 # Eventually change service to alternative
360 if timer.overrideAlternatives:
361 serviceref = timer.getAlternative(serviceref)
363 # Append to timerlist and abort if simulating
364 timers.append((name, begin, end, serviceref, timer.name))
368 # Check for existing recordings in directory
369 if timer.avoidDuplicateDescription == 3:
373 if dest and dest not in moviedict:
374 self.addDirectoryToMovieDict(moviedict, dest, serviceHandler)
375 for movieinfo in moviedict.get(dest, ()):
376 if self.checkSimilarity(timer, name, movieinfo.get("name"), shortdesc, movieinfo.get("shortdesc"), extdesc, movieinfo.get("extdesc") ):
377 print("[AutoTimer] We found a matching recorded movie, skipping event:", name)
387 # Check for double Timers
388 # We first check eit and if user wants us to guess event based on time
389 # we try this as backup. The allowed diff should be configurable though.
390 for rtimer in timerdict.get(serviceref, ()):
391 if rtimer.eit == eit or config.plugins.autotimer.try_guessing.value and getTimeDiff(rtimer, evtBegin, evtEnd) > ((duration/10)*8):
394 # Abort if we don't want to modify timers or timer is repeated
395 if config.plugins.autotimer.refresh.value == "none" or rtimer.repeated:
396 print("[AutoTimer] Won't modify existing timer because either no modification allowed or repeated timer")
399 if hasattr(rtimer, "isAutoTimer"):
400 rtimer.log(501, "[AutoTimer] AutoTimer %s modified this automatically generated timer." % (timer.name))
402 if config.plugins.autotimer.refresh.value != "all":
403 print("[AutoTimer] Won't modify existing timer because it's no timer set by us")
406 rtimer.log(501, "[AutoTimer] Warning, AutoTimer %s messed with a timer which might not belong to it." % (timer.name))
411 self.modifyTimer(rtimer, name, shortdesc, begin, end, serviceref)
413 elif timer.avoidDuplicateDescription >= 1 \
414 and not rtimer.disabled:
415 if self.checkSimilarity(timer, name, rtimer.name, shortdesc, rtimer.description, extdesc, rtimer.extdesc ):
416 # if searchForDuplicateDescription > 1 then check short description
418 print("[AutoTimer] We found a timer (similar service) with same description, skipping event")
421 # We found no timer we want to edit
423 # But there is a match
427 # We want to search for possible doubles
428 if timer.avoidDuplicateDescription >= 2:
429 for rtimer in chain.from_iterable( itervalues(timerdict) ):
430 if not rtimer.disabled:
431 if self.checkSimilarity(timer, name, rtimer.name, shortdesc, rtimer.description, extdesc, rtimer.extdesc ):
433 print("[AutoTimer] We found a timer (any service) with same description, skipping event")
438 if timer.checkCounter(timestamp):
439 print("[AutoTimer] Not adding new timer because counter is depleted.")
442 newEntry = RecordTimerEntry(ServiceReference(serviceref), begin, end, name, shortdesc, eit)
443 newEntry.log(500, "[AutoTimer] Try to add new timer based on AutoTimer %s." % (timer.name))
445 # Mark this entry as AutoTimer (only AutoTimers will have this Attribute set)
446 # It is only temporarily, after a restart it will be lost,
447 # because it won't be stored in the timer xml file
448 newEntry.isAutoTimer = True
451 if timer.hasAfterEvent():
452 afterEvent = timer.getAfterEventTimespan(localtime(end))
453 if afterEvent is None:
454 afterEvent = timer.getAfterEvent()
455 if afterEvent is not None:
456 newEntry.afterEvent = afterEvent
458 newEntry.dirname = timer.destination
459 newEntry.justplay = timer.justplay
460 newEntry.vpsplugin_enabled = timer.vps_enabled
461 newEntry.vpsplugin_overwrite = timer.vps_overwrite
463 if config.plugins.autotimer.add_autotimer_to_tags.value:
464 tags.append('AutoTimer')
465 if config.plugins.autotimer.add_name_to_tags.value:
466 tagname = timer.name.strip()
468 tagname = tagname[0].upper() + tagname[1:].replace(" ", "_")
473 # XXX: this won't perform a sanity check, but do we actually want to do so?
474 recordHandler.timeChanged(newEntry)
476 if renameTimer is not None and timer.series_labeling:
477 renameTimer(newEntry, name, evtBegin, evtEnd)
482 conflictString = similardict[eit].conflictString
483 newEntry.log(504, "[AutoTimer] Try to add similar Timer because of conflicts with %s." % (conflictString))
486 conflicts = recordHandler.record(newEntry)
489 # Maybe use newEntry.log
490 conflictString += ' / '.join(["%s (%s)" % (x.name, strftime("%Y%m%d %H%M", localtime(x.begin))) for x in conflicts])
491 print("[AutoTimer] conflict with %s detected" % (conflictString))
493 if config.plugins.autotimer.addsimilar_on_conflict.value:
494 # We start our search right after our actual index
495 # Attention we have to use a copy of the list, because we have to append the previous older matches
496 lepgm = len(epgmatches)
497 for i in xrange(lepgm):
498 servicerefS, eitS, nameS, beginS, durationS, shortdescS, extdescS = epgmatches[ (i+idx+1)%lepgm ]
499 if self.checkSimilarity(timer, name, nameS, shortdesc, shortdescS, extdesc, extdescS, force=True ):
500 # Check if the similar is already known
501 if eitS not in similardict:
502 print("[AutoTimer] Found similar Timer: " + name)
504 # Store the actual and similar eit and conflictString, so it can be handled later
505 newEntry.conflictString = conflictString
506 similardict[eit] = newEntry
507 similardict[eitS] = newEntry
509 if beginS <= evtBegin:
510 # Event is before our actual epgmatch so we have to append it to the epgmatches list
511 epgmatches.append((servicerefS, eitS, nameS, beginS, durationS, shortdescS, extdescS))
512 # If we need a second similar it will be found the next time
515 newEntry = similardict[eitS]
518 if conflicts is None:
519 timer.decrementCounter()
521 newEntry.extdesc = extdesc
522 timerdict[serviceref].append(newEntry)
524 if renameTimer is not None and timer.series_labeling:
525 renameTimer(newEntry, name, evtBegin, evtEnd)
527 # Similar timers are in new timers list and additionally in similar timers list
529 similars.append((name, begin, end, serviceref, timer.name))
532 # Don't care about similar timers
533 elif not similarTimer:
534 conflicting.append((name, begin, end, serviceref, timer.name))
536 if config.plugins.autotimer.disabled_on_conflict.value:
537 newEntry.log(503, "[AutoTimer] Timer disabled because of conflicts with %s." % (conflictString))
538 newEntry.disabled = True
539 # We might want to do the sanity check locally so we don't run it twice - but I consider this workaround a hack anyway
540 conflicts = recordHandler.record(newEntry)
541 return (new, modified)
543 def parseEPG(self, simulateOnly = False):
544 if NavigationInstance.instance is None:
545 print("[AutoTimer] Navigation is not available, can't parse EPG")
546 return (0, 0, 0, [], [], [])
554 if currentThread().getName() == 'MainThread':
555 doBlockingCallFromMainThread = lambda f, *a, **kw: f(*a, **kw)
557 doBlockingCallFromMainThread = blockingCallFromMainThread
559 # NOTE: the config option specifies "the next X days" which means today (== 1) + X
560 delta = timedelta(days = config.plugins.autotimer.maxdaysinfuture.value + 1)
561 evtLimit = mktime((date.today() + delta).timetuple())
562 checkEvtLimit = delta.days > 1
565 # Read AutoTimer configuration
569 epgcache = eEPGCache.getInstance()
570 serviceHandler = eServiceCenter.getInstance()
571 recordHandler = NavigationInstance.instance.RecordTimer
573 # Save Timer in a dict to speed things up a little
574 # We include processed timers as we might search for duplicate descriptions
575 # NOTE: It is also possible to use RecordTimer isInTimer(), but we won't get the timer itself on a match
576 timerdict = defaultdict(list)
577 doBlockingCallFromMainThread(self.populateTimerdict, epgcache, recordHandler, timerdict)
579 # Create dict of all movies in all folders used by an autotimer to compare with recordings
580 # The moviedict will be filled only if one AutoTimer is configured to avoid duplicate description for any recordings
581 moviedict = defaultdict(list)
584 for timer in self.getEnabledTimerList():
585 tup = doBlockingCallFromMainThread(self.parseTimer, timer, epgcache, serviceHandler, recordHandler, checkEvtLimit, evtLimit, timers, conflicting, similars, timerdict, moviedict, simulateOnly=simulateOnly)
589 return (len(timers), new, modified, timers, conflicting, similars)
591 # Supporting functions
593 def populateTimerdict(self, epgcache, recordHandler, timerdict):
594 for timer in chain(recordHandler.timer_list, recordHandler.processed_timers):
595 if timer and timer.service_ref:
596 if timer.eit is not None:
597 event = epgcache.lookupEventId(timer.service_ref.ref, timer.eit)
598 extdesc = event and event.getExtendedDescription() or ''
599 timer.extdesc = extdesc
600 elif not hasattr(timer, 'extdesc'):
602 timerdict[str(timer.service_ref)].append(timer)
604 def modifyTimer(self, timer, name, shortdesc, begin, end, serviceref):
606 timer.description = shortdesc
607 timer.begin = int(begin)
609 timer.service_ref = ServiceReference(serviceref)
611 def addDirectoryToMovieDict(self, moviedict, dest, serviceHandler):
612 movielist = serviceHandler.list(eServiceReference("2:0:1:0:0:0:0:0:0:0:" + dest))
613 if movielist is None:
614 print("[AutoTimer] listing of movies in " + dest + " failed")
616 append = moviedict[dest].append
618 movieref = movielist.getNext()
619 if not movieref.valid():
621 if movieref.flags & eServiceReference.mustDescent:
623 info = serviceHandler.info(movieref)
626 event = info.getEvent(movieref)
630 "name": info.getName(movieref),
631 "shortdesc": info.getInfoString(movieref, iServiceInformation.sDescription),
632 "extdesc": event.getExtendedDescription() or '' # XXX: does event.getExtendedDescription() actually return None on no description or an empty string?
635 def checkSimilarity(self, timer, name1, name2, shortdesc1, shortdesc2, extdesc1, extdesc2, force=False):
636 foundTitle = name1 == name2
637 foundShort = shortdesc1 == shortdesc2 if (timer.searchForDuplicateDescription > 0 or force) else True
639 # NOTE: only check extended if short description already is a match because otherwise
640 # it won't evaluate to True anyway
641 if (timer.searchForDuplicateDescription > 0 or force) and foundShort:
642 # Some channels indicate replays in the extended descriptions
643 # If the similarity percent is higher then 0.8 it is a very close match
644 foundExt = ( 0.8 < SequenceMatcher(lambda x: x == " ",extdesc1, extdesc2).ratio() )
646 return foundTitle and foundShort and foundExt