diff --git a/README.md b/README.md
index e88ecf1..1b3ad30 100755
--- a/README.md
+++ b/README.md
@@ -80,9 +80,15 @@ You're done! Reboot your RPi3 (So I2C gets enabled) and from now on, all images
If photoframe is unable to use the sensor, it "usually" gives you helpful hints. Check the `/var/log/syslog` file for `frame.py` entries.
+*Note*
+
+The sensor is automatically detected as long as it is a TCS34725 device and it's connected correctly to the I2C bus of the raspberry pi. Once detected you'll get a new read-out in the web interface which details both white balance (kelvin) and light (lux).
+
+If you don't get this read-out, look at your logfile. There will be hints like sensor not found or sensor not being the expected one, etc.
+
## Annoyed with the LED showing on the TCS34725 board from Adafruit?
-Just ground the LED pin (for example by connecting it to Pin 9 on your RPi3)
+Just ground the LED pin on the Adafruit board (for example by connecting it to Pin 9 on your RPi3)
## Ambient powersave?
@@ -94,7 +100,7 @@ regardless of what the sensor says. The sensor is only used to extend the period
# Power on/off?
-Photoframe listens to GPIO 26 to power off (and also power on). If you connect a switch between pin 37 (GPIO 26) and pin 39 (GND), you'll be able
+Photoframe listens to GPIO 26 (default, can be changed) to power off (and also power on). If you connect a switch between pin 37 (GPIO 26) and pin 39 (GND), you'll be able
to do a graceful shutdown as well as power on.
# How come you contact photoframe.sensenet.nu ???
@@ -184,3 +190,20 @@ sudo apt install exfat-fuse exfat-utils
After this, you should be able to use exFAT
+## How does "Refresh keywords" option work?
+
+By default, most photo providers will fetch a list of available photos for a given keyword. This list isn't refreshed until one of the following events happen:
+
+- No more photos are available from ANY photo provider in your frame
+- User presses "Forget Memory" in web UI
+- The age of the downloaded index exceeds the hours specified by "Refresh keywords" option
+
+To disable the last item on that list, set the "Refresh keywords" to 0 (zero). This effectively disables this and now the frame will only refresh if no more photos are available or if user presses the forget memory item.
+
+## Why isn't it showing all my photos?
+
+Given you haven't set any options to limit based on orientation or a refresh which is too short to show them all, it should walk through the provided list from your provider.
+
+*however*
+
+Not all content is supported. To help troubleshoot why some content is missing, you can press "Details" for any keyword (given that the provider supports it) and the frame will let you know what content it has found. It should also give you an indication if there's a lot of content which is currently unsupported.
diff --git a/frame.py b/frame.py
index 9df938d..306e57f 100755
--- a/frame.py
+++ b/frame.py
@@ -36,6 +36,8 @@
from modules.cachemanager import CacheManager
from modules.path import path
from modules.server import WebServer
+from modules.events import Events
+from modules.history import ImageHistory
# Make sure we run from our own directory
os.chdir(os.path.dirname(sys.argv[0]))
@@ -69,17 +71,21 @@ def __init__(self, cmdline):
if not path().validate():
sys.exit(255)
+ self.eventMgr = Events()
+ self.eventMgr.add('Hello world')
+
self.cacheMgr = CacheManager()
self.settingsMgr = settings()
self.displayMgr = display(self.emulator)
# Validate all settings, prepopulate with defaults if needed
self.validateSettings()
+ self.imageHistory = ImageHistory(self.settingsMgr)
self.driverMgr = drivers()
self.serviceMgr = ServiceManager(self.settingsMgr, self.cacheMgr)
self.colormatch = colormatch(self.settingsMgr.get('colortemp-script'), 2700) # 2700K = Soft white, lowest we'll go
- self.slideshow = slideshow(self.displayMgr, self.settingsMgr, self.colormatch)
+ self.slideshow = slideshow(self.displayMgr, self.settingsMgr, self.colormatch, self.imageHistory)
self.timekeeperMgr = timekeeper()
self.timekeeperMgr.registerListener(self.displayMgr.enable)
self.powerMgr = shutdown(self.settingsMgr.getUser('shutdown-pin'))
@@ -130,11 +136,12 @@ def setupWebserver(self, listen, port):
self._loadRoute('orientation', 'RouteOrientation', self.cacheMgr)
self._loadRoute('overscan', 'RouteOverscan', self.cacheMgr)
self._loadRoute('maintenance', 'RouteMaintenance', self.emulator, self.driverMgr, self.slideshow)
- self._loadRoute('details', 'RouteDetails', self.displayMgr, self.driverMgr, self.colormatch, self.slideshow)
+ self._loadRoute('details', 'RouteDetails', self.displayMgr, self.driverMgr, self.colormatch, self.slideshow, self.serviceMgr, self.settingsMgr)
self._loadRoute('upload', 'RouteUpload', self.settingsMgr, self.driverMgr)
self._loadRoute('oauthlink', 'RouteOAuthLink', self.serviceMgr, self.slideshow)
self._loadRoute('service', 'RouteService', self.serviceMgr, self.slideshow)
self._loadRoute('control', 'RouteControl', self.slideshow)
+ self._loadRoute('events', 'RouteEvents', self.eventMgr)
def validateSettings(self):
if not self.settingsMgr.load():
diff --git a/modules/cachemanager.py b/modules/cachemanager.py
index 57c542d..b64e5ad 100755
--- a/modules/cachemanager.py
+++ b/modules/cachemanager.py
@@ -48,7 +48,7 @@ def __init__(self):
def enableCache(self, enable):
self.enable = enable
- logging.info('Cache is set to %s' + repr(enable))
+ logging.info('Cache is set to ' + repr(enable))
def validate(self):
self.createDirs()
diff --git a/modules/colormatch.py b/modules/colormatch.py
index 40d0112..1947bcb 100755
--- a/modules/colormatch.py
+++ b/modules/colormatch.py
@@ -82,6 +82,9 @@ def adjust(self, filename, filenameTemp, temperature=None):
try:
result = subprocess.call([self.script, '-t', "%d" % temperature, filename + '[0]', filenameTemp], stderr=self.void) == 0
+ if os.path.exists(filenameTemp + '.cache'):
+ logging.warning('colormatch called without filename extension, lingering .cache file will stay behind')
+
return result
except:
logging.exception('Unable to run %s:', self.script)
diff --git a/modules/debug.py b/modules/debug.py
old mode 100644
new mode 100755
index e96ef53..ef960ea
--- a/modules/debug.py
+++ b/modules/debug.py
@@ -33,11 +33,11 @@ def _stringify(args):
return result.replace('\n', '\\n')
def subprocess_call(cmds, stderr=None, stdout=None):
- logging.debug('subprocess.call(%s)', _stringify(cmds))
+ #logging.debug('subprocess.call(%s)', _stringify(cmds))
return subprocess.call(cmds, stderr=stderr, stdout=stdout)
def subprocess_check_output(cmds, stderr=None):
- logging.debug('subprocess.check_output(%s)', _stringify(cmds))
+ #logging.debug('subprocess.check_output(%s)', _stringify(cmds))
return subprocess.check_output(cmds, stderr=stderr)
def stacktrace():
diff --git a/modules/dedupe.py b/modules/dedupe.py
new file mode 100755
index 0000000..1b72cc2
--- /dev/null
+++ b/modules/dedupe.py
@@ -0,0 +1,44 @@
+# This file is part of photoframe (https://github.com/mrworf/photoframe).
+#
+# photoframe is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# photoframe is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with photoframe. If not, see .
+#
+
+class DedupeManager:
+ def __init__(self, memoryLocation):
+ try:
+ from PIL import Image
+ import imagehash
+ self.hasImageHash = True
+ logging.info('ImageHash functionality is available')
+ except:
+ self.hasImageHash = False
+ logging.info('ImageHash functionality is unavailable')
+
+ def _hamming_distance(self, i1, i2):
+ x = i1 ^ i2
+ setBits = 0
+
+ while (x > 0):
+ setBits += x & 1
+ x >>= 1
+
+ return setBits
+
+ def _hamming(self, s1, s2):
+ h = 0
+ for i in range(0, len(s1)/2):
+ i1 = int(s1[i*2:i*2+2], 16)
+ i2 = int(s2[i*2:i*2+2], 16)
+ h += self._hamming_distance(i1, i2)
+ return h
diff --git a/modules/events.py b/modules/events.py
new file mode 100755
index 0000000..9d17eda
--- /dev/null
+++ b/modules/events.py
@@ -0,0 +1,58 @@
+# This file is part of photoframe (https://github.com/mrworf/photoframe).
+#
+# photoframe is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# photoframe is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with photoframe. If not, see .
+#
+
+class Events:
+ TYPE_PERSIST = 1
+ TYPE_NORMAL = 0
+
+ LEVEL_INFO = 0
+ LEVEL_WARN = 1
+ LEVEL_ERR = 2
+ LEVEL_DEBUG = 3
+
+ def __init__(self):
+ self.idcount = 0
+ self.msgs = []
+
+ def add(self, message, unique=None, link=None, level=LEVEL_INFO, type=TYPE_NORMAL):
+ record = {'id': self.idcount, 'unique' : unique, 'type' : type, 'level' : level, 'message' : message, 'link' : link}
+ if unique is not None:
+ unique = repr(unique) # Make it a string to be safe
+ for i in range(0, len(self.msgs)):
+ if self.msgs[i]['unique'] == unique:
+ self.msgs[i] = record
+ record = None
+ break
+
+ if record is not None:
+ self.msgs.append(record)
+ self.idcount += 1
+
+ def remove(self, id):
+ for i in range(0, len(self.msgs)):
+ if self.msgs[i]['id'] == id and self.msgs[i]['type'] != Events.TYPE_PERSIST:
+ self.msgs.pop(i)
+ break
+
+ def getAll(self):
+ return self.msgs
+
+ def getSince(self, id):
+ ret = []
+ for msg in self.msgs:
+ if msg['id'] > id:
+ ret.append(msg)
+ return ret
diff --git a/modules/helper.py b/modules/helper.py
index 7440b39..4e37029 100755
--- a/modules/helper.py
+++ b/modules/helper.py
@@ -34,6 +34,15 @@
class helper:
TOOL_ROTATE = '/usr/bin/jpegtran'
+ MIMETYPES = {
+ 'image/jpeg' : 'jpg',
+ 'image/png' : 'png',
+ 'image/gif' : 'gif',
+ 'image/bmp' : 'bmp'
+ # HEIF to be added once I get ImageMagick running with support
+ }
+
+
@staticmethod
def isValidUrl(url):
# Catches most invalid URLs
@@ -94,18 +103,18 @@ def _checkNetwork():
@staticmethod
def getExtension(mime):
- mapping = {
- 'image/jpeg' : 'jpg',
- 'image/png' : 'png',
- 'image/gif' : 'gif',
- 'image/x-adobe-dng' : 'dng',
- 'image/bmp' : 'bmp'
- }
mime = mime.lower()
- if mime in mapping:
- return mapping[mime]
+ if mime in helper.MIMETYPES:
+ return helper.MIMETYPES[mime]
return None
+ @staticmethod
+ def getSupportedTypes():
+ ret = []
+ for i in helper.MIMETYPES:
+ ret.append(i)
+ return ret
+
@staticmethod
def getMimetype(filename):
if not os.path.isfile(filename):
@@ -208,6 +217,10 @@ def makeFullframe(filename, displayWidth, displayHeight, zoomOnly=False, autoCho
logging.debug('Size of image is %dx%d, screen is %dx%d. New size is %dx%d', width, height, displayWidth, displayHeight, adjWidth, adjHeight)
+ if width < 100 or height < 100:
+ logging.error('Image size is REALLY small, please check "%s" ... something isn\'t right', filename)
+ #a=1/0
+
if adjHeight < displayHeight:
border = '0x%d' % width_border
spacing = '0x%d' % width_spacing
diff --git a/modules/history.py b/modules/history.py
new file mode 100755
index 0000000..3a69fd1
--- /dev/null
+++ b/modules/history.py
@@ -0,0 +1,75 @@
+# This file is part of photoframe (https://github.com/mrworf/photoframe).
+#
+# photoframe is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# photoframe is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with photoframe. If not, see .
+#
+
+import logging
+import os
+import shutil
+
+from modules.path import path as syspath
+
+class ImageHistory:
+ MAX_HISTORY = 20
+ def __init__(self, settings):
+ self._HISTORY = []
+ self.settings = settings
+
+ # Also, make sure folder exists AND clear any existing content
+ if not os.path.exists(syspath.HISTORYFOLDER):
+ os.mkdir(syspath.HISTORYFOLDER)
+
+ # Clear it
+ for p, _dirs, files in os.walk(syspath.HISTORYFOLDER):
+ for filename in [os.path.join(p, f) for f in files]:
+ try:
+ os.unlink(filename)
+ except:
+ logging.exception('Failed to delete "%s"' % filename)
+
+ def _find(self, file):
+ return next((entry for entry in self._HISTORY if entry.filename == file), None)
+
+ def add(self, image):
+ if image is None or image.error is not None:
+ return
+ historyFile = os.path.join(syspath.HISTORYFOLDER, image.getCacheId())
+ if not self._find(historyFile):
+ shutil.copy(image.filename, historyFile)
+ h = image.copy()
+ h.setFilename(historyFile)
+ h.allowCache(False)
+
+ self._HISTORY.insert(0, h)
+ self._obeyLimits()
+
+ def _obeyLimits(self):
+ # Make sure history isn't too big
+ while len(self._HISTORY) > ImageHistory.MAX_HISTORY:
+ entry = self._HISTORY.pop()
+ if not self._find(entry.filename):
+ os.unlink(entry.filename)
+
+ def getAvailable(self):
+ return len(self._HISTORY)
+
+ def getByIndex(self, index):
+ if index < 0 or index >= len(self._HISTORY):
+ logging.warning('History index requested is out of bounds (%d wanted, have 0-%d)', index, len(self._HISTORY)-1)
+ return None
+ entry = self._HISTORY[index]
+ # We need to make a copy which is safe to delete!
+ f = os.path.join(self.settings.get('tempfolder'), 'history')
+ shutil.copy(entry.filename, f)
+ return entry.copy().setFilename(f)
diff --git a/modules/images.py b/modules/images.py
index fa97ca2..5e7b02d 100755
--- a/modules/images.py
+++ b/modules/images.py
@@ -35,6 +35,21 @@ def __init__(self):
self.cacheAllow = False
self.cacheUsed = False
+ self.contentProvider = None
+ self.contentSource = None
+
+ def setContentProvider(self, provider):
+ if provider is None:
+ raise Exception('setContentProvider cannot be None')
+ self.contentProvider = repr(provider)
+ return self
+
+ def setContentSource(self, source):
+ if source is None:
+ raise Exception('setContentSource cannot be None')
+ self.contentSource = repr(source)
+ return self
+
def setId(self, id):
self.id = id
return self
@@ -71,3 +86,19 @@ def getCacheId(self):
if self.id is None:
return None
return hashlib.sha1(self.id).hexdigest()
+
+ def copy(self):
+ copy = ImageHolder()
+ copy.id = self.id
+ copy.mimetype = self.mimetype
+ copy.error = self.error
+ copy.source = self.source
+ copy.url = self.url
+ copy.filename = self.filename
+ copy.dimensions = self.dimensions
+ copy.cacheAllow = self.cacheAllow
+ copy.cacheUsed = self.cacheUsed
+
+ copy.contentProvider = self.contentProvider
+ copy.contentSource = self.contentSource
+ return copy
diff --git a/modules/memory.py b/modules/memory.py
new file mode 100755
index 0000000..b57a96f
--- /dev/null
+++ b/modules/memory.py
@@ -0,0 +1,103 @@
+# This file is part of photoframe (https://github.com/mrworf/photoframe).
+#
+# photoframe is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# photoframe is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with photoframe. If not, see .
+#
+import os
+import json
+import logging
+import hashlib
+
+class MemoryManager:
+ def __init__(self, memoryLocation):
+ self._MEMORY = []
+ self._MEMORY_KEY = None
+ self._DIR_MEMORY = memoryLocation
+ self._MEMORY_COUNT = {}
+
+ 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 _fetch(self, key):
+ if key is None:
+ raise Exception('No key provided to _fetch')
+ 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)):
+ try:
+ with open(os.path.join(self._DIR_MEMORY, '%s.json' % h), 'r') as f:
+ self._MEMORY = json.load(f)
+ except:
+ logging.exception('File %s is corrupt' % os.path.join(self._DIR_MEMORY, '%s.json' % h))
+ self._MEMORY = []
+ else:
+ logging.debug('_fetch returned no memory')
+ self._MEMORY = []
+ self._MEMORY_COUNT[h] = len(self._MEMORY)
+ self._MEMORY_KEY = h
+
+ def remember(self, itemId, keywords, alwaysRemember=True):
+ # The MEMORY makes sure that this image won't be shown again until memoryForget is called
+ self._fetch(keywords)
+ h = self._hashString(itemId)
+ if h not in self._MEMORY:
+ self._MEMORY.append(h)
+ k = self._hashString(keywords)
+ if k in self._MEMORY_COUNT:
+ self._MEMORY_COUNT[k] += 1
+ else:
+ self._MEMORY_COUNT[k] = 1
+
+ # 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)
+
+ def getList(self, keywords):
+ self._fetch(keywords)
+ return self._MEMORY
+
+ def count(self, keywords):
+ if self._MEMORY_KEY is None:
+ self._fetch(keywords)
+ h = self._hashString(keywords)
+ if h in self._MEMORY_COUNT:
+ return self._MEMORY_COUNT[h]
+ return 0
+
+ def seen(self, itemId, keywords):
+ self._fetch(keywords)
+ h = self._hashString(itemId)
+ return h in self._MEMORY
+
+ def forget(self, keywords):
+ self._fetch(keywords)
+ n = os.path.join(self._DIR_MEMORY, '%s.json' % self._MEMORY_KEY)
+ if os.path.exists(n):
+ logging.debug('Removed memory file %s' % n)
+ os.unlink(n)
+ logging.debug('Has %d memories before wipe' % len(self._MEMORY))
+ self._MEMORY = []
+ self._MEMORY_COUNT.pop(self._hashString(keywords), None)
diff --git a/modules/network.py b/modules/network.py
old mode 100644
new mode 100755
index 9bc7eae..a89adb8
--- a/modules/network.py
+++ b/modules/network.py
@@ -28,6 +28,11 @@ def __init__(self):
self.mimetype = 'none/none'
self.headers = []
self.httpcode = 0
+ self.errormsg = None
+
+ def setErrorMessage(self, msg):
+ self.errormsg = msg
+ return self
def setResult(self, result):
self.result = result
@@ -62,4 +67,10 @@ def isNoNetwork(self):
return self.result == RequestResult.NO_NETWORK
class RequestNoNetwork(Exception):
+ pass
+
+class RequestInvalidToken(Exception):
+ pass
+
+class RequestExpiredToken(Exception):
pass
\ No newline at end of file
diff --git a/modules/oauth.py b/modules/oauth.py
index 53f237f..9f64e8b 100755
--- a/modules/oauth.py
+++ b/modules/oauth.py
@@ -16,12 +16,14 @@
import requests
import logging
import time
-from oauthlib.oauth2 import TokenExpiredError
+from oauthlib.oauth2 import TokenExpiredError, InvalidGrantError
from requests_oauthlib import OAuth2Session
from modules.helper import helper
from modules.network import RequestResult
from modules.network import RequestNoNetwork
+from modules.network import RequestInvalidToken
+from modules.network import RequestExpiredToken
class OAuth:
def __init__(self, setToken, getToken, scope, extras=''):
@@ -63,8 +65,7 @@ def request(self, uri, destination=None, params=None, data=None, usePost=False):
auth = self.getSession()
if auth is None:
logging.error('Unable to get OAuth session, probably expired')
- ret.setResult(RequestResult.OAUTH_INVALID)
- return ret
+ raise RequestExpiredToken
if usePost:
result = auth.post(uri, stream=stream, params=params, json=data, timeout=180)
else:
@@ -75,8 +76,7 @@ def request(self, uri, destination=None, params=None, data=None, usePost=False):
auth = self.getSession(True)
if auth is None:
logging.error('Unable to get OAuth session, probably expired')
- ret.setResult(RequestResult.OAUTH_INVALID)
- return ret
+ raise RequestExpiredToken
if usePost:
result = auth.post(uri, stream=stream, params=params, json=data, timeout=180)
@@ -84,6 +84,9 @@ def request(self, uri, destination=None, params=None, data=None, usePost=False):
result = auth.get(uri, stream=stream, params=params, timeout=180)
if result is not None:
break
+ except InvalidGrantError:
+ logging.error('Token is no longer valid, need to re-authenticate')
+ raise RequestInvalidToken
except:
logging.exception('Issues downloading')
time.sleep(tries / 10) # Back off 10, 20, ... depending on tries
diff --git a/modules/path.py b/modules/path.py
old mode 100644
new mode 100755
index 5db6b08..8b5dbe2
--- a/modules/path.py
+++ b/modules/path.py
@@ -22,6 +22,7 @@ class path:
COLORMATCH = '/root/photoframe_config/colortemp.sh'
OPTIONSFILE = '/root/photoframe_config/options'
CACHEFOLDER = '/root/cache/'
+ HISTORYFOLDER = '/root/history/'
DRV_BUILTIN = '/root/photoframe/display-drivers'
DRV_EXTERNAL = '/root/photoframe_config/display-drivers/'
@@ -30,7 +31,7 @@ class path:
def reassignConfigTxt(self, newconfig):
path.CONFIG_TXT = newconfig
-
+
def reassignBase(self, newbase):
path.CONFIGFOLDER = path.CONFIGFOLDER.replace('/root/', newbase)
path.CONFIGFILE = path.CONFIGFILE.replace('/root/', newbase)
@@ -39,6 +40,7 @@ def reassignBase(self, newbase):
path.DRV_BUILTIN = path.DRV_BUILTIN.replace('/root/', newbase)
path.DRV_EXTERNAL = path.DRV_EXTERNAL.replace('/root/', newbase)
path.CACHEFOLDER = path.CACHEFOLDER.replace('/root/', newbase)
+ path.HISTORYFOLDER = path.HISTORYFOLDER.replace('/root/', newbase)
def validate(self):
# Supercritical, since we store all photoframe files in a subdirectory, make sure to create it
diff --git a/modules/servicemanager.py b/modules/servicemanager.py
old mode 100644
new mode 100755
index f0d9bf7..dcef0e7
--- a/modules/servicemanager.py
+++ b/modules/servicemanager.py
@@ -25,6 +25,7 @@
from modules.helper import helper
from modules.path import path
+from services.base import BaseService
class ServiceManager:
def __init__(self, settings, cacheMgr):
@@ -40,21 +41,21 @@ def __init__(self, settings, cacheMgr):
self._SERVICES = {}
self._CONFIGFILE = os.path.join(self._BASEDIR, 'services.json')
- self.nextService = False
- self.prevService = False
- self.forceService = None
- self.lastUsedService = None
-
# Logs services that appear to have no images or only images that have already been displayed
- # memoryForget will be called when all images of every services have been displayed
+ # memoryForgetAll will be called when all images of every services have been displayed
self._OUT_OF_IMAGES = []
# Logs the sequence in which services are being used
# useful for displaying previous images
self._HISTORY = []
- self._detectServices()
+ # Tracks current service showing the image
+ self.currentService = None
+
+ # Track configuration changes
+ self.configChanges = 0
+ self._detectServices()
self._load()
# Translate old config into new
@@ -133,6 +134,12 @@ def _load(self):
def _hash(self, text):
return hashlib.sha1(text).hexdigest()
+ def _configChanged(self):
+ self.configChanges += 1
+
+ def getConfigChange(self):
+ return self.configChanges
+
def addService(self, type, name):
svcname = self._resolveService(type)
if svcname is None:
@@ -145,6 +152,7 @@ def addService(self, type, name):
svc.setCacheManager(self._CACHEMGR)
self._SERVICES[genid] = {'service' : svc, 'id' : svc.getId(), 'name' : svc.getName()}
self._save()
+ self._configChanged()
return genid
return None
@@ -163,6 +171,7 @@ def deleteService(self, id):
self._HISTORY = filter(lambda h: h != self._SERVICES[id]['service'], self._HISTORY)
del self._SERVICES[id]
self._deletefolder(os.path.join(self._BASEDIR, id))
+ self._configChanged()
self._save()
def oauthCallback(self, request):
@@ -232,6 +241,7 @@ def addServiceKeywords(self, service, keywords):
return {'error' : 'Service does not use keywords'}
if svc in self._OUT_OF_IMAGES:
self._OUT_OF_IMAGES.remove(svc)
+ self._configChanged()
return svc.addKeywords(keywords)
def removeServiceKeywords(self, service, index):
@@ -242,6 +252,7 @@ def removeServiceKeywords(self, service, index):
if not svc.needKeywords():
logging.error('removeServiceKeywords: Does not use keywords')
return False
+ self._configChanged()
return svc.removeKeywords(index)
def sourceServiceKeywords(self, service, index):
@@ -253,6 +264,15 @@ def sourceServiceKeywords(self, service, index):
return None
return svc.getKeywordSourceUrl(index)
+ def detailsServiceKeywords(self, service, index):
+ if service not in self._SERVICES:
+ return None
+ svc = self._SERVICES[service]['service']
+ if not svc.hasKeywordDetails():
+ logging.error('Service does not support keyword details')
+ return None
+ return svc.getKeywordDetails(index)
+
def helpServiceKeywords(self, service):
if service not in self._SERVICES:
return False
@@ -264,19 +284,21 @@ def helpServiceKeywords(self, service):
def getServiceState(self, id):
if id not in self._SERVICES:
return None
-
svc = self._SERVICES[id]['service']
# Find out if service is ready
- state = svc.updateState()
- if state == svc.STATE_DO_OAUTH:
+ return svc.updateState()
+
+ def getServiceStateText(self, id):
+ state = self.getServiceState(id)
+ if state == BaseService.STATE_DO_OAUTH:
return 'OAUTH'
- elif state == svc.STATE_DO_CONFIG:
+ elif state == BaseService.STATE_DO_CONFIG:
return 'CONFIG'
- elif state == svc.STATE_NEED_KEYWORDS:
+ elif state == BaseService.STATE_NEED_KEYWORDS:
return 'NEED_KEYWORDS'
- elif state == svc.STATE_NO_IMAGES:
+ elif state == BaseService.STATE_NO_IMAGES:
return 'NO_IMAGES'
- elif state == svc.STATE_READY:
+ elif state == BaseService.STATE_READY:
return 'READY'
return 'ERROR'
@@ -285,7 +307,7 @@ def getAllServiceStates(self):
for id in self._SERVICES:
svc = self._SERVICES[id]['service']
name = svc.getName()
- state = self.getServiceState(id)
+ state = self.getServiceStateText(id)
additionalInfo = svc.explainState()
serviceStates.append((name, state, additionalInfo))
return serviceStates
@@ -320,23 +342,24 @@ def _migrate(self):
self._SETTINGS.save()
def getLastUsedServiceName(self):
- if self.lastUsedService is None:
+ if self.currentService is None:
return ""
- return self.lastUsedService.getName()
+ return self._SERVICES[self.currentService]['service'].getName()
def getServices(self, readyOnly=False):
result = []
for k in self._SERVICES:
- if readyOnly and self.getServiceState(k) != 'READY':
+ if readyOnly and self.getServiceState(k) != BaseService.STATE_READY:
continue
svc = self._SERVICES[k]
result.append({
'name' : svc['service'].getName(),
'service' : svc['service'].SERVICE_ID,
'id' : k,
- 'state' : self.getServiceState(k),
+ 'state' : self.getServiceStateText(k),
'useKeywords' : svc['service'].needKeywords(),
'hasSourceUrl' : svc['service'].hasKeywordSourceUrl(),
+ 'hasDetails' : svc['service'].hasKeywordDetails(),
'messages' : svc['service'].getMessages(),
})
return result
@@ -351,95 +374,82 @@ def _getOffsetService(self, availableServices, lastService, offset):
def selectRandomService(self, services):
# select service at random but weighted by the number of images each service provides
- numImages = [self._SERVICES[s['id']]['service'].getNumImages() for s in services]
+ numImages = [self._SERVICES[s['id']]['service'].getImagesTotal() for s in services]
totalImages = sum(numImages)
if totalImages == 0:
return 0
i = helper.getWeightedRandomIndex(numImages)
return services[i]['id']
- def chooseService(self, randomize, lastService=None):
+ def chooseService(self, randomize, retry=False):
+ result = None
availableServices = self.getServices(readyOnly=True)
if len(availableServices) == 0:
return None
- if lastService is None:
- if len(self._HISTORY) != 0:
- lastService = self._HISTORY[-1]
- elif self.lastUsedService != None:
- lastService = self.lastUsedService
- # if lastService is not ready anymore!
- if lastService not in [self._SERVICES[s['id']]['service'] for s in availableServices]:
- lastService = None
-
- if self.forceService is not None:
- svc = self.forceService
- elif randomize:
- availableServices = [s for s in availableServices if self._SERVICES[s['id']]['service'] not in self._OUT_OF_IMAGES]
- logging.debug("# of available services %d"%len(availableServices))
- if len(availableServices) == 0:
- self.memoryForget()
- availableServices = self.getServices(readyOnly=True)
-
- key = self.selectRandomService(availableServices)
- svc = self._SERVICES[key]['service']
+ if randomize:
+ availableServices = [s for s in availableServices if self._SERVICES[s['id']]['service'].getImagesRemaining() > 0]
+ if len(availableServices) > 0:
+ logging.debug('Found %d services with images' % len(availableServices))
+ key = self.selectRandomService(availableServices)
+ result = self._SERVICES[key]['service']
else:
- if lastService == None:
- key = availableServices[0]['id']
- svc = self._SERVICES[key]['service']
- svc.resetIndices()
- else:
- if self.nextService:
- svc = self._getOffsetService(availableServices, lastService, 1)
- svc.resetIndices()
- elif self.prevService:
- svc = self._getOffsetService(availableServices, lastService, -1)
- svc.resetToLastAlbum()
- else:
- svc = lastService
-
- self.forceService = None
- self.nextService = False
- self.prevService = False
- return svc
+ offset = 0
+ # Find where to start
+ for s in range(0, len(availableServices)):
+ if availableServices[s]['id'] == self.currentService:
+ offset = s
+ break
+ # Next, pick the service which has photos
+ for s in range(0, len(availableServices)):
+ index = (offset + s) % len(availableServices)
+ svc = self._SERVICES[availableServices[index]['id']]['service']
+ if svc.getImagesRemaining() > 0:
+ result = svc
+ break
+
+ # Oh snap, out of images, reset memory and try again
+ if result is None and not retry:
+ logging.info('All images have been shown, resetting counters')
+ self.memoryForgetAll()
+ return self.chooseService(randomize, retry=True) # Avoid calling us forever
+ else:
+ logging.debug('Picked %s which has %d images left to show', result.getName(), result.getImagesRemaining())
+ return result
+
+ def expireStaleKeywords(self):
+ maxage = self._SETTINGS.getUser('refresh')
+ for key in self._SERVICES:
+ svc = self._SERVICES[key]["service"]
+ for k in svc.getKeywords():
+ if svc.freshnessImagesFor(k) < maxage:
+ continue
+ logging.info('Expire is set to %dh, expiring %s which was %d hours old', maxage, k, svc.freshnessImagesFor(k))
+ svc._clearImagesFor(k)
+
+ def getTotalImageCount(self):
+ services = self.getServices(readyOnly=True)
+ return sum([self._SERVICES[s['id']]['service'].getImagesTotal() for s in services])
def servicePrepareNextItem(self, destinationDir, supportedMimeTypes, displaySize, randomize):
+ # We should expire any old index if setting is active
+ if self._SETTINGS.getUser('refresh') > 0:
+ self.expireStaleKeywords()
+
svc = self.chooseService(randomize)
if svc is None:
return None
result = svc.prepareNextItem(destinationDir, supportedMimeTypes, displaySize, randomize)
- if result.error is not None:
- # If we end up here, two things can have happened
- # 1. All images have been shown for this service
- # 2. No image or data was able to download from this service
- # Retry, but use next service instead
- # If all services are out of images
- # Try forgetting all data and do another run (see 'chooseService')
- state = svc.updateState()
- if state == svc.STATE_READY and randomize and svc not in self._OUT_OF_IMAGES:
- self._OUT_OF_IMAGES.append(svc)
- logging.info("%s has probably shown all available images" % svc.getName())
-
- self.nextService = True
- svc = self.chooseService(randomize, lastService=svc)
- if svc is None:
- return None
- result = svc.prepareNextItem(destinationDir, supportedMimeTypes, displaySize, randomize)
-
- if result.error is not None:
- logging.error('Service returned: ' + result.error)
- state = svc.updateState()
- if state == svc.STATE_READY and randomize and svc not in self._OUT_OF_IMAGES:
- self._OUT_OF_IMAGES.append(svc)
- logging.info("%s has probably shown all available images" % svc.getName())
-
- self.lastUsedService = svc
+ if result is None:
+ logging.warning('prepareNextItem for %s came back with None', svc.getName())
+ elif result.error is not None:
+ logging.warning('prepareNextItem for %s came back with an error: %s', svc.getName(), result.error)
return result
def hasKeywords(self):
# Check any and all services to see if any is ready and if they have keywords
for k in self._SERVICES:
- if self.getServiceState(k) != 'READY':
+ if self.getServiceStateText(k) != 'READY':
continue
words = self.getServiceKeywords(k)
if words is not None and len(words) > 0:
@@ -448,75 +458,22 @@ def hasKeywords(self):
def hasReadyServices(self):
for k in self._SERVICES:
- if self.getServiceState(k) != 'READY':
+ if self.getServiceStateText(k) != 'READY':
continue
return True
return False
- def memoryRemember(self, imageId):
- svc = self.lastUsedService
- # only remember service in _HISTORY if image has changed.
- # alwaysRemember is True if the current service is different to the service of the previous image
- alwaysRemember = (len(self._HISTORY) == 0) or (svc != self._HISTORY[-1])
- if svc.memoryRemember(imageId, alwaysRemember=alwaysRemember):
- self._HISTORY.append(svc)
-
- def memoryForget(self, forgetHistory=False):
+ def memoryForgetAll(self):
logging.info("Photoframe's memory was reset. Already displayed images will be shown again!")
for key in self._SERVICES:
svc = self._SERVICES[key]["service"]
- svc.memoryForget(forgetHistory=forgetHistory)
- for file in os.listdir(svc.getStoragePath()):
- os.unlink(os.path.join(svc.getStoragePath(), file))
- self._OUT_OF_IMAGES = []
- if forgetHistory:
- self._HISTORY = []
-
- def nextImage(self):
- #No need to change anything; all done in slideshow.py
- pass
-
- def prevImage(self):
- if len(self._HISTORY) <= 1:
- return False
-
- # delete last two memories, because the currentImage and the previous need to be forgotten
- currentService = self._HISTORY.pop()
- currentService.memoryForgetLast()
- if currentService in self._OUT_OF_IMAGES:
- self._OUT_OF_IMAGES.remove(currentService)
-
- previousService = self._HISTORY.pop()
- previousService.memoryForgetLast()
- if previousService in self._OUT_OF_IMAGES:
- self._OUT_OF_IMAGES.remove(previousService)
-
- # skip all previous images of services that are not ready
- while previousService.updateState() != previousService.STATE_READY:
- if len(self._HISTORY) == 0:
- previousService = None
- break
- previousService = self._HISTORY.pop()
- previousService.memoryForgetLast()
- if previousService in self._OUT_OF_IMAGES:
- self._OUT_OF_IMAGES.remove(previousService)
-
- self.forceService = previousService
- return True
+ for k in svc.getKeywords():
+ logging.info('%s was %d hours old when we refreshed' % (k, svc.freshnessImagesFor(k)))
+ svc._clearImagesFor(k)
def nextAlbum(self):
- if len(self._HISTORY) == 0:
- return False
- lastService = self._HISTORY[-1]
- if not lastService.nextAlbum():
- self.nextService = True
- return True
+ return False
def prevAlbum(self):
- if len(self._HISTORY) == 0:
- return False
- lastService = self._HISTORY[-1]
- if not lastService.prevAlbum():
- self.prevService = True
- return True
+ return False
diff --git a/modules/settings.py b/modules/settings.py
index deba0ea..f23e61c 100755
--- a/modules/settings.py
+++ b/modules/settings.py
@@ -43,7 +43,7 @@ def userDefaults(self):
'interval' : 60, # Delay in seconds between images (minimum)
'display-off' : 22, # What hour (24h) to disable display and sleep
'display-on' : 4, # What hour (24h) to enable display and continue
- 'refresh-content' : 24, # After how many hours we should force reload of image lists from server
+ 'refresh' : 0, # After how many hours we should force reload of image lists from server (zero means when all is shown)
'autooff-lux' : 0.01,
'autooff-time' : 0,
'powersave' : '',
@@ -69,6 +69,10 @@ def load(self):
self.settings['cfg'] = tmp
self.settings['cfg'].update(tmp2)
+ if 'refresh-content' in self.settings['cfg']:
+ self.settings['cfg']['refresh'] = self.settings['cfg']['refresh-content']
+ self.settings['cfg'].pop('refresh-content', None)
+
# Remove deprecated fields
for field in settings.DEPRECATED_USER:
self.settings['cfg'].pop(field, None)
diff --git a/modules/slideshow.py b/modules/slideshow.py
index b7761cc..1f85f82 100755
--- a/modules/slideshow.py
+++ b/modules/slideshow.py
@@ -23,37 +23,30 @@
class slideshow:
SHOWN_IP = False
- EVENTS = ["nextImage", "prevImage", "nextAlbum", "prevAlbum", "settingsChange", "memoryForget", "clearCache"]
+ EVENTS = ["nextImage", "prevImage", "nextAlbum", "prevAlbum", "settingsChange", "memoryForget", "clearCache", "forgetPreload"]
- def __init__(self, display, settings, colormatch):
+ def __init__(self, display, settings, colormatch, history):
self.countdown = 0
self.thread = None
self.services = None
self.display = display
self.settings = settings
self.colormatch = colormatch
+ self.history = history
self.cacheMgr = None
self.void = open(os.devnull, 'wb')
self.delayer = threading.Event()
self.cbStopped = None
+ self.eventList = []
+
self.imageCurrent = None
- self.imageMime = None
- self.imageOnScreen = False
- self.cleanConfig = False
- self.doControl = None
- self.doClearCache = False
- self.doMemoryForget = False
- self.ignoreRandomize = False
self.skipPreloadedImage = False
- self.supportedFormats = [
- 'image/jpeg',
- 'image/png',
- 'image/gif',
- 'image/bmp'
- # HEIF to be added once I get ImageMagick running with support
- ]
+ self.historyIndex = -1
+ self.minimumWait = 1
+
+ self.supportedFormats = helper.getSupportedTypes()
self.running = True
@@ -64,7 +57,7 @@ def setCountdown(self, seconds):
self.countdown = seconds
def getCurrentImage(self):
- return self.imageCurrent, self.imageMime
+ return self.imageCurrent.filename, self.imageCurrent.mimetype
def getColorInformation(self):
return {
@@ -95,13 +88,13 @@ def start(self, blank=False):
self.thread = threading.Thread(target=self.presentation)
self.thread.daemon = True
self.running = True
- self.imageOnScreen = False
+ self.imageCurrent = None
self.thread.start()
def stop(self, cbStopped=None):
self.cbStopped = cbStopped
self.running = False
- self.imageOnScreen = False
+ self.imageCurrent = None
self.delayer.set()
def trigger(self):
@@ -111,62 +104,52 @@ def trigger(self):
def createEvent(self, cmd):
if cmd not in slideshow.EVENTS:
- logging.warning("Unknown event '%s' detected!"%cmd)
+ logging.warning("Unknown event '%s' received, will not act upon it" % cmd)
return
-
- if cmd == "settingsChange":
- self.skipPreloadedImage = True
- elif cmd == "memoryForget":
- self.cleanConfig = True
- self.doMemoryForget = True
- elif cmd == "clearCache":
- self.cleanConfig = True
- self.doClearCache = True
else:
- self.doControl = cmd
- if self.settings.getUser("randomize_images"):
- if cmd == "nextAlbum":
- self.doControl = "nextImage"
- elif cmd == "prevAlbum":
- self.doControl = "prevImage"
+ logging.debug('Event %s added to the queue', cmd)
+ self.eventList.append(cmd)
self.delayer.set()
def handleEvents(self):
- if self.cleanConfig:
- logging.info('Change of configuration, flush data and restart')
- # We need to expunge any pending image now
- # so we get fresh data to show the user
- if self.doMemoryForget:
- self.services.memoryForget(forgetHistory=True)
- self.doMemoryForget = False
- if self.doClearCache:
- self.cacheMgr.empty()
- self.doClearCache = False
- if self.imageCurrent:
- self.imageCurrent = None
+ showNext = True
+ isRandom = self.settings.getUser("randomize_images")
+ while len(self.eventList) > 0:
+ event = self.eventList.pop(0)
+
+ if event == 'memoryForget' or event == 'clearCache':
+ if event == 'memoryForget':
+ self.services.memoryForget()
+ if event == 'clearCache':
+ self.cacheMgr.empty()
+ if self.imageCurrent:
+ self.imageCurrent = None
+ self.display.clear()
+ showNext = False
+ elif event == "nextImage":
+ logging.info('nextImage called, historyIndex is %d', self.historyIndex)
+ elif event == "prevImage":
+ if self.historyIndex == -1:
+ # special case, first time, history holds what we're showing, so step twice
+ self.historyIndex = min(self.history.getAvailable()-1, self.historyIndex+2)
+ else:
+ self.historyIndex = min(self.history.getAvailable()-1, self.historyIndex+1)
+ logging.info('prevImage called, historyIndex is %d', self.historyIndex)
+ showNext = False
+ elif event == "nextAlbum":
+ # FIX
self.skipPreloadedImage = True
- self.imageOnScreen = False
- self.display.clear()
- self.cleanConfig = False
-
- if self.doControl == "nextImage":
- #just skip delay and show the next (preloaded) image
- pass
- elif self.doControl == "prevImage":
- self.skipPreloadedImage = True
- self.ignoreRandomize = True
- if self.services.prevImage():
+ self.services.nextAlbum()
self.delayer.set()
- elif self.doControl == "nextAlbum":
- self.skipPreloadedImage = True
- self.services.nextAlbum()
- self.delayer.set()
- elif self.doControl == "prevAlbum":
- self.skipPreloadedImage = True
- self.services.prevAlbum()
- self.delayer.set()
- self.doControl = None
+ elif event == "prevAlbum":
+ # FIX
+ self.skipPreloadedImage = True
+ self.services.prevAlbum()
+ self.delayer.set()
+ elif event == 'forgetPreload':
+ self.skipPreloadedImage = True
+ return showNext
def startupScreen(self):
slideshow.SHOWN_IP = True
@@ -182,7 +165,7 @@ def startupScreen(self):
self.display.clear()
def waitForNetwork(self):
- self.imageOnScreen = False
+ self.imageCurrent = None
helper.waitForNetwork(
lambda: self.display.message('No internet connection\n\nCheck router, wifi-config.txt or cable'),
lambda: self.settings.getUser('offline-behavior') != 'wait'
@@ -211,13 +194,13 @@ def handleErrors(self, result):
msg += "\n\n"+additionalInfo
self.display.message(msg)
- self.imageOnScreen = False
+ self.imageCurrent = None
return True
if result.error is not None:
logging.debug('%s failed:\n\n%s' % (self.services.getLastUsedServiceName(), result.error))
self.display.message('%s failed:\n\n%s' % (self.services.getLastUsedServiceName(), result.error))
- self.imageOnScreen = False
+ self.imageCurrent = None
return True
return False
@@ -226,24 +209,23 @@ def _colormatch(self, filenameProcessed):
# For Now: Always process original image (no caching of colormatch-adjusted images)
# 'colormatched_tmp.jpg' will be deleted after the image is displayed
p, f = os.path.split(filenameProcessed)
- ofile = os.path.join(p, "colormatch_" + f)
+ ofile = os.path.join(p, "colormatch_" + f + '.png')
if self.colormatch.adjust(filenameProcessed, ofile):
os.unlink(filenameProcessed)
return ofile
logging.warning('Unable to adjust image to colormatch, using original')
return filenameProcessed
- def process(self, image):
-
- logging.info('Processing %s', image.url)
+ def remember(self, image):
+ logging.debug('Commit this to history')
+ self.history.add(image)
+ def process(self, image):
+ logging.debug('Processing %s', image.id)
imageSizing = self.settings.getUser('imagesizing')
- # Get the starting point
- filename = os.path.join(self.settings.get('tempfolder'), image.id)
-
# Make sure it's oriented correctly
- filename = helper.autoRotate(filename)
+ filename = helper.autoRotate(image.filename)
# At this point, we have a good image, store it if allowed
if image.cacheAllow and not image.cacheUsed:
@@ -264,26 +246,23 @@ def delayNextImage(self, time_process):
# Delay before we show the image (but take processing into account)
# This should keep us fairly consistent
delay = self.settings.getUser('interval')
- if time_process < delay and self.imageOnScreen:
+ if time_process < delay and self.imageCurrent:
self.delayer.wait(delay - time_process)
- elif not self.imageOnScreen:
- self.delayer.wait(1) # Always wait ONE second to avoid busy waiting)
+ elif not self.imageCurrent:
+ self.delayer.wait(self.minimumWait) # Always wait ONE second to avoid busy waiting)
self.delayer.clear()
+ if self.imageCurrent:
+ self.minimumWait = 1
+ else:
+ self.minimumWait = min(self.minimumWait * 2, 16)
- def showPreloadedImage(self, filename, mimetype, imageId):
- if not self.skipPreloadedImage:
- if not os.path.isfile(filename):
- logging.warning("Trying to show image '%s', but file does not exist!"%filename)
- self.delayer.set()
- return
- self.display.image(filename)
- self.imageCurrent = filename
- self.imageMime = mimetype
- self.imageOnScreen = True
- self.services.memoryRemember(imageId)
- os.unlink(filename)
-
- self.skipPreloadedImage = False
+ def showPreloadedImage(self, image):
+ if not os.path.isfile(image.filename):
+ logging.warning("Trying to show image '%s', but file does not exist!" % filename)
+ self.delayer.set()
+ return
+ self.display.image(image.filename)
+ self.imageCurrent = image
def presentation(self):
self.services.getServices(readyOnly=True)
@@ -297,6 +276,8 @@ def presentation(self):
logging.info('Starting presentation')
i = 0
+ result = None
+ lastCfg = self.services.getConfigChange()
while self.running:
i += 1
time_process = time.time()
@@ -305,13 +286,16 @@ def presentation(self):
self.cacheMgr.garbageCollect()
displaySize = {'width': self.settings.getUser('width'), 'height': self.settings.getUser('height'), 'force_orientation': self.settings.getUser('force_orientation')}
- randomize = (not self.ignoreRandomize) and bool(self.settings.getUser('randomize_images'))
- self.ignoreRandomize = False
+ randomize = self.settings.getUser('randomize_images')
try:
- result = self.services.servicePrepareNextItem(self.settings.get('tempfolder'), self.supportedFormats, displaySize, randomize)
- if self.handleErrors(result):
- continue
+ if self.historyIndex == -1:
+ result = self.services.servicePrepareNextItem(self.settings.get('tempfolder'), self.supportedFormats, displaySize, randomize)
+ self.remember(result)
+ else:
+ logging.info('Fetching history image %d of %d', self.historyIndex, self.history.getAvailable())
+ result = self.history.getByIndex(self.historyIndex)
+ self.historyIndex = max(-1, self.historyIndex-1)
except RequestNoNetwork:
offline = self.settings.getUser('offline-behavior')
if offline == 'wait':
@@ -320,16 +304,33 @@ def presentation(self):
elif offline == 'ignore':
pass
- filenameProcessed = self.process(result)
- if filenameProcessed is None:
- continue
+ if not self.handleErrors(result):
+ filenameProcessed = self.process(result)
+ result = result.copy().setFilename(filenameProcessed)
+ else:
+ result = None
time_process = time.time() - time_process
+ logging.debug('Took %f seconds to process, next image is %s', time_process, result.filename)
self.delayNextImage(time_process)
- if self.running:
+
+ showNextImage = self.handleEvents()
+
+ # Handle changes to config to avoid showing an image which is unexpected
+ if self.services.getConfigChange() != lastCfg:
+ logging.debug('Services have changed, skip next photo and get fresh one')
+ self.skipPreloadedImage = True
+ lastCfg = self.services.getConfigChange()
+
+ if self.running and result is not None:
# Skip this section if we were killed while waiting around
- self.handleEvents()
- self.showPreloadedImage(filenameProcessed, result.mimetype, result.id)
+ if showNextImage and not self.skipPreloadedImage:
+ self.showPreloadedImage(result)
+ else:
+ self.imageCurrent = None
+ self.skipPreloadedImage = False
+ logging.debug('Deleting temp file "%s"' % result.filename)
+ os.unlink(result.filename)
self.thread = None
logging.info('slideshow has ended')
@@ -340,3 +341,6 @@ def presentation(self):
tmp = self.cbStopped
self.cbStopped = None
tmp()
+
+#TODO:
+#- Once in history, STOP PRELOADING THE IMAGE, IT BREAKS THINGS BADLY
diff --git a/routes/details.py b/routes/details.py
index 5827f35..a79b21f 100755
--- a/routes/details.py
+++ b/routes/details.py
@@ -22,11 +22,13 @@
from baseroute import BaseRoute
class RouteDetails(BaseRoute):
- def setupex(self, displaymgr, drivermgr, colormatch, slideshow):
+ def setupex(self, displaymgr, drivermgr, colormatch, slideshow, servicemgr, settings):
self.displaymgr = displaymgr
self.drivermgr = drivermgr
self.colormatch = colormatch
self.slideshow = slideshow
+ self.servicemgr = servicemgr
+ self.settings = settings
self.void = open(os.devnull, 'wb')
@@ -86,5 +88,14 @@ def handle(self, app, about):
'temperature' : h & (1 << 3 | 1 << 19) > 0
}
return self.jsonify(result)
+ elif about == 'messages':
+ # This should be made more general purpose since other parts need similar service
+ msgs = []
+ images = self.servicemgr.getTotalImageCount
+ timeneeded = images * self.settings.getUser('interval')
+ timeavailable = self.settings.getUser('refresh') * 3600
+ if timeavailable > 0 and timeneeded > timeavailable:
+ msgs.append({'level':'WARNING', 'message' : 'Change every %d seconds with %d images will take %dh, refresh keywords is %dh' % (self.settings.getUser('interval'), images, timeneeded/3600, timeavailable/3600), 'link' : None})
+ return self.jsonify(msgs)
self.setAbort(404)
diff --git a/routes/events.py b/routes/events.py
new file mode 100755
index 0000000..e819c9a
--- /dev/null
+++ b/routes/events.py
@@ -0,0 +1,34 @@
+# This file is part of photoframe (https://github.com/mrworf/photoframe).
+#
+# photoframe is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# photoframe is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with photoframe. If not, see .
+#
+
+from baseroute import BaseRoute
+
+class RouteEvents(BaseRoute):
+ def setupex(self, events):
+ self.events = events
+
+ self.addUrl('/events').addDefault('since', None).addDefault('id', None)
+ self.addUrl('/events/').addDefault('id', None)
+ self.addUrl('/events/remove/').addDefault('since', None)
+
+ def handle(self, app, since, id):
+ if since is not None:
+ return self.jsonify(self.events.getSince(since))
+ elif id is not None:
+ self.events.remove(id)
+ return 'ok'
+ else:
+ return self.jsonify(self.events.getAll())
diff --git a/routes/keywords.py b/routes/keywords.py
old mode 100644
new mode 100755
index dd7bf58..08c946e
--- a/routes/keywords.py
+++ b/routes/keywords.py
@@ -26,11 +26,14 @@ def setupex(self, servicemgr, slideshow):
self.addUrl('/keywords//add').clearMethods().addMethod('POST')
self.addUrl('/keywords//delete').clearMethods().addMethod('POST')
self.addUrl('/keywords//source/')
+ self.addUrl('/keywords//details/')
def handle(self, app, service, index=None):
if self.getRequest().method == 'GET':
if 'source' in self.getRequest().url:
return self.redirect(self.servicemgr.sourceServiceKeywords(service, index))
+ elif 'details' in self.getRequest().url:
+ return self.jsonify(self.servicemgr.detailsServiceKeywords(service, index))
elif 'help' in self.getRequest().url:
return self.jsonify({'message' : self.servicemgr.helpServiceKeywords(service)})
else:
diff --git a/services/base.py b/services/base.py
index c607058..b0972d2 100755
--- a/services/base.py
+++ b/services/base.py
@@ -20,13 +20,18 @@
import logging
import requests
import time
+import uuid
from modules.oauth import OAuth
from modules.helper import helper
from modules.network import RequestResult
from modules.network import RequestNoNetwork
+from modules.network import RequestInvalidToken
+from modules.network import RequestExpiredToken
from modules.images import ImageHolder
+from modules.memory import MemoryManager
+
# This is the base implementation of a service. It provides all the
# basic features like OAuth and Authentication as well as state and
# all other goodies. Most calls will not be overriden unless specified.
@@ -74,28 +79,18 @@ def __init__(self, configDir, id, name, needConfig=False, needOAuth=False):
'_KEYWORDS' : [],
'_NUM_IMAGES' : {},
'_NEXT_SCAN' : {},
- '_EXTRAS' : None
+ '_EXTRAS' : None,
+ '_INDEX_IMAGE' : 0,
+ '_INDEX_KEYWORD' : 0
}
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.memory = MemoryManager(os.path.join(self._DIR_BASE, 'memory'))
self.loadState()
self.preSetup()
@@ -142,7 +137,7 @@ def updateState(self):
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:
+ elif self.getImagesTotal() == 0:
self._CURRENT_STATE = BaseService.STATE_NO_IMAGES
else:
self._CURRENT_STATE = BaseService.STATE_READY
@@ -181,17 +176,26 @@ def setName(self, newName):
def getId(self):
return self._ID
- def getNumImages(self, excludeUnsuported=True):
+ def getImagesTotal(self):
# 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"] or keyword not in self._STATE['_NEXT_SCAN'] or self._STATE['_NEXT_SCAN'][keyword] < time.time():
- images = self.getImagesFor(keyword)
+ 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
- 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 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
@@ -256,6 +260,12 @@ def hasOAuth(self):
# 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
@@ -311,14 +321,6 @@ def getConfigurationFields(self):
###[ 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():
@@ -352,6 +354,16 @@ 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
@@ -364,6 +376,8 @@ def removeKeywords(self, 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):
@@ -376,7 +390,7 @@ def helpKeywords(self):
def getRandomKeywordIndex(self):
# select keyword index at random but weighted by the number of images of each album
- totalImages = self.getNumImages()
+ totalImages = self.getImagesTotal()
if totalImages == 0:
return 0
numImages = [self._STATE['_NUM_IMAGES'][kw] for kw in self._STATE['_NUM_IMAGES']]
@@ -417,7 +431,13 @@ def prepareNextItem(self, destinationFile, supportedMimeTypes, displaySize, rand
# You will probably only need to implement 'getImagesFor' and 'addUrlParams'
if self.needKeywords():
- result = self.selectImageFromAlbum(destinationFile, supportedMimeTypes, displaySize, randomize)
+ 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:
@@ -425,6 +445,19 @@ def prepareNextItem(self, destinationFile, supportedMimeTypes, displaySize, rand
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
@@ -440,7 +473,23 @@ def getImagesFor(self, keyword):
# "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')
+ 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
@@ -457,110 +506,166 @@ def getContentUrl(self, image, hints):
###[ Helpers ]######################################
- def selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize):
+ 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.
- keywordList = list(self.getKeywords())
- keywordCount = len(keywordList)
- if keywordCount == 0:
- return ImageHolder().setError('No albums have been specified')
+ keywords = self.getKeywords()
+ index = self.getRandomKeywordIndex()
- 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, 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 not randomize and (index + i) >= keywordCount:
+ if (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]
+ self.setIndex(keyword = (index + i) % keywordCount)
+ keyword = keywordList[self.getIndexKeyword()]
# 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
+ # We use a wrapper to clear things up
+ images = self._getImagesFor(keyword)
+ if images is None or len(images) == 0:
+ self.setIndex(0)
continue
- if len(images) > 0 and images[0].error is not None:
+ elif images[0].error is not None:
+ # Something went wrong, only return first image since it holds the error
return images[0]
- self._STATE["_NUM_IMAGES"][keyword] = len(images)
- self._STATE['_NEXT_SCAN'][keyword] = time.time() + self.REFRESH_DELAY
- if len(images) == 0:
- self.imageIndex = 0
- continue
self.saveState()
- image = self.selectImage(images, supportedMimeTypes, displaySize, randomize)
+ image = self.selectNextImage(keyword, images, supportedMimeTypes, displaySize)
if image is None:
- self.imageIndex = 0
+ self.setIndex(0)
continue
- filename = os.path.join(destinationDir, image.id)
+ return self.fetchImage(image, destinationDir, supportedMimeTypes, displaySize)
+ return None
- 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')
+ def selectRandomImage(self, keywords, images, supportedMimeTypes, displaySize):
+ imageCount = len(images)
+ index = random.SystemRandom().randint(0, imageCount-1)
- try:
- result = self.requestUrl(url, destination=filename)
- except requests.exceptions.RequestException:
- logging.exception('request to download image failed')
- result = RequestResult().setResult(RequestResult.NO_NETWORK)
+ logging.debug('There are %d images total' % imageCount)
+ for i in range(0, imageCount):
+ image = images[(index + i) % imageCount]
- 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
+ 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.resetIndices()
+ self.setIndex((index + i) % imageCount)
+ return image
return None
- def selectImage(self, images, supportedMimeTypes, displaySize, randomize):
+ def selectNextImage(self, keywords, images, supportedMimeTypes, displaySize):
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
+ index = self.getIndexImage()
- self.imageIndex = (index + i) % imageCount
- image = images[self.imageIndex]
+ for i in range(index, imageCount):
+ image = images[i]
orgFilename = image.filename if image.filename is not None else image.id
- if randomize and self.memorySeen(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('Unsupported media: %s' % (image.mimetype))
+ logging.debug('Skipping unsupported media: %s' % (image.mimetype))
continue
+ self.setIndex(i)
return image
return None
@@ -569,7 +674,15 @@ def requestUrl(self, url, destination=None, params=None, data=None, usePost=Fals
if self._OAUTH is not None:
# Use OAuth path
- result = self._OAUTH.request(url, destination, params, data=data, usePost=usePost)
+ 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:
@@ -656,112 +769,46 @@ def hashString(self, text):
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)):
- try:
- with open(os.path.join(self._DIR_MEMORY, '%s.json' % h), 'r') as f:
- self._MEMORY = json.load(f)
- except:
- logging.exception('File %s is corrupt' % os.path.join(self._DIR_MEMORY, '%s.json' % h))
- self._MEMORY = []
- else:
- self._MEMORY = []
- self._MEMORY_KEY = h
+ 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 _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 getIndexImage(self):
+ return self._STATE['_INDEX_IMAGE']
- 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()
+ 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
- self.imageIndex = 0
- self.keywordIndex += 1
- if self.keywordIndex >= len(self._STATE['_KEYWORDS']):
- self.keywordIndex = 0
- return False
- return True
+ 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
- self.imageIndex = 0
- self.keywordIndex -= 1
- if self.keywordIndex < 0:
- self.keywordIndex = len(self._STATE['_KEYWORDS']) - 1
- return False
- return True
+ return not self.setIndex(0, addKeyword=-1)
diff --git a/services/svc_googlephotos.py b/services/svc_googlephotos.py
index b2be5fe..ff1ff96 100755
--- a/services/svc_googlephotos.py
+++ b/services/svc_googlephotos.py
@@ -17,7 +17,10 @@
import os
import json
import logging
+import time
+
from modules.network import RequestResult
+from modules.helper import helper
class GooglePhotos(BaseService):
SERVICE_NAME = 'GooglePhotos'
@@ -91,6 +94,60 @@ def getKeywordSourceUrl(self, index):
return 'https://photos.google.com/'
return extras[keywords]['sourceUrl']
+ def getKeywordDetails(self, index):
+ # Override so we can tell more, for google it means we simply review what we would show
+ keys = self.getKeywords()
+ if index < 0 or index >= len(keys):
+ return 'Out of range, index = %d' % index
+ keyword = keys[index]
+
+ # This is not going to be fast...
+ data = self.getImagesFor(keyword, rawReturn=True)
+ mimes = helper.getSupportedTypes()
+ memory = self.memory.getList(keyword)
+
+ countv = 0
+ counti = 0
+ countu = 0
+ types = {}
+ for entry in data:
+ if entry['mimeType'].startswith('video/'):
+ countv += 1
+ elif entry['mimeType'].startswith('image/'):
+ if entry['mimeType'].lower() in mimes:
+ counti += 1
+ else:
+ countu += 1
+
+ if entry['mimeType'] in types:
+ types[entry['mimeType']] += 1
+ else:
+ types[entry['mimeType']] = 1
+
+ longer = ['Below is a breakdown of the content found in this album']
+ unsupported = []
+ for i in types:
+ if i in mimes:
+ longer.append('%s has %d items' % (i, types[i]))
+ else:
+ unsupported.append('%s has %d items' % (i, types[i]))
+
+ extra = ''
+ if len(unsupported) > 0:
+ longer.append('')
+ longer.append('Mime types listed below were also found but are as of yet not supported:')
+ longer.extend(unsupported)
+ if countu > 0:
+ extra = ' where %d is not yet unsupported' % countu
+ return {
+ 'short': '%d items fetched from album, %d images%s, %d videos, %d is unknown. %d has been shown' % (len(data), counti + countu, extra, countv, len(data) - counti - countv, len(memory)),
+ 'long' : longer
+ }
+
+ def hasKeywordDetails(self):
+ # Override so we can tell more, for google it means we simply review what we would show
+ return True
+
def removeKeywords(self, index):
# Override since we need to delete our private data
keys = self.getKeywords()
@@ -202,7 +259,7 @@ def translateKeywordToId(self, keyword):
source = data['albums'][i]['productUrl']
break
if albumid is None and 'nextPageToken' in data:
- logging.info('Another page of albums available')
+ logging.debug('Another page of albums available')
params['pageToken'] = data['nextPageToken']
continue
break
@@ -227,7 +284,7 @@ def translateKeywordToId(self, keyword):
source = data['sharedAlbums'][i]['productUrl']
break
if albumid is None and 'nextPageToken' in data:
- logging.info('Another page of shared albums available')
+ logging.debug('Another page of shared albums available')
params['pageToken'] = data['nextPageToken']
continue
break
@@ -246,7 +303,20 @@ def selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize,
else:
return BaseService.createImageHolder(self).setError('No (new) images could be found.\nCheck spelling or make sure you have added albums')
- def getImagesFor(self, keyword):
+ def freshnessImagesFor(self, keyword):
+ filename = os.path.join(self.getStoragePath(), self.hashString(keyword) + '.json')
+ if not os.path.exists(filename):
+ return 0 # Superfresh
+ # Hours should be returned
+ return (time.time() - os.stat(filename).st_mtime) / 3600
+
+ def clearImagesFor(self, keyword):
+ filename = os.path.join(self.getStoragePath(), self.hashString(keyword) + '.json')
+ if os.path.exists(filename):
+ logging.info('Cleared image information for %s' % keyword)
+ os.unlink(filename)
+
+ def getImagesFor(self, keyword, rawReturn=False):
filename = os.path.join(self.getStoragePath(), self.hashString(keyword) + '.json')
result = []
if not os.path.exists(filename):
@@ -299,9 +369,11 @@ def getImagesFor(self, keyword):
logging.exception('Failed to save copy of corrupt file, deleting instead')
os.unlink(filename)
albumdata = None
- return self.parseAlbumInfo(albumdata)
+ if rawReturn:
+ return albumdata
+ return self.parseAlbumInfo(albumdata, keyword)
- def parseAlbumInfo(self, data):
+ def parseAlbumInfo(self, data, keyword):
# parse GooglePhoto specific keys into a format that the base service can understand
if data is None:
return None
@@ -312,6 +384,8 @@ def parseAlbumInfo(self, data):
item.setSource(entry['productUrl']).setMimetype(entry['mimeType'])
item.setDimensions(entry['mediaMetadata']['width'], entry['mediaMetadata']['height'])
item.allowCache(True)
+ item.setContentProvider(self)
+ item.setContentSource(keyword)
parsedImages.append(item)
return parsedImages
diff --git a/static/css/index.css b/static/css/index.css
index 2e900cf..d6b1094 100755
--- a/static/css/index.css
+++ b/static/css/index.css
@@ -150,6 +150,39 @@ div.help div:first-child h3:first-child {
margin-top: 0pt;
}
+div.details {
+ position:fixed;
+ width:100%;
+ height:100%;
+ top:0px;
+ left:0px;
+ z-index:1000;
+}
+
+div.details div:first-child {
+ overflow: scroll;
+ opacity: 1;
+ background-color: #ffffff;
+ margin: auto;
+ display: inline-block;
+ border: 2px solid;
+ width: 75%;
+ height: 75%;
+ padding: 10pt;
+}
+
+div.details div:first-child h3:first-child {
+ margin-top: 0pt;
+}
+
+div.details_scroll {
+ overflow: scroll;
+ display: block;
+ height: 75%;
+ border: 1px inset;
+ white-space: pre;
+}
+
p.message {
margin: 2pt;
font-weight: bold;
diff --git a/static/js/main.js b/static/js/main.js
index e30e911..63997fa 100755
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -339,6 +339,26 @@ $("#shutdown").click(function() {
$("input[class='keyword-search']").click(function(){
window.open("/keywords/" + $(this).data('service') + '/source/' + $(this).data('index'), "_blank");
});
+$("input[class='keyword-details']").click(function(){
+ console.log('Show details');
+ $('#details_short').html('Loading...');
+ $('#details_long').html('Loading...');
+ $('#help_details').show();
+ $("button[name=details_close]").click(function() {
+ $(this).parent().parent().hide();
+ });
+ $.ajax({
+ url:"/keywords/" + $(this).data('service') + "/details/" + $(this).data('index')
+ }).done(function(data){
+ $('#details_short').text(data.short);
+ str = "";
+ for (line in data.long) {
+ str += data.long[line] + "\n";
+ }
+ $('#details_long').text(str);
+ });
+ //window.open("/details.html?service=" + $(this).data('service') + '&index=' + $(this).data('index'), "_blank");
+});
$("input[class='keyword-delete']").click(function(){
if (confirm("Are you sure?")) {
$.ajax({
diff --git a/static/template/main.html b/static/template/main.html
index bb38563..7494e58 100755
--- a/static/template/main.html
+++ b/static/template/main.html
@@ -10,10 +10,12 @@
PhotoFrame Configuration
Change every seconds
+ Refresh keywords after hours
+
How to handle images which don't fill the screen
-
+
+
+
Summary
+ Stuff and things
+
Details
+
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+ Some
+ Lines
+ Of
+ Text
+