PlexKodiConnect/resources/lib/utils.py

1213 lines
38 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
"""
from __future__ import absolute_import, division, unicode_literals
2017-12-10 00:35:08 +11:00
from logging import getLogger
2017-01-29 23:52:46 +11:00
from pstats import Stats
from sqlite3 import connect, OperationalError
from datetime import datetime, timedelta
2017-01-29 23:52:46 +11:00
from StringIO import StringIO
2018-02-11 22:59:04 +11:00
from time import localtime, strftime
2017-01-29 23:52:46 +11:00
from unicodedata import normalize
2015-12-25 07:07:00 +11:00
import xml.etree.ElementTree as etree
2018-09-06 01:36:38 +10:00
import defusedxml.ElementTree as defused_etree # etree parse unsafe
from functools import wraps, partial
from urllib import quote_plus
2018-04-28 17:12:29 +10:00
import hashlib
2018-05-01 22:48:49 +10:00
import re
2018-09-11 04:53:46 +10:00
import gc
import xbmc
import xbmcaddon
import xbmcgui
2015-12-25 07:07:00 +11:00
from . import path_ops, variables as v, state
2017-01-29 23:40:34 +11:00
2016-02-12 00:03:04 +11:00
###############################################################################
2015-12-25 07:07:00 +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')
EPOCH = datetime.utcfromtimestamp(0)
2016-09-03 03:31:27 +10:00
# 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'''thetvdb:\/\/(.+?)\?''')
# 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
2016-09-03 03:31:27 +10:00
###############################################################################
# Main methods
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:
2018-02-11 22:59:04 +11:00
win.setProperty(try_encode(prop), try_encode(value))
2016-09-03 03:31:27 +10:00
else:
2018-02-11 22:59:04 +11:00
return try_decode(win.getProperty(prop))
2016-09-03 03:31:27 +10:00
def plex_command(key, value):
"""
Used to funnel states between different Python instances. NOT really thread
safe - let's hope the Kodi user can't click fast enough
key: state.py variable
value: either 'True' or 'False'
"""
while window('plex_command'):
2017-09-03 21:30:50 +10:00
xbmc.sleep(20)
window('plex_command', value='%s-%s' % (key, value))
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!
addon = xbmcaddon.Addon(id='plugin.video.plexkodiconnect')
2016-09-03 03:31:27 +10:00
if value is not None:
# Takes string or unicode by default!
2018-02-11 22:59:04 +11:00
addon.setSetting(try_encode(setting), try_encode(value))
2016-09-03 03:31:27 +10:00
else:
# Should return unicode by default, but just in case
2018-02-11 22:59:04 +11:00
return try_decode(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)
2018-02-11 03:59:20 +11:00
message=lang(30128), Dialog content. Don't use with 'OK', 'yesno'
2017-08-18 17:53:10 +10:00
line1=str(), For 'OK' and 'yesno' dialogs use line1...line3!
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
}
2017-01-25 05:59:38 +11:00
for key, value in types.iteritems():
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
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-09-11 04:53:46 +10:00
def ERROR(txt='', hide_tb=False, notify=False):
import sys
short = str(sys.exc_info()[1])
LOG.error('Error encountered: %s - %s', txt, short)
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
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 __unicode__(self):
return '<{0}:{1}:{2}>'.format(self.__class__.__name__,
self.id,
self.get('title', 'None'))
def __repr__(self):
return self.__unicode__().encode('utf8')
2017-12-09 23:47:19 +11:00
def millis_to_kodi_time(milliseconds):
2017-12-08 17:53:01 +11:00
"""
2017-12-09 23:47:19 +11:00
Converts time in milliseconds to the time dict used by the Kodi JSON RPC:
{
'hours': [int],
'minutes': [int],
'seconds'[int],
'milliseconds': [int]
}
2017-12-08 17:53:01 +11:00
Pass in the time in milliseconds as an int
"""
seconds = milliseconds / 1000
minutes = seconds / 60
hours = minutes / 60
seconds = seconds % 60
minutes = minutes % 60
milliseconds = milliseconds % 1000
return {'hours': hours,
'minutes': minutes,
'seconds': seconds,
'milliseconds': milliseconds}
2017-12-09 23:47:19 +11:00
def kodi_time_to_millis(time):
"""
Converts the Kodi time dict
{
'hours': [int],
'minutes': [int],
'seconds'[int],
'milliseconds': [int]
}
2017-12-14 20:21:30 +11:00
to milliseconds [int]. Will not return negative results but 0!
2017-12-09 23:47:19 +11:00
"""
2017-12-14 20:21:30 +11:00
ret = (time['hours'] * 3600 +
time['minutes'] * 60 +
time['seconds']) * 1000 + time['milliseconds']
ret = 0 if ret < 0 else ret
return ret
2017-12-09 23:47:19 +11:00
2018-02-11 23:24:00 +11:00
def try_encode(input_str, encoding='utf-8'):
2016-09-03 03:31:27 +10:00
"""
2018-02-11 23:24:00 +11:00
Will try to encode input_str (in unicode) to encoding. This possibly
2016-09-03 03:31:27 +10:00
fails with e.g. Android TV's Python, which does not accept arguments for
string.encode()
"""
2018-02-11 23:24:00 +11:00
if isinstance(input_str, str):
2016-09-03 03:31:27 +10:00
# already encoded
2018-02-11 23:24:00 +11:00
return input_str
2016-09-03 03:31:27 +10:00
try:
2018-02-11 23:24:00 +11:00
input_str = input_str.encode(encoding, "ignore")
2016-09-03 03:31:27 +10:00
except TypeError:
2018-02-11 23:24:00 +11:00
input_str = input_str.encode()
return input_str
2016-09-03 03:31:27 +10:00
2018-02-11 22:59:04 +11:00
def try_decode(string, encoding='utf-8'):
2016-09-03 03:31:27 +10:00
"""
Will try to decode string (encoded) using encoding. This possibly
fails with e.g. Android TV's Python, which does not accept arguments for
string.encode()
"""
if isinstance(string, unicode):
# already decoded
return string
try:
string = string.decode(encoding, "ignore")
except TypeError:
string = string.decode()
return string
2016-01-27 03:20:13 +11:00
2015-12-25 07:07:00 +11:00
def slugify(text):
"""
Normalizes text (in unicode or string) to e.g. enable safe filenames.
Returns unicode
"""
if not isinstance(text, unicode):
text = unicode(text)
return unicode(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].
"""
# Get rid of all whitespace except a normal space
text = re.sub(r'(?! )\s', '', text)
# ASCII characters 0 to 31 (non-printable, just in case)
text = re.sub(u'[\x00-\x1f]', '', text)
if v.PLATFORM == 'Windows':
# Whitespace at the end of the filename is illegal
text = text.strip()
# Dot at the end of a filename is illegal
text = re.sub(r'\.+$', '', text)
# Illegal Windows characters
text = re.sub(r'[/\\:*?"<>|\^]', '', text)
elif v.PLATFORM == 'MacOSX':
# Colon is illegal
text = re.sub(r':', '', text)
# Files cannot begin with a dot
text = re.sub(r'^\.+', '', text)
else:
# Linux
text = re.sub(r'/', '', text)
# Ensure that filename length is at most 255 chars (including 3 chars for
2018-05-01 22:48:49 +10:00
# filename extension and 1 dot to separate the extension)
text = text[:min(len(text), 251)]
2018-05-01 22:48:49 +10:00
return text
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.iteritems():
string = string.replace(key, value)
return string
2018-02-11 22:59:04 +11:00
def unix_date_to_kodi(stamp):
2016-12-28 03:33:52 +11:00
"""
converts a Unix time stamp (seconds passed sinceJanuary 1 1970) to a
propper, human-readable time stamp used by Kodi
2016-03-12 00:42:14 +11:00
2016-12-28 03:33:52 +11:00
Output: Y-m-d h:m:s = 2009-04-05 23:16:04
2016-12-28 03:33:52 +11:00
None if an error was encountered
"""
try:
stamp = float(stamp) + state.KODI_PLEX_TIME_OFFSET
2017-01-29 23:52:46 +11:00
date_time = localtime(stamp)
localdate = strftime('%Y-%m-%d %H:%M:%S', date_time)
2016-12-28 03:33:52 +11:00
except:
localdate = None
return localdate
2016-03-12 00:42:14 +11:00
2018-02-11 22:59:04 +11:00
def unix_timestamp(seconds_into_the_future=None):
"""
Returns a Unix time stamp (seconds passed since January 1 1970) for NOW as
an integer.
2018-02-11 22:59:04 +11:00
Optionally, pass seconds_into_the_future: positive int's will result in a
future timestamp, negative the past
"""
2018-02-11 22:59:04 +11:00
if seconds_into_the_future:
future = datetime.utcnow() + timedelta(seconds=seconds_into_the_future)
else:
future = datetime.utcnow()
return int((future - EPOCH).total_seconds())
2018-02-11 22:59:04 +11:00
def kodi_sql(media_type=None):
"""
Open a connection to the Kodi database.
media_type: 'video' (standard if not passed), 'plex', 'music', 'texture'
"""
2017-01-05 06:09:09 +11:00
if media_type == "plex":
2018-02-11 22:59:04 +11:00
db_path = v.DB_PLEX_PATH
elif media_type == "music":
2018-02-11 22:59:04 +11:00
db_path = v.DB_MUSIC_PATH
elif media_type == "texture":
2018-02-11 22:59:04 +11:00
db_path = v.DB_TEXTURE_PATH
2015-12-25 07:07:00 +11:00
else:
2018-02-11 22:59:04 +11:00
db_path = v.DB_VIDEO_PATH
return connect(db_path, timeout=60.0)
2015-12-25 07:07:00 +11:00
2016-12-21 02:13:19 +11:00
def create_actor_db_index():
"""
Index the "actors" because we got a TON - speed up SELECT and WHEN
"""
2018-02-11 22:59:04 +11:00
conn = kodi_sql('video')
2016-12-21 02:13:19 +11:00
cursor = conn.cursor()
try:
cursor.execute("""
CREATE UNIQUE INDEX index_name
ON actor (name);
""")
2017-01-29 23:52:46 +11:00
except OperationalError:
2016-12-21 02:13:19 +11:00
# Index already exists
pass
conn.commit()
conn.close()
2018-03-01 04:48:39 +11:00
def wipe_database():
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
"""
2015-12-25 07:07:00 +11:00
# Clean up the playlists
2018-02-11 22:59:04 +11:00
delete_playlists()
2015-12-25 07:07:00 +11:00
# Clean up the video nodes
2018-02-11 22:59:04 +11:00
delete_nodes()
2015-12-25 07:07:00 +11:00
# Wipe the kodi databases
2018-02-11 22:59:04 +11:00
LOG.info("Resetting the Kodi video database.")
connection = kodi_sql('video')
2015-12-25 07:07:00 +11:00
cursor = connection.cursor()
cursor.execute('SELECT tbl_name FROM sqlite_master WHERE type="table"')
rows = cursor.fetchall()
for row in rows:
tablename = row[0]
if tablename != "version":
cursor.execute("DELETE FROM %s" % tablename)
2015-12-25 07:07:00 +11:00
connection.commit()
cursor.close()
2016-01-01 10:16:16 +11:00
if settings('enableMusic') == "true":
2018-02-11 22:59:04 +11:00
LOG.info("Resetting the Kodi music database.")
connection = kodi_sql('music')
2015-12-25 07:07:00 +11:00
cursor = connection.cursor()
cursor.execute('SELECT tbl_name FROM sqlite_master WHERE type="table"')
rows = cursor.fetchall()
for row in rows:
tablename = row[0]
if tablename != "version":
cursor.execute("DELETE FROM %s" % tablename)
2015-12-25 07:07:00 +11:00
connection.commit()
cursor.close()
# Wipe the Plex database
2018-02-11 22:59:04 +11:00
LOG.info("Resetting the Plex database.")
connection = kodi_sql('plex')
2015-12-25 07:07:00 +11:00
cursor = connection.cursor()
# First get the paths to all synced playlists
playlist_paths = []
cursor.execute('SELECT kodi_path FROM playlists')
for entry in cursor.fetchall():
playlist_paths.append(entry[0])
2015-12-25 07:07:00 +11:00
cursor.execute('SELECT tbl_name FROM sqlite_master WHERE type="table"')
rows = cursor.fetchall()
for row in rows:
tablename = row[0]
if tablename != "version":
cursor.execute("DELETE FROM %s" % tablename)
2015-12-25 07:07:00 +11:00
connection.commit()
cursor.close()
# Delete all synced playlists
for path in playlist_paths:
try:
path_ops.remove(path)
except (OSError, IOError):
pass
2018-03-01 04:48:39 +11:00
LOG.info("Resetting all cached artwork.")
# Remove all existing textures first
path = path_ops.translate_path("special://thumbnails/")
if path_ops.exists(path):
path_ops.rmtree(path, ignore_errors=True)
2018-03-01 04:48:39 +11:00
# remove all existing data from texture DB
connection = kodi_sql('texture')
cursor = connection.cursor()
query = 'SELECT tbl_name FROM sqlite_master WHERE type=?'
cursor.execute(query, ("table", ))
rows = cursor.fetchall()
for row in rows:
table_name = row[0]
if table_name != "version":
cursor.execute("DELETE FROM %s" % table_name)
connection.commit()
cursor.close()
# reset the install run flag
2015-12-25 07:07:00 +11:00
settings('SyncInstallRunDone', value="false")
2018-03-01 04:48:39 +11:00
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
# first stop any db sync
plex_command('STOP_SYNC', 'True')
count = 10
while window('plex_dbScan') == "true":
LOG.debug("Sync is running, will retry: %s...", count)
count -= 1
if count == 0:
# Could not stop the database from running. Please try again later.
2018-09-19 00:26:40 +10:00
messageDialog(lang(29999), lang(39601))
2018-03-01 04:48:39 +11:00
return
xbmc.sleep(1000)
# Wipe everything
wipe_database()
# 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)
reboot_kodi()
2015-12-25 07:07:00 +11:00
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!
"""
2018-02-11 22:59:04 +11:00
LOG.info("current DB: %s minimum DB: %s", current, minimum)
2017-05-30 01:05:22 +10:00
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_string(text):
2018-02-11 22:59:04 +11:00
"""
For theme media, do not modify unless modified in TV Tunes
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.strip()
# Remove dots from the last character as windows can not have directories
# with dots at the end
text = text.rstrip('.')
2018-02-11 22:59:04 +11:00
text = try_encode(normalize('NFKD', unicode(text, 'utf-8')))
2015-12-25 07:07:00 +11:00
return text
2018-02-11 22:59:04 +11: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', unicode(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
"""
2015-12-25 07:07:00 +11:00
i = "\n" + level*" "
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = i + " "
2015-12-25 07:07:00 +11:00
if not elem.tail or not elem.tail.strip():
elem.tail = i
2018-02-13 07:27:22 +11:00
for elem in elem:
indent(elem, level+1)
2015-12-25 07:07:00 +11:00
if not elem.tail or not elem.tail.strip():
elem.tail = i
2015-12-25 07:07:00 +11:00
else:
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = i
2015-12-25 07:07:00 +11:00
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 etree.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:
2018-09-06 01:36:38 +10:00
self.tree = defused_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)
# This will abort __enter__
self.__exit__(IOError, None, None)
# Create topmost xml entry
self.tree = etree.ElementTree(
element=etree.Element(self.top_element))
self.write_xml = True
except etree.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'))
self.__exit__(etree.ParseError, None, None)
self.root = self.tree.getroot()
return self
def __exit__(self, e_typ, e_val, trcbak):
if e_typ:
raise
# 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
self.tree.write(self.path, encoding="UTF-8")
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 = 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.strip() == value and
old.attrib == attrib):
# 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 = etree.SubElement(element, node_list[-1])
# Write new values
element.text = value
if attrib:
for key, attribute in attrib.iteritems():
element.set(key, attribute)
return element
2018-02-11 22:59:04 +11:00
def passwords_xml():
"""
To add network credentials to Kodi's password xml
"""
path = path_ops.translate_path('special://userdata/')
2015-12-25 07:07:00 +11:00
xmlpath = "%spasswords.xml" % path
try:
2018-09-06 01:36:38 +10:00
xmlparse = defused_etree.parse(xmlpath)
2017-05-23 05:31:19 +10:00
except IOError:
# Document is blank or missing
2015-12-25 07:07:00 +11:00
root = etree.Element('passwords')
2018-02-11 22:59:04 +11:00
skip_find = True
2017-05-23 05:31:19 +10:00
except etree.ParseError:
2018-02-11 22:59:04 +11:00
LOG.error('Error parsing %s', xmlpath)
2017-05-23 05:31:19 +10:00
# "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(
2017-05-23 05:31:19 +10:00
'passwords.xml', 'http://forum.kodi.tv/'))
return
2015-12-25 07:07:00 +11:00
else:
root = xmlparse.getroot()
2018-02-11 22:59:04 +11:00
skip_find = False
2015-12-25 07:07:00 +11:00
credentials = settings('networkCreds')
if credentials:
# Present user with options
2018-02-11 22:59:04 +11:00
option = dialog('select',
"Modify/Remove network credentials",
["Modify", "Remove"])
2015-12-25 07:07:00 +11:00
if option < 0:
# User cancelled dialog
return
elif option == 1:
# User selected remove
2018-02-11 22:59:04 +11:00
success = False
for paths in root.getiterator('passwords'):
2015-12-25 07:07:00 +11:00
for path in paths:
if path.find('.//from').text == "smb://%s/" % credentials:
paths.remove(path)
2018-02-11 22:59:04 +11:00
LOG.info("Successfully removed credentials for: %s",
credentials)
etree.ElementTree(root).write(xmlpath,
encoding="UTF-8")
2018-02-11 22:59:04 +11:00
success = True
if not success:
LOG.error("Failed to find saved server: %s in passwords.xml",
credentials)
dialog('notification',
heading='{plex}',
message="%s not found" % credentials,
icon='{warning}',
sound=False)
return
2015-12-25 07:07:00 +11:00
settings('networkCreds', value="")
2018-02-11 22:59:04 +11:00
dialog('notification',
heading='{plex}',
message="%s removed from passwords.xml" % credentials,
icon='{plex}',
sound=False)
2015-12-25 07:07:00 +11:00
return
elif option == 0:
# User selected to modify
2018-02-11 22:59:04 +11:00
server = dialog('input',
"Modify the computer name or ip address",
credentials)
2015-12-25 07:07:00 +11:00
if not server:
return
else:
# No credentials added
2018-09-19 00:26:40 +10:00
messageDialog("Network credentials",
2018-02-11 22:59:04 +11:00
'Input the server name or IP address as indicated in your plex '
'library paths. For example, the server name: '
'\\\\SERVER-PC\\path\\ or smb://SERVER-PC/path is SERVER-PC')
server = dialog('input', "Enter the server name or IP address")
2015-12-25 07:07:00 +11:00
if not server:
return
server = quote_plus(server)
2015-12-25 07:07:00 +11:00
# Network username
2018-02-11 22:59:04 +11:00
user = dialog('input', "Enter the network username")
2015-12-25 07:07:00 +11:00
if not user:
return
user = quote_plus(user)
2015-12-25 07:07:00 +11:00
# Network password
2018-02-11 22:59:04 +11:00
password = dialog('input',
"Enter the network password",
'', # Default input
type='{alphanum}',
option='{hide}')
# Need to url-encode the password
password = quote_plus(password)
2016-03-16 19:55:19 +11:00
# Add elements. Annoying etree bug where findall hangs forever
2018-02-11 22:59:04 +11:00
if skip_find is False:
skip_find = True
2016-03-16 19:55:19 +11:00
for path in root.findall('.//path'):
if path.find('.//from').text.lower() == "smb://%s/" % server.lower():
# Found the server, rewrite credentials
2018-02-11 22:59:04 +11:00
path.find('.//to').text = ("smb://%s:%s@%s/"
% (user, password, server))
skip_find = False
2016-03-16 19:55:19 +11:00
break
2018-02-11 22:59:04 +11:00
if skip_find:
2015-12-25 07:07:00 +11:00
# Server not found, add it.
path = etree.SubElement(root, 'path')
2018-02-11 22:59:04 +11:00
etree.SubElement(path, 'from', attrib={'pathversion': "1"}).text = \
"smb://%s/" % server
2015-12-25 07:07:00 +11:00
topath = "smb://%s:%s@%s/" % (user, password, server)
etree.SubElement(path, 'to', attrib={'pathversion': "1"}).text = topath
# Add credentials
2015-12-25 07:07:00 +11:00
settings('networkCreds', value="%s" % server)
2018-02-11 22:59:04 +11:00
LOG.info("Added server: %s to passwords.xml", server)
2015-12-25 07:07:00 +11:00
# Prettify and write to file
2018-02-11 23:24:00 +11:00
indent(root)
etree.ElementTree(root).write(xmlpath, encoding="UTF-8")
2015-12-25 07:07:00 +11:00
2018-02-11 22:59:04 +11:00
def playlist_xsp(mediatype, tagname, viewid, viewtype="", delete=False):
2016-03-08 01:31:07 +11:00
"""
Feed with tagname as unicode
"""
path = path_ops.translate_path("special://profile/playlists/video/")
2015-12-25 07:07:00 +11:00
if viewtype == "mixed":
plname = "%s - %s" % (tagname, mediatype)
2016-03-03 03:27:21 +11:00
xsppath = "%sPlex %s - %s.xsp" % (path, viewid, mediatype)
2015-12-25 07:07:00 +11:00
else:
plname = tagname
2016-03-03 03:27:21 +11:00
xsppath = "%sPlex %s.xsp" % (path, viewid)
2015-12-25 07:07:00 +11:00
# Create the playlist directory
if not path_ops.exists(path):
2018-02-11 22:59:04 +11:00
LOG.info("Creating directory: %s", path)
path_ops.makedirs(path)
2015-12-25 07:07:00 +11:00
# Only add the playlist if it doesn't already exists
if path_ops.exists(xsppath):
2018-02-11 22:59:04 +11:00
LOG.info('Path %s does exist', xsppath)
2015-12-25 07:07:00 +11:00
if delete:
path_ops.remove(xsppath)
2018-02-11 22:59:04 +11:00
LOG.info("Successfully removed playlist: %s.", tagname)
2015-12-25 07:07:00 +11:00
return
# Using write process since there's no guarantee the xml declaration works
# with etree
2015-12-25 07:07:00 +11:00
itemtypes = {
'homevideos': 'movies',
'movie': 'movies',
'show': 'tvshows'
2015-12-25 07:07:00 +11:00
}
2018-02-11 22:59:04 +11:00
LOG.info("Writing playlist file to: %s", xsppath)
with open(path_ops.encode_path(xsppath), 'wb') as filer:
2018-02-11 22:59:04 +11:00
filer.write(try_encode(
'<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>\n'
'<smartplaylist type="%s">\n\t'
'<name>Plex %s</name>\n\t'
'<match>all</match>\n\t'
'<rule field="tag" operator="is">\n\t\t'
'<value>%s</value>\n\t'
'</rule>\n'
'</smartplaylist>\n'
% (itemtypes.get(mediatype, mediatype), plname, tagname)))
2018-02-11 22:59:04 +11:00
LOG.info("Successfully added playlist: %s", tagname)
2018-02-11 22:59:04 +11:00
def delete_playlists():
"""
Clean up the playlists
"""
path = path_ops.translate_path('special://profile/playlists/video/')
for root, _, files in path_ops.walk(path):
for file in files:
if file.startswith('Plex'):
path_ops.remove(path_ops.path.join(root, file))
2018-02-11 22:59:04 +11:00
def delete_nodes():
"""
Clean up video nodes
"""
path = path_ops.translate_path("special://profile/library/video/")
for root, dirs, _ in path_ops.walk(path):
for directory in dirs:
if directory.startswith('Plex-'):
path_ops.rmtree(path_ops.path.join(root, directory))
break
2018-04-28 17:12:29 +10:00
def generate_file_md5(path):
"""
2018-05-01 22:48:49 +10:00
Generates the md5 hash value for the file located at path [unicode].
2018-07-11 05:19:08 +10:00
The hash does not include the path and filename and is thus identical for
a file that was moved/changed name.
Returns a unique unicode containing only hexadecimal digits
2018-04-28 17:12:29 +10:00
"""
m = hashlib.md5()
with open(path_ops.encode_path(path), 'rb') as f:
2018-04-28 17:12:29 +10:00
while True:
piece = f.read(32768)
if not piece:
break
m.update(piece)
2018-07-11 05:19:08 +10:00
return m.hexdigest().decode('utf-8')
2018-04-28 17:12:29 +10:00
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
2017-05-17 21:55:24 +10:00
def thread_methods(cls=None, add_stops=None, add_suspends=None):
2016-09-03 03:31:27 +10:00
"""
Decorator to add the following methods to a threading class:
2018-02-12 00:57:39 +11:00
suspend(): pauses the thread
resume(): resumes the thread
stop(): stopps/kills the thread
2016-09-03 03:31:27 +10:00
2018-02-12 00:57:39 +11:00
suspended(): returns True if thread is suspended
stopped(): returns True if thread is stopped (or should stop ;-))
ALSO returns True if PKC should exit
2016-09-03 03:31:27 +10:00
Also adds the following class attributes:
2018-02-11 23:24:00 +11:00
thread_stopped
thread_suspended
stops
suspends
invoke with either
2018-04-16 02:37:27 +10:00
@thread_methods
class MyClass():
or
2018-04-16 02:37:27 +10:00
@thread_methods(add_stops=['SUSPEND_LIBRARY_TRHEAD'],
add_suspends=['DB_SCAN', 'WHATEVER'])
class MyClass():
"""
2017-05-17 21:55:24 +10:00
# So we don't need to invoke with ()
if cls is None:
2017-05-17 21:55:24 +10:00
return partial(thread_methods,
add_stops=add_stops,
add_suspends=add_suspends)
2017-05-17 21:55:24 +10:00
# Because we need a reference, not a copy of the immutable objects in
# state, we need to look up state every time explicitly
2018-02-11 23:24:00 +11:00
cls.stops = ['STOP_PKC']
2017-05-17 21:55:24 +10:00
if add_stops is not None:
2018-02-11 23:24:00 +11:00
cls.stops.extend(add_stops)
cls.suspends = add_suspends or []
2017-05-17 21:55:24 +10:00
2016-09-03 03:31:27 +10:00
# Attach new attributes to class
2018-02-11 23:24:00 +11:00
cls.thread_stopped = False
cls.thread_suspended = False
2016-09-03 03:31:27 +10:00
# Define new class methods and attach them to class
2018-02-12 00:57:39 +11:00
def stop(self):
2018-02-11 23:24:00 +11:00
"""
Call to stop this thread
"""
self.thread_stopped = True
2018-02-12 00:57:39 +11:00
cls.stop = stop
2016-09-03 03:31:27 +10:00
2018-02-12 00:57:39 +11:00
def suspend(self):
2018-02-11 23:24:00 +11:00
"""
Call to suspend this thread
"""
self.thread_suspended = True
2018-02-12 00:57:39 +11:00
cls.suspend = suspend
2016-09-03 03:31:27 +10:00
2018-02-12 00:57:39 +11:00
def resume(self):
2018-02-11 23:24:00 +11:00
"""
Call to revive a suspended thread back to life
"""
self.thread_suspended = False
2018-02-12 00:57:39 +11:00
cls.resume = resume
2016-09-03 03:31:27 +10:00
2018-02-12 00:57:39 +11:00
def suspended(self):
2018-02-11 23:24:00 +11:00
"""
Returns True if the thread is suspended
"""
if self.thread_suspended is True:
2017-05-17 21:55:24 +10:00
return True
2018-02-11 23:24:00 +11:00
for suspend in self.suspends:
2017-05-17 21:55:24 +10:00
if getattr(state, suspend):
return True
return False
2018-02-12 00:57:39 +11:00
cls.suspended = suspended
2016-09-03 03:31:27 +10:00
2018-02-12 00:57:39 +11:00
def stopped(self):
2018-02-11 23:24:00 +11:00
"""
Returns True if the thread is stopped
"""
if self.thread_stopped is True:
2017-05-17 21:55:24 +10:00
return True
2018-02-11 23:24:00 +11:00
for stop in self.stops:
2017-05-17 21:55:24 +10:00
if getattr(state, stop):
return True
return False
2018-02-12 00:57:39 +11:00
cls.stopped = stopped
2016-09-03 03:31:27 +10:00
# Return class to render this a decorator
return cls