mirror of
https://github.com/SickGear/SickGear.git
synced 2024-12-12 14:13:38 +00:00
362 lines
13 KiB
Python
362 lines
13 KiB
Python
|
# 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 <http://www.gnu.org/licenses/>.
|
|||
|
|
|||
|
try:
|
|||
|
import json as json
|
|||
|
except (StandardError, Exception):
|
|||
|
import simplejson as json
|
|||
|
from os import path, sep
|
|||
|
import datetime
|
|||
|
import socket
|
|||
|
import time
|
|||
|
import traceback
|
|||
|
import urllib
|
|||
|
import urllib2
|
|||
|
import xbmc
|
|||
|
import xbmcaddon
|
|||
|
import xbmcgui
|
|||
|
import xbmcvfs
|
|||
|
|
|||
|
|
|||
|
class SickGearWatchedStateUpdater:
|
|||
|
|
|||
|
def __init__(self):
|
|||
|
self.wait_onstartup = 4000
|
|||
|
|
|||
|
icon_size = '%s'
|
|||
|
try:
|
|||
|
if 1350 > xbmcgui.Window.getWidth(xbmcgui.Window()):
|
|||
|
icon_size += '-sm'
|
|||
|
except (StandardError, 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 (StandardError, Exception) as e:
|
|||
|
return self.report_contact_fail(e)
|
|||
|
|
|||
|
self.log('Started')
|
|||
|
self.notify('Started in background')
|
|||
|
|
|||
|
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 = 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: <type \'{0}\'>',
|
|||
|
'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 (StandardError, 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 (StandardError, Exception):
|
|||
|
pass
|
|||
|
|
|||
|
def update_sickgear(self, media_id, path_file, play_count, profile):
|
|||
|
|
|||
|
self.notify('Update sent to SickGear')
|
|||
|
|
|||
|
url = 'http://%s:%s/update_watched_state_kodi/' % (
|
|||
|
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 = urllib.urlencode(dict(payload=payload_json))
|
|||
|
try:
|
|||
|
rq = urllib2.Request(url, data=payload)
|
|||
|
r = urllib2.urlopen(rq)
|
|||
|
response = json.load(r)
|
|||
|
r.close()
|
|||
|
if 'OK' == r.msg:
|
|||
|
self.payload_prep(response)
|
|||
|
if not all(response.values()):
|
|||
|
msg = 'Success, watched state updated'
|
|||
|
else:
|
|||
|
msg = 'Success, %s/%s watched stated updated' % (
|
|||
|
len([v for v in response.values() if v]), len(response.values()))
|
|||
|
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 (urllib2.URLError, IOError) as e:
|
|||
|
self.log(u'Couldn\'t contact SickGear %s' % self.ex(e), error=True)
|
|||
|
self.notify(msg_bad, error=True, period=15)
|
|||
|
except (StandardError, 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 payload_prep(payload):
|
|||
|
|
|||
|
name = 'sickgear_buffer.txt'
|
|||
|
# try to locate /temp at parent location
|
|||
|
path_temp = path.join(path.dirname(path.dirname(path.realpath(__file__))), 'temp')
|
|||
|
path_data = path.join(path_temp, name)
|
|||
|
|
|||
|
data_pool = {}
|
|||
|
if xbmcvfs.exists(path_data):
|
|||
|
fh = None
|
|||
|
try:
|
|||
|
fh = xbmcvfs.File(path_data)
|
|||
|
data_pool = json.load(fh)
|
|||
|
except (StandardError, Exception):
|
|||
|
pass
|
|||
|
fh and fh.close()
|
|||
|
|
|||
|
temp_ok = True
|
|||
|
if not any([data_pool]):
|
|||
|
temp_ok = xbmcvfs.exists(path_temp) or xbmcvfs.exists(path.join(path_temp, sep))
|
|||
|
if not temp_ok:
|
|||
|
temp_ok = xbmcvfs.mkdirs(path_temp)
|
|||
|
|
|||
|
response_data = False
|
|||
|
for k, v in payload.items():
|
|||
|
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, basestring):
|
|||
|
# 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(data_pool.keys())[max_payload:]:
|
|||
|
data_pool.pop(k)
|
|||
|
payload.update(dict(date_watched=ts_now))
|
|||
|
data_pool.update({ts_now: payload})
|
|||
|
|
|||
|
output = json.dumps(data_pool)
|
|||
|
if temp_ok:
|
|||
|
fh = None
|
|||
|
try:
|
|||
|
fh = xbmcvfs.File(path_data, 'w')
|
|||
|
fh.write(output)
|
|||
|
except (StandardError, Exception):
|
|||
|
pass
|
|||
|
fh and fh.close()
|
|||
|
|
|||
|
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:
|
|||
|
if not self.kodi_request(dict(
|
|||
|
method='Settings.SetSettingValue',
|
|||
|
params=dict(setting='services.%s' % setting.keys()[0], value=setting.values()[0])
|
|||
|
)).get('result', {}):
|
|||
|
settings[setting] = self.kodi_request(dict(
|
|||
|
method='Settings.GetSettingValue',
|
|||
|
params=dict(setting='services.%s' % setting.keys()[0])
|
|||
|
)).get('result', {}).get('value')
|
|||
|
except (StandardError, Exception):
|
|||
|
return
|
|||
|
|
|||
|
setting_states = [setting.values()[0] 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:
|
|||
|
# noinspection PyProtectedMember
|
|||
|
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 __name__ == '__main__':
|
|||
|
if __dev__:
|
|||
|
devenv.setup_devenv(True)
|
|||
|
WSU = SickGearWatchedStateUpdater()
|
|||
|
WSU.run()
|
|||
|
del WSU
|
|||
|
|
|||
|
if __dev__:
|
|||
|
devenv.stop()
|