SickGear/sickbeard/clients/kodi/service.sickgear.watchedstate.updater/service.py
JackDandy 8d9406d5fc Add choose to delete watched episodes from a list of played media at Kodi, Emby, and/or Plex.
Add episode watched state system that integrates with Kodi, Plex, and/or Emby, instructions at Shows/History/Layout/"Watched".
Add installable SickGear Kodi repository containing addon "SickGear Watched State Updater".
Change add Emby setting for watched state scheduler at Config/Notifications/Emby/"Update watched interval".
Change add Plex setting for watched state scheduler at Config/Notifications/Plex/"Update watched interval".
Add API cmd=sg.updatewatchedstate, instructions for use are linked to in layout "Watched" at /history.
Change history page table filter input values are saved across page refreshes.
Change history page table filter inputs, accept values like "dvd or web" to only display both.
Change history page table filter inputs, press 'ESC' key inside a filter input to reset it.
Add provider activity stats to Shows/History/Layout/ drop down.
Change move provider failures table from Manage/Media Search to Shows/History/Layout/Provider fails.
Change sort provider failures by most recent failure, and with paused providers at the top.
Change remove table form non-testing version 20007, that was reassigned.
2018-03-06 02:12:45 +00:00

361 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()