PlexKodiConnect/resources/lib/utils.py

915 lines
30 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python
2015-12-25 07:07:00 +11:00
# -*- coding: utf-8 -*-
2018-02-11 22:59:04 +11:00
"""
Various functions and decorators for PKC
"""
2017-12-10 00:35:08 +11:00
from logging import getLogger
from sqlite3 import OperationalError
2018-11-19 00:59:17 +11:00
from datetime import datetime
2017-01-29 23:52:46 +11:00
from unicodedata import normalize
from threading import Lock
2020-12-27 23:04:46 +11:00
import urllib
# even with the above import urllib, Python3 sometimes runs into this issue
# AttributeError: module 'urllib' has no attribute 'parse'
# Hence import explicitly
import urllib.parse
# Originally tried faster cElementTree, but does NOT work reliably with Kodi
# etree parse unsafe; make sure we're always receiving unicode
from .defusedxml import ElementTree as etree
from .defusedxml.ElementTree import ParseError
import xml.etree.ElementTree as undefused_etree
2018-11-19 00:59:17 +11:00
from functools import wraps
2018-05-01 22:48:49 +10:00
import re
2018-09-11 04:53:46 +10:00
import gc
2019-01-09 04:00:54 +11:00
import xbmc
import xbmcaddon
import xbmcgui
2015-12-25 07:07:00 +11:00
from . import pathvalidate, path_ops, variables as v
2017-01-29 23:40:34 +11:00
2018-06-22 03:24:37 +10:00
LOG = getLogger('PLEX.utils')
2016-09-03 03:31:27 +10:00
WINDOW = xbmcgui.Window(10000)
ADDON = xbmcaddon.Addon(id='plugin.video.plexkodiconnect')
# If several threads access the settings.xml file concurrently, it gets
# corrupted
SETTINGS_LOCK = Lock()
# Grab Plex id from '...plex_id=XXXX....'
2018-05-01 22:48:49 +10:00
REGEX_PLEX_ID = re.compile(r'''plex_id=(\d+)''')
# Return the numbers at the end of an url like '.../.../XXXX'
REGEX_END_DIGITS = re.compile(r'''/(.+)/(\d+)$''')
REGEX_PLEX_DIRECT = re.compile(r'''\.plex\.direct:\d+$''')
# Plex API
REGEX_IMDB = re.compile(r'''/(tt\d+)''')
REGEX_TVDB = re.compile(r'''(?:the)?tvdb(?::\/\/|[2-5]?-)(\d+?)\?''')
2019-10-04 05:30:05 +10:00
REGEX_TMDB = re.compile(r'''themoviedb:\/\/(.+?)\?''')
REGEX_ANIDB = re.compile(r'''anidb[2-4]?-(\d+?)\?''')
# Plex music
REGEX_MUSICPATH = re.compile(r'''^\^(.+)\$$''')
# Grab Plex id from an URL-encoded string
REGEX_PLEX_ID_FROM_URL = re.compile(r'''metadata%2F(\d+)''')
2018-05-01 22:48:49 +10:00
2020-12-20 06:43:08 +11:00
SAFE_URL_CHARACTERS = "%/:=&?~#+!$,;'@()*[]"
HTTP_DAV_FTP = re.compile(r'(http(s)?|dav(s)?|(s)?ftp)://((.+):(.+)@)?([\w\.]+)(:([\d]+))?/')
2016-09-03 03:31:27 +10:00
2018-09-11 04:53:46 +10:00
def garbageCollect():
gc.collect(2)
def setGlobalProperty(key, val):
2018-09-16 22:00:52 +10:00
xbmcgui.Window(10000).setProperty(
'plugin.video.plexkodiconnect.{0}'.format(key), val)
def setGlobalBoolProperty(key, boolean):
xbmcgui.Window(10000).setProperty(
'plugin.video.plexkodiconnect.{0}'.format(key), boolean and '1' or '')
def getGlobalProperty(key):
return xbmc.getInfoLabel(
'Window(10000).Property(plugin.video.plexkodiconnect.{0})'.format(key))
2018-09-11 04:53:46 +10:00
def reboot_kodi(message=None):
"""
Displays an OK prompt with 'Kodi will now restart to apply the changes'
Kodi will then reboot.
Set optional custom message
"""
2018-06-22 03:24:37 +10:00
message = message or lang(33033)
2018-09-19 00:26:40 +10:00
messageDialog(lang(29999), message)
xbmc.executebuiltin('RestartApp')
2018-02-11 22:59:04 +11:00
def window(prop, value=None, clear=False, windowid=10000):
2016-09-03 03:31:27 +10:00
"""
Get or set window property - thread safe!
Returns unicode.
Property and value may be string or unicode
"""
if windowid != 10000:
win = xbmcgui.Window(windowid)
else:
win = WINDOW
if clear:
2018-02-11 22:59:04 +11:00
win.clearProperty(prop)
2016-09-03 03:31:27 +10:00
elif value is not None:
win.setProperty(prop, value)
2016-09-03 03:31:27 +10:00
else:
return win.getProperty(prop)
2016-09-03 03:31:27 +10:00
def settings(setting, value=None):
"""
Get or add addon setting. Returns unicode
setting and value can either be unicode or string
"""
2016-11-01 05:42:52 +11:00
# We need to instantiate every single time to read changed variables!
with SETTINGS_LOCK:
2021-11-03 17:03:50 +11:00
addon = xbmcaddon.Addon('plugin.video.plexkodiconnect')
if value is not None:
# Takes string or unicode by default!
addon.setSetting(setting, value)
else:
# Should return unicode by default, but just in case
return addon.getSetting(setting)
2016-09-03 03:31:27 +10:00
2018-06-22 03:24:37 +10:00
def lang(stringid):
2018-02-11 22:59:04 +11:00
"""
Central string retrieval from strings.po. If not found within PKC,
standard XBMC/Kodi strings are retrieved.
Will return unicode
2018-02-11 22:59:04 +11:00
"""
return (ADDON.getLocalizedString(stringid) or
xbmc.getLocalizedString(stringid))
2016-09-03 03:31:27 +10:00
2018-09-11 04:53:46 +10:00
def messageDialog(heading, msg):
"""
Shows a dialog using the Plex layout
"""
from .windows import optionsdialog
2018-09-16 00:15:14 +10:00
optionsdialog.show(heading, msg, lang(186))
2018-09-11 04:53:46 +10:00
2018-09-16 00:30:17 +10:00
def yesno_dialog(heading, msg):
"""
Shows a dialog with a yes and a no button using the Plex layout.
Returns True if the user selected yes, False otherwise
"""
from .windows import optionsdialog
2018-09-16 00:30:17 +10:00
return optionsdialog.show(heading, msg, lang(107), lang(106)) == 0
2017-01-25 04:48:13 +11:00
def dialog(typus, *args, **kwargs):
"""
Displays xbmcgui Dialog. Pass a string as typus:
'yesno', 'ok', 'notification', 'input', 'select', 'numeric'
2017-08-18 17:53:10 +10:00
kwargs:
heading='{plex}' title bar (here PlexKodiConnect)
message=lang(30128), Dialog content
2017-08-18 17:53:10 +10:00
time=5000,
sound=True,
nolabel=str(), For 'yesno' dialogs
yeslabel=str(), For 'yesno' dialogs
2017-01-25 04:48:13 +11:00
Icons:
icon='{plex}' Display Plex standard icon
icon='{info}' xbmcgui.NOTIFICATION_INFO
icon='{warning}' xbmcgui.NOTIFICATION_WARNING
icon='{error}' xbmcgui.NOTIFICATION_ERROR
2017-01-25 05:59:38 +11:00
Input Types:
type='{alphanum}' xbmcgui.INPUT_ALPHANUM (standard keyboard)
type='{numeric}' xbmcgui.INPUT_NUMERIC (format: #)
type='{date}' xbmcgui.INPUT_DATE (format: DD/MM/YYYY)
type='{time}' xbmcgui.INPUT_TIME (format: HH:MM)
type='{ipaddress}' xbmcgui.INPUT_IPADDRESS (format: #.#.#.#)
type='{password}' xbmcgui.INPUT_PASSWORD
(return md5 hash of input, input is masked)
2018-02-11 03:59:20 +11:00
Options:
option='{password}' xbmcgui.PASSWORD_VERIFY (verifies an existing
(default) md5 hashed password)
option='{hide}' xbmcgui.ALPHANUM_HIDE_INPUT (masks input)
2017-01-25 04:48:13 +11:00
"""
2018-02-11 03:59:20 +11:00
if 'icon' in kwargs:
2017-01-25 05:59:38 +11:00
types = {
2017-01-25 04:48:13 +11:00
'{plex}': 'special://home/addons/plugin.video.plexkodiconnect/icon.png',
'{info}': xbmcgui.NOTIFICATION_INFO,
'{warning}': xbmcgui.NOTIFICATION_WARNING,
'{error}': xbmcgui.NOTIFICATION_ERROR
}
for key, value in types.items():
2017-01-25 04:48:13 +11:00
kwargs['icon'] = kwargs['icon'].replace(key, value)
2017-01-25 05:59:38 +11:00
if 'type' in kwargs:
types = {
'{alphanum}': xbmcgui.INPUT_ALPHANUM,
'{numeric}': xbmcgui.INPUT_NUMERIC,
'{date}': xbmcgui.INPUT_DATE,
'{time}': xbmcgui.INPUT_TIME,
'{ipaddress}': xbmcgui.INPUT_IPADDRESS,
'{password}': xbmcgui.INPUT_PASSWORD
}
kwargs['type'] = types[kwargs['type']]
2018-02-11 03:59:20 +11:00
if 'option' in kwargs:
types = {
'{password}': xbmcgui.PASSWORD_VERIFY,
'{hide}': xbmcgui.ALPHANUM_HIDE_INPUT
}
kwargs['option'] = types[kwargs['option']]
if 'heading' in kwargs:
2016-10-23 02:15:10 +11:00
kwargs['heading'] = kwargs['heading'].replace("{plex}",
2018-06-22 03:24:37 +10:00
lang(29999))
2018-02-11 03:59:20 +11:00
dia = xbmcgui.Dialog()
2016-10-23 02:15:10 +11:00
types = {
2018-02-11 03:59:20 +11:00
'yesno': dia.yesno,
'ok': dia.ok,
'notification': dia.notification,
'input': dia.input,
'select': dia.select,
'numeric': dia.numeric,
'contextmenu': dia.contextmenu
2016-10-23 02:15:10 +11:00
}
2017-01-25 04:48:13 +11:00
return types[typus](*args, **kwargs)
2016-10-23 02:15:10 +11:00
2018-10-24 16:08:32 +11:00
def ERROR(txt='', hide_tb=False, notify=False, cancel_sync=False):
2018-09-11 04:53:46 +10:00
import sys
short = str(sys.exc_info()[1])
LOG.error('Error encountered: %s - %s', txt, short)
2018-10-24 16:08:32 +11:00
if cancel_sync:
2018-11-21 17:59:35 +11:00
from . import app
app.APP.stop_threads(block=False)
2018-09-11 04:53:46 +10:00
if hide_tb:
return short
import traceback
trace = traceback.format_exc()
LOG.error("_____________________________________________________________")
for line in trace.splitlines():
LOG.error(' ' + line)
LOG.error("_____________________________________________________________")
if notify:
dialog('notification',
heading='{plex}',
message=short,
icon='{error}')
return short
def rreplace(s, old, new, occurrence=-1):
"""
Replaces the string old [str, unicode] with new from the RIGHT given a
string s.
"""
li = s.rsplit(old, occurrence)
return new.join(li)
2018-09-11 04:53:46 +10:00
class AttributeDict(dict):
"""
Turns an etree xml response's xml.attrib into an object with attributes
"""
def __getattr__(self, attr):
return self.get(attr)
def __setattr__(self, attr, value):
self[attr] = value
def __repr__(self):
2018-09-11 04:53:46 +10:00
return '<{0}:{1}:{2}>'.format(self.__class__.__name__,
self.id,
self.get('title', 'None'))
2018-10-20 23:49:04 +11:00
def cast(func, value):
"""
2019-06-12 21:17:17 +10:00
Cast the specified value to the specified type (returned by func). Currently
this only support int, float, bool. Should be extended if needed.
2018-10-20 23:49:04 +11:00
Parameters:
func (func): Calback function to used cast to type (int, bool, float).
value (any): value to be cast and returned.
2019-06-12 21:17:17 +10:00
Returns None if something goes wrong
2018-10-20 23:49:04 +11:00
"""
2019-03-29 06:07:40 +11:00
if value is None:
return value
elif func == bool:
return bool(int(value))
elif func == str:
if isinstance(value, (int, float)):
return str(value)
elif isinstance(value, str):
2019-03-29 06:07:40 +11:00
return value
else:
return value.decode('utf-8')
elif func == str:
if isinstance(value, (int, float)):
2019-03-29 06:07:40 +11:00
return str(value)
elif isinstance(value, str):
return value
else:
return value.encode('utf-8')
2019-06-12 19:24:56 +10:00
elif func == int:
try:
2019-06-12 21:17:17 +10:00
return int(value)
2019-06-12 19:24:56 +10:00
except ValueError:
try:
# Converting e.g. '8.0' fails; need to convert to float first
return int(float(value))
except ValueError:
2019-06-12 21:17:17 +10:00
return
2019-06-12 19:24:56 +10:00
elif func == float:
2019-03-29 06:07:40 +11:00
try:
2019-06-12 21:17:17 +10:00
return float(value)
2019-03-29 06:07:40 +11:00
except ValueError:
2019-06-12 21:17:17 +10:00
return
2019-03-29 06:07:40 +11:00
return func(value)
def extend_url(url, params):
"""
2020-12-19 18:19:24 +11:00
Pass in an url [string] and params [dict]. Returns the extended url
2019-03-29 06:07:40 +11:00
'<url><? or &><urllib.urlencode(params)>'
2020-12-19 18:19:24 +11:00
in string
2019-03-29 06:07:40 +11:00
"""
2020-12-19 18:19:24 +11:00
params = urllib.parse.urlencode(params)
2019-03-29 06:07:40 +11:00
if '?' in url:
2020-12-19 18:19:24 +11:00
return f'{url}&{params}'
2019-03-29 06:07:40 +11:00
else:
2020-12-19 18:19:24 +11:00
return f'{url}?{params}'
2018-10-20 23:49:04 +11:00
2019-03-30 20:32:56 +11:00
def parse_qs(qs, keep_blank_values=0, strict_parsing=0):
"""
2020-12-20 06:43:08 +11:00
Pass in the query string qs as string. Returns a dict with lists as values
as unicode
2019-03-30 20:32:56 +11:00
"""
2020-12-27 23:04:46 +11:00
return urllib.parse.parse_qs(qs, keep_blank_values, strict_parsing)
2019-03-30 20:32:56 +11:00
def parse_qsl(qs, keep_blank_values=0, strict_parsing=0):
"""
2020-12-20 06:43:08 +11:00
Pass in string. Returns a list of string tuples
2019-03-30 20:32:56 +11:00
"""
2020-12-27 23:04:46 +11:00
return urllib.parse.parse_qsl(qs, keep_blank_values, strict_parsing)
2019-03-30 20:32:56 +11:00
def urlparse(url, scheme='', allow_fragments=True):
"""
2020-12-20 06:43:08 +11:00
Pass in string.
2019-03-30 20:32:56 +11:00
CAREFUL: returns an encoded urlparse.ParseResult()!
"""
2020-12-27 23:04:46 +11:00
return urllib.parse.urlparse(url, scheme, allow_fragments)
2019-03-30 20:32:56 +11:00
def escape_path(path, safe_url_char=SAFE_URL_CHARACTERS):
2019-09-14 05:17:13 +10:00
"""
Uses urllib.quote to escape to escape path [unicode]. See here for the
reasoning whether a character is safe or not and whether or not it should
be escaped:
https://bugs.python.org/issue918368
Letters, digits, and the characters '_.-' are never quoted. Choosing the
"wrong" characters for a password for USERNAME:PASSWORD@host.com will get
you in trouble (e.g. '@')
Returns the escaped path as unicode
"""
is_http_dav_ftp = HTTP_DAV_FTP.match(path)
if is_http_dav_ftp:
2020-06-08 03:58:46 +10:00
# If path seems to be a http(s), dav(s) or (s)ftp url, the escape path will be constructed
# using RegExp and using safe_url_char as safe characters not to be escaped
protocol = is_http_dav_ftp.group(1)
user = is_http_dav_ftp.group(6)
passwd = is_http_dav_ftp.group(7)
if user and passwd:
user = quote(user, safe=safe_url_char)
passwd = quote(passwd, safe=safe_url_char)
host = is_http_dav_ftp.group(8)
port = is_http_dav_ftp.group(10)
url_path = path.replace(is_http_dav_ftp.group(), '', 1)
if url_path:
url_path = quote(path.replace(is_http_dav_ftp.group(), '', 1),
safe=safe_url_char)
return f'{protocol}://' + \
(f'{user}:{passwd}@' if user and passwd else '') + \
host + \
(f':{port}' if port else '') + \
(f'/{url_path}' if url_path else '')
else:
# If paths does not seem to be a http(s), dav(s) or (s)ftp url (e.g.
# plugin://) escape path as before
return quote(path, safe=SAFE_URL_CHARACTERS)
2019-09-14 05:17:13 +10:00
2019-03-30 20:32:56 +11:00
def quote(s, safe='/'):
"""
Pass in unicode, returns unicode
2019-03-30 20:32:56 +11:00
"""
return urllib.parse.quote(s, safe)
2019-03-30 20:32:56 +11:00
def quote_plus(s, safe=''):
"""
Pass in unicode, returns unicode
2019-03-30 20:32:56 +11:00
"""
return urllib.parse.quote_plus(s, safe)
2019-03-30 20:32:56 +11:00
def unquote(s):
"""
Pass in unicode, returns unicode
2019-03-30 20:32:56 +11:00
"""
return urllib.parse.unquote(s)
2019-03-30 20:32:56 +11:00
def slugify(text):
"""
Normalizes text (in unicode or string) to e.g. enable safe filenames.
Returns unicode
"""
if not isinstance(text, str):
text = str(text)
return str(normalize('NFKD', text).encode('ascii', 'ignore'))
2018-05-01 22:48:49 +10:00
def valid_filename(text):
"""
Return a valid filename after passing it in [unicode].
"""
return pathvalidate.sanitize_filename(text,
replacement_text='_',
platform='auto',
max_len=248)
2018-05-01 22:48:49 +10:00
2017-05-07 02:36:24 +10:00
def escape_html(string):
"""
Escapes the following:
< to &lt;
> to &gt;
& to &amp;
"""
escapes = {
'<': '&lt;',
'>': '&gt;',
'&': '&amp;'
}
for key, value in escapes.items():
2017-05-07 02:36:24 +10:00
string = string.replace(key, value)
return string
def delete_temporary_subtitles():
"""
Permanently deletes all temporary subtitle files
"""
for root, _, files in path_ops.walk(v.EXTERNAL_SUBTITLE_TEMP_PATH):
for file in files:
try:
path_ops.remove(path_ops.path.join(root, file))
except (OSError, IOError) as err:
LOG.error('Could not delete temporary subtitle: %s, %s: %s',
root, file, err)
def wipe_database(reboot=True):
2018-02-11 22:59:04 +11:00
"""
2018-03-01 04:48:39 +11:00
Deletes all Plex playlists as well as video nodes, then clears Kodi as well
as Plex databases completely.
Will also delete all cached artwork.
2018-02-11 22:59:04 +11:00
"""
LOG.warn('Start wiping')
2019-01-09 04:00:54 +11:00
from .library_sync.sections import delete_files
from .library_sync.common import PLAYLIST_SYNC_ENABLED
2018-11-09 07:22:16 +11:00
from . import kodi_db, plex_db
2019-01-09 04:00:54 +11:00
delete_files()
if PLAYLIST_SYNC_ENABLED:
from .playlists import remove_synced_playlists
remove_synced_playlists()
try:
with plex_db.PlexDB() as plexdb:
if plexdb.songs_have_been_synced():
LOG.info('Detected that music has also been synced - wiping music')
music = True
else:
LOG.info('No music has been synced in the past - not wiping')
music = False
except OperationalError:
# Plex DB completely empty yet. Wipe existing Kodi music only if we
# expect to sync Plex music
music = settings('enableMusic') == 'true'
2019-11-15 18:24:21 +11:00
LOG.info("Resetting all cached artwork.")
2018-11-09 07:22:16 +11:00
kodi_db.wipe_dbs(music)
2018-11-06 04:18:46 +11:00
plex_db.wipe()
# reset the install run flag
2015-12-25 07:07:00 +11:00
settings('SyncInstallRunDone', value="false")
settings('sections_asked_for_machine_identifier', value='')
init_dbs()
LOG.info('Wiping done')
no_reboot = settings('kodi_db_has_been_wiped_clean') == 'true' or not reboot
settings('kodi_db_has_been_wiped_clean', value='true')
if not no_reboot:
# Root cause is sqlite WAL mode - Kodi might still have DB access open
LOG.warn('Need to restart Kodi before filling Kodi DB again')
reboot_kodi()
def init_dbs():
"""
Call e.g. on startup to ensure that Plex and Kodi DBs look like they should
"""
from . import kodi_db, plex_db
# Ensure that Plex DB is set-up
plex_db.initialize()
# Hack to speed up look-ups for actors (giant table!)
kodi_db.create_kodi_db_indicees()
kodi_db.setup_kodi_default_entries()
with kodi_db.KodiVideoDB() as kodidb:
# Setup the paths for addon-paths (even when using direct paths)
kodidb.setup_path_table()
LOG.info('Init DBs done')
2015-12-25 07:07:00 +11:00
2018-03-01 04:48:39 +11:00
def default_kodi_skin_warning_message():
""""To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended
to use Kodi's default skin \"Estuary\" for initial set-up and for possible
database resets. Continue?"
"""
if yesno_dialog(lang(29999), lang(30029)):
LOG.warn('User accepted risk of a non-default skin')
return True
else:
LOG.warn('User chose to stop due to skin not being Estuary')
return False
def reset(ask_user=True):
2018-03-01 04:48:39 +11:00
"""
User navigated to the PKC settings, Advanced, and wants to reset the Kodi
database and possibly PKC entirely
"""
# Are you sure you want to reset your local Kodi database?
2018-09-19 00:26:40 +10:00
if ask_user and not yesno_dialog(lang(29999), lang(39600)):
2018-03-01 04:48:39 +11:00
return
if not default_kodi_skin_warning_message():
return
2018-11-26 07:07:25 +11:00
from . import app
2018-03-01 04:48:39 +11:00
# first stop any db sync
app.APP.suspend_threads()
# Reset all PlexKodiConnect Addon settings? (this is usually NOT
# recommended and unnecessary!)
2018-09-19 00:26:40 +10:00
if ask_user and yesno_dialog(lang(29999), lang(39603)):
2015-12-25 07:07:00 +11:00
# Delete the settings
2018-02-11 22:59:04 +11:00
LOG.info("Deleting: settings.xml")
path_ops.remove("%ssettings.xml" % v.ADDON_PROFILE)
# Wipe everything
wipe_database()
reboot_kodi()
2015-12-25 07:07:00 +11:00
def log_xml(xml, logger, loglevel):
"""
Logs an etree xml. Pass the loglevel for which logging will happen, e.g.
loglevel=logging.DEBUG
"""
if LOG.isEnabledFor(loglevel):
string = undefused_etree.tostring(xml, encoding='utf8')
string = string.decode('utf-8')
logger('\n' + string)
2017-05-30 01:05:22 +10:00
def compare_version(current, minimum):
"""
2017-05-30 01:11:04 +10:00
Returns True if current is >= then minimum. False otherwise. Returns True
if there was no valid input for current!
2017-05-30 01:05:22 +10:00
Input strings: e.g. "1.2.3"; always with Major, Minor and Patch!
"""
try:
2018-02-11 22:59:04 +11:00
curr_major, curr_minor, curr_patch = current.split(".")
2017-05-30 01:05:22 +10:00
except ValueError:
# there WAS no current DB, e.g. deleted.
return True
2018-02-11 22:59:04 +11:00
min_major, min_minor, min_patch = minimum.split(".")
curr_major = int(curr_major)
curr_minor = int(curr_minor)
curr_patch = int(curr_patch)
min_major = int(min_major)
min_minor = int(min_minor)
min_patch = int(min_patch)
if curr_major > min_major:
2017-05-30 01:05:22 +10:00
return True
2018-02-11 22:59:04 +11:00
elif curr_major < min_major:
2017-05-30 01:05:22 +10:00
return False
2018-02-11 22:59:04 +11:00
if curr_minor > min_minor:
2017-05-30 01:05:22 +10:00
return True
2018-02-11 22:59:04 +11:00
elif curr_minor < min_minor:
2017-05-30 01:05:22 +10:00
return False
2018-02-11 22:59:04 +11:00
return curr_patch >= min_patch
2017-05-30 01:05:22 +10:00
def normalize_nodes(text):
2018-02-11 22:59:04 +11:00
"""
For video nodes. Returns unicode
2018-02-11 22:59:04 +11:00
"""
2015-12-25 07:07:00 +11:00
text = text.replace(":", "")
text = text.replace("/", "-")
text = text.replace("\\", "-")
text = text.replace("<", "")
text = text.replace(">", "")
text = text.replace("*", "")
text = text.replace("?", "")
text = text.replace('|', "")
text = text.replace('(', "")
text = text.replace(')', "")
2015-12-25 07:07:00 +11:00
text = text.strip()
# Remove dots from the last character as windows can not have directories
# with dots at the end
text = text.rstrip('.')
text = normalize('NFKD', str(text, 'utf-8'))
2015-12-25 07:07:00 +11:00
return text
2015-12-25 07:07:00 +11:00
def indent(elem, level=0):
"""
Prettifies xml trees. Pass the etree root in
"""
2019-02-03 06:22:06 +11:00
try:
i = "\n" + level * " "
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = i + " "
if not elem.tail or not elem.tail.strip():
elem.tail = i
for elem in elem:
indent(elem, level + 1)
if not elem.tail or not elem.tail.strip():
elem.tail = i
else:
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = i
except Exception as err:
LOG.info('Indentation failed with: %s', err)
2015-12-25 07:07:00 +11:00
# Python's arrow library is broken for Kodi
# Importing from several Python instances is faulty :-(
# def localdate_from_utc_string(timestring):
# """helper to convert internal utc time (used in pvr) to local timezone"""
# utc_datetime = arrow.get(timestring)
# local_datetime = utc_datetime.to('local')
# return local_datetime.format("YYYY-MM-DD HH:mm:ss")
# def localized_date_time(timestring):
# """returns localized version of the timestring (used in pvr)"""
# date_time = arrow.get(timestring)
# local_date = date_time.strftime(xbmc.getRegion("dateshort"))
# local_time = date_time.strftime(xbmc.getRegion("time").replace(":%S", ""))
# return local_date, local_time
class XmlKodiSetting(object):
"""
Used to load a Kodi XML settings file from special://profile as an etree
object to read settings or set them. Usage:
with XmlKodiSetting(filename,
path=None,
force_create=False,
top_element=None) as xml:
xml.get_setting('test')
filename [str]: filename of the Kodi settings file under
path [str]: if set, replace special://profile path with custom
path
force_create: will create the XML file if it does not exist
top_element [str]: Name of the top xml element; used if xml does not
yet exist
Raises IOError if the file does not exist or is empty and force_create
has been set to False.
Raises utils.ParseError if the file could not be parsed by etree
xml.write_xml Set to True if we need to write the XML to disk
"""
def __init__(self, filename, path=None, force_create=False,
top_element=None):
self.filename = filename
if path is None:
self.path = path_ops.path.join(v.KODI_PROFILE, filename)
else:
self.path = path_ops.path.join(path, filename)
self.force_create = force_create
self.top_element = top_element
self.tree = None
self.root = None
self.write_xml = False
def __enter__(self):
try:
self.tree = etree.parse(self.path)
except IOError:
# Document is blank or missing
if self.force_create is False:
2018-02-11 22:59:04 +11:00
LOG.debug('%s does not seem to exist; not creating', self.path)
raise
# Create topmost xml entry
self.tree = undefused_etree.ElementTree(
undefused_etree.Element(self.top_element))
self.write_xml = True
except ParseError:
2018-02-11 22:59:04 +11:00
LOG.error('Error parsing %s', self.path)
# "Kodi cannot parse {0}. PKC will not function correctly. Please
# visit {1} and correct your file!"
2018-09-19 00:26:40 +10:00
messageDialog(lang(29999), lang(39716).format(
self.filename,
'http://kodi.wiki'))
raise
self.root = self.tree.getroot()
return self
def __exit__(self, e_typ, e_val, trcbak):
if e_typ:
# re-raise any exception
return False
# Only safe to file if we did not botch anything
if self.write_xml is True:
self._remove_empty_elements()
# Indent and make readable
indent(self.root)
# Safe the changed xml
try:
self.tree.write(self.path, encoding='utf-8')
except IOError as err:
LOG.error('Could not save xml %s. Error: %s',
self.filename, err)
# Could not change the Kodi settings file {0}. PKC might not
# work correctly. Error: {1}
if not settings('%s_ioerror' % self.filename):
messageDialog(lang(29999),
lang(30417).format(self.filename, err))
settings('%s_ioerror' % self.filename,
value='warning_shown')
return True
def _is_empty(self, element, empty_elements):
empty = True
for child in element:
empty_child = True
if list(child):
empty_child = self._is_empty(child, empty_elements)
if empty_child and (child.attrib or
(child.text and child.text.strip())):
empty_child = False
if empty_child:
empty_elements.append((element, child))
else:
# At least one non-empty entry - hence we cannot delete the
# original element itself
empty = False
return empty
def _remove_empty_elements(self):
"""
Deletes all empty XML elements, otherwise Kodi/PKC gets confused
This is recursive, so an empty element with empty children will also
get deleted
"""
empty_elements = []
self._is_empty(self.root, empty_elements)
for element, child in empty_elements:
element.remove(child)
@staticmethod
def _set_sub_element(element, subelement):
"""
Returns an etree element's subelement. Creates one if not exist
"""
answ = element.find(subelement)
if answ is None:
answ = undefused_etree.SubElement(element, subelement)
return answ
def get_setting(self, node_list):
"""
node_list is a list of node names starting from the outside, ignoring
the outter advancedsettings.
Example nodelist=['video', 'busydialogdelayms'] for the following xml
would return the etree Element:
<busydialogdelayms>750</busydialogdelayms>
for the following example xml:
<advancedsettings>
<video>
<busydialogdelayms>750</busydialogdelayms>
</video>
</advancedsettings>
Returns the etree element or None if not found
"""
element = self.root
for node in node_list:
element = element.find(node)
if element is None:
break
return element
2018-02-13 07:20:26 +11:00
def set_setting(self, node_list, value=None, attrib=None, append=False):
"""
node_list is a list of node names starting from the outside, ignoring
the outter advancedsettings.
Example nodelist=['video', 'busydialogdelayms'] for the following xml
would return the etree Element:
<busydialogdelayms>750</busydialogdelayms>
for the following example xml:
<advancedsettings>
<video>
<busydialogdelayms>750</busydialogdelayms>
</video>
</advancedsettings>
value, e.g. '750' will be set accordingly, returning the new
etree Element. Advancedsettings might be generated if it did not exist
already
If the dict attrib is set, the Element's attributs will be appended
accordingly
2018-02-13 07:20:26 +11:00
If append is True, the last element of node_list with value and attrib
will always be added. WARNING: this will set self.write_xml to True!
Returns the (last) etree element
"""
attrib = attrib or {}
value = value or ''
2018-02-13 07:20:26 +11:00
if not append:
old = self.get_setting(node_list)
2018-02-13 07:20:26 +11:00
if (old is not None and
((old.text is not None and old.text.strip() == value) or
(old.text is None and value == '')) and
(old.attrib or {}) == attrib):
2018-02-13 07:20:26 +11:00
# Already set exactly these values
return old
LOG.debug('Adding etree to: %s, value: %s, attrib: %s, append: %s',
node_list, value, attrib, append)
self.write_xml = True
element = self.root
2018-02-13 07:20:26 +11:00
nodes = node_list[:-1] if append else node_list
for node in nodes:
element = self._set_sub_element(element, node)
2018-02-13 07:20:26 +11:00
if append:
element = undefused_etree.SubElement(element, node_list[-1])
# Write new values
element.text = value
if attrib:
for key, attribute in attrib.items():
element.set(key, attribute)
return element
2016-09-03 03:31:27 +10:00
###############################################################################
# WRAPPERS
2018-02-11 23:24:00 +11:00
def catch_exceptions(warnuser=False):
"""
2016-09-03 03:31:27 +10:00
Decorator for methods to catch exceptions and log them. Useful for e.g.
librarysync threads using itemtypes.py, because otherwise we would not
get informed of crashes
warnuser=True: sets the window flag 'plex_scancrashed' to true
which will trigger a Kodi infobox to inform user
"""
2016-09-03 03:31:27 +10:00
def decorate(func):
2018-02-11 23:24:00 +11:00
"""
Decorator construct
"""
2016-09-03 03:31:27 +10:00
@wraps(func)
def wrapper(*args, **kwargs):
2018-02-11 23:24:00 +11:00
"""
Wrapper construct
"""
2016-09-03 03:31:27 +10:00
try:
return func(*args, **kwargs)
2018-02-11 23:24:00 +11:00
except Exception as err:
LOG.error('%s has crashed. Error: %s', func.__name__, err)
2016-09-03 03:31:27 +10:00
import traceback
2018-02-11 22:59:04 +11:00
LOG.error("Traceback:\n%s", traceback.format_exc())
2016-09-03 03:31:27 +10:00
if warnuser:
window('plex_scancrashed', value='true')
return
return wrapper
return decorate
2018-02-11 23:24:00 +11:00
def log_time(func):
"""
2016-09-03 03:31:27 +10:00
Decorator for functions and methods to log the time it took to run the code
"""
2016-09-03 03:31:27 +10:00
@wraps(func)
def wrapper(*args, **kwargs):
starttotal = datetime.now()
result = func(*args, **kwargs)
elapsedtotal = datetime.now() - starttotal
2018-02-11 22:59:04 +11:00
LOG.info('It took %s to run the function %s',
elapsedtotal, func.__name__)
2016-09-03 03:31:27 +10:00
return result
return wrapper