plugin.audio.librespot/resources/lib/deps/simplecache.py

308 lines
12 KiB
Python
Raw Normal View History

2024-02-21 05:35:31 +00:00
#!/usr/bin/python
# -*- coding: utf-8 -*-
'''provides a simple stateless caching system for Kodi addons and plugins'''
import sys
import xbmcvfs
import xbmcgui
import xbmc
import xbmcaddon
import datetime
import time
import sqlite3
import json
from functools import reduce
class SimpleCache(object):
'''simple stateless caching system for Kodi'''
enable_mem_cache = True
data_is_json = False
global_checksum = None
_exit = False
_auto_clean_interval = datetime.timedelta(hours=4)
_win = None
_busy_tasks = []
_database = None
def __init__(self, addon_id):
'''Initialize our caching class'''
self.addon_id = addon_id
self._win = xbmcgui.Window(10000)
self._monitor = xbmc.Monitor()
self.check_cleanup()
self._log_msg("Initialized")
def close(self):
'''tell any tasks to stop immediately (as we can be called multithreaded) and cleanup objects'''
self._exit = True
# wait for all tasks to complete
while self._busy_tasks and not self._monitor.abortRequested():
xbmc.sleep(25)
del self._win
del self._monitor
self._log_msg("Closed")
def __del__(self):
'''make sure close is called'''
if not self._exit:
self.close()
def get(self, endpoint, checksum="", json_data=False):
'''
get object from cache and return the results
endpoint: the (unique) name of the cache object as reference
checkum: optional argument to check if the checksum in the cacheobject matches the checkum provided
'''
checksum = self._get_checksum(checksum)
cur_time = self._get_timestamp(datetime.datetime.now())
result = None
# 1: try memory cache first
if self.enable_mem_cache:
result = self._get_mem_cache(endpoint, checksum, cur_time, json_data)
# 2: fallback to _database cache
if result is None:
result = self._get_db_cache(endpoint, checksum, cur_time, json_data)
return result
def set(self, endpoint, data, checksum="", expiration=datetime.timedelta(days=30), json_data=False):
'''
set data in cache
'''
task_name = "set.%s" % endpoint
self._busy_tasks.append(task_name)
checksum = self._get_checksum(checksum)
expires = self._get_timestamp(datetime.datetime.now() + expiration)
# memory cache: write to window property
if self.enable_mem_cache and not self._exit:
self._set_mem_cache(endpoint, checksum, expires, data, json_data)
# db cache
if not self._exit:
self._set_db_cache(endpoint, checksum, expires, data, json_data)
# remove this task from list
self._busy_tasks.remove(task_name)
def check_cleanup(self):
'''check if cleanup is needed - public method, may be called by calling addon'''
cur_time = datetime.datetime.now()
lastexecuted = self._win.getProperty("simplecache.clean.lastexecuted")
if not lastexecuted:
self._win.setProperty("simplecache.clean.lastexecuted", repr(cur_time))
elif (eval(lastexecuted) + self._auto_clean_interval) < cur_time:
# cleanup needed...
self._do_cleanup()
def _get_mem_cache(self, endpoint, checksum, cur_time, json_data):
'''
get cache data from memory cache
we use window properties because we need to be stateless
'''
result = None
cachedata = self._win.getProperty(endpoint)
if cachedata:
if json_data or self.data_is_json:
cachedata = json.loads(cachedata)
else:
cachedata = eval(cachedata)
if cachedata[0] > cur_time:
if not checksum or checksum == cachedata[2]:
result = cachedata[1]
return result
def _set_mem_cache(self, endpoint, checksum, expires, data, json_data):
'''
window property cache as alternative for memory cache
usefull for (stateless) plugins
'''
cachedata = (expires, data, checksum)
if json_data or self.data_is_json:
cachedata_str = json.dumps(cachedata)
else:
cachedata_str = repr(cachedata)
self._win.setProperty(endpoint, cachedata_str)
def _get_db_cache(self, endpoint, checksum, cur_time, json_data):
'''get cache data from sqllite _database'''
result = None
query = "SELECT expires, data, checksum FROM simplecache WHERE id = ?"
cache_data = self._execute_sql(query, (endpoint,))
if cache_data:
cache_data = cache_data.fetchone()
if cache_data and cache_data[0] > cur_time:
if not checksum or cache_data[2] == checksum:
if json_data or self.data_is_json:
result = json.loads(cache_data[1])
else:
result = eval(cache_data[1])
# also set result in memory cache for further access
if self.enable_mem_cache:
self._set_mem_cache(endpoint, checksum, cache_data[0], result, json_data)
return result
def _set_db_cache(self, endpoint, checksum, expires, data, json_data):
''' store cache data in _database '''
query = "INSERT OR REPLACE INTO simplecache( id, expires, data, checksum) VALUES (?, ?, ?, ?)"
if json_data or self.data_is_json:
data = json.dumps(data)
else:
data = repr(data)
self._execute_sql(query, (endpoint, expires, data, checksum))
def _do_cleanup(self):
'''perform cleanup task'''
if self._exit or self._monitor.abortRequested():
return
self._busy_tasks.append(__name__)
cur_time = datetime.datetime.now()
cur_timestamp = self._get_timestamp(cur_time)
self._log_msg("Running cleanup...")
if self._win.getProperty("simplecachecleanbusy"):
return
self._win.setProperty("simplecachecleanbusy", "busy")
query = "SELECT id, expires FROM simplecache"
for cache_data in self._execute_sql(query).fetchall():
cache_id = cache_data[0]
cache_expires = cache_data[1]
if self._exit or self._monitor.abortRequested():
return
# always cleanup all memory objects on each interval
self._win.clearProperty(cache_id)
# clean up db cache object only if expired
if cache_expires < cur_timestamp:
query = 'DELETE FROM simplecache WHERE id = ?'
self._execute_sql(query, (cache_id,))
self._log_msg("delete from db %s" % cache_id)
# compact db
self._execute_sql("VACUUM")
# remove task from list
self._busy_tasks.remove(__name__)
self._win.setProperty("simplecache.clean.lastexecuted", repr(cur_time))
self._win.clearProperty("simplecachecleanbusy")
self._log_msg("Auto cleanup done")
def _get_database(self):
'''get reference to our sqllite _database - performs basic integrity check'''
addon = xbmcaddon.Addon(self.addon_id)
dbpath = addon.getAddonInfo('profile')
dbfile = xbmcvfs.translatePath("%s/simplecache.db" % dbpath)
if not xbmcvfs.exists(dbpath):
xbmcvfs.mkdirs(dbpath)
del addon
try:
connection = sqlite3.connect(dbfile, timeout=30, isolation_level=None)
connection.execute('SELECT * FROM simplecache LIMIT 1')
return connection
except Exception as error:
# our _database is corrupt or doesn't exist yet, we simply try to recreate it
if xbmcvfs.exists(dbfile):
xbmcvfs.delete(dbfile)
try:
connection = sqlite3.connect(dbfile, timeout=30, isolation_level=None)
connection.execute(
"""CREATE TABLE IF NOT EXISTS simplecache(
id TEXT UNIQUE, expires INTEGER, data TEXT, checksum INTEGER)""")
return connection
except Exception as error:
self._log_msg("Exception while initializing _database: %s" % str(error), xbmc.LOGWARNING)
self.close()
return None
def _execute_sql(self, query, data=None):
'''little wrapper around execute and executemany to just retry a db command if db is locked'''
retries = 0
result = None
error = None
# always use new db object because we need to be sure that data is available for other simplecache instances
with self._get_database() as _database:
while not retries == 10 and not self._monitor.abortRequested():
if self._exit:
return None
try:
if isinstance(data, list):
result = _database.executemany(query, data)
elif data:
result = _database.execute(query, data)
else:
result = _database.execute(query)
return result
except sqlite3.OperationalError as error:
if "_database is locked" in error:
self._log_msg("retrying DB commit...")
retries += 1
self._monitor.waitForAbort(0.5)
else:
break
except Exception as error:
break
self._log_msg("_database ERROR ! -- %s" % str(error), xbmc.LOGWARNING)
return None
@staticmethod
def _log_msg(msg, loglevel=xbmc.LOGDEBUG):
'''helper to send a message to the kodi log'''
xbmc.log("Skin Helper Simplecache --> %s" % msg, level=loglevel)
@staticmethod
def _get_timestamp(date_time):
'''Converts a datetime object to unix timestamp'''
return int(time.mktime(date_time.timetuple()))
def _get_checksum(self, stringinput):
'''get int checksum from string'''
if not stringinput and not self.global_checksum:
return 0
if self.global_checksum:
stringinput = "%s-%s" %(self.global_checksum, stringinput)
else:
stringinput = str(stringinput)
return reduce(lambda x, y: x + y, map(ord, stringinput))
def use_cache(cache_days=14):
'''
wrapper around our simple cache to use as decorator
Usage: define an instance of SimpleCache with name "cache" (self.cache) in your class
Any method that needs caching just add @use_cache as decorator
NOTE: use unnamed arguments for calling the method and named arguments for optional settings
'''
def decorator(func):
'''our decorator'''
def decorated(*args, **kwargs):
'''process the original method and apply caching of the results'''
method_class = args[0]
method_class_name = method_class.__class__.__name__
cache_str = "%s.%s" % (method_class_name, func.__name__)
# cache identifier is based on positional args only
# named args are considered optional and ignored
for item in args[1:]:
cache_str += u".%s" % item
cache_str = cache_str.lower()
cachedata = method_class.cache.get(cache_str)
global_cache_ignore = False
try:
global_cache_ignore = method_class.ignore_cache
except Exception:
pass
if cachedata is not None and not kwargs.get("ignore_cache", False) and not global_cache_ignore:
return cachedata
else:
result = func(*args, **kwargs)
method_class.cache.set(cache_str, result, expiration=datetime.timedelta(days=cache_days))
return result
return decorated
return decorator