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