Exemple #1
0
class BaseService:
    SERVICE_DEPRECATED = False

    STATE_ERROR = -1
    STATE_UNINITIALIZED = 0

    STATE_DO_CONFIG = 1
    STATE_DO_OAUTH = 2
    STATE_NEED_KEYWORDS = 3
    STATE_NO_IMAGES = 4

    STATE_READY = 999

    def __init__(self, configDir, id, name, needConfig=False, needOAuth=False):
        # MUST BE CALLED BY THE IMPLEMENTING CLASS!
        self._ID = id
        self._NAME = name
        self._OAUTH = None
        self._CACHEMGR = None

        self._CURRENT_STATE = BaseService.STATE_UNINITIALIZED
        self._ERROR = None

        # NUM_IMAGES keeps track of how many images are being provided by each keyword
        # As for now, unsupported images (mimetype, orientation) and already displayed images are NOT excluded due to simplicity,
        # but it should still serve as a rough estimate to ensure that every image has a similar chance of being shown in "random_image_mode"!
        self._STATE = {
            '_OAUTH_CONFIG': None,
            '_OAUTH_CONTEXT': None,
            '_CONFIG': None,
            '_KEYWORDS': [],
            '_NUM_IMAGES': {},
            '_EXTRAS': None
        }
        self._NEED_CONFIG = needConfig
        self._NEED_OAUTH = needOAuth

        self._DIR_BASE = self._prepareFolders(configDir)
        self._DIR_MEMORY = os.path.join(self._DIR_BASE, 'memory')
        self._DIR_PRIVATE = os.path.join(self._DIR_BASE, 'private')
        self._FILE_STATE = os.path.join(self._DIR_BASE, 'state.json')

        # MEMORY stores unique image ids of already displayed images
        # it prevents an image from being shown multiple times, before ALL images have been displayed
        # From time to time, memory is saved to disk as a backup.
        self._MEMORY = None
        self._MEMORY_KEY = None

        # HISTORY stores (keywordId, imageId)-pairs
        # That way it can be useful to determine any previously displayed image
        # Unlike memory, the history is only stored in RAM
        self._HISTORY = []

        self.resetIndices()

        self.loadState()
        self.preSetup()

    def setCacheManager(self, cacheMgr):
        self._CACHEMGR = cacheMgr

    def _prepareFolders(self, configDir):
        basedir = os.path.join(configDir, self._ID)
        if not os.path.exists(basedir):
            os.mkdir(basedir)
        if not os.path.exists(basedir + '/memory'):
            os.mkdir(basedir + '/memory')
        if not os.path.exists(basedir + '/private'):
            os.mkdir(basedir + '/private')
        return basedir

    ###[ Used by service to do any kind of house keeping ]###########################

    def preSetup(self):
        # If you need to do anything before initializing, override this
        # NOTE! No auth or oauth has been done at this point, only state has been loaded
        pass

    def postSetup(self):
        # If you need to do anything right after initializing, override this
        # NOTE! At this point, any auth and/or oauth will have been performed. State is not saved after this call
        pass

    ###[ Used by photoframe to determinte what to do next ]###########################

    def updateState(self):
        # Determines what the user needs to do next to configure this service
        # if this doesn't return ready, caller must take appropiate action
        if self._NEED_OAUTH and self._OAUTH is None:
            self._OAUTH = OAuth(self._setOAuthToken, self._getOAuthToken,
                                self.getOAuthScope(), self._ID)
            if self._STATE['_OAUTH_CONFIG'] is not None:
                self._OAUTH.setOAuth(self._STATE['_OAUTH_CONFIG'])
                self.postSetup()

        if self._NEED_CONFIG and not self.hasConfiguration():
            self._CURRENT_STATE = BaseService.STATE_DO_CONFIG
        elif self._NEED_OAUTH and (not self.hasOAuthConfig
                                   or not self.hasOAuth()):
            self._CURRENT_STATE = BaseService.STATE_DO_OAUTH
        elif self.needKeywords() and len(self.getKeywords()) == 0:
            self._CURRENT_STATE = BaseService.STATE_NEED_KEYWORDS
        elif self.getNumImages() == 0:
            self._CURRENT_STATE = BaseService.STATE_NO_IMAGES
        else:
            self._CURRENT_STATE = BaseService.STATE_READY

        return self._CURRENT_STATE

    ###[ Allows loading/saving of service state ]###########################

    def loadState(self):
        # Load any stored state data from storage
        # Normally you don't override this
        if os.path.exists(self._FILE_STATE):
            try:
                with open(self._FILE_STATE, 'r') as f:
                    self._STATE.update(json.load(f))
            except:
                logging.exception('Unable to load state for service')
                os.unlink(self._FILE_STATE)

    def saveState(self):
        # Stores the state data under the unique ID for
        # this service provider's instance
        # normally you don't override this
        with open(self._FILE_STATE, 'w') as f:
            json.dump(self._STATE, f)

    ###[ Get info about instance ]###########################

    def getName(self):
        # Retrieves the name of this instance
        return self._NAME

    def setName(self, newName):
        self._NAME = newName

    def getId(self):
        return self._ID

    def getNumImages(self, excludeUnsuported=True):
        # return the total number of images provided by this service
        if self.needKeywords():
            for keyword in self.getKeywords():
                if keyword not in self._STATE["_NUM_IMAGES"]:
                    images = self.getImagesFor(keyword)
                    if images is not None:
                        self._STATE["_NUM_IMAGES"][keyword] = len(images)
        return sum([
            self._STATE["_NUM_IMAGES"][k] for k in self._STATE["_NUM_IMAGES"]
        ])

    def getMessages(self):
        # override this if you wish to show a message associated with
        # the provider's instance. Return None to hide
        # Format: [{'level' : 'INFO', 'message' : None, 'link' : None}]
        msgs = []
        if self._CURRENT_STATE in [
                self.STATE_NEED_KEYWORDS, self.STATE_NO_IMAGES
        ]:
            msgs.append({
                'level': 'INFO',
                'message':
                'Please add one or more items in order to show photos from this provider (see help button)',
                'link': None
            })
        if 0 in self._STATE["_NUM_IMAGES"].values():
            msgs.append({
                'level':
                'WARNING',
                'message':
                'At least one keyword does not appear to provide any images! Please remove keyword(s) %s'
                % str([
                    str(keyword) for keyword in self._STATE["_KEYWORDS"]
                    if self._STATE["_NUM_IMAGES"][keyword] == 0
                ]),
                'link':
                None
            })
        return msgs

    def explainState(self):
        # override this if you wish to show additional on-screen information for a specific state
        # return String
        return None

    ###[ All the OAuth functionality ]###########################

    def getOAuthScope(self):
        # *Override* to define any needed OAuth scope
        # must return array of string(s)
        return None

    def setOAuthConfig(self, config):
        # Provides OAuth config data for linking.
        # Without this information, OAuth cannot be done.
        # If config is invalid, returns False
        self._STATE['_OAUTH_CONFIG'] = config
        if self._OAUTH is not None:
            self._OAUTH.setOAuth(self._STATE['_OAUTH_CONFIG'])
            self.postSetup()

        self.saveState()
        return True

    def helpOAuthConfig(self):
        return 'Should explain what kind of content to provide'

    def hasOAuthConfig(self):
        # Returns true/false if we have a config for oauth
        return self._STATE['_OAUTH_CONFIG'] is not None

    def hasOAuth(self):
        # Tests if we have a functional OAuth link,
        # returns False if we need to set it up
        return self._STATE['_OAUTH_CONTEXT'] is not None

    def startOAuth(self):
        # Returns a HTTP redirect to begin OAuth or None if
        # oauth isn't configured. Normally not overriden
        return self._OAUTH.initiate()

    def finishOAuth(self, url):
        # Called when OAuth sequence has completed
        self._OAUTH.complete(url)
        self.saveState()

    def _setOAuthToken(self, token):
        self._STATE['_OAUTH_CONTEXT'] = token
        self.saveState()

    def _getOAuthToken(self):
        return self._STATE['_OAUTH_CONTEXT']

    def migrateOAuthToken(self, token):
        if self._STATE['_OAUTH_CONTEXT'] is not None:
            logging.error('Cannot migrate token, already have one!')
            return
        logging.debug('Setting token to %s' % repr(token))
        self._STATE['_OAUTH_CONTEXT'] = token
        self.saveState()

    ###[ For services which require static auth ]###########################

    def validateConfiguration(self, config):
        # Allow service to validate config, if correct, return None
        # If incorrect, return helpful error message.
        # config is a map with fields and their values
        return 'Not overriden yet but config is enabled'

    def setConfiguration(self, config):
        # Setup any needed authentication data for this
        # service.
        self._STATE['_CONFIG'] = config
        self.saveState()

    def getConfiguration(self):
        return self._STATE['_CONFIG']

    def hasConfiguration(self):
        # Checks if it has auth data
        return self._STATE['_CONFIG'] != None

    def getConfigurationFields(self):
        # Returns a key/value map with:
        # "field" => [ "type" => "STR/INT", "name" => "Human readable", "description" => "Longer text" ]
        # Allowing UX to be dynamically created
        # Supported field types are: STR, INT, PW (password means it will obscure it on input)
        return {
            'username': {
                'type': 'STR',
                'name': 'Username',
                'description': 'Username to use for login'
            }
        }

    ###[ Keyword management ]###########################

    def resetIndices(self):
        self.keywordIndex = 0
        self.imageIndex = 0

    def resetToLastAlbum(self):
        self.keywordIndex = max(0, len(self.getKeywords()) - 1)
        self.imageIndex = 0

    def validateKeywords(self, keywords):
        # Quick check, don't allow duplicates!
        if keywords in self.getKeywords():
            logging.error('Keyword is already in list')
            return {'error': 'Keyword already in list', 'keywords': keywords}

        return {'error': None, 'keywords': keywords}

    def addKeywords(self, keywords):
        # This is how the user will configure it, this adds a new set of keywords to this
        # service module. Return none on success, string with error on failure
        keywords = keywords.strip()

        if not self.needKeywords():
            return {'error': 'Doesn\'t use keywords', 'keywords': keywords}
        if keywords == '':
            return {
                'error': 'Keyword string cannot be empty',
                'kewords': keywords
            }

        tst = self.validateKeywords(keywords)
        if tst['error'] is None:
            keywords = tst['keywords']
            self._STATE['_KEYWORDS'].append(keywords)
            self.saveState()
        return tst

    def getKeywords(self):
        # Returns an array of all keywords
        return self._STATE['_KEYWORDS']

    def getKeywordSourceUrl(self, index):
        # Override to provide a source link
        return None

    def hasKeywordSourceUrl(self):
        # Override to provide source url support
        return False

    def removeKeywords(self, index):
        if index < 0 or index > (len(self._STATE['_KEYWORDS']) - 1):
            logging.error('removeKeywords: Out of range %d' % index)
            return False
        kw = self._STATE['_KEYWORDS'].pop(index)
        if kw in self._STATE['_NUM_IMAGES']:
            del self._STATE['_NUM_IMAGES'][kw]
        self.saveState()
        return True

    def needKeywords(self):
        # Some services don't have keywords. Override this to return false
        # to remove the keywords options.
        return True

    def helpKeywords(self):
        return 'Has not been defined'

    def getRandomKeywordIndex(self):
        # select keyword index at random but weighted by the number of images of each album
        totalImages = self.getNumImages()
        if totalImages == 0:
            return 0
        numImages = [
            self._STATE['_NUM_IMAGES'][kw] for kw in self._STATE['_NUM_IMAGES']
        ]
        return helper.getWeightedRandomIndex(numImages)

    def getKeywordLink(self, index):
        if index < 0 or index > (len(self._STATE['_KEYWORDS']) - 1):
            logging.error('removeKeywords: Out of range %d' % index)
            return

    ###[ Extras - Allows easy access to config ]#################

    def getExtras(self):
        return self._STATE['_EXTRAS']

    def setExtras(self, data):
        self._STATE['_EXTRAS'] = data
        self.saveState()

    ###[ Actual hard work ]###########################

    def prepareNextItem(self, destinationFile, supportedMimeTypes, displaySize,
                        randomize):
        # This call requires the service to download the next item it
        # would like to show. The destinationFile has to be used as where to save it
        # and you are only allowed to provide content listed in the supportedMimeTypes.
        # displaySize holds the keys width & height to provide a hint for the service to avoid downloading HUGE files
        # Return for this function is a key/value map with the following MANDATORY
        # fields:
        #  "id" : a unique - preferably not-changing - ID to identify the same image in future requests, e.g. hashString(imageUrl)
        #  "mimetype" : the filetype you downloaded, for example "image/jpeg"
        #  "error" : None or a human readable text string as to why you failed
        #  "source" : Link to where the item came from or None if not provided
        #
        # NOTE! If you need to index anything before you can get the first item, this would
        # also be the place to do it.
        #
        # If your service uses keywords (as albums) 'selectImageFromAlbum' of the baseService class should do most of the work for you
        # You will probably only need to implement 'getImagesFor' and 'addUrlParams'

        if self.needKeywords():
            result = self.selectImageFromAlbum(destinationFile,
                                               supportedMimeTypes, displaySize,
                                               randomize)
            if result is None:
                result = ImageHolder().setError(
                    'No (new) images could be found')
        else:
            result = ImageHolder().setError(
                'prepareNextItem() not implemented')

        return result

    def getImagesFor(self, keyword):
        # You need to override this function if your service needs keywords and
        # you want to use 'selectImageFromAlbum' of the baseService class
        # This function should collect data about all images matching a specific keyword
        # Return for this function is a list of multiple key/value maps each containing the following MANDATORY fields:
        # "id":       a unique - preferably not-changing - ID to identify the same image in future requests, e.g. hashString(imageUrl)
        # "url":      Link to the actual image file
        # "sources":  Link to where the item came from or None if not provided
        # "mimetype": the filetype of the image, for example "image/jpeg"
        #             can be None, but you should catch unsupported mimetypes after the image has downloaded (example: svc_simpleurl.py)
        # "size":     a key/value map containing "width" and "height" of the image
        #             can be None, but the service won't be able to determine a recommendedImageSize for 'addUrlParams'
        # "filename": the original filename of the image or None if unknown (only used for debugging purposes)
        # "error":    If present, will generate an error shown to the user with the text within this key as the message

        return ImageHolder().setError('getImagesFor() not implemented')

    def addUrlParams(self, url, recommendedSize, displaySize):
        # If the service provider allows 'width' / 'height' parameters
        # override this function and place them inside the url.
        # If the recommendedSize is None (due to unknown imageSize)
        # use the displaySize instead (better than nothing, but image quality might suffer a little bit)

        return url

    ###[ Helpers ]######################################

    def selectImageFromAlbum(self, destinationDir, supportedMimeTypes,
                             displaySize, randomize):
        # chooses an album and selects an image from that album --> return {'id':, 'mimetype':, 'error':, 'source':}
        # if no (new) images can be found --> return None

        keywordList = list(self.getKeywords())
        keywordCount = len(keywordList)
        if keywordCount == 0:
            return ImageHolder().setError('No albums have been specified')

        if randomize:
            index = self.getRandomKeywordIndex()
        else:
            index = self.keywordIndex

        # if current keywordList[index] does not contain any new images --> just run through all albums
        for i in range(0, keywordCount):
            if not randomize and (index + i) >= keywordCount:
                # (non-random image order): return if the last album is exceeded --> serviceManager should use next service
                break
            self.keywordIndex = (index + i) % keywordCount
            keyword = keywordList[self.keywordIndex]

            # a provider-specific implementation for 'getImagesFor' is obligatory!
            images = self.getImagesFor(keyword)
            if images is None:
                logging.warning(
                    'Function returned None, this is used sometimes when a temporary error happens. Still logged'
                )
                self.imageIndex = 0
                continue
            if len(images) > 0 and images[0].error is not None:
                return images[0]
            self._STATE["_NUM_IMAGES"][keyword] = len(images)
            if len(images) == 0:
                self.imageIndex = 0
                continue
            self.saveState()

            image = self.selectImage(images, supportedMimeTypes, displaySize,
                                     randomize)
            if image is None:
                self.imageIndex = 0
                continue

            filename = os.path.join(destinationDir, image.id)

            # you should implement 'addUrlParams' if the provider allows 'width' / 'height' parameters!
            recommendedSize = self.calcRecommendedSize(image.dimensions,
                                                       displaySize)
            url = self.addUrlParams(image.url, recommendedSize, displaySize)

            if image.cacheAllow:
                # Look it up in the cache mgr
                if self._CACHEMGR is None:
                    logging.error('CacheManager is not available')
                else:
                    cacheFile = self._CACHEMGR.getCachedImage(
                        image.getCacheId(), filename)
                    if cacheFile:
                        image.setFilename(cacheFile)
                        image.cacheUsed = True

            if not image.cacheUsed:
                try:
                    result = self.requestUrl(url, destination=filename)
                except requests.exceptions.RequestException:
                    logging.exception('request to download image failed')
                    result = RequestResult().setResult(
                        RequestResult.NO_NETWORK)

                if not result.isSuccess():
                    return ImageHolder().setError(
                        '%d: Unable to download image!' % result.httpcode)
                else:
                    image.setFilename(filename)
            image.setMimetype(helper.getMimetype(image.filename))
            return image

        self.resetIndices()
        return None

    def selectImage(self, images, supportedMimeTypes, displaySize, randomize):
        imageCount = len(images)
        if randomize:
            index = random.SystemRandom().randint(0, imageCount - 1)
        else:
            index = self.imageIndex

        for i in range(0, imageCount):
            if not randomize and (index + i) >= imageCount:
                break

            self.imageIndex = (index + i) % imageCount
            image = images[self.imageIndex]

            orgFilename = image.filename if image.filename is not None else image.id
            if randomize and self.memorySeen(image.id):
                logging.debug("Skipping already displayed image '%s'!" %
                              orgFilename)
                continue
            if not self.isCorrectOrientation(image.dimensions, displaySize):
                logging.debug("Skipping image '%s' due to wrong orientation!" %
                              orgFilename)
                continue
            if image.mimetype is not None and image.mimetype not in supportedMimeTypes:
                # Make sure we don't get a video, unsupported for now (gif is usually bad too)
                logging.debug('Unsupported media: %s' % (image.mimetype))
                continue

            return image
        return None

    def requestUrl(self,
                   url,
                   destination=None,
                   params=None,
                   data=None,
                   usePost=False):
        result = RequestResult()

        if self._OAUTH is not None:
            # Use OAuth path
            result = self._OAUTH.request(url,
                                         destination,
                                         params,
                                         data=data,
                                         usePost=usePost)
        else:
            tries = 0
            while tries < 5:
                try:
                    if usePost:
                        r = requests.post(url, params=params, json=data)
                    else:
                        r = requests.get(url, params=params)
                    break
                except:
                    logging.exception('Issues downloading')
                time.sleep(tries /
                           10)  # Back off 10, 20, ... depending on tries
                tries += 1
                logging.warning('Retrying again, attempt #%d', tries)

            if tries == 5:
                logging.error('Failed to download due to network issues')
                raise RequestNoNetwork

            if r:
                result.setHTTPCode(r.status_code).setHeaders(
                    r.headers).setResult(RequestResult.SUCCESS)

                if destination is None:
                    result.setContent(r.content)
                else:
                    with open(destination, 'wb') as f:
                        for chunk in r.iter_content(chunk_size=1024):
                            f.write(chunk)
                    result.setFilename(destination)
        return result

    def calcRecommendedSize(self, imageSize, displaySize):
        # The recommended image size is basically the displaySize extended along one side to match the aspect ratio of your image
        # e.g. displaySize: 1920x1080, imageSize: 4000x3000 --> recImageSize: 1920x1440
        # If possible every request url should contain the recommended width/height as parameters to reduce image file sizes.
        # That way the image provider does most of the scaling (instead of the rather slow raspberryPi),
        # the image only needs to be cropped (zoomOnly) or downscaled a little bit (blur / do nothing) during post-processing.

        if imageSize is None or "width" not in imageSize or "height" not in imageSize:
            return None

        oar = float(imageSize['width']) / float(imageSize['height'])
        dar = float(displaySize['width']) / float(displaySize['height'])

        newImageSize = {}
        if imageSize['width'] > displaySize['width'] and imageSize[
                'height'] > displaySize['height']:
            if oar <= dar:
                newImageSize['width'] = displaySize['width']
                newImageSize['height'] = int(float(displaySize['width']) / oar)
            else:
                newImageSize['width'] = int(float(displaySize['height']) * oar)
                newImageSize['height'] = displaySize['height']
        else:
            newImageSize['width'] = imageSize['width']
            newImageSize['height'] = imageSize['height']

        return newImageSize

    def isCorrectOrientation(self, imageSize, displaySize):
        if displaySize['force_orientation'] == 0:
            return True
        if imageSize is None or "width" not in imageSize or "height" not in imageSize:
            # always show image if size is unknown!
            return True

        # NOTE: square images are being treated as portrait-orientation
        image_orientation = 0 if int(imageSize["width"]) > int(
            imageSize["height"]) else 1
        display_orientation = 0 if displaySize["width"] > displaySize[
            "height"] else 1

        return image_orientation == display_orientation

    def getStoragePath(self):
        return self._DIR_PRIVATE

    def hashString(self, text):
        if type(text) is not unicode:
            # make sure it's unicode
            a = text.decode('ascii', errors='replace')
        else:
            a = text
        a = a.encode('utf-8', errors='replace')
        return hashlib.sha1(a).hexdigest()

    def createImageHolder(self):
        return ImageHolder()

    ###[ Memory management ]=======================================================

    def _fetchMemory(self, key):
        if key is None:
            key = ''
        h = self.hashString(key)
        if self._MEMORY_KEY == h:
            return
        # Save work and swap
        if self._MEMORY is not None and len(self._MEMORY) > 0:
            with open(
                    os.path.join(self._DIR_MEMORY,
                                 '%s.json' % self._MEMORY_KEY), 'w') as f:
                json.dump(self._MEMORY, f)
        if os.path.exists(os.path.join(self._DIR_MEMORY, '%s.json' % h)):
            with open(os.path.join(self._DIR_MEMORY, '%s.json' % h), 'r') as f:
                self._MEMORY = json.load(f)
        else:
            self._MEMORY = []
        self._MEMORY_KEY = h

    def _differentThanLastHistory(self, keywordindex, imageIndex):
        # just a little helper function to compare indices with the indices of the previously displayed image
        if len(self._HISTORY) == 0:
            return True
        if keywordindex == self._HISTORY[-1][
                0] and imageIndex == self._HISTORY[-1][1]:
            return False
        return True

    def memoryRemember(self, itemId, keywords=None, alwaysRemember=True):
        # some debug info about the service of the currently displayed image
        logging.debug("Displaying new image")
        logging.debug(self._NAME)
        logging.debug("keyword: %d; index: %d" %
                      (self.keywordIndex, self.imageIndex))

        # The MEMORY makes sure that this image won't be shown again until memoryForget is called
        self._fetchMemory(keywords)
        h = self.hashString(itemId)
        if h not in self._MEMORY:
            self._MEMORY.append(h)
        # save memory
        if (len(self._MEMORY) % 20) == 0:
            logging.info('Interim saving of memory every 20 entries')
            with open(
                    os.path.join(self._DIR_MEMORY,
                                 '%s.json' % self._MEMORY_KEY), 'w') as f:
                json.dump(self._MEMORY, f)

        # annoying behaviour fix: only remember current image in history if the image has actually changed
        rememberInHistory = alwaysRemember or self._differentThanLastHistory(
            self.keywordIndex, self.imageIndex)
        if rememberInHistory:
            # The HISTORY makes it possible to show previously displayed images
            self._HISTORY.append((self.keywordIndex, self.imageIndex))

        # (non-random image order only): on 'prepareNextItem' --> make sure to preload the following image
        self.imageIndex += 1

        return rememberInHistory

    def memorySeen(self, itemId, keywords=None):
        self._fetchMemory(keywords)
        h = self.hashString(itemId)
        return h in self._MEMORY

    def memoryForgetLast(self, keywords=None):
        # remove the currently displayed image from memory as well as history
        # implications:
        # - the image will be treated as never seen before (random image order)
        # - the same image will be preloaded again during 'prepareNextItem' (non-random image order)
        self._fetchMemory(keywords)
        if len(self._MEMORY) != 0:
            self._MEMORY.pop()
        if len(self._HISTORY) != 0:
            self.keywordIndex, self.imageIndex = self._HISTORY.pop()
        else:
            logging.warning(
                "Trying to forget a single memory, but 'self._HISTORY' is empty. This should have never happened!"
            )

    def memoryForget(self, keywords=None, forgetHistory=False):
        self._fetchMemory(keywords)
        n = os.path.join(self._DIR_MEMORY, '%s.json' % self._MEMORY_KEY)
        if os.path.exists(n):
            os.unlink(n)
        self._MEMORY = []
        if forgetHistory:
            self._HISTORY = []
        self._STATE['_NUM_IMAGES'] = {
            k: v
            for k, v in self._STATE['_NUM_IMAGES'].items() if v != 0
        }
        self.saveState()

    ###[ Slideshow controls ]=======================================================

    def nextAlbum(self):
        # skip to the next album
        # return False if service is out of albums to tell the serviceManager that it should use the next Service instead
        self.imageIndex = 0
        self.keywordIndex += 1
        if self.keywordIndex >= len(self._STATE['_KEYWORDS']):
            self.keywordIndex = 0
            return False
        return True

    def prevAlbum(self):
        # skip to the previous album
        # return False if service is already on its first album to tell the serviceManager that it should use the previous Service instead
        self.imageIndex = 0
        self.keywordIndex -= 1
        if self.keywordIndex < 0:
            self.keywordIndex = len(self._STATE['_KEYWORDS']) - 1
            return False
        return True
