Add episode watched state system that integrates with Kodi, Plex, and/or Emby, instructions at Shows/History/Layout/"Watched". Add installable SickGear Kodi repository containing addon "SickGear Watched State Updater". Change add Emby setting for watched state scheduler at Config/Notifications/Emby/"Update watched interval". Change add Plex setting for watched state scheduler at Config/Notifications/Plex/"Update watched interval". Add API cmd=sg.updatewatchedstate, instructions for use are linked to in layout "Watched" at /history. Change history page table filter input values are saved across page refreshes. Change history page table filter inputs, accept values like "dvd or web" to only display both. Change history page table filter inputs, press 'ESC' key inside a filter input to reset it. Add provider activity stats to Shows/History/Layout/ drop down. Change move provider failures table from Manage/Media Search to Shows/History/Layout/Provider fails. Change sort provider failures by most recent failure, and with paused providers at the top. Change remove table form non-testing version 20007, that was reassigned.pull/1072/head
@ -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,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() |