# coding=utf-8 # # This file is part of SickGear. # # SickGear 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. # # SickGear 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 SickGear. If not, see . try: import json as json except (BaseException, Exception): import simplejson as json from os import path, sep import datetime import socket # noinspection PyUnresolvedReferences,PyProtectedMember from ssl import _create_unverified_context import sys import time import traceback # these are Kodi specific libs, so block the error reports in pycharm # noinspection PyUnresolvedReferences import xbmc # noinspection PyUnresolvedReferences import xbmcaddon # noinspection PyUnresolvedReferences import xbmcgui # noinspection PyUnresolvedReferences import xbmcvfs ADDON_VERSION = '1.0.9' # try to locate /temp at parent location PATH_TEMP = path.join(path.dirname(path.dirname(path.realpath(__file__))), 'temp') PY2 = 2 == sys.version_info[0] if PY2: # noinspection PyCompatibility,PyUnresolvedReferences from urllib2 import Request, urlopen, URLError # noinspection PyUnresolvedReferences from urllib import urlencode # noinspection PyCompatibility,PyUnresolvedReferences string_types = (basestring,) binary_type = str def iterkeys(d, **kw): # noinspection PyCompatibility return d.iterkeys(**kw) def itervalues(d, **kw): # noinspection PyCompatibility return d.itervalues(**kw) def iteritems(d, **kw): # noinspection PyCompatibility return d.iteritems(**kw) # noinspection PyUnusedLocal def decode_bytes(d, **kw): if not isinstance(d, binary_type): return bytes(d) return d else: # noinspection PyCompatibility,PyUnresolvedReferences from urllib.error import URLError # noinspection PyCompatibility,PyUnresolvedReferences from urllib.parse import urlencode # noinspection PyCompatibility,PyUnresolvedReferences from urllib.request import Request, urlopen string_types = (str,) binary_type = bytes def iterkeys(d, **kw): return iter(d.keys(**kw)) def itervalues(d, **kw): return iter(d.values(**kw)) def iteritems(d, **kw): return iter(d.items(**kw)) def decode_bytes(d, encoding='utf-8', errors='replace'): if not isinstance(d, binary_type): # noinspection PyArgumentList return bytes(d, encoding=encoding, errors=errors) return d def decode_str(s, encoding='utf-8', errors=None): if isinstance(s, binary_type): if None is errors: return s.decode(encoding) return s.decode(encoding, errors) return s class SickGearWatchedStateUpdater(object): def __init__(self): self.wait_onstartup = 4000 icon_size = '%s' try: if 1350 > xbmcgui.Window.getWidth(xbmcgui.Window()): icon_size += '-sm' except (BaseException, Exception): pass icon = 'special://home/addons/service.sickgear.watchedstate.updater/resources/icon-%s.png' % icon_size self.addon = xbmcaddon.Addon() self.red_logo = icon % 'red' self.green_logo = icon % 'green' self.black_logo = icon % 'black' self.addon_name = self.addon.getAddonInfo('name') self.kodi_ip = self.addon.getSetting('kodi_ip') self.kodi_port = int(self.addon.getSetting('kodi_port')) self.kodi_events = None self.sock_kodi = None def run(self): """ Main start :return: :rtype: """ if not self.enable_kodi_allow_remote(): return self.sock_kodi = socket.socket() self.sock_kodi.setblocking(True) xbmc.sleep(self.wait_onstartup) try: self.sock_kodi.connect((self.kodi_ip, self.kodi_port)) except (BaseException, Exception) as e: return self.report_contact_fail(e) self.log('Started') self.notify('Started in background') cache_pkg = 'special://home/addons/packages/service.sickgear.watchedstate.updater-%s.zip' % ADDON_VERSION if xbmcvfs.exists(cache_pkg): try: xbmcvfs.delete(cache_pkg) except (BaseException, Exception): pass self.kodi_events = xbmc.Monitor() sock_buffer, depth, methods, method = '', 0, {'VideoLibrary.OnUpdate': self.video_library_on_update}, None # socks listener parsing Kodi json output into action to perform while not self.kodi_events.abortRequested(): chunk = decode_str(self.sock_kodi.recv(1)) sock_buffer += chunk if chunk in '{}': if '{' == chunk: depth += 1 else: depth -= 1 if not depth: json_msg = json.loads(sock_buffer) try: method = json_msg.get('method') method_handler = methods[method] method_handler(json_msg) except KeyError: if 'System.OnQuit' == method: break if __dev__: self.log('pass on event: %s' % json_msg.get('method')) sock_buffer = '' self.sock_kodi.close() del self.kodi_events self.log('Stopped') def is_enabled(self, name): """ Return state of an Add-on setting as Boolean :param name: Name of Addon setting :type name: String :return: Success as True if addon setting is enabled, else False :rtype: Bool """ return 'true' == self.addon.getSetting(name) def log(self, msg, error=False): """ Add a message to the Kodi logging system (provided setting allows it) :param msg: Text to add to log file :type msg: String :param error: Specify whether text indicates an error or action :type error: Boolean :return: :rtype: """ if self.is_enabled('verbose_log'): xbmc.log('[%s]:: %s' % (self.addon_name, msg), (xbmc.LOGNOTICE, xbmc.LOGERROR)[error]) def notify(self, msg, period=4, error=None): """ Invoke the Kodi onscreen notification panel with a message (provided setting allows it) :param msg: Text to display in panel :type msg: String :param period: Wait seconds before closing dialog :type period: Integer :param error: Specify whether text indicates an error or action :type error: Boolean :return: :rtype: """ if not error and self.is_enabled('action_notification') or (error and self.is_enabled('error_notification')): xbmc.executebuiltin('Notification(%s, "%s", %s, %s)' % ( self.addon_name, msg, 1000 * period, ((self.green_logo, self.red_logo)[any([error])], self.black_logo)[None is error])) @staticmethod def ex(e): return '\n'.join(['\nEXCEPTION Raised: --> Python callback/script returned the following error <--', 'Error type: ', 'Error content: {1!r}', '{2}', '--> End of Python script error report <--\n' ]).format(type(e).__name__, e.args, traceback.format_exc()) def report_contact_fail(self, e): msg = 'Failed to contact Kodi at %s:%s' % (self.kodi_ip, self.kodi_port) self.log('%s %s' % (msg, self.ex(e)), error=True) self.notify(msg, period=20, error=True) def kodi_request(self, params): params.update(dict(jsonrpc='2.0', id='SickGear')) try: response = xbmc.executeJSONRPC(json.dumps(params)) except (BaseException, Exception) as e: return self.report_contact_fail(e) try: return json.loads(response) except UnicodeDecodeError: return json.loads(response.decode('utf-8', 'ignore')) def video_library_on_update(self, json_msg): """ Actions to perform for: Kodi Notifications / VideoLibrary / VideoLibrary.OnUpdate invoked in Kodi when: A video item has been updated source: http://kodi.wiki/view/JSON-RPC_API/v8#VideoLibrary.OnUpdate :param json_msg: A JSON parsed from socks :type json_msg: String :return: :rtype: """ try: # note: this is called multiple times when a season is marked as un-/watched if 'episode' == json_msg['params']['data']['item']['type']: media_id = json_msg['params']['data']['item']['id'] play_count = json_msg['params']['data']['playcount'] json_resp = self.kodi_request(dict( method='Profiles.GetCurrentProfile')) current_profile = json_resp['result']['label'] json_resp = self.kodi_request(dict( method='VideoLibrary.GetEpisodeDetails', params=dict(episodeid=media_id, properties=['file']))) path_file = json_resp['result']['episodedetails']['file'].encode('utf-8') self.update_sickgear(media_id, path_file, play_count, current_profile) except (BaseException, Exception): pass def update_sickgear(self, media_id, path_file, play_count, profile): self.notify('Update sent to SickGear') file_name = 'sickgear_extra.txt' data_extra = self.load_json(file_name) scheme = data_extra.get('scheme', 'http') url = '%s://%s:%s/update-watched-state-kodi-legacy/' % ( scheme, self.addon.getSetting('sickgear_ip'), self.addon.getSetting('sickgear_port')) self.log('Notify state to %s with path_file=%s' % (url, path_file)) msg_bad = 'Failed to contact SickGear on port %s at %s' % ( self.addon.getSetting('sickgear_port'), self.addon.getSetting('sickgear_ip')) payload_json = self.payload_prep(dict(media_id=media_id, path_file=path_file, played=play_count, label=profile)) if payload_json: payload = urlencode(dict(payload=payload_json, version=ADDON_VERSION)) r = None change_scheme = False try: rq = Request(url, data=decode_bytes(payload)) param = ({'context': _create_unverified_context()}, {})[url.startswith('http:')] r = urlopen(rq, **param) except (BaseException, Exception): change_scheme = True try: if change_scheme: old_scheme, scheme = 'http', 'https' if url.startswith('https'): old_scheme, scheme = 'https', 'http' url = url.replace(old_scheme, scheme) self.log('Change scheme, notify state to %s' % url) rq = Request(url, data=decode_bytes(payload)) param = ({'context': _create_unverified_context()}, {})[url.startswith('http:')] r = urlopen(rq, **param) response = json.load(r) r.close() if 'OK' == r.msg: if change_scheme: data_extra['scheme'] = scheme output = json.dumps(data_extra) self.save_json(file_name, output) self.payload_prep(response) if not all(itervalues(response)): msg = 'Success, watched state updated' else: msg = 'Success, %s/%s watched stated updated' % ( len([None for v in itervalues(response) if v]), len([None for _ in itervalues(response)])) self.log(msg) self.notify(msg, error=False) else: msg_bad = 'Failed to update watched state' self.log(msg_bad) self.notify(msg_bad, error=True) except (BaseException, Exception) as e: self.log(u'Couldn\'t contact SickGear %s' % self.ex(e), error=True) self.notify(msg_bad, error=True, period=15) @staticmethod def load_json(file_name): result = {} file_path = path.join(PATH_TEMP, file_name) if xbmcvfs.exists(file_path): fh = None try: fh = xbmcvfs.File(file_path) result = json.load(fh) except (BaseException, Exception): pass fh and fh.close() return result @staticmethod def save_json(file_name, data): temp_ok = xbmcvfs.exists(PATH_TEMP) or xbmcvfs.exists(path.join(PATH_TEMP, sep)) if not temp_ok: temp_ok = xbmcvfs.mkdirs(PATH_TEMP) if temp_ok: fh = None try: fh = xbmcvfs.File(path.join(PATH_TEMP, file_name), 'w') fh.write(data) except (BaseException, Exception): pass fh and fh.close() def payload_prep(self, payload): # type: (dict) -> str file_name = 'sickgear_buffer.txt' data_pool = self.load_json(file_name) response_data = False for k, v in iteritems(payload): if response_data or k in data_pool: response_data = True if not v: # whether no fail response or bad input, remove this from data data_pool.pop(k) elif isinstance(v, string_types): # error so retry next time continue if not response_data: ts_now = time.mktime(datetime.datetime.now().timetuple()) timeout = 100 while ts_now in data_pool and timeout: ts_now = time.mktime(datetime.datetime.now().timetuple()) timeout -= 1 max_payload = 50-1 for k in list(iterkeys(data_pool))[max_payload:]: data_pool.pop(k) payload.update(dict(date_watched=ts_now)) data_pool.update({ts_now: payload}) output = json.dumps(data_pool) self.save_json(file_name, output) return output def enable_kodi_allow_remote(self): try: # setting esenabled: allow remote control by programs on this system # setting esallinterfaces: allow remote control by programs on other systems settings = [dict(esenabled=True), dict(esallinterfaces=True)] for setting in settings: name = next(iterkeys(setting)) if not self.kodi_request(dict( method='Settings.SetSettingValue', params=dict(setting='services.%s' % name, value=next(itervalues(setting))) )).get('result', {}): settings[setting] = self.kodi_request(dict( method='Settings.GetSettingValue', params=dict(setting='services.%s' % name) )).get('result', {}).get('value') except (BaseException, Exception): return setting_states = [next(itervalues(setting)) for setting in settings] if not all(setting_states): if not (any(setting_states)): msg = 'Please enable *all* Kodi settings to allow remote control by programs...' else: msg = 'Please enable Kodi setting to allow remote control by programs on other systems' msg = 'Failed startup. %s in system service/remote control' % msg self.log(msg, error=True) self.notify(msg, period=20, error=True) return return True __dev__ = True if __dev__: try: # specific to a dev env # noinspection PyProtectedMember, PyUnresolvedReferences import _devenv as devenv except ImportError: __dev__ = False if 1 < len(sys.argv): if __dev__: devenv.setup_devenv(False) if sys.argv[2].endswith('send_all'): print('>>>>>> TESTTESTTEST') elif '__main__' == __name__: if __dev__: devenv.setup_devenv(True) WSU = SickGearWatchedStateUpdater() WSU.run() del WSU if __dev__: devenv.stop()