Exemple #2
0
class BaseService:
    REFRESH_DELAY = 60 * 60  # Number of seconds before we refresh the index in case no photos
    SERVICE_DEPRECATED = False

    STATE_ERROR = -1
    STATE_UNINITIALIZED = 0

    STATE_DO_CONFIG = 1
    STATE_DO_OAUTH = 2
    STATE_NEED_KEYWORDS = 3
    STATE_NO_IMAGES = 4

    STATE_READY = 999

    def __init__(self, configDir, id, name, needConfig=False, needOAuth=False):
        # MUST BE CALLED BY THE IMPLEMENTING CLASS!
        self._ID = id
        self._NAME = name
        self._OAUTH = None
        self._CACHEMGR = None

        self._CURRENT_STATE = BaseService.STATE_UNINITIALIZED
        self._ERROR = None

        # NUM_IMAGES keeps track of how many images are being provided by each keyword
        # As for now, unsupported images (mimetype, orientation) and already displayed images are NOT excluded due to simplicity,
        # but it should still serve as a rough estimate to ensure that every image has a similar chance of being shown in "random_image_mode"!
        # NEXT_SCAN is used to determine when a keyword should be re-indexed. This used in the case number of photos are zero to avoid hammering
        # services.
        self._STATE = {
            '_OAUTH_CONFIG': None,
            '_OAUTH_CONTEXT': None,
            '_CONFIG': None,
            '_KEYWORDS': [],
            '_NUM_IMAGES': {},
            '_NEXT_SCAN': {},
            '_EXTRAS': None,
            '_INDEX_IMAGE': 0,
            '_INDEX_KEYWORD': 0
        }
        self._NEED_CONFIG = needConfig
        self._NEED_OAUTH = needOAuth

        self._DIR_BASE = self._prepareFolders(configDir)
        self._DIR_PRIVATE = os.path.join(self._DIR_BASE, 'private')
        self._FILE_STATE = os.path.join(self._DIR_BASE, 'state.json')

        self.memory = MemoryManager(os.path.join(self._DIR_BASE, 'memory'))

        self.loadState()
        self.preSetup()

    def setCacheManager(self, cacheMgr):
        self._CACHEMGR = cacheMgr

    def _prepareFolders(self, configDir):
        basedir = os.path.join(configDir, self._ID)
        if not os.path.exists(basedir):
            os.mkdir(basedir)
        if not os.path.exists(basedir + '/memory'):
            os.mkdir(basedir + '/memory')
        if not os.path.exists(basedir + '/private'):
            os.mkdir(basedir + '/private')
        return basedir

    ###[ Used by service to do any kind of house keeping ]###########################

    def preSetup(self):
        # If you need to do anything before initializing, override this
        # NOTE! No auth or oauth has been done at this point, only state has been loaded
        pass

    def postSetup(self):
        # If you need to do anything right after initializing, override this
        # NOTE! At this point, any auth and/or oauth will have been performed. State is not saved after this call
        pass

    ###[ Used by photoframe to determinte what to do next ]###########################

    def updateState(self):
        # Determines what the user needs to do next to configure this service
        # if this doesn't return ready, caller must take appropiate action
        if self._NEED_OAUTH and self._OAUTH is None:
            self._OAUTH = OAuth(self._setOAuthToken, self._getOAuthToken,
                                self.getOAuthScope(), self._ID)
            if self._STATE['_OAUTH_CONFIG'] is not None:
                self._OAUTH.setOAuth(self._STATE['_OAUTH_CONFIG'])
                self.postSetup()

        if self._NEED_CONFIG and not self.hasConfiguration():
            self._CURRENT_STATE = BaseService.STATE_DO_CONFIG
        elif self._NEED_OAUTH and (not self.hasOAuthConfig
                                   or not self.hasOAuth()):
            self._CURRENT_STATE = BaseService.STATE_DO_OAUTH
        elif self.needKeywords() and len(self.getKeywords()) == 0:
            self._CURRENT_STATE = BaseService.STATE_NEED_KEYWORDS
        elif self.getImagesTotal() == 0:
            self._CURRENT_STATE = BaseService.STATE_NO_IMAGES
        else:
            self._CURRENT_STATE = BaseService.STATE_READY

        return self._CURRENT_STATE

    ###[ Allows loading/saving of service state ]###########################

    def loadState(self):
        # Load any stored state data from storage
        # Normally you don't override this
        if os.path.exists(self._FILE_STATE):
            try:
                with open(self._FILE_STATE, 'r') as f:
                    self._STATE.update(json.load(f))
            except:
                logging.exception('Unable to load state for service')
                os.unlink(self._FILE_STATE)

    def saveState(self):
        # Stores the state data under the unique ID for
        # this service provider's instance
        # normally you don't override this
        with open(self._FILE_STATE, 'w') as f:
            json.dump(self._STATE, f)

    ###[ Get info about instance ]###########################

    def getName(self):
        # Retrieves the name of this instance
        return self._NAME

    def setName(self, newName):
        self._NAME = newName

    def getId(self):
        return self._ID

    def getImagesTotal(self):
        # return the total number of images provided by this service
        sum = 0
        if self.needKeywords():
            for keyword in self.getKeywords():
                if keyword not in self._STATE[
                        "_NUM_IMAGES"] or keyword not in self._STATE[
                            '_NEXT_SCAN'] or self._STATE['_NEXT_SCAN'][
                                keyword] < time.time():
                    logging.debug(
                        'Keywords either not scanned or we need to scan now')
                    self._getImagesFor(keyword)  # Will make sure to get images
                    self._STATE['_NEXT_SCAN'][keyword] = time.time(
                    ) + self.REFRESH_DELAY
                sum = sum + self._STATE["_NUM_IMAGES"][keyword]
        return sum

    def getImagesSeen(self):
        count = 0
        if self.needKeywords():
            for keyword in self.getKeywords():
                count += self.memory.count(keyword)
        return count

    def getImagesRemaining(self):
        return self.getImagesTotal() - self.getImagesSeen()

    def getMessages(self):
        # override this if you wish to show a message associated with
        # the provider's instance. Return None to hide
        # Format: [{'level' : 'INFO', 'message' : None, 'link' : None}]
        msgs = []
        if self._CURRENT_STATE in [self.STATE_NEED_KEYWORDS
                                   ]:  # , self.STATE_NO_IMAGES]:
            msgs.append({
                'level': 'INFO',
                'message':
                'Please add one or more items in order to show photos from this provider (see help button)',
                'link': None
            })
        if 0 in self._STATE["_NUM_IMAGES"].values():
            # Find first keyword with zero (unicode issue)
            removeme = []
            for keyword in self._STATE["_KEYWORDS"]:
                if self._STATE["_NUM_IMAGES"][keyword] == 0:
                    removeme.append(keyword)
            msgs.append({
                'level':
                'WARNING',
                'message':
                'The following keyword(s) do not yield any photos: %s' %
                ', '.join(map(u'"{0}"'.format, removeme)),
                'link':
                None
            })
        return msgs

    def explainState(self):
        # override this if you wish to show additional on-screen information for a specific state
        # return String
        return None

    ###[ All the OAuth functionality ]###########################

    def getOAuthScope(self):
        # *Override* to define any needed OAuth scope
        # must return array of string(s)
        return None

    def setOAuthConfig(self, config):
        # Provides OAuth config data for linking.
        # Without this information, OAuth cannot be done.
        # If config is invalid, returns False
        self._STATE['_OAUTH_CONFIG'] = config
        if self._OAUTH is not None:
            self._OAUTH.setOAuth(self._STATE['_OAUTH_CONFIG'])
            self.postSetup()

        self.saveState()
        return True

    def helpOAuthConfig(self):
        return 'Should explain what kind of content to provide'

    def hasOAuthConfig(self):
        # Returns true/false if we have a config for oauth
        return self._STATE['_OAUTH_CONFIG'] is not None

    def hasOAuth(self):
        # Tests if we have a functional OAuth link,
        # returns False if we need to set it up
        return self._STATE['_OAUTH_CONTEXT'] is not None

    def invalidateOAuth(self):
        # Removes previously negotiated OAuth
        self._STATE['_OAUTH_CONFIG'] = None
        self._STATE['_OAUTH_CONTEXT'] = None
        self.saveState()

    def startOAuth(self):
        # Returns a HTTP redirect to begin OAuth or None if
        # oauth isn't configured. Normally not overriden
        return self._OAUTH.initiate()

    def finishOAuth(self, url):
        # Called when OAuth sequence has completed
        self._OAUTH.complete(url)
        self.saveState()

    def _setOAuthToken(self, token):
        self._STATE['_OAUTH_CONTEXT'] = token
        self.saveState()

    def _getOAuthToken(self):
        return self._STATE['_OAUTH_CONTEXT']

    def migrateOAuthToken(self, token):
        if self._STATE['_OAUTH_CONTEXT'] is not None:
            logging.error('Cannot migrate token, already have one!')
            return
        logging.debug('Setting token to %s' % repr(token))
        self._STATE['_OAUTH_CONTEXT'] = token
        self.saveState()

    ###[ For services which require static auth ]###########################

    def validateConfiguration(self, config):
        # Allow service to validate config, if correct, return None
        # If incorrect, return helpful error message.
        # config is a map with fields and their values
        return 'Not overriden yet but config is enabled'

    def setConfiguration(self, config):
        # Setup any needed authentication data for this
        # service.
        self._STATE['_CONFIG'] = config
        self.saveState()

    def getConfiguration(self):
        return self._STATE['_CONFIG']

    def hasConfiguration(self):
        # Checks if it has auth data
        return self._STATE['_CONFIG'] != None

    def getConfigurationFields(self):
        # Returns a key/value map with:
        # "field" => [ "type" => "STR/INT", "name" => "Human readable", "description" => "Longer text" ]
        # Allowing UX to be dynamically created
        # Supported field types are: STR, INT, PW (password means it will obscure it on input)
        return {
            'username': {
                'type': 'STR',
                'name': 'Username',
                'description': 'Username to use for login'
            }
        }

    ###[ Keyword management ]###########################

    def validateKeywords(self, keywords):
        # Quick check, don't allow duplicates!
        if keywords in self.getKeywords():
            logging.error('Keyword is already in list')
            return {'error': 'Keyword already in list', 'keywords': keywords}

        return {'error': None, 'keywords': keywords}

    def addKeywords(self, keywords):
        # This is how the user will configure it, this adds a new set of keywords to this
        # service module. Return none on success, string with error on failure
        keywords = keywords.strip()

        if not self.needKeywords():
            return {'error': 'Doesn\'t use keywords', 'keywords': keywords}
        if keywords == '':
            return {
                'error': 'Keyword string cannot be empty',
                'kewords': keywords
            }

        tst = self.validateKeywords(keywords)
        if tst['error'] is None:
            keywords = tst['keywords']
            self._STATE['_KEYWORDS'].append(keywords)
            self.saveState()
        return tst

    def getKeywords(self):
        # Returns an array of all keywords
        return self._STATE['_KEYWORDS']

    def getKeywordSourceUrl(self, index):
        # Override to provide a source link
        return None

    def getKeywordDetails(self, index):
        # Override so we can tell more
        # Format of data is:
        # ('short': short, 'long' : ["line1", "line2", ...]) where short is a string and long is a string array
        return None

    def hasKeywordDetails(self):
        # Override so we can tell more
        return False

    def hasKeywordSourceUrl(self):
        # Override to provide source url support
        return False

    def removeKeywords(self, index):
        if index < 0 or index > (len(self._STATE['_KEYWORDS']) - 1):
            logging.error('removeKeywords: Out of range %d' % index)
            return False
        kw = self._STATE['_KEYWORDS'].pop(index)
        if kw in self._STATE['_NUM_IMAGES']:
            del self._STATE['_NUM_IMAGES'][kw]
        self.saveState()
        # Also kill the memory of this keyword
        self.memory.forget(kw)
        return True

    def needKeywords(self):
        # Some services don't have keywords. Override this to return false
        # to remove the keywords options.
        return True

    def helpKeywords(self):
        return 'Has not been defined'

    def getRandomKeywordIndex(self):
        # select keyword index at random but weighted by the number of images of each album
        totalImages = self.getImagesTotal()
        if totalImages == 0:
            return 0
        numImages = [
            self._STATE['_NUM_IMAGES'][kw] for kw in self._STATE['_NUM_IMAGES']
        ]
        return helper.getWeightedRandomIndex(numImages)

    def getKeywordLink(self, index):
        if index < 0 or index > (len(self._STATE['_KEYWORDS']) - 1):
            logging.error('removeKeywords: Out of range %d' % index)
            return

    ###[ Extras - Allows easy access to config ]#################

    def getExtras(self):
        return self._STATE['_EXTRAS']

    def setExtras(self, data):
        self._STATE['_EXTRAS'] = data
        self.saveState()

    ###[ Actual hard work ]###########################

    def prepareNextItem(self, destinationFile, supportedMimeTypes, displaySize,
                        randomize):
        # This call requires the service to download the next item it
        # would like to show. The destinationFile has to be used as where to save it
        # and you are only allowed to provide content listed in the supportedMimeTypes.
        # displaySize holds the keys width & height to provide a hint for the service to avoid downloading HUGE files
        # Return for this function is a key/value map with the following MANDATORY
        # fields:
        #  "id" : a unique - preferably not-changing - ID to identify the same image in future requests, e.g. hashString(imageUrl)
        #  "mimetype" : the filetype you downloaded, for example "image/jpeg"
        #  "error" : None or a human readable text string as to why you failed
        #  "source" : Link to where the item came from or None if not provided
        #
        # NOTE! If you need to index anything before you can get the first item, this would
        # also be the place to do it.
        #
        # If your service uses keywords (as albums) 'selectImageFromAlbum' of the baseService class should do most of the work for you
        # You will probably only need to implement 'getImagesFor' and 'addUrlParams'

        if self.needKeywords():
            if len(self.getKeywords()) == 0:
                return ImageHolder().setError('No albums have been specified')

            if randomize:
                result = self.selectRandomImageFromAlbum(
                    destinationFile, supportedMimeTypes, displaySize)
            else:
                result = self.selectNextImageFromAlbum(destinationFile,
                                                       supportedMimeTypes,
                                                       displaySize)
            if result is None:
                result = ImageHolder().setError(
                    'No (new) images could be found')
        else:
            result = ImageHolder().setError(
                'prepareNextItem() not implemented')

        return result

    def _getImagesFor(self, keyword):
        images = self.getImagesFor(keyword)
        if images is None:
            logging.warning(
                'Function returned None, this is used sometimes when a temporary error happens. Still logged'
            )

        if images is not None and len(images) > 0:
            self._STATE["_NUM_IMAGES"][keyword] = len(images)
            # Change next time for refresh (postpone if you will)
            self._STATE['_NEXT_SCAN'][keyword] = time.time(
            ) + self.REFRESH_DELAY
        else:
            self._STATE["_NUM_IMAGES"][keyword] = 0
        return images

    def getImagesFor(self, keyword):
        # You need to override this function if your service needs keywords and
        # you want to use 'selectImageFromAlbum' of the baseService class
        # This function should collect data about all images matching a specific keyword
        # Return for this function is a list of multiple key/value maps each containing the following MANDATORY fields:
        # "id":       a unique - preferably not-changing - ID to identify the same image in future requests, e.g. hashString(imageUrl)
        # "url":      Link to the actual image file
        # "sources":  Link to where the item came from or None if not provided
        # "mimetype": the filetype of the image, for example "image/jpeg"
        #             can be None, but you should catch unsupported mimetypes after the image has downloaded (example: svc_simpleurl.py)
        # "size":     a key/value map containing "width" and "height" of the image
        #             can be None, but the service won't be able to determine a recommendedImageSize for 'addUrlParams'
        # "filename": the original filename of the image or None if unknown (only used for debugging purposes)
        # "error":    If present, will generate an error shown to the user with the text within this key as the message

        return [ImageHolder().setError('getImagesFor() not implemented')]

    def _clearImagesFor(self, keyword):
        self._STATE["_NUM_IMAGES"].pop(keyword, None)
        self._STATE['_NEXT_SCAN'].pop(keyword, None)
        self.memory.forget(keyword)
        self.clearImagesFor(keyword)

    def clearImagesFor(self, keyword):
        # You can hook this function to do any additional needed cleanup
        # keyword is the item for which you need to clear the images for
        pass

    def freshnessImagesFor(self, keyword):
        # You need to implement this function if you intend to support refresh of content
        # keyword is the item for which you need to clear the images for. Should return age of content in hours
        return 0

    def getContentUrl(self, image, hints):
        # Allows a content provider to do extra operations as needed to
        # extract the correct URL.
        #
        # image is an image object
        #
        # hints is a map holding various hints to be used by the content provider.
        # "size" holds "width" and "height" of the ideal image dimensions based on display size
        # "display" holds "width" and "height" of the physical display
        #
        # By default, if you don't override this, it will simply use the image.url as the return
        return image.url

    ###[ Helpers ]######################################

    def selectRandomImageFromAlbum(self, destinationDir, supportedMimeTypes,
                                   displaySize):
        # chooses an album and selects an image from that album. Returns an image object or None
        # if no images are available.

        keywords = self.getKeywords()
        index = self.getRandomKeywordIndex()

        # if current keywordList[index] does not contain any new images --> just run through all albums
        for i in range(0, len(keywords)):
            self.setIndex(keyword=(index + i) % len(keywords))
            keyword = keywords[self.getIndexKeyword()]

            # a provider-specific implementation for 'getImagesFor' is obligatory!
            # We use a wrapper to clear things up
            images = self._getImagesFor(keyword)
            if images is None or len(images) == 0:
                self.setIndex(0)
                continue
            elif images[0].error is not None:
                # Something went wrong, only return first image since it holds the error
                return images[0]
            self.saveState()

            image = self.selectRandomImage(keyword, images, supportedMimeTypes,
                                           displaySize)
            if image is None:
                self.setIndex(0)
                continue

            return self.fetchImage(image, destinationDir, supportedMimeTypes,
                                   displaySize)
        return None

    def generateFilename(self):
        return str(uuid.uuid4())

    def fetchImage(self, image, destinationDir, supportedMimeTypes,
                   displaySize):
        filename = os.path.join(destinationDir, self.generateFilename())

        if image.cacheAllow:
            # Look it up in the cache mgr
            if self._CACHEMGR is None:
                logging.error('CacheManager is not available')
            else:
                cacheFile = self._CACHEMGR.getCachedImage(
                    image.getCacheId(), filename)
                if cacheFile:
                    image.setFilename(cacheFile)
                    image.cacheUsed = True

        if not image.cacheUsed:
            recommendedSize = self.calcRecommendedSize(image.dimensions,
                                                       displaySize)
            if recommendedSize is None:
                recommendedSize = displaySize
            url = self.getContentUrl(image, {
                'size': recommendedSize,
                'display': displaySize
            })
            if url is None:
                return ImageHolder().setError(
                    'Unable to download image, no URL')

            try:
                result = self.requestUrl(url, destination=filename)
            except (RequestResult.RequestExpiredToken, RequestInvalidToken):
                logging.exception('Cannot fetch due to token issues')
                result = RequestResult().setResult(RequestResult.OAUTH_INVALID)
                self._OAUTH = None
            except requests.exceptions.RequestException:
                logging.exception('request to download image failed')
                result = RequestResult().setResult(RequestResult.NO_NETWORK)

            if not result.isSuccess():
                return ImageHolder().setError('%d: Unable to download image!' %
                                              result.httpcode)
            else:
                image.setFilename(filename)
        if image.filename is not None:
            image.setMimetype(helper.getMimetype(image.filename))
        return image

    def selectNextImageFromAlbum(self, destinationDir, supportedMimeTypes,
                                 displaySize):
        # chooses an album and selects an image from that album. Returns an image object or None
        # if no images are available.

        keywordList = self.getKeywords()
        keywordCount = len(keywordList)
        index = self.getIndexKeyword()

        # if current keywordList[index] does not contain any new images --> just run through all albums
        for i in range(0, keywordCount):
            if (index + i) >= keywordCount:
                # (non-random image order): return if the last album is exceeded --> serviceManager should use next service
                break
            self.setIndex(keyword=(index + i) % keywordCount)
            keyword = keywordList[self.getIndexKeyword()]

            # a provider-specific implementation for 'getImagesFor' is obligatory!
            # We use a wrapper to clear things up
            images = self._getImagesFor(keyword)
            if images is None or len(images) == 0:
                self.setIndex(0)
                continue
            elif images[0].error is not None:
                # Something went wrong, only return first image since it holds the error
                return images[0]
            self.saveState()

            image = self.selectNextImage(keyword, images, supportedMimeTypes,
                                         displaySize)
            if image is None:
                self.setIndex(0)
                continue

            return self.fetchImage(image, destinationDir, supportedMimeTypes,
                                   displaySize)
        return None

    def selectRandomImage(self, keywords, images, supportedMimeTypes,
                          displaySize):
        imageCount = len(images)
        index = random.SystemRandom().randint(0, imageCount - 1)

        logging.debug('There are %d images total' % imageCount)
        for i in range(0, imageCount):
            image = images[(index + i) % imageCount]

            orgFilename = image.filename if image.filename is not None else image.id
            if self.memory.seen(image.id, keywords):
                logging.debug("Skipping already displayed image '%s'!" %
                              orgFilename)
                continue

            # No matter what, we need to track that we considered this image
            self.memory.remember(image.id, keywords)

            if not self.isCorrectOrientation(image.dimensions, displaySize):
                logging.debug("Skipping image '%s' due to wrong orientation!" %
                              orgFilename)
                continue
            if image.mimetype is not None and image.mimetype not in supportedMimeTypes:
                # Make sure we don't get a video, unsupported for now (gif is usually bad too)
                logging.debug('Skipping unsupported media: %s' %
                              (image.mimetype))
                continue

            self.setIndex((index + i) % imageCount)
            return image
        return None

    def selectNextImage(self, keywords, images, supportedMimeTypes,
                        displaySize):
        imageCount = len(images)
        index = self.getIndexImage()

        for i in range(index, imageCount):
            image = images[i]

            orgFilename = image.filename if image.filename is not None else image.id
            if self.memory.seen(image.id, keywords):
                logging.debug("Skipping already displayed image '%s'!" %
                              orgFilename)
                continue

            # No matter what, we need to track that we considered this image
            self.memory.remember(image.id, keywords)

            if not self.isCorrectOrientation(image.dimensions, displaySize):
                logging.debug("Skipping image '%s' due to wrong orientation!" %
                              orgFilename)
                continue
            if image.mimetype is not None and image.mimetype not in supportedMimeTypes:
                # Make sure we don't get a video, unsupported for now (gif is usually bad too)
                logging.debug('Skipping unsupported media: %s' %
                              (image.mimetype))
                continue

            self.setIndex(i)
            return image
        return None

    def requestUrl(self,
                   url,
                   destination=None,
                   params=None,
                   data=None,
                   usePost=False):
        result = RequestResult()

        if self._OAUTH is not None:
            # Use OAuth path
            try:
                result = self._OAUTH.request(url,
                                             destination,
                                             params,
                                             data=data,
                                             usePost=usePost)
            except (RequestExpiredToken, RequestInvalidToken):
                logging.exception('Cannot fetch due to token issues')
                result = RequestResult().setResult(RequestResult.OAUTH_INVALID)
                self.invalidateOAuth()
            except requests.exceptions.RequestException:
                logging.exception('request to download image failed')
                result = RequestResult().setResult(RequestResult.NO_NETWORK)
        else:
            tries = 0
            while tries < 5:
                try:
                    if usePost:
                        r = requests.post(url,
                                          params=params,
                                          json=data,
                                          timeout=180)
                    else:
                        r = requests.get(url, params=params, timeout=180)
                    break
                except:
                    logging.exception('Issues downloading')
                time.sleep(tries *
                           10)  # Back off 10, 20, ... depending on tries
                tries += 1
                logging.warning('Retrying again, attempt #%d', tries)

            if tries == 5:
                logging.error('Failed to download due to network issues')
                raise RequestNoNetwork

            if r:
                result.setHTTPCode(r.status_code).setHeaders(
                    r.headers).setResult(RequestResult.SUCCESS)

                if destination is None:
                    result.setContent(r.content)
                else:
                    with open(destination, 'wb') as f:
                        for chunk in r.iter_content(chunk_size=1024):
                            f.write(chunk)
                    result.setFilename(destination)
        return result

    def calcRecommendedSize(self, imageSize, displaySize):
        # The recommended image size is basically the displaySize extended along one side to match the aspect ratio of your image
        # e.g. displaySize: 1920x1080, imageSize: 4000x3000 --> recImageSize: 1920x1440
        # If possible every request url should contain the recommended width/height as parameters to reduce image file sizes.
        # That way the image provider does most of the scaling (instead of the rather slow raspberryPi),
        # the image only needs to be cropped (zoomOnly) or downscaled a little bit (blur / do nothing) during post-processing.

        if imageSize is None or "width" not in imageSize or "height" not in imageSize:
            return None

        oar = float(imageSize['width']) / float(imageSize['height'])
        dar = float(displaySize['width']) / float(displaySize['height'])

        newImageSize = {}
        if imageSize['width'] > displaySize['width'] and imageSize[
                'height'] > displaySize['height']:
            if oar <= dar:
                newImageSize['width'] = displaySize['width']
                newImageSize['height'] = int(float(displaySize['width']) / oar)
            else:
                newImageSize['width'] = int(float(displaySize['height']) * oar)
                newImageSize['height'] = displaySize['height']
        else:
            newImageSize['width'] = imageSize['width']
            newImageSize['height'] = imageSize['height']

        return newImageSize

    def isCorrectOrientation(self, imageSize, displaySize):
        if displaySize['force_orientation'] == 0:
            return True
        if imageSize is None or "width" not in imageSize or "height" not in imageSize:
            # always show image if size is unknown!
            return True

        # NOTE: square images are being treated as portrait-orientation
        image_orientation = 0 if int(imageSize["width"]) > int(
            imageSize["height"]) else 1
        display_orientation = 0 if displaySize["width"] > displaySize[
            "height"] else 1

        return image_orientation == display_orientation

    def getStoragePath(self):
        return self._DIR_PRIVATE

    def hashString(self, text):
        if type(text) is not unicode:
            # make sure it's unicode
            a = text.decode('ascii', errors='replace')
        else:
            a = text
        a = a.encode('utf-8', errors='replace')
        return hashlib.sha1(a).hexdigest()

    def createImageHolder(self):
        return ImageHolder()

    def setIndex(self, image=None, keyword=None, addImage=0, addKeyword=0):
        wrapped = False
        if addImage != 0:
            self._STATE['_INDEX_IMAGE'] += addImage
        elif image is not None:
            self._STATE['_INDEX_IMAGE'] = image
        if addKeyword != 0:
            self._STATE['_INDEX_KEYWORD'] += addKeyword
        elif keyword is not None:
            self._STATE['_INDEX_KEYWORD'] = keyword

        # Sanity
        if self._STATE['_INDEX_KEYWORD'] > len(self._STATE['_KEYWORDS']):
            if addKeyword != 0:
                self._STATE['_INDEX_KEYWORD'] = 0  # Wraps when adding
                wrapped = True
            else:
                self._STATE['_INDEX_KEYWORD'] = len(
                    self._STATE['_KEYWORDS']) - 1
        elif self._STATE['_INDEX_KEYWORD'] < 0:
            if addKeyword != 0:
                self._STATE['_INDEX_KEYWORD'] = len(
                    self._STATE['_KEYWORDS']) - 1  # Wraps when adding
                wrapped = True
            else:
                self._STATE['_INDEX_KEYWORD'] = 0
        return wrapped

    def getIndexImage(self):
        return self._STATE['_INDEX_IMAGE']

    def getIndexKeyword(self):
        return self._STATE['_INDEX_KEYWORD']

    ###[ Slideshow controls ]=======================================================

    def nextAlbum(self):
        # skip to the next album
        # return False if service is out of albums to tell the serviceManager that it should use the next Service instead
        return not self.setIndex(0, addKeyword=1)

    def prevAlbum(self):
        # skip to the previous album
        # return False if service is already on its first album to tell the serviceManager that it should use the previous Service instead
        return not self.setIndex(0, addKeyword=-1)
