@ -0,0 +1,34 @@ |
|||
SickGear PostProcessing script for NZBGet |
|||
========================================= |
|||
|
|||
If NZBGet v17+ is installed on the same system as SickGear then as a local install, |
|||
|
|||
1) Add the location of this script file to NZBGet Settings/PATHS/ScriptDir |
|||
|
|||
2) Navigate to any named TV category at Settings/Categories, click "Choose" Category.Extensions then Apply SickGear-NG |
|||
|
|||
This is the best set up to automatically get script updates from SickGear |
|||
|
|||
############# |
|||
|
|||
If NZBGet v16 or earlier is installed, then as an older install, |
|||
|
|||
1) Copy the directory with/or this single script file to path set in NZBGet Settings/PATHS/ScriptDir |
|||
|
|||
2) Refresh the NZBGet page and navigate to Settings/SickGear-NG |
|||
|
|||
3) Click View -> Compact to remove any tick and un hide tips and suggestions |
|||
|
|||
4) The bare minimum change is the sg_base_path setting or enter `python -m pip install requests` at admin commandline |
|||
|
|||
5) Navigate to any named TV category at Settings/Categories, click "Choose" Category.Extensions then Apply SickGear-NG |
|||
|
|||
You will need to manually update your script with this set up |
|||
|
|||
############# |
|||
|
|||
Notes: |
|||
Debian doesn't have pip, _if_ requests is needed, try "apt install python-requests" |
|||
|
|||
----- |
|||
Enjoy |
@ -0,0 +1,647 @@ |
|||
#!/usr/bin/env python |
|||
# |
|||
# ############################################################################## |
|||
# ############################################################################## |
|||
# |
|||
# SickGear PostProcessing script for NZBGet |
|||
# ========================================= |
|||
# |
|||
# If NZBGet v17+ is installed on the same system as SickGear then as a local install, |
|||
# |
|||
# 1) Add the location of this script file to NZBGet Settings/PATHS/ScriptDir |
|||
# |
|||
# 2) Navigate to any named TV category at Settings/Categories, click "Choose" Category.Extensions then Apply SickGear-NG |
|||
# |
|||
# This is the best set up to automatically get script updates from SickGear |
|||
# |
|||
# ############# |
|||
# |
|||
# If NZBGet v16 or earlier is installed, then as an older install, |
|||
# |
|||
# 1) Copy the directory with/or this single script file to path set in NZBGet Settings/PATHS/ScriptDir |
|||
# |
|||
# 2) Refresh the NZBGet page and navigate to Settings/SickGear-NG |
|||
# |
|||
# 3) Click View -> Compact to remove any tick and un hide tips and suggestions |
|||
# |
|||
# 4) The bare minimum change is the sg_base_path setting or enter `python -m pip install requests` at admin commandline |
|||
# |
|||
# 5) Navigate to any named TV category at Settings/Categories, click "Choose" Category.Extensions then Apply SickGear-NG |
|||
# |
|||
# You will need to manually update your script with this set up |
|||
# |
|||
# ############ |
|||
# |
|||
# Notes: |
|||
# Debian doesn't have pip, _if_ requests is needed, try "apt install python-requests" |
|||
# ----- |
|||
# Enjoy |
|||
# |
|||
# ############################################################################## |
|||
# ############################################################################## |
|||
# |
|||
# Copyright (C) 2016 SickGear Developers |
|||
# |
|||
# This program 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 2 of the License, or |
|||
# (at your option) any later version. |
|||
# |
|||
# This program 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 this program; if not, write to the Free Software |
|||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|||
# |
|||
|
|||
############################################################################## |
|||
### NZBGET QUEUE/POST-PROCESSING SCRIPT ### |
|||
### QUEUE EVENTS: NZB_ADDED, NZB_DELETED, URL_COMPLETED, NZB_MARKED ### |
|||
|
|||
# Send PostProcessing requests to SickGear |
|||
# |
|||
# PostProcessing-Script version: 1.3. |
|||
# <!-- |
|||
# For more info and updates please visit forum topic at |
|||
# --> |
|||
# <span style="display:block;position:absolute;right:20px;top:105px;width:138px;height:74px;background:url(https://raw.githubusercontent.com/SickGear/SickGear/master/gui/slick/images/sickgear.png)"></span> |
|||
# <span style="display:inline-block;margin-top:10px" class="label label-important"> |
|||
# Setup steps</span> <span class="label label-important" style="display:inline-block;cursor:pointer" data-toggle="modal" href="#InfoDialog">NZBGet Version</span> |
|||
# <span style="display:block;color:#666"> |
|||
# <span style="display:block;padding:4px;margin-top:3px;background-color:#efefef;border:1px solid #ccc;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px"> |
|||
# <span style="width:1em;float:left;padding:3px 0 0 3px"> |
|||
# <span class="label label-important">1</span> |
|||
# </span> |
|||
# <span style="display:block;margin-left:1.75em;padding:3px 3px 3px 0"> |
|||
# If <span style="font-weight:bold">NZBGet v17 or newer</span> is installed on the same system as SickGear, then add the |
|||
# location of this script file to NZBGet Settings/PATHS/ScriptDir |
|||
# <br /><br /> |
|||
# Or, if <span style="font-weight:bold">NZBGet v16 or earlier</span> is installed on the same system as SickGear and |
|||
# if python <a href="https://pypi.python.org/pypi/requests" title="requests library page" target="_blank">requests library</a> |
|||
# is not installed, then <strong style="font-weight:bold;color:#128D12 !important">sg_base_path</strong> must be set |
|||
# </span> |
|||
# </span> |
|||
# <span style="display:block;padding:4px;margin-top:3px;background-color:#efefef;border:1px solid #ccc;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px"> |
|||
# <span style="width:1em;float:left;padding:3px 0 0 3px"> |
|||
# <span class="label label-important">2</span> |
|||
# </span> |
|||
# <span style="display:block;margin-left:1.75em;padding:3px 3px 3px 0"> |
|||
# Then, for <span style="font-weight:bold">any install</span> type, click <span class="btn" style="padding:1px 5px 0;line-height:16px">Choose</span> |
|||
# then apply "<span style="color:#222">SickGear-NG</span>" in a TV Category at NZBGet Settings/CATEGORIES, |
|||
# save all changes and reload NZBGet |
|||
# |
|||
# </span> |
|||
# </span> |
|||
# </span> |
|||
# |
|||
# <span class="label label-warning">Note</span> This script requires Python 2.7+ and may not work with Python 3.x+ |
|||
# |
|||
############################################################################## |
|||
### OPTIONS ### |
|||
# |
|||
#test connection@Test SickGear connection |
|||
# |
|||
# <span class="label label-info"> |
|||
# Optional</span> |
|||
# SickGear <span style="font-weight:bold;color:#128D12 !important">base installation path</span>. |
|||
# use where NZBGet v16 or older is installed on the same system as SickGear, and no python requests library is installed |
|||
# (use "pip list" to check installed modules) |
|||
#sg_base_path= |
|||
|
|||
# <span class="label label-info"> |
|||
# Optional</span> |
|||
# SickGear server ipaddress [default:127.0.0.1 aka localhost]. |
|||
# change if SickGear is not installed on the same localhost as NZBGet |
|||
#sg_host=localhost |
|||
|
|||
# <span class="label label-info"> |
|||
# Optional</span> |
|||
# SickGear HTTP Port [default:8081] (1025-65535). |
|||
#sg_port=8081 |
|||
|
|||
# <span class="label label-info"> |
|||
# Optional</span> |
|||
# SickGear Username. |
|||
#sg_username= |
|||
|
|||
# <span class="label label-info"> |
|||
# Optional</span> |
|||
# SickGear Password. |
|||
#sg_password= |
|||
|
|||
# <span class="label label-info"> |
|||
# Optional</span> |
|||
# SickGear has SSL enabled [default:No] (yes, no). |
|||
#sg_ssl=no |
|||
|
|||
# <span class="label label-warning"> |
|||
# Advanced use</span> |
|||
# SickGear Web Root. |
|||
# change if using a custom SickGear web_root setting (e.g. for a reverse proxy) |
|||
#sg_web_root= |
|||
|
|||
# <span class="label label-info"> |
|||
# Optional</span> |
|||
# Print more logging messages [default:No] (yes, no). |
|||
# For debugging or if you need to report a bug. |
|||
#sg_verbose=no |
|||
|
|||
### NZBGET QUEUE/POST-PROCESSING SCRIPT ### |
|||
############################################################################## |
|||
import locale |
|||
import os |
|||
import re |
|||
import sys |
|||
|
|||
__version__ = '1.3' |
|||
|
|||
verbose = 0 or 'yes' == os.environ.get('NZBPO_SG_VERBOSE', 'no') |
|||
|
|||
# NZBGet exit codes for post-processing scripts (Queue-scripts don't have any special exit codes). |
|||
POSTPROCESS_SUCCESS, POSTPROCESS_ERROR, POSTPROCESS_NONE = 93, 94, 95 |
|||
|
|||
failed = False |
|||
|
|||
# define minimum dir size, downloads under this size will be handled as failure |
|||
min_dir_size = 20 * 1024 * 1024 |
|||
|
|||
|
|||
class Logger: |
|||
INFO, DETAIL, ERROR, WARNING = 'INFO', 'DETAIL', 'ERROR', 'WARNING' |
|||
# '[NZB]' send a command message to NZBGet (no log) |
|||
NZB = 'NZB' |
|||
|
|||
def __init__(self): |
|||
pass |
|||
|
|||
@staticmethod |
|||
def safe_print(msg_type, message): |
|||
try: |
|||
print '[%s] %s' % (msg_type, message.encode(SYS_ENCODING)) |
|||
except (StandardError, Exception): |
|||
try: |
|||
print '[%s] %s' % (msg_type, message) |
|||
except (StandardError, Exception): |
|||
try: |
|||
print '[%s] %s' % (msg_type, repr(message)) |
|||
except (StandardError, Exception): |
|||
pass |
|||
|
|||
@staticmethod |
|||
def log(message, msg_type=INFO): |
|||
size = 900 |
|||
if size > len(message): |
|||
Logger.safe_print(msg_type, message) |
|||
else: |
|||
for group in (message[pos:pos + size] for pos in xrange(0, len(message), size)): |
|||
Logger.safe_print(msg_type, group) |
|||
|
|||
|
|||
if 'nt' == os.name: |
|||
import ctypes |
|||
|
|||
class WinEnv: |
|||
def __init__(self): |
|||
pass |
|||
|
|||
@staticmethod |
|||
def get_environment_variable(name): |
|||
name = unicode(name) # ensures string argument is unicode |
|||
n = ctypes.windll.kernel32.GetEnvironmentVariableW(name, None, 0) |
|||
env_value = None |
|||
if n: |
|||
buf = ctypes.create_unicode_buffer(u'\0'*n) |
|||
ctypes.windll.kernel32.GetEnvironmentVariableW(name, buf, n) |
|||
env_value = buf.value |
|||
verbose and Logger.log('Get var(%s) = %s' % (name, env_value or n)) |
|||
return env_value |
|||
|
|||
def __getitem__(self, key): |
|||
return self.get_environment_variable(key) |
|||
|
|||
def get(self, key, default=None): |
|||
r = self.get_environment_variable(key) |
|||
return r if r is not None else default |
|||
|
|||
env_var = WinEnv() |
|||
else: |
|||
class LinuxEnv(object): |
|||
def __init__(self, environ): |
|||
self.environ = environ |
|||
|
|||
def __getitem__(self, key): |
|||
v = self.environ.get(key) |
|||
try: |
|||
return v.decode(SYS_ENCODING) if isinstance(v, str) else v |
|||
except (UnicodeDecodeError, UnicodeEncodeError): |
|||
return v |
|||
|
|||
def get(self, key, default=None): |
|||
v = self[key] |
|||
return v if v is not None else default |
|||
|
|||
env_var = LinuxEnv(os.environ) |
|||
|
|||
|
|||
SYS_ENCODING = None |
|||
try: |
|||
locale.setlocale(locale.LC_ALL, '') |
|||
except (locale.Error, IOError): |
|||
pass |
|||
try: |
|||
SYS_ENCODING = locale.getpreferredencoding() |
|||
except (locale.Error, IOError): |
|||
pass |
|||
if not SYS_ENCODING or SYS_ENCODING in ('ANSI_X3.4-1968', 'US-ASCII', 'ASCII'): |
|||
SYS_ENCODING = 'UTF-8' |
|||
|
|||
|
|||
verbose and Logger.log('%s(%s) env dump = %s' % (('posix', 'nt')['nt' == os.name], SYS_ENCODING, os.environ)) |
|||
|
|||
|
|||
class Ek: |
|||
def __init__(self): |
|||
pass |
|||
|
|||
@staticmethod |
|||
def fix_string_encoding(x): |
|||
if str == type(x): |
|||
try: |
|||
return x.decode(SYS_ENCODING) |
|||
except UnicodeDecodeError: |
|||
return None |
|||
elif unicode == type(x): |
|||
return x |
|||
return None |
|||
|
|||
@staticmethod |
|||
def fix_list_encoding(x): |
|||
if type(x) not in (list, tuple): |
|||
return x |
|||
return filter(lambda i: None is not i, map(Ek.fix_string_encoding, i)) |
|||
|
|||
@staticmethod |
|||
def encode_item(x): |
|||
try: |
|||
return x.encode(SYS_ENCODING) |
|||
except UnicodeEncodeError: |
|||
return x.encode(SYS_ENCODING, 'ignore') |
|||
|
|||
@staticmethod |
|||
def ek(func, *args, **kwargs): |
|||
if 'nt' == os.name: |
|||
func_result = func(*args, **kwargs) |
|||
else: |
|||
func_result = func(*[Ek.encode_item(x) if type(x) == str else x for x in args], **kwargs) |
|||
|
|||
if type(func_result) in (list, tuple): |
|||
return Ek.fix_list_encoding(func_result) |
|||
elif str == type(func_result): |
|||
return Ek.fix_string_encoding(func_result) |
|||
return func_result |
|||
|
|||
|
|||
# Depending on the mode in which the script was called (queue-script NZBNA_DELETESTATUS |
|||
# or post-processing-script) a different set of parameters (env. vars) |
|||
# is passed. They also have different prefixes: |
|||
# - NZBNA in queue-script mode; |
|||
# - NZBPP in pp-script mode. |
|||
env_run_mode = ('PP', 'NA')['NZBNA_EVENT' in os.environ] |
|||
|
|||
|
|||
def nzbget_var(name, default='', namespace=env_run_mode): |
|||
return env_var.get('NZB%s_%s' % (namespace, name), default) |
|||
|
|||
|
|||
def nzbget_opt(name, default=''): |
|||
return nzbget_var(name, default, 'OP') |
|||
|
|||
|
|||
def nzbget_plugin_opt(name, default=''): |
|||
return nzbget_var('SG_%s' % name, default, 'PO') |
|||
|
|||
|
|||
sg_path = nzbget_plugin_opt('BASE_PATH') |
|||
if not sg_path or not Ek.ek(os.path.isdir, sg_path): |
|||
try: |
|||
script_path = Ek.ek(os.path.dirname, __file__) |
|||
sg_path = Ek.ek(os.path.dirname, Ek.ek(os.path.dirname, script_path)) |
|||
except (StandardError, Exception): |
|||
pass |
|||
if sg_path and Ek.ek(os.path.isdir, Ek.ek(os.path.join, sg_path, 'lib')): |
|||
sys.path.insert(1, Ek.ek(os.path.join, sg_path, 'lib')) |
|||
|
|||
|
|||
try: |
|||
import requests |
|||
except ImportError: |
|||
Logger.log('You must set SickGear sg_base_path in script config or install python requests library', Logger.ERROR) |
|||
sys.exit(1) |
|||
|
|||
|
|||
def get_size(start_path='.'): |
|||
if Ek.ek(os.path.isfile, start_path): |
|||
return Ek.ek(os.path.getsize, start_path) |
|||
total_size = 0 |
|||
for dirpath, dirnames, filenames in Ek.ek(os.walk, start_path): |
|||
for f in filenames: |
|||
if not f.lower().endswith(('.nzb', '.jpg', '.jpeg', '.gif', '.png', '.tif', '.nfo', '.txt', '.srt', '.sub', |
|||
'.sbv', '.idx', '.bat', '.sh', '.exe', '.pdf')): |
|||
fp = Ek.ek(os.path.join, dirpath, f) |
|||
total_size += Ek.ek(os.path.getsize, fp) |
|||
return total_size |
|||
|
|||
|
|||
def try_int(s, s_default=0): |
|||
try: |
|||
return int(s) |
|||
except (StandardError, Exception): |
|||
return s_default |
|||
|
|||
|
|||
def try_float(s, s_default=0): |
|||
try: |
|||
return float(s) |
|||
except (StandardError, Exception): |
|||
return s_default |
|||
|
|||
|
|||
class ExitReason: |
|||
def __init__(self): |
|||
pass |
|||
PP_SUCCESS = 0 |
|||
FAIL_SUCCESS = 1 |
|||
MARKED_BAD_SUCCESS = 2 |
|||
DELETED = 5 |
|||
SAME_DUPEKEY = 10 |
|||
UNFINISHED_DOWNLOAD = 11 |
|||
NONE = 20 |
|||
NONE_SG = 21 |
|||
PP_ERROR = 25 |
|||
FAIL_ERROR = 26 |
|||
MARKED_BAD_ERROR = 27 |
|||
|
|||
|
|||
def script_exit(status, reason, runmode=None): |
|||
Logger.log('NZBPR_SICKGEAR_PROCESSED=%s_%s_%s' % (status, runmode or env_run_mode, reason), Logger.NZB) |
|||
sys.exit(status) |
|||
|
|||
|
|||
def get_old_status(): |
|||
old_status = env_var.get('NZBPR_SICKGEAR_PROCESSED', '') |
|||
status_regex = re.compile(r'(\d+)_(\w\w)_(\d+)') |
|||
if old_status and status_regex.search(old_status) is not None: |
|||
s = status_regex.match(old_status) |
|||
return try_int(s.group(1)), s.group(2), try_int(s.group(3)) |
|||
return POSTPROCESS_NONE, env_run_mode, ExitReason.NONE |
|||
|
|||
|
|||
markbad = 'NZB_MARKED' == env_var.get('NZBNA_EVENT') and 'BAD' == env_var.get('NZBNA_MARKSTATUS') |
|||
|
|||
good_statuses = [(POSTPROCESS_SUCCESS, 'PP', ExitReason.FAIL_SUCCESS), # successfully failed pp'ed |
|||
(POSTPROCESS_SUCCESS, 'NA', ExitReason.FAIL_SUCCESS), # queue, successfully failed sent |
|||
(POSTPROCESS_SUCCESS, 'NA', ExitReason.MARKED_BAD_SUCCESS)] # queue, mark bad+successfully failed sent |
|||
|
|||
if not markbad: |
|||
good_statuses.append((POSTPROCESS_SUCCESS, 'PP', ExitReason.PP_SUCCESS)) # successfully pp'ed |
|||
|
|||
|
|||
# Start up checks |
|||
def start_check(): |
|||
|
|||
# Check if the script is called from a compatible NZBGet version (as queue-script or as pp-script) |
|||
nzbget_version = re.search(r'^(\d+\.\d+)', nzbget_opt('VERSION', '0.1')) |
|||
nzbget_version = nzbget_version.group(1) if nzbget_version and len(nzbget_version.groups()) >= 1 else '0.1' |
|||
nzbget_version = try_float(nzbget_version) |
|||
if 17 > nzbget_version: |
|||
Logger.log('This script is designed to be called from NZBGet 17.0 or later.') |
|||
sys.exit(0) |
|||
|
|||
if 'NZB_ADDED' == env_var.get('NZBNA_EVENT'): |
|||
Logger.log('NZBPR_SICKGEAR_PROCESSED=', Logger.NZB) # reset var in case of Download Again |
|||
sys.exit(0) |
|||
|
|||
# This script processes only certain queue events. |
|||
# For compatibility with newer NZBGet versions it ignores event types it doesn't know |
|||
if env_var.get('NZBNA_EVENT') not in ['NZB_DELETED', 'URL_COMPLETED', 'NZB_MARKED', None]: |
|||
sys.exit(0) |
|||
|
|||
if 'NZB_MARKED' == env_var.get('NZBNA_EVENT') and 'BAD' != env_var.get('NZBNA_MARKSTATUS'): |
|||
Logger.log('Marked as [%s], nothing to do, existing' % env_var.get('NZBNA_MARKSTATUS', '')) |
|||
sys.exit(0) |
|||
|
|||
old_exit_status = get_old_status() |
|||
if old_exit_status in good_statuses and not ( |
|||
ExitReason.FAIL_SUCCESS == old_exit_status[2] and 'SUCCESS' == nzbget_var('TOTALSTATUS')): |
|||
Logger.log('Found result from a previous completed run, exiting') |
|||
script_exit(old_exit_status[0], old_exit_status[2], old_exit_status[1]) |
|||
|
|||
# If called via "Post-process again" from history details dialog the download may not exist anymore |
|||
if 'NZBNA_EVENT' not in os.environ and 'NZBPP_DIRECTORY' in os.environ: |
|||
directory = nzbget_var('DIRECTORY') |
|||
if not directory or not Ek.ek(os.path.exists, directory): |
|||
Logger.log('No files for postprocessor, look back in your NZBGet logs if required, exiting') |
|||
script_exit(POSTPROCESS_NONE, ExitReason.NONE) |
|||
|
|||
|
|||
def call_nzbget_direct(url_command): |
|||
# Connect to NZBGet and call an RPC-API method without using python's XML-RPC which is slow for large amount of data |
|||
# First we need connection info: host, port and password of NZBGet server, NZBGet passes configuration options to |
|||
# scripts using environment variables |
|||
host, port, username, password = [nzbget_opt('CONTROL%s' % name) for name in 'IP', 'PORT', 'USERNAME', 'PASSWORD'] |
|||
url = 'http://%s:%s/jsonrpc/%s' % ((host, '127.0.0.1')['0.0.0.0' == host], port, url_command) |
|||
|
|||
try: |
|||
response = requests.get(url, auth=(username, password)) |
|||
except requests.RequestException: |
|||
return '' |
|||
|
|||
return response.content if response.ok else '' |
|||
|
|||
|
|||
def call_sickgear(nzb_name, dir_name, test=False): |
|||
|
|||
global failed |
|||
ssl, host, port, username, password, webroot = [nzbget_plugin_opt(name, default) for name, default in |
|||
('SSL', 'no'), ('HOST', 'localhost'), ('PORT', '8081'), |
|||
('USERNAME', ''), ('PASSWORD', ''), ('WEB_ROOT', '')] |
|||
protocol = 'http%s://' % ('', 's')['yes' == ssl] |
|||
webroot = any(webroot) and '/%s' % webroot.strip('/') or '' |
|||
url = '%s%s:%s%s/home/postprocess/processEpisode' % (protocol, host, port, webroot) |
|||
|
|||
dupescore = nzbget_var('DUPESCORE') |
|||
dupekey = nzbget_var('DUPEKEY') |
|||
nzbid = nzbget_var('NZBID') |
|||
params = {'nzbName': '%s.nzb' % (nzb_name and re.sub('(?i)\.nzb$', '', nzb_name) or None), 'dir': dir_name, |
|||
'failed': int(failed), 'quiet': 1, 'stream': 1, 'force': 1, 'dupekey': dupekey, 'dupescore': dupescore, |
|||
'nzbid': nzbid, 'ppVersion': __version__, 'is_basedir': 0, 'client': 'nzbget'} |
|||
if test: |
|||
params['test'] = '1' |
|||
Logger.log('Opening URL: %s with params: %s' % (url, params)) |
|||
try: |
|||
s = requests.Session() |
|||
if username or password: |
|||
login = '%s%s:%s%s/login' % (protocol, host, port, webroot) |
|||
login_params = {'username': username, 'password': password} |
|||
s.post(login, data=login_params, stream=True, verify=False) |
|||
r = s.get(url, auth=(username, password), params=params, stream=True, verify=False, timeout=900) |
|||
except (StandardError, Exception): |
|||
Logger.log('Unable to open URL: %s' % url, Logger.ERROR) |
|||
return False |
|||
|
|||
success = False |
|||
try: |
|||
if r.status_code not in [requests.codes.ok, requests.codes.created, requests.codes.accepted]: |
|||
Logger.log('Server returned status %s' % str(r.status_code), Logger.ERROR) |
|||
return False |
|||
|
|||
for line in r.iter_lines(): |
|||
if line: |
|||
Logger.log(line, Logger.DETAIL) |
|||
if test: |
|||
if 'Connection success!' in line: |
|||
return True |
|||
elif not failed and 'Failed download detected:' in line: |
|||
failed = True |
|||
global markbad |
|||
markbad = True |
|||
Logger.log('MARK=BAD', Logger.NZB) |
|||
success = ('Processing succeeded' in line or 'Successfully processed' in line or |
|||
(1 == failed and 'Successful failed download processing' in line)) |
|||
except Exception as e: |
|||
Logger.log(str(e), Logger.ERROR) |
|||
|
|||
return success |
|||
|
|||
|
|||
def find_dupekey_history(dupekey, nzb_id): |
|||
|
|||
if not dupekey: |
|||
return False |
|||
data = call_nzbget_direct('history?hidden=true') |
|||
cur_status = cur_dupekey = cur_id = '' |
|||
cur_dupescore = 0 |
|||
for line in data.splitlines(): |
|||
if line.startswith('"NZBID" : '): |
|||
cur_id = line[10:-1] |
|||
elif line.startswith('"Status" : '): |
|||
cur_status = line[12:-2] |
|||
elif line.startswith('"DupeKey" : '): |
|||
cur_dupekey = line[13:-2] |
|||
elif line.startswith('"DupeScore" : '): |
|||
cur_dupescore = try_int(line[14:-1]) |
|||
elif cur_id and line.startswith('}'): |
|||
if (cur_status.startswith('SUCCESS') and dupekey == cur_dupekey and |
|||
cur_dupescore >= try_int(nzbget_var('DUPESCORE')) and cur_id != nzb_id): |
|||
return True |
|||
cur_status = cur_dupekey = cur_id = '' |
|||
cur_dupescore = 0 |
|||
return False |
|||
|
|||
|
|||
def find_dupekey_queue(dupekey, nzb_id): |
|||
|
|||
if not dupekey: |
|||
return False |
|||
data = call_nzbget_direct('listgroups') |
|||
cur_status = cur_dupekey = cur_id = '' |
|||
for line in data.splitlines(): |
|||
if line.startswith('"NZBID" : '): |
|||
cur_id = line[10:-1] |
|||
elif line.startswith('"Status" : '): |
|||
cur_status = line[12:-2] |
|||
elif line.startswith('"DupeKey" : '): |
|||
cur_dupekey = line[13:-2] |
|||
elif cur_id and line.startswith('}'): |
|||
if 'PAUSED' != cur_status and dupekey == cur_dupekey and cur_id != nzb_id: |
|||
return True |
|||
cur_status = cur_dupekey = cur_id = '' |
|||
return False |
|||
|
|||
|
|||
def check_for_failure(directory): |
|||
|
|||
failure = True |
|||
dupekey = nzbget_var('DUPEKEY') |
|||
if 'PP' == env_run_mode: |
|||
total_status = nzbget_var('TOTALSTATUS') |
|||
status = nzbget_var('STATUS') |
|||
if 'WARNING' == total_status and status in ['WARNING/REPAIRABLE', 'WARNING/SPACE', 'WARNING/DAMAGED']: |
|||
Logger.log('WARNING/REPAIRABLE' == status and 'Download is damaged but probably can be repaired' or |
|||
'WARNING/SPACE' == status and 'Out of Diskspace' or |
|||
'Par-check is required but is disabled in settings', Logger.WARNING) |
|||
script_exit(POSTPROCESS_ERROR, ExitReason.UNFINISHED_DOWNLOAD) |
|||
elif 'DELETED' == total_status: |
|||
Logger.log('Download was deleted and manually processed, nothing to do, exiting') |
|||
script_exit(POSTPROCESS_NONE, ExitReason.DELETED) |
|||
elif 'SUCCESS' == total_status: |
|||
# check for min dir size |
|||
if get_size(directory) > min_dir_size: |
|||
failure = False |
|||
else: |
|||
nzb_id = nzbget_var('NZBID') |
|||
if (not markbad and find_dupekey_queue(dupekey, nzb_id)) or find_dupekey_history(dupekey, nzb_id): |
|||
Logger.log('Download with same Dupekey in download queue or history, exiting') |
|||
script_exit(POSTPROCESS_NONE, ExitReason.SAME_DUPEKEY) |
|||
nzb_delete_status = nzbget_var('DELETESTATUS') |
|||
if nzb_delete_status == 'MANUAL': |
|||
Logger.log('Download was manually deleted, exiting') |
|||
script_exit(POSTPROCESS_NONE, ExitReason.DELETED) |
|||
|
|||
# Check if it's a Failed Download not added by SickGear |
|||
if failure and (not dupekey or not dupekey.startswith('SickGear-')): |
|||
Logger.log('Failed download was not added by SickGear, exiting') |
|||
script_exit(POSTPROCESS_NONE, ExitReason.NONE_SG) |
|||
|
|||
return failure |
|||
|
|||
|
|||
# Check if the script is executed from settings page with a custom command |
|||
command = os.environ.get('NZBCP_COMMAND') |
|||
if None is not command: |
|||
if 'test connection' == command: |
|||
Logger.log('Test connection...') |
|||
result = call_sickgear('', '', test=True) |
|||
if True is result: |
|||
Logger.log('Connection Test successful!') |
|||
sys.exit(POSTPROCESS_SUCCESS) |
|||
Logger.log('Connection Test failed!', Logger.ERROR) |
|||
sys.exit(POSTPROCESS_ERROR) |
|||
|
|||
Logger.log('Invalid command passed to SickGear-NG: ' + command, Logger.ERROR) |
|||
sys.exit(POSTPROCESS_ERROR) |
|||
|
|||
|
|||
# Script body |
|||
def main(): |
|||
|
|||
global failed |
|||
# Do start up check |
|||
start_check() |
|||
|
|||
# Read context (what nzb is currently being processed) |
|||
directory = nzbget_var('DIRECTORY') |
|||
nzbname = nzbget_var('NZBNAME') |
|||
failed = check_for_failure(directory) |
|||
|
|||
if call_sickgear(nzbname, directory): |
|||
Logger.log('Successfully post-processed %s' % nzbname) |
|||
sys.stdout.flush() |
|||
script_exit(POSTPROCESS_SUCCESS, |
|||
failed and (markbad and ExitReason.MARKED_BAD_SUCCESS or ExitReason.FAIL_SUCCESS) or |
|||
ExitReason.PP_SUCCESS) |
|||
|
|||
Logger.log('Failed to post-process %s' % nzbname, Logger.ERROR) |
|||
sys.stdout.flush() |
|||
script_exit(POSTPROCESS_ERROR, |
|||
failed and (markbad and ExitReason.MARKED_BAD_ERROR or ExitReason.FAIL_ERROR) or |
|||
ExitReason.PP_ERROR) |
|||
|
|||
|
|||
# Execute main script function |
|||
main() |
|||
|
|||
script_exit(POSTPROCESS_NONE, ExitReason.NONE) |
After Width: | Height: | Size: 252 B |
@ -0,0 +1,19 @@ |
|||
<!DOCTYPE html> |
|||
<html> |
|||
<head></head> |
|||
<body> |
|||
<h1>Index of $basepath</h1> |
|||
<table border="1" cellpadding="5" cellspacing="0" class="whitelinks"> |
|||
<tr> |
|||
<th>Name</th> |
|||
</tr> |
|||
#for $file in $filelist |
|||
<tr> |
|||
<td><a href="$file">$file</a></td> |
|||
</tr> |
|||
#end for |
|||
</table> |
|||
<hr> |
|||
<em>Tornado Server for SickGear</em> |
|||
</body> |
|||
</html> |
@ -0,0 +1,27 @@ |
|||
## |
|||
#from sickbeard import WEB_PORT, WEB_ROOT, ENABLE_HTTPS |
|||
#set sg_host = $getVar('sbHost', 'localhost') |
|||
#set sg_port = str($getVar('sbHttpPort', WEB_PORT)) |
|||
#set sg_root = $getVar('sbRoot', WEB_ROOT) |
|||
#set sg_use_https = $getVar('sbHttpsEnabled', ENABLE_HTTPS) |
|||
## |
|||
#set $base_url = 'http%s://%s:%s%s' % (('', 's')[any([sg_use_https])], $sg_host, $sg_port, $sg_root) |
|||
## |
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
|||
<addon id="repository.sickgear" name="SickGear Add-on repository" version="1.0.0" provider-name="SickGear"> |
|||
<extension point="xbmc.addon.repository" |
|||
name="SickGear Add-on Repository"> |
|||
<info compressed="true">$base_url/kodi/addons.xml</info> |
|||
<checksum>$base_url/kodi/addons.xml.md5</checksum> |
|||
<datadir zip="true">$base_url/kodi</datadir> |
|||
<hashes>false</hashes> |
|||
</extension> |
|||
<extension point="xbmc.addon.metadata"> |
|||
<summary>Install Add-ons for SickGear</summary> |
|||
<description>Download and install add-ons from a repository at a running SickGear instance.[CR][CR]Contains:[CR]* Watchedstate updater service</description> |
|||
<disclaimer></disclaimer> |
|||
<platform>all</platform> |
|||
<website>https://github.com/SickGear/SickGear</website> |
|||
<nofanart>true</nofanart> |
|||
</extension> |
|||
</addon> |
@ -0,0 +1,5 @@ |
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
|||
<addons> |
|||
$watchedstate_updater_addon_xml |
|||
$repo_xml |
|||
</addons> |
@ -0,0 +1,236 @@ |
|||
/** @namespace $.SickGear.Root */ |
|||
/** @namespace $.SickGear.history.isCompact */ |
|||
/** @namespace $.SickGear.history.isTrashit */ |
|||
/** @namespace $.SickGear.history.useSubtitles */ |
|||
/** @namespace $.SickGear.history.layoutName */ |
|||
/* |
|||
2017 Jason Mulligan <jason.mulligan@avoidwork.com> |
|||
@version 3.5.11 |
|||
*/ |
|||
!function(i){function e(i){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=[],d=0,r=void 0,a=void 0,s=void 0,f=void 0,u=void 0,l=void 0,v=void 0,B=void 0,c=void 0,p=void 0,y=void 0,m=void 0,x=void 0,g=void 0;if(isNaN(i))throw new Error("Invalid arguments");return s=!0===e.bits,y=!0===e.unix,a=e.base||2,p=void 0!==e.round?e.round:y?1:2,m=void 0!==e.spacer?e.spacer:y?"":" ",g=e.symbols||e.suffixes||{},x=2===a?e.standard||"jedec":"jedec",c=e.output||"string",u=!0===e.fullform,l=e.fullforms instanceof Array?e.fullforms:[],r=void 0!==e.exponent?e.exponent:-1,B=Number(i),v=B<0,f=a>2?1e3:1024,v&&(B=-B),(-1===r||isNaN(r))&&(r=Math.floor(Math.log(B)/Math.log(f)))<0&&(r=0),r>8&&(r=8),0===B?(n[0]=0,n[1]=y?"":t[x][s?"bits":"bytes"][r]):(d=B/(2===a?Math.pow(2,10*r):Math.pow(1e3,r)),s&&(d*=8)>=f&&r<8&&(d/=f,r++),n[0]=Number(d.toFixed(r>0?p:0)),n[1]=10===a&&1===r?s?"kb":"kB":t[x][s?"bits":"bytes"][r],y&&(n[1]="jedec"===x?n[1].charAt(0):r>0?n[1].replace(/B$/,""):n[1],o.test(n[1])&&(n[0]=Math.floor(n[0]),n[1]=""))),v&&(n[0]=-n[0]),n[1]=g[n[1]]||n[1],"array"===c?n:"exponent"===c?r:"object"===c?{value:n[0],suffix:n[1],symbol:n[1]}:(u&&(n[1]=l[r]?l[r]:b[x][r]+(s?"bit":"byte")+(1===n[0]?"":"s")),n.join(m))}var o=/^(b|B)$/,t={iec:{bits:["b","Kib","Mib","Gib","Tib","Pib","Eib","Zib","Yib"],bytes:["B","KiB","MiB","GiB","TiB","PiB","EiB","ZiB","YiB"]},jedec:{bits:["b","Kb","Mb","Gb","Tb","Pb","Eb","Zb","Yb"],bytes:["B","KB","MB","GB","TB","PB","EB","ZB","YB"]}},b={iec:["","kibi","mebi","gibi","tebi","pebi","exbi","zebi","yobi"],jedec:["","kilo","mega","giga","tera","peta","exa","zetta","yotta"]};e.partial=function(i){return function(o){return e(o,i)}},"undefined"!=typeof exports?module.exports=e:"function"==typeof define&&define.amd?define(function(){return e}):i.filesize=e}("undefined"!=typeof window?window:global); |
|||
|
|||
function rowCount(){ |
|||
var output$ = $('#row-count'); |
|||
if(!output$.length) |
|||
return; |
|||
|
|||
var tbody$ = $('#tbody'), |
|||
nRows = tbody$.find('tr').length, |
|||
compacted = tbody$.find('tr.hide').length, |
|||
compactedFiltered = tbody$.find('tr.filtered.hide').length, |
|||
filtered = tbody$.find('tr.filtered').length; |
|||
output$.text((filtered |
|||
? nRows - (filtered + compacted - compactedFiltered) + ' / ' + nRows + ' filtered' |
|||
: nRows) + (1 === nRows ? ' row' : ' rows')); |
|||
} |
|||
|
|||
$(document).ready(function() { |
|||
|
|||
var extraction = {0: function(node) { |
|||
var dataSort = $(node).find('div[data-sort]').attr('data-sort') |
|||
|| $(node).find('span[data-sort]').attr('data-sort'); |
|||
return !dataSort ? dataSort : dataSort.toLowerCase();}}, |
|||
tbody$ = $('#tbody'), |
|||
headers = {}, |
|||
layoutName = '' + $.SickGear.history.layoutName; |
|||
|
|||
if ('detailed' === layoutName) { |
|||
|
|||
jQuery.extend(extraction, { |
|||
4: function (node) { |
|||
return $(node).find('span').text().toLowerCase(); |
|||
} |
|||
}); |
|||
|
|||
jQuery.extend(headers, {4: {sorter: 'quality'}}); |
|||
|
|||
} else if ('compact' === layoutName) { |
|||
|
|||
jQuery.extend(extraction, { |
|||
1: function (node) { |
|||
return $(node).find('span[data-sort]').attr('data-sort').toLowerCase(); |
|||
}, |
|||
2: function (node) { |
|||
return $(node).attr('provider').toLowerCase(); |
|||
}, |
|||
5: function (node) { |
|||
return $(node).attr('quality').toLowerCase(); |
|||
} |
|||
}); |
|||
|
|||
var disable = {sorter: !1}, qualSort = {sorter: 'quality'}; |
|||
jQuery.extend(headers, $.SickGear.history.useSubtitles ? {4: disable, 5: qualSort} : {3: disable, 4: qualSort}); |
|||
|
|||
} else if (-1 !== layoutName.indexOf('watched')) { |
|||
|
|||
jQuery.extend(extraction, { |
|||
3: function(node) { |
|||
return $(node).find('span[data-sort]').attr('data-sort'); |
|||
}, |
|||
5: function(node) { |
|||
return $(node).find('span[data-sort]').attr('data-sort'); |
|||
}, |
|||
6: function (node) { |
|||
return $(node).find('input:checked').length; |
|||
} |
|||
}); |
|||
|
|||
jQuery.extend(headers, {4: {sorter: 'quality'}}); |
|||
|
|||
rowCount(); |
|||
} else if (-1 !== layoutName.indexOf('compact_stats')) { |
|||
jQuery.extend(extraction, { |
|||
3: function (node) { |
|||
return $(node).find('div[data-sort]').attr('data-sort'); |
|||
} |
|||
}); |
|||
|
|||
} |
|||
|
|||
var isWatched = -1 !== $('select[name="HistoryLayout"]').val().indexOf('watched'), |
|||
options = { |
|||
widgets: ['zebra', 'filter'], |
|||
widgetOptions : { |
|||
filter_hideEmpty: !0, filter_matchType : {'input': 'match', 'select': 'match'}, |
|||
filter_resetOnEsc: !0, filter_saveFilters: !0, filter_searchDelay: 300 |
|||
}, |
|||
sortList: isWatched ? [[1, 1], [0, 1]] : [0, 1], |
|||
textExtraction: extraction, |
|||
headers: headers}, |
|||
stateLayoutDate = function(table$, glyph$){table$.toggleClass('event-age');glyph$.toggleClass('age date');}; |
|||
|
|||
if(isWatched){ |
|||
jQuery.extend(options, { |
|||
selectorSort: '.tablesorter-header-inside', |
|||
headerTemplate: '<div class="tablesorter-header-inside" style="margin:0 -8px 0 -4px">{content}{icon}</div>', |
|||
onRenderTemplate: function(index, template){ |
|||
if(0 === index){ |
|||
template = '<i id="watched-date" class="icon-glyph date add-qtip" title="Change date layout" style="float:left;margin:4px -14px 0 2px"></i>' |
|||
+ template; |
|||
} |
|||
return template; |
|||
}, |
|||
onRenderHeader: function(){ |
|||
var table$ = $('#history-table'), glyph$ = $('#watched-date'); |
|||
if($.tablesorter.storage(table$, 'isLayoutAge')){ |
|||
stateLayoutDate(table$, glyph$); |
|||
} |
|||
$(this).find('#watched-date').on('click', function(){ |
|||
stateLayoutDate(table$, glyph$); |
|||
$.tablesorter.storage(table$, 'isLayoutAge', table$.hasClass('event-age')); |
|||
return !1; |
|||
}); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
$('#history-table').tablesorter(options).bind('filterEnd', function(){ |
|||
rowCount(); |
|||
}); |
|||
|
|||
$('#limit').change(function(){ |
|||
window.location.href = $.SickGear.Root + '/history/?limit=' + $(this).val() |
|||
}); |
|||
|
|||
$('#show-watched-help').click(function () { |
|||
$('#watched-help').fadeToggle('fast', 'linear'); |
|||
$.get($.SickGear.Root + '/history/toggle_help'); |
|||
}); |
|||
|
|||
var addQTip = (function(){ |
|||
$(this).css('cursor', 'help'); |
|||
$(this).qtip({ |
|||
show: {solo:true}, |
|||
position: {viewport:$(window), my:'left center', adjust:{y: -10, x: 2}}, |
|||
style: {tip: {corner:true, method:'polygon'}, classes:'qtip-dark qtip-rounded qtip-shadow'} |
|||
}); |
|||
}); |
|||
$('.add-qtip').each(addQTip); |
|||
|
|||
$.SickGear.sumChecked = (function(){ |
|||
var dedupe = [], sum = 0, output; |
|||
|
|||
$('.del-check:checked').each(function(){ |
|||
if ($(this).closest('tr').find('.tvShow .strike-deleted').length) |
|||
return; |
|||
var pathFile = $(this).closest('tr').attr('data-file'); |
|||
if (-1 === jQuery.inArray(pathFile, dedupe)) { |
|||
dedupe.push(pathFile); |
|||
output = $(this).closest('td').prev('td.size').find('span[data-sort]').attr('data-sort'); |
|||
sum = sum + parseInt(output, 10); |
|||
} |
|||
}); |
|||
$('#del-watched').attr('disabled', !dedupe.length && !$('#tbody').find('tr').find('.tvShow .strike-deleted').length); |
|||
|
|||
output = filesize(sum, {symbols: {B: 'Bytes'}}); |
|||
$('#sum-size').text(/\s(MB)$/.test(output) ? filesize(sum, {round:1}) |
|||
: /^1\sB/.test(output) ? output.replace('Bytes', 'Byte') : output); |
|||
}); |
|||
$.SickGear.sumChecked(); |
|||
|
|||
var className='.del-check', lastCheck = null, check, found; |
|||
tbody$.on('click', className, function(ev){ |
|||
if(!lastCheck || !ev.shiftKey){ |
|||
lastCheck = this; |
|||
} else { |
|||
check = this; found = 0; |
|||
$('#tbody').find('> tr:visible').find(className).each(function(){ |
|||
if (2 === found) |
|||
return !1; |
|||
if (1 === found) |
|||
this.checked = lastCheck.checked; |
|||
found += (1 && (this === check || this === lastCheck)); |
|||
}); |
|||
} |
|||
$(this).closest('table').trigger('update'); |
|||
$.SickGear.sumChecked(); |
|||
}); |
|||
|
|||
$('.shows-less').click(function(){ |
|||
var table$ = $(this).nextAll('table:first'); |
|||
table$ = table$.length ? table$ : $(this).parent().nextAll('table:first'); |
|||
table$.hide(); |
|||
$(this).hide(); |
|||
$(this).prevAll('input:first').show(); |
|||
}); |
|||
$('.shows-more').click(function(){ |
|||
var table$ = $(this).nextAll('table:first'); |
|||
table$ = table$.length ? table$ : $(this).parent().nextAll('table:first'); |
|||
table$.show(); |
|||
$(this).hide(); |
|||
$(this).nextAll('input:first').show(); |
|||
}); |
|||
|
|||
$('.provider-retry').click(function () { |
|||
$(this).addClass('disabled'); |
|||
var match = $(this).attr('id').match(/^(.+)-btn-retry$/); |
|||
$.ajax({ |
|||
url: $.SickGear.Root + '/manage/manageSearches/retryProvider?provider=' + match[1], |
|||
type: 'GET', |
|||
complete: function () { |
|||
window.location.reload(true); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
$('.provider-failures').tablesorter({widgets : ['zebra'], |
|||
headers : { 0:{sorter:!1}, 1:{sorter:!1}, 2:{sorter:!1}, 3:{sorter:!1}, 4:{sorter:!1}, 5:{sorter:!1} } |
|||
}); |
|||
|
|||
$('.provider-fail-parent-toggle').click(function(){ |
|||
$(this).closest('tr').nextUntil('tr:not(.tablesorter-childRow)').find('td').toggle(); |
|||
return !1; |
|||
}); |
|||
|
|||
// Make table cell focusable
|
|||
// http://css-tricks.com/simple-css-row-column-highlighting/
|
|||
var focus$ = $('.focus-highlight'); |
|||
if (focus$.length){ |
|||
focus$.find('td, th') |
|||
.attr('tabindex', '1') |
|||
// add touch device support
|
|||
.on('touchstart', function(){ |
|||
$(this).focus(); |
|||
}); |
|||
} |
|||
}); |
@ -0,0 +1 @@ |
|||
from plex import * |
@ -0,0 +1,423 @@ |
|||
# -*- 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/>. |
|||
|
|||
from time import sleep |
|||
|
|||
import datetime |
|||
import math |
|||
import os |
|||
import platform |
|||
import re |
|||
import sys |
|||
|
|||
try: |
|||
from urllib import urlencode # Python2 |
|||
except ImportError: |
|||
import urllib |
|||
from urllib.parse import urlencode # Python3 |
|||
|
|||
try: |
|||
import urllib.request as urllib2 |
|||
except ImportError: |
|||
import urllib2 |
|||
|
|||
from sickbeard import logger |
|||
from sickbeard.helpers import getURL, tryInt |
|||
|
|||
try: |
|||
from lxml import etree |
|||
except ImportError: |
|||
try: |
|||
import xml.etree.cElementTree as etree |
|||
except ImportError: |
|||
import xml.etree.ElementTree as etree |
|||
|
|||
|
|||
class Plex: |
|||
def __init__(self, settings=None): |
|||
|
|||
settings = settings or {} |
|||
self._plex_host = settings.get('plex_host') or '127.0.0.1' |
|||
self.plex_port = settings.get('plex_port') or '32400' |
|||
|
|||
self.username = settings.get('username', '') |
|||
self.password = settings.get('password', '') |
|||
self.token = settings.get('token', '') |
|||
|
|||
self.device_name = settings.get('device_name', '') |
|||
self.client_id = settings.get('client_id') or '5369636B47656172' |
|||
self.machine_client_identifier = '' |
|||
|
|||
self.default_home_users = settings.get('default_home_users', '') |
|||
|
|||
# Progress percentage to consider video as watched |
|||
# if set to anything > 0, videos with watch progress greater than this will be considered watched |
|||
self.default_progress_as_watched = settings.get('default_progress_as_watched', 0) |
|||
|
|||
# Sections to scan. If empty all sections will be looked at, |
|||
# the section id should be used which is the number found be in the url on PlexWeb after /section/[ID] |
|||
self.section_list = settings.get('section_list', []) |
|||
|
|||
# Sections to skip scanning, for use when Settings['section_list'] is not specified, |
|||
# the same as section_list, the section id should be used |
|||
self.ignore_sections = settings.get('ignore_sections', []) |
|||
|
|||
# Filter sections by paths that are in this array |
|||
self.section_filter_path = settings.get('section_filter_path', []) |
|||
|
|||
# Results |
|||
self.show_states = {} |
|||
self.file_count = 0 |
|||
|
|||
# Conf |
|||
self.config_version = 2.0 |
|||
self.use_logger = False |
|||
self.test = None |
|||
self.home_user_tokens = {} |
|||
|
|||
if self.username and '' == self.token: |
|||
self.token = self.get_token(self.username, self.password) |
|||
|
|||
@property |
|||
def plex_host(self): |
|||
|
|||
if not self._plex_host.startswith('http'): |
|||
return 'http://%s' % self.plex_host |
|||
return self._plex_host |
|||
|
|||
@plex_host.setter |
|||
def plex_host(self, value): |
|||
|
|||
self._plex_host = value |
|||
|
|||
def log(self, msg, debug=True): |
|||
|
|||
try: |
|||
if self.use_logger: |
|||
msg = 'Plex:: ' + msg |
|||
if debug: |
|||
logger.log(msg, logger.DEBUG) |
|||
else: |
|||
logger.log(msg) |
|||
# else: |
|||
# print(msg.encode('ascii', 'replace').decode()) |
|||
except (StandardError, Exception): |
|||
pass |
|||
|
|||
def get_token(self, user, passw): |
|||
|
|||
auth = '' |
|||
try: |
|||
auth = getURL('https://plex.tv/users/sign_in.json', |
|||
headers={'X-Plex-Device-Name': 'SickGear', |
|||
'X-Plex-Platform': platform.system(), 'X-Plex-Device': platform.system(), |
|||
'X-Plex-Platform-Version': platform.release(), |
|||
'X-Plex-Provides': 'Python', 'X-Plex-Product': 'Python', |
|||
'X-Plex-Client-Identifier': self.client_id, |
|||
'X-Plex-Version': str(self.config_version), |
|||
'X-Plex-Username': user |
|||
}, |
|||
json=True, |
|||
data=urlencode({b'user[login]': user, b'user[password]': passw}).encode('utf-8') |
|||
)['user']['authentication_token'] |
|||
except IndexError: |
|||
self.log('Error getting Plex Token') |
|||
|
|||
return auth |
|||
|
|||
def get_access_token(self, token): |
|||
|
|||
resources = self.get_url_x('https://plex.tv/api/resources?includeHttps=1', token=token) |
|||
if None is resources: |
|||
return '' |
|||
|
|||
devices = resources.findall('Device') |
|||
for device in devices: |
|||
if 1 == len(devices) \ |
|||
or self.machine_client_identifier == device.get('clientIdentifier') \ |
|||
or (self.device_name |
|||
and (self.device_name.lower() in device.get('name').lower() |
|||
or self.device_name.lower() in device.get('clientIdentifier').lower()) |
|||
): |
|||
access_token = device.get('accessToken') |
|||
if not access_token: |
|||
return '' |
|||
return access_token |
|||
|
|||
connections = device.findall('Connection') |
|||
for connection in connections: |
|||
if self.plex_host == connection.get('address'): |
|||
access_token = device.get('accessToken') |
|||
if not access_token: |
|||
return '' |
|||
uri = connection.get('uri') |
|||
match = re.compile('(http[s]?://.*?):(\d*)').match(uri) |
|||
if match: |
|||
self.plex_host = match.group(1) |
|||
self.plex_port = match.group(2) |
|||
return access_token |
|||
return '' |
|||
|
|||
def get_plex_home_user_tokens(self): |
|||
|
|||
user_tokens = {} |
|||
|
|||
# check Plex is contactable |
|||
home_users = self.get_url_x('https://plex.tv/api/home/users') |
|||
if None is not home_users: |
|||
for user in home_users.findall('User'): |
|||
user_id = user.get('id') |
|||
# use empty byte data to force POST |
|||
switch_page = self.get_url_x('https://plex.tv/api/home/users/%s/switch' % user_id, data=b'') |
|||
if None is not switch_page: |
|||
home_token = 'user' == switch_page.tag and switch_page.get('authenticationToken') |
|||
if home_token: |
|||
username = switch_page.get('title') |
|||
user_tokens[username] = self.get_access_token(home_token) |
|||
return user_tokens |
|||
|
|||
def get_url_x(self, url, token=None, **kwargs): |
|||
|
|||
if not token: |
|||
token = self.token |
|||
if not url.startswith('http'): |
|||
url = 'http://' + url |
|||
|
|||
for x in range(0, 3): |
|||
if 0 < x: |
|||
sleep(0.5) |
|||
try: |
|||
headers = {'X-Plex-Device-Name': 'SickGear', |
|||
'X-Plex-Platform': platform.system(), 'X-Plex-Device': platform.system(), |
|||
'X-Plex-Platform-Version': platform.release(), |
|||
'X-Plex-Provides': 'controller', 'X-Plex-Product': 'Python', |
|||
'X-Plex-Client-Identifier': self.client_id, |
|||
'X-Plex-Version': str(self.config_version), |
|||
'X-Plex-Token': token, |
|||
'Accept': 'application/xml' |
|||
} |
|||
if self.username: |
|||
headers.update({'X-Plex-Username': self.username}) |
|||
page = getURL(url, headers=headers, **kwargs) |
|||
if page: |
|||
parsed = etree.fromstring(page) |
|||
if None is not parsed and len(parsed): |
|||
return parsed |
|||
return None |
|||
|
|||
except Exception as e: |
|||
self.log('Error requesting page: %s' % e) |
|||
continue |
|||
return None |
|||
|
|||
# uses the Plex API to delete files instead of system functions, useful for remote installations |
|||
def delete_file(self, media_id=0): |
|||
|
|||
try: |
|||
endpoint = ('/library/metadata/%s' % str(media_id)) |
|||
req = urllib2.Request('%s:%s%s' % (self.plex_host, self.plex_port, endpoint), |
|||
None, {'X-Plex-Token': self.token}) |
|||
req.get_method = lambda: 'DELETE' |
|||
urllib2.urlopen(req) |
|||
except (StandardError, Exception): |
|||
return False |
|||
return True |
|||
|
|||
@staticmethod |
|||
def get_media_info(video_node): |
|||
|
|||
progress = 0 |
|||
if None is not video_node.get('viewOffset') and None is not video_node.get('duration'): |
|||
progress = tryInt(video_node.get('viewOffset')) * 100 / tryInt(video_node.get('duration')) |
|||
|
|||
for media in video_node.findall('Media'): |
|||
for part in media.findall('Part'): |
|||
file_name = part.get('file') |
|||
# if '3' > sys.version: # remove HTML quoted characters, only works in python < 3 |
|||
# file_name = urllib2.unquote(file_name.encode('utf-8', errors='replace')) |
|||
# else: |
|||
file_name = urllib2.unquote(file_name) |
|||
|
|||
return {'path_file': file_name, 'media_id': video_node.get('ratingKey'), |
|||
'played': int(video_node.get('viewCount') or 0), 'progress': progress} |
|||
|
|||
def check_users_watched(self, users, media_id): |
|||
|
|||
if not self.home_user_tokens: |
|||
self.home_user_tokens = self.get_plex_home_user_tokens() |
|||
|
|||
result = {} |
|||
if 'all' in users: |
|||
users = self.home_user_tokens.keys() |
|||
|
|||
for user in users: |
|||
user_media_page = self.get_url_pms('/library/metadata/%s' % media_id, token=self.home_user_tokens[user]) |
|||
if None is not user_media_page: |
|||
video_node = user_media_page.find('Video') |
|||
|
|||
progress = 0 |
|||
if None is not video_node.get('viewOffset') and None is not video_node.get('duration'): |
|||
progress = tryInt(video_node.get('viewOffset')) * 100 / tryInt(video_node.get('duration')) |
|||
|
|||
played = int(video_node.get('viewCount') or 0) |
|||
if not progress and not played: |
|||
continue |
|||
|
|||
date_watched = 0 |
|||
if (0 < tryInt(video_node.get('viewCount'))) or (0 < self.default_progress_as_watched < progress): |
|||
last_viewed_at = video_node.get('lastViewedAt') |
|||
if last_viewed_at and last_viewed_at not in ('', '0'): |
|||
date_watched = last_viewed_at |
|||
|
|||
if date_watched: |
|||
result[user] = dict(played=played, progress=progress, date_watched=date_watched) |
|||
else: |
|||
self.log('Do not have the token for %s.' % user) |
|||
|
|||
return result |
|||
|
|||
def get_url_pms(self, endpoint=None, **kwargs): |
|||
|
|||
return endpoint and self.get_url_x( |
|||
'%s:%s%s' % (self.plex_host, self.plex_port, endpoint), **kwargs) |
|||
|
|||
# parse episode information from season pages |
|||
def stat_show(self, node): |
|||
|
|||
episodes = [] |
|||
if 'directory' == node.tag.lower() and 'show' == node.get('type'): |
|||
show = self.get_url_pms(node.get('key')) |
|||
if None is show: # Check if show page is None or empty |
|||
self.log('Failed to load show page. Skipping...') |
|||
return None |
|||
|
|||
for season_node in show.findall('Directory'): # Each directory is a season |
|||
if 'season' != season_node.get('type'): # skips Specials |
|||
continue |
|||
|
|||
season_key = season_node.get('key') |
|||
season = self.get_url_pms(season_key) |
|||
if None is not season: |
|||
episodes += [season] |
|||
|
|||
elif 'mediacontainer' == node.tag.lower() and 'episode' == node.get('viewGroup'): |
|||
episodes = [node] |
|||
|
|||
check_users = [] |
|||
if self.default_home_users: |
|||
check_users = self.default_home_users.strip(' ,').lower().split(',') |
|||
for k in range(0, len(check_users)): # Remove extra spaces and commas |
|||
check_users[k] = check_users[k].strip(', ') |
|||
|
|||
for episode_node in episodes: |
|||
for video_node in episode_node.findall('Video'): |
|||
|
|||
media_info = self.get_media_info(video_node) |
|||
|
|||
if check_users: |
|||
user_info = self.check_users_watched(check_users, media_info['media_id']) |
|||
for user_name, user_media_info in user_info.items(): |
|||
self.show_states.update({len(self.show_states): dict( |
|||
path_file=media_info['path_file'], |
|||
media_id=media_info['media_id'], |
|||
played=(100 * user_media_info['played']) or user_media_info['progress'] or 0, |
|||
label=user_name, |
|||
date_watched=user_media_info['date_watched'])}) |
|||
else: |
|||
self.show_states.update({len(self.show_states): dict( |
|||
path_file=media_info['path_file'], |
|||
media_id=media_info['media_id'], |
|||
played=(100 * media_info['played']) or media_info['progress'] or 0, |
|||
label=self.username, |
|||
date_watched=video_node.get('lastViewedAt'))}) |
|||
|
|||
self.file_count += 1 |
|||
|
|||
return True |
|||
|
|||
def fetch_show_states(self, fetch_all=False): |
|||
|
|||
error_log = [] |
|||
self.show_states = {} |
|||
|
|||
server_check = self.get_url_pms('/') |
|||
if None is server_check or 'MediaContainer' != server_check.tag: |
|||
error_log.append('Cannot reach server!') |
|||
|
|||
else: |
|||
if not self.device_name: |
|||
self.device_name = server_check.get('friendlyName') |
|||
|
|||
if not self.machine_client_identifier: |
|||
self.machine_client_identifier = server_check.get('machineIdentifier') |
|||
|
|||
access_token = None |
|||
if self.token: |
|||
access_token = self.get_access_token(self.token) |
|||
if access_token: |
|||
self.token = access_token |
|||
if not self.home_user_tokens: |
|||
self.home_user_tokens = self.get_plex_home_user_tokens() |
|||
else: |
|||
error_log.append('Access Token not found') |
|||
|
|||
resp_sections = None |
|||
if None is access_token or len(access_token): |
|||
resp_sections = self.get_url_pms('/library/sections/') |
|||
|
|||
if None is not resp_sections: |
|||
|
|||
unpather = [] |
|||
for loc in self.section_filter_path: |
|||
loc = re.sub(r'[/\\]+', '/', loc.lower()) |
|||
loc = re.sub(r'^(.{,2})[/\\]', '', loc) |
|||
unpather.append(loc) |
|||
self.section_filter_path = unpather |
|||
|
|||
for section in resp_sections.findall('Directory'): |
|||
if 'show' != section.get('type') or not section.findall('Location'): |
|||
continue |
|||
|
|||
section_path = re.sub(r'[/\\]+', '/', section.find('Location').get('path').lower()) |
|||
section_path = re.sub(r'^(.{,2})[/\\]', '', section_path) |
|||
if not any([section_path in path for path in self.section_filter_path]): |
|||
continue |
|||
|
|||
if section.get('key') not in self.ignore_sections \ |
|||
and section.get('title') not in self.ignore_sections: |
|||
section_key = section.get('key') |
|||
|
|||
for (user, token) in (self.home_user_tokens or {'': None}).iteritems(): |
|||
self.username = user |
|||
|
|||
resp_section = self.get_url_pms('/library/sections/%s/%s' % ( |
|||
section_key, ('recentlyViewed', 'all')[fetch_all]), token=token) |
|||
if None is not resp_section: |
|||
view_group = 'MediaContainer' == resp_section.tag and \ |
|||
resp_section.get('viewGroup') or '' |
|||
if 'show' == view_group and fetch_all: |
|||
for DirectoryNode in resp_section.findall('Directory'): |
|||
self.stat_show(DirectoryNode) |
|||
elif 'episode' == view_group and not fetch_all: |
|||
self.stat_show(resp_section) |
|||
|
|||
if 0 < len(error_log): |
|||
self.log('Library errors...') |
|||
for item in error_log: |
|||
self.log(item) |
|||
|
|||
return 0 < len(error_log) |
After Width: | Height: | Size: 58 KiB |
@ -0,0 +1,22 @@ |
|||
# /tests/_devenv.py |
|||
# |
|||
# To trigger dev env |
|||
# |
|||
# import _devenv as devenv |
|||
# |
|||
|
|||
__remotedebug__ = True |
|||
|
|||
if __remotedebug__: |
|||
import sys |
|||
sys.path.append('C:\Program Files\JetBrains\PyCharm 2017.2.1\debug-eggs\pycharm-debug.egg') |
|||
import pydevd |
|||
|
|||
|
|||
def setup_devenv(state): |
|||
pydevd.settrace('localhost', port=(65001, 65000)[bool(state)], stdoutToServer=True, stderrToServer=True, |
|||
suspend=False) |
|||
|
|||
|
|||
def stop(): |
|||
pydevd.stoptrace() |
@ -0,0 +1,35 @@ |
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
|||
<addon id="service.sickgear.watchedstate.updater" name="SickGear Watched State Updater" version="1.0.3" provider-name="SickGear"> |
|||
<requires> |
|||
<import addon="xbmc.python" version="2.19.0" /> |
|||
<import addon="xbmc.json" version="6.20.0" /> |
|||
<import addon="xbmc.addon" version="14.0.0" /> |
|||
</requires> |
|||
<extension point="xbmc.service" library="service.py" start="login" /> |
|||
<extension point="xbmc.python.pluginsource" library="service.py" > |
|||
<provides>executable</provides> |
|||
</extension> |
|||
<extension point="xbmc.addon.metadata"> |
|||
<summary lang="en">SickGear Watched State Updater</summary> |
|||
<description lang="en">This Add-on notifies SickGear when an episode watched state is changed in Kodi</description> |
|||
<platform>all</platform> |
|||
<language>en</language> |
|||
<disclaimer/> |
|||
<license/> |
|||
<forum/> |
|||
<website>https://github.com/sickgear/sickgear</website> |
|||
<email/> |
|||
<nofanart>true</nofanart> |
|||
<source>https://github.com/sickgear/sickgear</source> |
|||
<assets> |
|||
<icon>icon.png</icon> |
|||
</assets> |
|||
<news>[B]1.0.0[/B] (2017-10-04) |
|||
- Initial release |
|||
[B]1.0.2[/B] (2017-11-15) |
|||
- Devel release for an SG API change |
|||
[B]1.0.3[/B] (2018-02-28) |
|||
- Add episodeid to payload |
|||
</news> |
|||
</extension> |
|||
</addon> |
@ -0,0 +1,2 @@ |
|||
[B]1.0.0[/B] (2017-10-04) |
|||
- Initial release |
After Width: | Height: | Size: 311 KiB |
After Width: | Height: | Size: 29 KiB |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 34 KiB |
@ -0,0 +1,18 @@ |
|||
<?xml version="1.0" encoding="utf-8" standalone="yes"?> |
|||
<strings> |
|||
<string id="32000">General</string> |
|||
<string id="32011">Action Notifications</string> |
|||
<string id="32012">Error Notifications</string> |
|||
<string id="32021">Verbose Logs</string> |
|||
|
|||
<string id="32100">Servers</string> |
|||
<string id="32111">SickGear IP</string> |
|||
<string id="32112">SickGear Port</string> |
|||
<string id="32121">Kodi IP</string> |
|||
<string id="32122">Kodi JSON RPC Port</string> |
|||
|
|||
<string id="32500">The following required Kodi settings should already be enabled:</string> |
|||
<string id="32511">At "System / Service(s) settings / Control (aka Remote control)"</string> |
|||
<string id="32512">* Allow remote control from/by applications/programs on this system</string> |
|||
<string id="32513">* Allow remote control from/by applications/programs on other systems</string> |
|||
</strings> |
@ -0,0 +1,21 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<settings> |
|||
<category label="32000"> |
|||
<setting label="32011" type="bool" id="action_notification" default="true" /> |
|||
<setting label="32012" type="bool" id="error_notification" default="true" /> |
|||
<setting label="32021" type="bool" id="verbose_log" default="true" /> |
|||
|
|||
<setting label="32500" type="lsep" /> |
|||
<setting label="32511" type="lsep" /> |
|||
<setting label="32512" type="lsep" /> |
|||
<setting label="32513" type="lsep" /> |
|||
</category> |
|||
|
|||
<category label="32100"> |
|||
<setting label="32111" type="ipaddress" id="sickgear_ip" default="127.0.0.1" /> |
|||
<setting label="32112" type="number" id="sickgear_port" default="8081" /> |
|||
|
|||
<setting label="32121" type="ipaddress" id="kodi_ip" default="127.0.0.1" /> |
|||
<setting label="32122" type="number" id="kodi_port" default="9090" /> |
|||
</category> |
|||
</settings> |
@ -0,0 +1,361 @@ |
|||
# 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() |
@ -0,0 +1,153 @@ |
|||
# coding=utf-8 |
|||
# |
|||
# Author: SickGear |
|||
# |
|||
# 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/>. |
|||
|
|||
import re |
|||
import traceback |
|||
|
|||
from . import generic |
|||
from sickbeard import logger |
|||
from sickbeard.bs4_parser import BS4Parser |
|||
from sickbeard.helpers import tryInt, sanitizeSceneName |
|||
from lib.unidecode import unidecode |
|||
from six.moves.html_parser import HTMLParser |
|||
|
|||
|
|||
class ShowRSSProvider(generic.TorrentProvider): |
|||
|
|||
def __init__(self): |
|||
|
|||
generic.TorrentProvider.__init__(self, 'showRSS') |
|||
|
|||
self.url_base = 'https://showrss.info/' |
|||
self.urls = {'config_provider_home_uri': self.url_base, |
|||
'login_action': self.url_base + 'login', |
|||
'browse': self.url_base + 'browse/all', |
|||
'search': self.url_base + 'browse/%s'} |
|||
|
|||
self.url = self.urls['config_provider_home_uri'] |
|||
|
|||
self.username, self.password, self.shows = 3 * [None] |
|||
|
|||
def _authorised(self, **kwargs): |
|||
|
|||
return super(ShowRSSProvider, self)._authorised(logged_in=(lambda y=None: self.logged_in(y))) |
|||
|
|||
def logged_in(self, y): |
|||
if all([None is y or 'logout' in y, |
|||
bool(filter(lambda c: 'remember_web_' in c, self.session.cookies.keys()))]): |
|||
if None is not y: |
|||
self.shows = dict(re.findall('<option value="(\d+)">(.*?)</option>', y)) |
|||
h = HTMLParser() |
|||
for k, v in self.shows.items(): |
|||
self.shows[k] = sanitizeSceneName(h.unescape(unidecode(v.decode('utf-8')))) |
|||
return True |
|||
return False |
|||
|
|||
def _search_provider(self, search_params, **kwargs): |
|||
|
|||
results = [] |
|||
if not self._authorised(): |
|||
return results |
|||
|
|||
items = {'Cache': [], 'Season': [], 'Episode': [], 'Propers': []} |
|||
|
|||
rc = dict((k, re.compile('(?i)' + v)) for (k, v) in {'get': 'magnet'}.items()) |
|||
urls = [] |
|||
for mode in search_params.keys(): |
|||
for search_string in search_params[mode]: |
|||
if 'Cache' == mode: |
|||
search_url = self.urls['browse'] |
|||
else: |
|||
search_string = isinstance(search_string, unicode) and unidecode(search_string) or search_string |
|||
show_name = filter(lambda x: x.lower() == re.sub('\s.*', '', search_string.lower()), |
|||
self.shows.values()) |
|||
if not show_name: |
|||
continue |
|||
search_url = self.urls['search'] % self.shows.keys()[self.shows.values().index(show_name[0])] |
|||
|
|||
if search_url in urls: |
|||
continue |
|||
urls += [search_url] |
|||
|
|||
html = self.get_url(search_url) |
|||
if self.should_skip(): |
|||
return results |
|||
|
|||
cnt = len(items[mode]) |
|||
try: |
|||
if not html or self._has_no_results(html): |
|||
raise generic.HaltParseException |
|||
|
|||
with BS4Parser(html, features=['html5lib', 'permissive']) as soup: |
|||
torrent_rows = soup.select('ul.user-timeline > li') |
|||
|
|||
if not len(torrent_rows): |
|||
raise generic.HaltParseException |
|||
|
|||
for tr in torrent_rows: |
|||
try: |
|||
anchor = tr.find('a', href=rc['get']) |
|||
title = self.regulate_title(anchor) |
|||
download_url = self._link(anchor['href']) |
|||
except (AttributeError, TypeError, ValueError): |
|||
continue |
|||
|
|||
if title and download_url: |
|||
items[mode].append((title, download_url, None, None)) |
|||
|
|||
except generic.HaltParseException: |
|||
pass |
|||
except (StandardError, Exception): |
|||
logger.log(u'Failed to parse. Traceback: %s' % traceback.format_exc(), logger.ERROR) |
|||
self._log_search(mode, len(items[mode]) - cnt, search_url) |
|||
|
|||
results = self._sort_seeding(mode, results + items[mode]) |
|||
|
|||
return results |
|||
|
|||
@staticmethod |
|||
def regulate_title(anchor): |
|||
title = '' |
|||
t1 = anchor.attrs.get('title').strip() |
|||
t2 = anchor.get_text().strip() |
|||
diff, x, offset = 0, 0, 0 |
|||
for x, c in enumerate(t2): |
|||
if c.lower() == t1[x-offset].lower(): |
|||
title += t1[x-offset] |
|||
diff = 0 |
|||
elif ' ' != c and ' ' == t1[x-offset]: |
|||
title += c |
|||
diff = 0 |
|||
if ' ' == t2[x+1]: |
|||
offset += 1 |
|||
else: |
|||
diff += 1 |
|||
if 1 < diff: |
|||
break |
|||
return '%s%s' % (title, re.sub('(?i)(xvid|divx|[hx].?26[45])\s(\w+)$', r'\1-\2', |
|||
''.join(t1[x - (offset + diff)::]).strip())) |
|||
|
|||
@staticmethod |
|||
def ui_string(key): |
|||
|
|||
return ('showrss_tip' == key |
|||
and 'lists are not needed, the SickGear list is used as usual' or '') |
|||
|
|||
|
|||
provider = ShowRSSProvider() |
@ -0,0 +1,60 @@ |
|||
# |
|||
# 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/>. |
|||
|
|||
import threading |
|||
|
|||
import sickbeard |
|||
from sickbeard import watchedstate_queue |
|||
|
|||
|
|||
class WatchedStateUpdater(object): |
|||
def __init__(self, name, queue_item): |
|||
|
|||
self.amActive = False |
|||
self.lock = threading.Lock() |
|||
self.name = name |
|||
self.queue_item = queue_item |
|||
|
|||
@property |
|||
def prevent_run(self): |
|||
return sickbeard.watchedStateQueueScheduler.action.is_in_queue(self.queue_item) |
|||
|
|||
def run(self): |
|||
if self.is_enabled(): |
|||
self.amActive = True |
|||
new_item = self.queue_item() |
|||
sickbeard.watchedStateQueueScheduler.action.add_item(new_item) |
|||
self.amActive = False |
|||
|
|||
|
|||
class EmbyWatchedStateUpdater(WatchedStateUpdater): |
|||
|
|||
def __init__(self): |
|||
super(EmbyWatchedStateUpdater, self).__init__('Emby', watchedstate_queue.EmbyWatchedStateQueueItem) |
|||
|
|||
@staticmethod |
|||
def is_enabled(): |
|||
return sickbeard.USE_EMBY and sickbeard.EMBY_WATCHEDSTATE_SCHEDULED |
|||
|
|||
|
|||
class PlexWatchedStateUpdater(WatchedStateUpdater): |
|||
|
|||
def __init__(self): |
|||
super(PlexWatchedStateUpdater, self).__init__('Plex', watchedstate_queue.PlexWatchedStateQueueItem) |
|||
|
|||
@staticmethod |
|||
def is_enabled(): |
|||
return sickbeard.USE_PLEX and sickbeard.PLEX_WATCHEDSTATE_SCHEDULED |
@ -0,0 +1,83 @@ |
|||
# |
|||
# 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/>. |
|||
|
|||
from __future__ import with_statement |
|||
|
|||
from sickbeard import generic_queue, logger |
|||
from sickbeard.webserve import History |
|||
|
|||
EMBYWATCHEDSTATE = 10 |
|||
PLEXWATCHEDSTATE = 20 |
|||
|
|||
|
|||
class WatchedStateQueue(generic_queue.GenericQueue): |
|||
def __init__(self): |
|||
super(WatchedStateQueue, self).__init__() |
|||
# self.queue_name = 'WATCHEDSTATEQUEUE' |
|||
self.queue_name = 'Q' |
|||
|
|||
def is_in_queue(self, itemtype): |
|||
with self.lock: |
|||
for cur_item in self.queue + [self.currentItem]: |
|||
if isinstance(cur_item, itemtype): |
|||
return True |
|||
return False |
|||
|
|||
# method for possible UI usage, can be removed if not used |
|||
def queue_length(self): |
|||
length = {'emby': 0, 'plex': 0} |
|||
with self.lock: |
|||
for cur_item in [self.currentItem] + self.queue: |
|||
if isinstance(cur_item, EmbyWatchedStateQueueItem): |
|||
length['emby'] += 1 |
|||
elif isinstance(cur_item, PlexWatchedStateQueueItem): |
|||
length['plex'] += 1 |
|||
|
|||
return length |
|||
|
|||
def add_item(self, item): |
|||
if isinstance(item, EmbyWatchedStateQueueItem) and not self.is_in_queue(EmbyWatchedStateQueueItem): |
|||
# emby watched state item |
|||
generic_queue.GenericQueue.add_item(self, item) |
|||
elif isinstance(item, PlexWatchedStateQueueItem) and not self.is_in_queue(PlexWatchedStateQueueItem): |
|||
# plex watched state item |
|||
generic_queue.GenericQueue.add_item(self, item) |
|||
else: |
|||
logger.log(u'Not adding item, it\'s already in the queue', logger.DEBUG) |
|||
|
|||
|
|||
class EmbyWatchedStateQueueItem(generic_queue.QueueItem): |
|||
def __init__(self): |
|||
super(EmbyWatchedStateQueueItem, self).__init__('Emby Watched', EMBYWATCHEDSTATE) |
|||
|
|||
def run(self): |
|||
super(EmbyWatchedStateQueueItem, self).run() |
|||
try: |
|||
History.update_watched_state_emby() |
|||
finally: |
|||
self.finish() |
|||
|
|||
|
|||
class PlexWatchedStateQueueItem(generic_queue.QueueItem): |
|||
def __init__(self): |
|||
super(PlexWatchedStateQueueItem, self).__init__('Plex Watched', PLEXWATCHEDSTATE) |
|||
|
|||
def run(self): |
|||
super(PlexWatchedStateQueueItem, self).run() |
|||
try: |
|||
History.update_watched_state_plex() |
|||
finally: |
|||
self.finish() |