Exemple #3
0
class BaseService:
  STATE_ERROR = -1
  STATE_UNINITIALIZED = 0

  STATE_DO_CONFIG = 1
  STATE_DO_OAUTH = 2

  STATE_READY = 999

  def __init__(self, configDir, id, name, needConfig=False, needOAuth=False):
    # MUST BE CALLED BY THE IMPLEMENTING CLASS!
    self._ID = id
    self._NAME = name
    self._OAUTH = None

    self._STATE = BaseService.STATE_UNINITIALIZED
    self._ERROR = None

    self._STATE = {
      '_OAUTH_CONFIG' : None,
      '_OAUTH_CONTEXT' : None,
      '_CONFIG' : None,
      '_KEYWORDS' : [],
      '_EXTRAS' : None
    }
    self._NEED_CONFIG = needConfig
    self._NEED_OAUTH = needOAuth

    self._DIR_BASE = self._prepareFolders(configDir)
    self._DIR_MEMORY = os.path.join(self._DIR_BASE, 'memory')
    self._DIR_PRIVATE = os.path.join(self._DIR_BASE, 'private')
    self._FILE_STATE = os.path.join(self._DIR_BASE, 'state.json')

    self._MEMORY = None
    self._MEMORY_KEY = None

    self.loadState()
    self.preSetup()

  def _prepareFolders(self, configDir):
    basedir = os.path.join(configDir, self._ID)
    if not os.path.exists(basedir):
      os.mkdir(basedir)
    if not os.path.exists(basedir + '/memory'):
      os.mkdir(basedir + '/memory')
    if not os.path.exists(basedir + '/private'):
      os.mkdir(basedir + '/private')
    return basedir

  ###[ Used by service to do any kind of house keeping ]###########################

  def preSetup(self):
    # If you need to do anything before initializing, override this
    # NOTE! No auth or oauth has been done at this point, only state has been loaded
    pass

  def postSetup(self):
    # If you need to do anything right after initializing, override this
    # NOTE! At this point, any auth and/or oauth will have been performed. State is not saved after this call
    pass

  ###[ Used by photoframe to determinte what to do next ]###########################

  def updateState(self):
    # Determines what the user needs to do next to configure this service
    # if this doesn't return ready, caller must take appropiate action
    if self._NEED_OAUTH and self._OAUTH is None:
      self._OAUTH = OAuth(self._setOAuthToken, self._getOAuthToken, self.getOAuthScope(), self._ID)
      if self._STATE['_OAUTH_CONFIG'] is not None:
        self._OAUTH.setOAuth(self._STATE['_OAUTH_CONFIG'])
        self.postSetup()

    if self._NEED_CONFIG and not self.hasConfiguration():
      return BaseService.STATE_DO_CONFIG
    if self._NEED_OAUTH and (not self.hasOAuthConfig or not self.hasOAuth()):
      return BaseService.STATE_DO_OAUTH

    return BaseService.STATE_READY

  ###[ Allows loading/saving of service state ]###########################

  def loadState(self):
    # Load any stored state data from storage
    # Normally you don't override this
    if os.path.exists(self._FILE_STATE):
      with open(self._FILE_STATE, 'r') as f:
        self._STATE.update( json.load(f) )

  def saveState(self):
    # Stores the state data under the unique ID for
    # this service provider's instance
    # normally you don't override this
    with open(self._FILE_STATE, 'w') as f:
      json.dump(self._STATE, f)

  ###[ Get info about instance ]###########################

  def getName(self):
    # Retrieves the name of this instance
    return self._NAME

  def setName(self, newName):
    self._NAME = newName

  def getId(self):
    return self._ID

  def getMessages(self):
    # override this if you wish to show a message associated with
    # the provider's instance. Return None to hide
    # FOrmat: [{'level' : 'INFO', 'message' : None, 'link' : None}]
    if self.needKeywords() and len(self.getKeywords()) == 0:
      return [
        {
          'level': 'INFO',
          'message' : 'Please add one or more items in order to show photos from this provider (see help button)',
          'link': None
        }
      ]
    return []

  ###[ All the OAuth functionality ]###########################

  def getOAuthScope(self):
    # *Override* to define any needed OAuth scope
    # must return array of string(s)
    return None

  def setOAuthConfig(self, config):
    # Provides OAuth config data for linking.
    # Without this information, OAuth cannot be done.
    # If config is invalid, returns False
    self._STATE['_OAUTH_CONFIG'] = config
    if self._OAUTH is not None:
      self._OAUTH.setOAuth(self._STATE['_OAUTH_CONFIG'])
      self.postSetup()

    self.saveState()
    return True

  def helpOAuthConfig(self):
    return 'Should explain what kind of content to provide'

  def hasOAuthConfig(self):
    # Returns true/false if we have a config for oauth
    return self._STATE['_OAUTH_CONFIG'] is not None

  def hasOAuth(self):
    # Tests if we have a functional OAuth link,
    # returns False if we need to set it up
    return self._STATE['_OAUTH_CONTEXT'] is not None

  def startOAuth(self):
    # Returns a HTTP redirect to begin OAuth or None if
    # oauth isn't configured. Normally not overriden
    return self._OAUTH.initiate()

  def finishOAuth(self, url):
    # Called when OAuth sequence has completed
    self._OAUTH.complete(url)
    self.saveState()

  def _setOAuthToken(self, token):
    self._STATE['_OAUTH_CONTEXT'] = token
    self.saveState()

  def _getOAuthToken(self):
    return self._STATE['_OAUTH_CONTEXT']

  def migrateOAuthToken(self, token):
    if self._STATE['_OAUTH_CONTEXT'] is not None:
      logging.error('Cannot migrate token, already have one!')
      return
    logging.debug('Setting token to %s' % repr(token))
    self._STATE['_OAUTH_CONTEXT'] = token
    self.saveState()

  ###[ For services which require static auth ]###########################

  def validateConfiguration(self, config):
    # Allow service to validate config, if correct, return None
    # If incorrect, return helpful error message.
    # config is a map with fields and their values
    return 'Not overriden yet but config is enabled'

  def setConfiguration(self, config):
    # Setup any needed authentication data for this
    # service.
    self._STATE['_CONFIG'] = config
    self.saveState()

  def getConfiguration(self):
    return self._STATE['_CONFIG']

  def hasConfiguration(self):
    # Checks if it has auth data
    return self._STATE['_CONFIG'] != None

  def getConfigurationFields(self):
    # Returns a key/value map with:
    # "field" => [ "type" => "STR/INT", "name" => "Human readable", "description" => "Longer text" ]
    # Allowing UX to be dynamically created
    # Supported field types are: STR, INT, PW (password means it will obscure it on input)
    return {'username' : {'type':'STR', 'name':'Username', 'description':'Username to use for login'}}

  ###[ Keyword management ]###########################

  def validateKeywords(self, keywords):
    return {'error':None, 'keywords': keywords}

  def addKeywords(self, keywords):
    # This is how the user will configure it, this adds a new set of keywords to this
    # service module. Return none on success, string with error on failure
    keywords = keywords.strip()

    if not self.needKeywords():
      return {'error' : 'Doesn\'t use keywords', 'keywords' : keywords}
    if keywords == '':
      return {'error' : 'Keyword string cannot be empty', 'kewords' : keywords}

    tst = self.validateKeywords(keywords)
    if tst['error'] is None:
      keywords = tst['keywords']
      self._STATE['_KEYWORDS'].append(keywords)
      self.saveState()
    return tst

  def getKeywords(self):
    # Returns an array of all keywords
    return self._STATE['_KEYWORDS']

  def getKeywordSourceUrl(self, index):
    # Override to provide a source link
    return None

  def hasKeywordSourceUrl(self):
    # Override to provide source url support
    return False

  def removeKeywords(self, index):
    if index < 0 or index > (len(self._STATE['_KEYWORDS'])-1):
      logging.error('removeKeywords: Out of range %d' % index)
      return False
    self._STATE['_KEYWORDS'].pop(index)
    self.saveState()
    return True

  def needKeywords(self):
    # Some services don't have keywords. Override this to return false
    # to remove the keywords options.
    return True

  def helpKeywords(self):
    return 'Has not been defined'

  def getRandomKeywordIndex(self):
    if len(self._STATE['_KEYWORDS']) == 0:
      return 0
    return random.SystemRandom().randint(0,len(self._STATE['_KEYWORDS'])-1)

  def getKeywordLink(self, index):
    if index < 0 or index > (len(self._STATE['_KEYWORDS'])-1):
      logging.error('removeKeywords: Out of range %d' % index)
      return

  ###[ Extras - Allows easy access to config ]#################

  def getExtras(self):
    return self._STATE['_EXTRAS']

  def setExtras(self, data):
    self._STATE['_EXTRAS'] = data
    self.saveState()

  ###[ Actual hard work ]###########################

  def prepareNextItem(self, destinationFile, supportedMimeTypes, displaySize):
    # This call requires the service to download the next item it
    # would like to show. The destinationFile has to be used as where to save it
    # and you are only allowed to provide content listed in the supportedMimeTypes.
    # displaySize holds the keys width & height to provide a hint for the service to avoid downloading HUGE files
    # Return for this function is a key/value map with the following MANDATORY
    # fields:
    #  "mimetype" : the filetype you downloaded, for example "image/jpeg"
    #  "error" : None or a human readable text string as to why you failed
    #  "source" : Link to where the item came from or None if not provided
    #
    # NOTE! If you need to index anything before you can get the first item, this would
    # also be the place to do it.
    result = {'mimetype' : None, 'error' : 'You haven\'t implemented this yet', 'source':None}
    return result

  ###[ Helpers ]######################################

  def requestUrl(self, url, destination=None, params=None, data=None, usePost=False):
    result = {'status':500, 'content' : None}

    if self._OAUTH is not None:
      # Use OAuth path
      result = self._OAUTH.request(url, destination, params, data=data, usePost=usePost)
    else:
      if usePost:
        r = requests.post(url, params=params, json=data)
      else:
        r = requests.get(url, params=params)

      result['status'] = r.status_code
      if destination is None:
        result['content'] = r.content
      else:
        with open(destination, 'wb') as f:
          for chunk in r.iter_content(chunk_size=1024):
            f.write(chunk)
    return result

  def getStoragePath(self):
    return self._DIR_PRIVATE

  def hashString(self, text):
    return hashlib.sha1(text.encode('ascii', 'ignore')).hexdigest()

  ###[ Memory management ]=======================================================

  def _fetchMemory(self, key):
    if key is None:
      key = ''
    h =  self.hashString(key)
    if self._MEMORY_KEY == h:
      return
    # Save work and swap
    if self._MEMORY is not None and len(self._MEMORY) > 0:
      with open(os.path.join(self._DIR_MEMORY, '%s.json' % self._MEMORY_KEY), 'w') as f:
        json.dump(self._MEMORY, f)
    if os.path.exists(os.path.join(self._DIR_MEMORY, '%s.json' % h)):
      with open(os.path.join(self._DIR_MEMORY, '%s.json' % h), 'r') as f:
        self._MEMORY = json.load(f)
    else:
      self._MEMORY = []
    self._MEMORY_KEY = h

  def memoryRemember(self, itemId, keywords=None):
    self._fetchMemory(keywords)
    h = self.hashString(itemId)
    if h in self._MEMORY:
      return
    self._MEMORY.append(h)

    if (len(self._MEMORY) % 20) == 0:
      logging.info('Interim saving of memory every 20 entries')
      with open(os.path.join(self._DIR_MEMORY, '%s.json' % self._MEMORY_KEY), 'w') as f:
        json.dump(self._MEMORY, f)

  def memorySeen(self, itemId, keywords=None):
    self._fetchMemory(keywords)
    h = self.hashString(itemId)
    return h in self._MEMORY

  def memoryForget(self, keywords=None):
    self._fetchMemory(keywords)
    n = os.path.join(self._DIR_MEMORY, '%s.json' % self._MEMORY_KEY)
    if os.path.exists(n):
      os.unlink(n)
    self._MEMORY = []