From db543b8912b01c3b46d0167363ec84eee9b2fa53 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Thu, 4 Feb 2016 08:34:15 -0800 Subject: [PATCH 01/47] Add {machine_id} to notification options --- data/interfaces/default/settings.html | 4 ++++ plexpy/notification_handler.py | 1 + 2 files changed, 5 insertions(+) diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index bbcec5f6..af706cf1 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -1242,6 +1242,10 @@ available_notification_agents = sorted(notifiers.available_notification_agents() {user_id} The unique identifier for the user. + + {machine_id} + The unique identifier for the player. + diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py index 0fda82e1..7b5ec947 100644 --- a/plexpy/notification_handler.py +++ b/plexpy/notification_handler.py @@ -512,6 +512,7 @@ def build_notify_text(session=None, timeline=None, state=None): 'transcode_audio_channels': session.get('transcode_audio_channels',''), 'session_key': session.get('session_key',''), 'user_id': session.get('user_id',''), + 'machine_id': session.get('machine_id',''), # Metadata parameters 'media_type': metadata['media_type'], 'title': full_title, From a4dfc57cbe18d4e7e3fa5e58832bd85e7065e89e Mon Sep 17 00:00:00 2001 From: Tim Van Date: Sun, 7 Feb 2016 15:00:06 +0200 Subject: [PATCH 02/47] Fix some issues with possible mismatching serverIDs causing bad ssl connections. --- data/interfaces/default/settings.html | 4 +-- data/interfaces/default/welcome.html | 10 +++--- plexpy/config.py | 2 +- plexpy/plextv.py | 10 ++++++ plexpy/webserve.py | 46 ++++++++++++--------------- 5 files changed, 38 insertions(+), 34 deletions(-) diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index af706cf1..3e10c49c 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -355,9 +355,9 @@ available_notification_agents = sorted(notifiers.available_notification_agents()
-

Force PlexPy to connect to your Plex Server via SSL. Your server needs to have remote access enabled.

+

If you have secure connections enabled on your Plex Server, communicate with it securely.

diff --git a/data/interfaces/default/welcome.html b/data/interfaces/default/welcome.html index 5174ade1..8f2325a8 100644 --- a/data/interfaces/default/welcome.html +++ b/data/interfaces/default/welcome.html @@ -83,7 +83,7 @@ from plexpy import common
@@ -244,7 +244,7 @@ from plexpy import common }, render: { option: function (item, escape) { - return '
' + item.value + '
'; + return '
' + item.value + ' (' + item.label + ')
'; }, item: function (item, escape) { // first item is rendered before initialization bug? @@ -254,7 +254,7 @@ from plexpy import common .filter('[value="' + item.value + '"]').data()); } - return '
' + item.value + '
'; + return '
' + item.value + ' (' + item.label + ')
'; } }, onChange: function (item) { @@ -378,8 +378,8 @@ from plexpy import common var pms_ip = $("#pms_ip").val().trim(); var pms_port = $("#pms_port").val().trim(); var pms_identifier = $("#pms_identifier").val(); - var pms_ssl = $("#pms_ssl").val(); - var pms_is_remote = $("#pms_is_remote").val(); + var pms_ssl = $("#pms_ssl").is(':checked') ? 1 : 0; + var pms_is_remote = $("#pms_is_remote").is(':checked') ? 1 : 0; if ((pms_ip !== '') || (pms_port !== '')) { $("#pms-verify-status").html(' Validating server...'); $('#pms-verify-status').fadeIn('fast'); diff --git a/plexpy/config.py b/plexpy/config.py index 45eb1c79..984d57bc 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -25,7 +25,7 @@ _CONFIG_DEFINITIONS = { 'PMS_NAME': (unicode, 'PMS', ''), 'PMS_PORT': (int, 'PMS', 32400), 'PMS_TOKEN': (str, 'PMS', ''), - 'PMS_SSL': (int, 'General', 0), + 'PMS_SSL': (int, 'PMS', 0), 'PMS_URL': (str, 'PMS', ''), 'PMS_USE_BIF': (int, 'PMS', 0), 'PMS_UUID': (str, 'PMS', ''), diff --git a/plexpy/plextv.py b/plexpy/plextv.py index b3774342..df93548e 100644 --- a/plexpy/plextv.py +++ b/plexpy/plextv.py @@ -493,6 +493,16 @@ class PlexTV(object): connections = d.getElementsByTagName('Connection') for c in connections: + # If this is a remote server don't show any local IPs. + if helpers.get_xml_attr(d, 'publicAddressMatches') == '0' and \ + helpers.get_xml_attr(c, 'local') == '1': + continue + + # If this is a local server don't show any remote IPs. + if helpers.get_xml_attr(d, 'publicAddressMatches') == '1' and \ + helpers.get_xml_attr(c, 'local') == '0': + continue + server = {'httpsRequired': helpers.get_xml_attr(d, 'httpsRequired'), 'clientIdentifier': helpers.get_xml_attr(d, 'clientIdentifier'), 'label': helpers.get_xml_attr(d, 'name'), diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 831aaef0..a81bf203 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -1187,6 +1187,11 @@ class WebInterface(object): (kwargs['monitor_remote_access'] != plexpy.CONFIG.MONITOR_REMOTE_ACCESS): reschedule = True + # If we change the SSL setting for PMS, make sure we grab the new url. + if 'pms_ssl' in kwargs and \ + (kwargs['pms_ssl'] != plexpy.CONFIG.PMS_SSL): + server_changed = True + # Remove config with 'hscard-' prefix and change home_stats_cards to list if 'home_stats_cards' in kwargs: for k in kwargs.keys(): @@ -1397,33 +1402,22 @@ class WebInterface(object): def get_server_id(self, hostname=None, port=None, identifier=None, ssl=0, remote=0, **kwargs): from plexpy import http_handler - # Attempt to get the pms_identifier from plex.tv if the server is published - # Works for all PMS SSL settings + # Grab our serverId. + # This endpoint always works over plain HTTP even when secure connections on PMS is set to required. if not identifier and hostname and port: - plex_tv = plextv.PlexTV() - servers = plex_tv.discover() - - for server in servers: - if server['ip'] == hostname and server['port'] == port: - identifier = server['clientIdentifier'] - break - - # Fallback to checking /identity endpoint is server is unpublished - # Cannot set SSL settings on the PMS if unpublished so 'http' is okay - if not identifier: - request_handler = http_handler.HTTPHandler(host=hostname, - port=port, - token=None) - uri = '/identity' - request = request_handler.make_request(uri=uri, - proto='http', - request_type='GET', - output_format='xml', - no_token=True, - timeout=10) - if request: - xml_head = request.getElementsByTagName('MediaContainer')[0] - identifier = xml_head.getAttribute('machineIdentifier') + request_handler = http_handler.HTTPHandler(host=hostname, + port=port, + token=None) + uri = '/identity' + request = request_handler.make_request(uri=uri, + proto='http', + request_type='GET', + output_format='xml', + no_token=True, + timeout=10) + if request: + xml_head = request.getElementsByTagName('MediaContainer')[0] + identifier = xml_head.getAttribute('machineIdentifier') if identifier: cherrypy.response.headers['Content-type'] = 'application/json' From 98c1063e079a5068bdd48f785f058ad332abe626 Mon Sep 17 00:00:00 2001 From: Tim Van Date: Sun, 7 Feb 2016 22:21:46 +0200 Subject: [PATCH 03/47] Allows us to retrieve the serverId again if we have secure connections required. --- lib/IPy.py | 1652 ++++++++++++++++++++++++++++++++++++++++++++ plexpy/helpers.py | 28 +- plexpy/webserve.py | 44 +- 3 files changed, 1706 insertions(+), 18 deletions(-) create mode 100644 lib/IPy.py diff --git a/lib/IPy.py b/lib/IPy.py new file mode 100644 index 00000000..d1ea8457 --- /dev/null +++ b/lib/IPy.py @@ -0,0 +1,1652 @@ +""" +IPy - class and tools for handling of IPv4 and IPv6 addresses and networks. +See README file for learn how to use IPy. + +Further Information might be available at: +https://github.com/haypo/python-ipy +""" + +__version__ = '0.83' + +import bisect +import collections +import sys +import types + +# Definition of the Ranges for IPv4 IPs +# this should include www.iana.org/assignments/ipv4-address-space +# and www.iana.org/assignments/multicast-addresses +IPv4ranges = { + '0': 'PUBLIC', # fall back + '00000000': 'PRIVATE', # 0/8 + '00001010': 'PRIVATE', # 10/8 + '0110010001': 'CARRIER_GRADE_NAT', #100.64/10 + '01111111': 'PRIVATE', # 127.0/8 + '1': 'PUBLIC', # fall back + '1010100111111110': 'PRIVATE', # 169.254/16 + '101011000001': 'PRIVATE', # 172.16/12 + '1100000010101000': 'PRIVATE', # 192.168/16 + '111': 'RESERVED', # 224/3 + } + +# Definition of the Ranges for IPv6 IPs +# http://www.iana.org/assignments/ipv6-address-space/ +# http://www.iana.org/assignments/ipv6-unicast-address-assignments/ +# http://www.iana.org/assignments/ipv6-multicast-addresses/ +IPv6ranges = { + '00000000' : 'RESERVED', # ::/8 + '0' * 96 : 'RESERVED', # ::/96 Formerly IPV4COMP [RFC4291] + '0' * 128 : 'UNSPECIFIED', # ::/128 + '0' * 127 + '1' : 'LOOPBACK', # ::1/128 + '0' * 80 + '1' * 16 : 'IPV4MAP', # ::ffff:0:0/96 + '00000000011001001111111110011011' + '0' * 64 : 'WKP46TRANS', # 0064:ff9b::/96 Well-Known-Prefix [RFC6052] + '00000001' : 'UNASSIGNED', # 0100::/8 + '0000001' : 'RESERVED', # 0200::/7 Formerly NSAP [RFC4048] + '0000010' : 'RESERVED', # 0400::/7 Formerly IPX [RFC3513] + '0000011' : 'RESERVED', # 0600::/7 + '00001' : 'RESERVED', # 0800::/5 + '0001' : 'RESERVED', # 1000::/4 + '001' : 'GLOBAL-UNICAST', # 2000::/3 [RFC4291] + '00100000000000010000000' : 'SPECIALPURPOSE', # 2001::/23 [RFC4773] + '00100000000000010000000000000000' : 'TEREDO', # 2001::/32 [RFC4380] + '00100000000000010000000000000010' + '0' * 16 : 'BMWG', # 2001:0002::/48 Benchmarking [RFC5180] + '0010000000000001000000000001' : 'ORCHID', # 2001:0010::/28 (Temp until 2014-03-21) [RFC4843] + '00100000000000010000001' : 'ALLOCATED APNIC', # 2001:0200::/23 + '00100000000000010000010' : 'ALLOCATED ARIN', # 2001:0400::/23 + '00100000000000010000011' : 'ALLOCATED RIPE NCC', # 2001:0600::/23 + '00100000000000010000100' : 'ALLOCATED RIPE NCC', # 2001:0800::/23 + '00100000000000010000101' : 'ALLOCATED RIPE NCC', # 2001:0a00::/23 + '00100000000000010000110' : 'ALLOCATED APNIC', # 2001:0c00::/23 + '00100000000000010000110110111000' : 'DOCUMENTATION', # 2001:0db8::/32 [RFC3849] + '00100000000000010000111' : 'ALLOCATED APNIC', # 2001:0e00::/23 + '00100000000000010001001' : 'ALLOCATED LACNIC', # 2001:1200::/23 + '00100000000000010001010' : 'ALLOCATED RIPE NCC', # 2001:1400::/23 + '00100000000000010001011' : 'ALLOCATED RIPE NCC', # 2001:1600::/23 + '00100000000000010001100' : 'ALLOCATED ARIN', # 2001:1800::/23 + '00100000000000010001101' : 'ALLOCATED RIPE NCC', # 2001:1a00::/23 + '0010000000000001000111' : 'ALLOCATED RIPE NCC', # 2001:1c00::/22 + '00100000000000010010' : 'ALLOCATED RIPE NCC', # 2001:2000::/20 + '001000000000000100110' : 'ALLOCATED RIPE NCC', # 2001:3000::/21 + '0010000000000001001110' : 'ALLOCATED RIPE NCC', # 2001:3800::/22 + '0010000000000001001111' : 'RESERVED', # 2001:3c00::/22 Possible future allocation to RIPE NCC + '00100000000000010100000' : 'ALLOCATED RIPE NCC', # 2001:4000::/23 + '00100000000000010100001' : 'ALLOCATED AFRINIC', # 2001:4200::/23 + '00100000000000010100010' : 'ALLOCATED APNIC', # 2001:4400::/23 + '00100000000000010100011' : 'ALLOCATED RIPE NCC', # 2001:4600::/23 + '00100000000000010100100' : 'ALLOCATED ARIN', # 2001:4800::/23 + '00100000000000010100101' : 'ALLOCATED RIPE NCC', # 2001:4a00::/23 + '00100000000000010100110' : 'ALLOCATED RIPE NCC', # 2001:4c00::/23 + '00100000000000010101' : 'ALLOCATED RIPE NCC', # 2001:5000::/20 + '0010000000000001100' : 'ALLOCATED APNIC', # 2001:8000::/19 + '00100000000000011010' : 'ALLOCATED APNIC', # 2001:a000::/20 + '00100000000000011011' : 'ALLOCATED APNIC', # 2001:b000::/20 + '0010000000000010' : '6TO4', # 2002::/16 "6to4" [RFC3056] + '001000000000001100' : 'ALLOCATED RIPE NCC', # 2003::/18 + '001001000000' : 'ALLOCATED APNIC', # 2400::/12 + '001001100000' : 'ALLOCATED ARIN', # 2600::/12 + '00100110000100000000000' : 'ALLOCATED ARIN', # 2610::/23 + '00100110001000000000000' : 'ALLOCATED ARIN', # 2620::/23 + '001010000000' : 'ALLOCATED LACNIC', # 2800::/12 + '001010100000' : 'ALLOCATED RIPE NCC', # 2a00::/12 + '001011000000' : 'ALLOCATED AFRINIC', # 2c00::/12 + '00101101' : 'RESERVED', # 2d00::/8 + '0010111' : 'RESERVED', # 2e00::/7 + '0011' : 'RESERVED', # 3000::/4 + '010' : 'RESERVED', # 4000::/3 + '011' : 'RESERVED', # 6000::/3 + '100' : 'RESERVED', # 8000::/3 + '101' : 'RESERVED', # a000::/3 + '110' : 'RESERVED', # c000::/3 + '1110' : 'RESERVED', # e000::/4 + '11110' : 'RESERVED', # f000::/5 + '111110' : 'RESERVED', # f800::/6 + '1111110' : 'ULA', # fc00::/7 [RFC4193] + '111111100' : 'RESERVED', # fe00::/9 + '1111111010' : 'LINKLOCAL', # fe80::/10 + '1111111011' : 'RESERVED', # fec0::/10 Formerly SITELOCAL [RFC4291] + '11111111' : 'MULTICAST', # ff00::/8 + '1111111100000001' : 'NODE-LOCAL MULTICAST', # ff01::/16 + '1111111100000010' : 'LINK-LOCAL MULTICAST', # ff02::/16 + '1111111100000100' : 'ADMIN-LOCAL MULTICAST', # ff04::/16 + '1111111100000101' : 'SITE-LOCAL MULTICAST', # ff05::/16 + '1111111100001000' : 'ORG-LOCAL MULTICAST', # ff08::/16 + '1111111100001110' : 'GLOBAL MULTICAST', # ff0e::/16 + '1111111100001111' : 'RESERVED MULTICAST', # ff0f::/16 + '111111110011' : 'PREFIX-BASED MULTICAST', # ff30::/12 [RFC3306] + '111111110111' : 'RP-EMBEDDED MULTICAST', # ff70::/12 [RFC3956] + } + +MAX_IPV4_ADDRESS = 0xffffffff +MAX_IPV6_ADDRESS = 0xffffffffffffffffffffffffffffffff +IPV6_TEST_MAP = 0xffffffffffffffffffffffff00000000 +IPV6_MAP_MASK = 0x00000000000000000000ffff00000000 + +if sys.version_info >= (3,): + INT_TYPES = (int,) + STR_TYPES = (str,) + xrange = range +else: + INT_TYPES = (int, long) + STR_TYPES = (str, unicode) + + +class IPint(object): + """Handling of IP addresses returning integers. + + Use class IP instead because some features are not implemented for + IPint.""" + + def __init__(self, data, ipversion=0, make_net=0): + """Create an instance of an IP object. + + Data can be a network specification or a single IP. IP + addresses can be specified in all forms understood by + parseAddress(). The size of a network can be specified as + + /prefixlen a.b.c.0/24 2001:658:22a:cafe::/64 + -lastIP a.b.c.0-a.b.c.255 2001:658:22a:cafe::-2001:658:22a:cafe:ffff:ffff:ffff:ffff + /decimal netmask a.b.c.d/255.255.255.0 not supported for IPv6 + + If no size specification is given a size of 1 address (/32 for + IPv4 and /128 for IPv6) is assumed. + + If make_net is True, an IP address will be transformed into the network + address by applying the specified netmask. + + >>> print(IP('127.0.0.0/8')) + 127.0.0.0/8 + >>> print(IP('127.0.0.0/255.0.0.0')) + 127.0.0.0/8 + >>> print(IP('127.0.0.0-127.255.255.255')) + 127.0.0.0/8 + >>> print(IP('127.0.0.1/255.0.0.0', make_net=True)) + 127.0.0.0/8 + + See module documentation for more examples. + """ + + # Print no Prefixlen for /32 and /128 + self.NoPrefixForSingleIp = 1 + + # Do we want prefix printed by default? see _printPrefix() + self.WantPrefixLen = None + + netbits = 0 + prefixlen = -1 + + # handling of non string values in constructor + if isinstance(data, INT_TYPES): + self.ip = int(data) + if ipversion == 0: + if self.ip <= MAX_IPV4_ADDRESS: + ipversion = 4 + else: + ipversion = 6 + if ipversion == 4: + if self.ip > MAX_IPV4_ADDRESS: + raise ValueError("IPv4 Address can't be larger than %x: %x" % (MAX_IPV4_ADDRESS, self.ip)) + prefixlen = 32 + elif ipversion == 6: + if self.ip > MAX_IPV6_ADDRESS: + raise ValueError("IPv6 Address can't be larger than %x: %x" % (MAX_IPV6_ADDRESS, self.ip)) + prefixlen = 128 + else: + raise ValueError("only IPv4 and IPv6 supported") + self._ipversion = ipversion + self._prefixlen = prefixlen + # handle IP instance as an parameter + elif isinstance(data, IPint): + self._ipversion = data._ipversion + self._prefixlen = data._prefixlen + self.ip = data.ip + elif isinstance(data, STR_TYPES): + # TODO: refactor me! + # splitting of a string into IP and prefixlen et. al. + x = data.split('-') + if len(x) == 2: + # a.b.c.0-a.b.c.255 specification ? + (ip, last) = x + (self.ip, parsedVersion) = parseAddress(ip) + if parsedVersion != 4: + raise ValueError("first-last notation only allowed for IPv4") + (last, lastversion) = parseAddress(last) + if lastversion != 4: + raise ValueError("last address should be IPv4, too") + if last < self.ip: + raise ValueError("last address should be larger than first") + size = last - self.ip + netbits = _count1Bits(size) + # make sure the broadcast is the same as the last ip + # otherwise it will return /16 for something like: + # 192.168.0.0-192.168.191.255 + if IP('%s/%s' % (ip, 32-netbits)).broadcast().int() != last: + raise ValueError("the range %s is not on a network boundary." % data) + elif len(x) == 1: + x = data.split('/') + # if no prefix is given use defaults + if len(x) == 1: + ip = x[0] + prefixlen = -1 + elif len(x) > 2: + raise ValueError("only one '/' allowed in IP Address") + else: + (ip, prefixlen) = x + if prefixlen.find('.') != -1: + # check if the user might have used a netmask like + # a.b.c.d/255.255.255.0 + (netmask, vers) = parseAddress(prefixlen) + if vers != 4: + raise ValueError("netmask must be IPv4") + prefixlen = _netmaskToPrefixlen(netmask) + elif len(x) > 2: + raise ValueError("only one '-' allowed in IP Address") + else: + raise ValueError("can't parse") + + (self.ip, parsedVersion) = parseAddress(ip) + if ipversion == 0: + ipversion = parsedVersion + if prefixlen == -1: + bits = _ipVersionToLen(ipversion) + prefixlen = bits - netbits + self._ipversion = ipversion + self._prefixlen = int(prefixlen) + + if make_net: + self.ip = self.ip & _prefixlenToNetmask(self._prefixlen, self._ipversion) + + if not _checkNetaddrWorksWithPrefixlen(self.ip, + self._prefixlen, self._ipversion): + raise ValueError("%s has invalid prefix length (%s)" % (repr(self), self._prefixlen)) + else: + raise TypeError("Unsupported data type: %s" % type(data)) + + def int(self): + """Return the first / base / network addess as an (long) integer. + + The same as IP[0]. + + >>> "%X" % IP('10.0.0.0/8').int() + 'A000000' + """ + return self.ip + + def version(self): + """Return the IP version of this Object. + + >>> IP('10.0.0.0/8').version() + 4 + >>> IP('::1').version() + 6 + """ + return self._ipversion + + def prefixlen(self): + """Returns Network Prefixlen. + + >>> IP('10.0.0.0/8').prefixlen() + 8 + """ + return self._prefixlen + + def net(self): + """ + Return the base (first) address of a network as an (long) integer. + """ + return self.int() + + def broadcast(self): + """ + Return the broadcast (last) address of a network as an (long) integer. + + The same as IP[-1].""" + return self.int() + self.len() - 1 + + def _printPrefix(self, want): + """Prints Prefixlen/Netmask. + + Not really. In fact it is our universal Netmask/Prefixlen printer. + This is considered an internal function. + + want == 0 / None don't return anything 1.2.3.0 + want == 1 /prefix 1.2.3.0/24 + want == 2 /netmask 1.2.3.0/255.255.255.0 + want == 3 -lastip 1.2.3.0-1.2.3.255 + """ + + if (self._ipversion == 4 and self._prefixlen == 32) or \ + (self._ipversion == 6 and self._prefixlen == 128): + if self.NoPrefixForSingleIp: + want = 0 + if want == None: + want = self.WantPrefixLen + if want == None: + want = 1 + if want: + if want == 2: + # this should work with IP and IPint + netmask = self.netmask() + if not isinstance(netmask, INT_TYPES): + netmask = netmask.int() + return "/%s" % (intToIp(netmask, self._ipversion)) + elif want == 3: + return "-%s" % (intToIp(self.ip + self.len() - 1, self._ipversion)) + else: + # default + return "/%d" % (self._prefixlen) + else: + return '' + + # We have different flavours to convert to: + # strFullsize 127.0.0.1 2001:0658:022a:cafe:0200:c0ff:fe8d:08fa + # strNormal 127.0.0.1 2001:658:22a:cafe:200:c0ff:fe8d:08fa + # strCompressed 127.0.0.1 2001:658:22a:cafe::1 + # strHex 0x7F000001 0x20010658022ACAFE0200C0FFFE8D08FA + # strDec 2130706433 42540616829182469433547974687817795834 + + def strBin(self, wantprefixlen = None): + """Return a string representation as a binary value. + + >>> print(IP('127.0.0.1').strBin()) + 01111111000000000000000000000001 + >>> print(IP('2001:0658:022a:cafe:0200::1').strBin()) + 00100000000000010000011001011000000000100010101011001010111111100000001000000000000000000000000000000000000000000000000000000001 + """ + + bits = _ipVersionToLen(self._ipversion) + if self.WantPrefixLen == None and wantprefixlen == None: + wantprefixlen = 0 + ret = _intToBin(self.ip) + return '0' * (bits - len(ret)) + ret + self._printPrefix(wantprefixlen) + + def strCompressed(self, wantprefixlen = None): + """Return a string representation in compressed format using '::' Notation. + + >>> IP('127.0.0.1').strCompressed() + '127.0.0.1' + >>> IP('2001:0658:022a:cafe:0200::1').strCompressed() + '2001:658:22a:cafe:200::1' + >>> IP('ffff:ffff:ffff:ffff:ffff:f:f:fffc/127').strCompressed() + 'ffff:ffff:ffff:ffff:ffff:f:f:fffc/127' + """ + + if self.WantPrefixLen == None and wantprefixlen == None: + wantprefixlen = 1 + + if self._ipversion == 4: + return self.strFullsize(wantprefixlen) + else: + if self.ip >> 32 == 0xffff: + ipv4 = intToIp(self.ip & MAX_IPV4_ADDRESS, 4) + text = "::ffff:" + ipv4 + self._printPrefix(wantprefixlen) + return text + # find the longest sequence of '0' + hextets = [int(x, 16) for x in self.strFullsize(0).split(':')] + # every element of followingzeros will contain the number of zeros + # following the corresponding element of hextets + followingzeros = [0] * 8 + for i in xrange(len(hextets)): + followingzeros[i] = _countFollowingZeros(hextets[i:]) + # compressionpos is the position where we can start removing zeros + compressionpos = followingzeros.index(max(followingzeros)) + if max(followingzeros) > 1: + # genererate string with the longest number of zeros cut out + # now we need hextets as strings + hextets = [x for x in self.strNormal(0).split(':')] + while compressionpos < len(hextets) and hextets[compressionpos] == '0': + del(hextets[compressionpos]) + hextets.insert(compressionpos, '') + if compressionpos + 1 >= len(hextets): + hextets.append('') + if compressionpos == 0: + hextets = [''] + hextets + return ':'.join(hextets) + self._printPrefix(wantprefixlen) + else: + return self.strNormal(0) + self._printPrefix(wantprefixlen) + + def strNormal(self, wantprefixlen = None): + """Return a string representation in the usual format. + + >>> print(IP('127.0.0.1').strNormal()) + 127.0.0.1 + >>> print(IP('2001:0658:022a:cafe:0200::1').strNormal()) + 2001:658:22a:cafe:200:0:0:1 + """ + + if self.WantPrefixLen == None and wantprefixlen == None: + wantprefixlen = 1 + + if self._ipversion == 4: + ret = self.strFullsize(0) + elif self._ipversion == 6: + ret = ':'.join(["%x" % x for x in [int(x, 16) for x in self.strFullsize(0).split(':')]]) + else: + raise ValueError("only IPv4 and IPv6 supported") + + + + return ret + self._printPrefix(wantprefixlen) + + def strFullsize(self, wantprefixlen = None): + """Return a string representation in the non-mangled format. + + >>> print(IP('127.0.0.1').strFullsize()) + 127.0.0.1 + >>> print(IP('2001:0658:022a:cafe:0200::1').strFullsize()) + 2001:0658:022a:cafe:0200:0000:0000:0001 + """ + + if self.WantPrefixLen == None and wantprefixlen == None: + wantprefixlen = 1 + + return intToIp(self.ip, self._ipversion) + self._printPrefix(wantprefixlen) + + def strHex(self, wantprefixlen = None): + """Return a string representation in hex format in lower case. + + >>> print(IP('127.0.0.1').strHex()) + 0x7f000001 + >>> print(IP('2001:0658:022a:cafe:0200::1').strHex()) + 0x20010658022acafe0200000000000001 + """ + + if self.WantPrefixLen == None and wantprefixlen == None: + wantprefixlen = 0 + + x = '0x%x' % self.ip + return x + self._printPrefix(wantprefixlen) + + def strDec(self, wantprefixlen = None): + """Return a string representation in decimal format. + + >>> print(IP('127.0.0.1').strDec()) + 2130706433 + >>> print(IP('2001:0658:022a:cafe:0200::1').strDec()) + 42540616829182469433547762482097946625 + """ + + if self.WantPrefixLen == None and wantprefixlen == None: + wantprefixlen = 0 + + x = '%d' % self.ip + return x + self._printPrefix(wantprefixlen) + + def iptype(self): + """Return a description of the IP type ('PRIVATE', 'RESERVED', etc). + + >>> print(IP('127.0.0.1').iptype()) + PRIVATE + >>> print(IP('192.168.1.1').iptype()) + PRIVATE + >>> print(IP('195.185.1.2').iptype()) + PUBLIC + >>> print(IP('::1').iptype()) + LOOPBACK + >>> print(IP('2001:0658:022a:cafe:0200::1').iptype()) + ALLOCATED RIPE NCC + + The type information for IPv6 is out of sync with reality. + """ + + # this could be greatly improved + + if self._ipversion == 4: + iprange = IPv4ranges + elif self._ipversion == 6: + iprange = IPv6ranges + else: + raise ValueError("only IPv4 and IPv6 supported") + + bits = self.strBin() + for i in xrange(len(bits), 0, -1): + if bits[:i] in iprange: + return iprange[bits[:i]] + return "unknown" + + + def netmask(self): + """Return netmask as an integer. + + >>> "%X" % IP('195.185.0.0/16').netmask().int() + 'FFFF0000' + """ + + # TODO: unify with prefixlenToNetmask? + bits = _ipVersionToLen(self._ipversion) + locallen = bits - self._prefixlen + + return ((2 ** self._prefixlen) - 1) << locallen + + + def strNetmask(self): + """Return netmask as an string. Mostly useful for IPv6. + + >>> print(IP('195.185.0.0/16').strNetmask()) + 255.255.0.0 + >>> print(IP('2001:0658:022a:cafe::0/64').strNetmask()) + /64 + """ + + # TODO: unify with prefixlenToNetmask? + # Note: call to _ipVersionToLen() also validates version is 4 or 6 + bits = _ipVersionToLen(self._ipversion) + if self._ipversion == 4: + locallen = bits - self._prefixlen + return intToIp(((2 ** self._prefixlen) - 1) << locallen, 4) + elif self._ipversion == 6: + return "/%d" % self._prefixlen + + def len(self): + """Return the length of a subnet. + + >>> print(IP('195.185.1.0/28').len()) + 16 + >>> print(IP('195.185.1.0/24').len()) + 256 + """ + + bits = _ipVersionToLen(self._ipversion) + locallen = bits - self._prefixlen + return 2 ** locallen + + + def __nonzero__(self): + """All IPy objects should evaluate to true in boolean context. + Ordinarily they do, but if handling a default route expressed as + 0.0.0.0/0, the __len__() of the object becomes 0, which is used + as the boolean value of the object. + """ + return True + + + def __len__(self): + """ + Return the length of a subnet. + + Called to implement the built-in function len(). + It will break with large IPv6 Networks. + Use the object's len() instead. + """ + return self.len() + + def __add__(self, other): + """Emulate numeric objects through network aggregation""" + if self._ipversion != other._ipversion: + raise ValueError("Only networks with the same IP version can be added.") + if self._prefixlen != other._prefixlen: + raise ValueError("Only networks with the same prefixlen can be added.") + if self._prefixlen < 1: + raise ValueError("Networks with a prefixlen longer than /1 can't be added.") + if self > other: + # fixed by Skinny Puppy + return other.__add__(self) + if other.int() - self[-1].int() != 1: + raise ValueError("Only adjacent networks can be added together.") + ret = IP(self.int(), ipversion=self._ipversion) + ret._prefixlen = self.prefixlen() - 1 + if not _checkNetaddrWorksWithPrefixlen(ret.ip, ret._prefixlen, + ret._ipversion): + raise ValueError("The resulting %s has invalid prefix length (%s)" + % (repr(ret), ret._prefixlen)) + return ret + + def __sub__(self, other): + """Return the prefixes that are in this IP but not in the other""" + return _remove_subprefix(self, other) + + def __getitem__(self, key): + """Called to implement evaluation of self[key]. + + >>> ip=IP('127.0.0.0/30') + >>> for x in ip: + ... print(repr(x)) + ... + IP('127.0.0.0') + IP('127.0.0.1') + IP('127.0.0.2') + IP('127.0.0.3') + >>> ip[2] + IP('127.0.0.2') + >>> ip[-1] + IP('127.0.0.3') + """ + + if isinstance(key, slice): + return [self.ip + int(x) for x in xrange(*key.indices(len(self)))] + if not isinstance(key, INT_TYPES): + raise TypeError + if key < 0: + if abs(key) <= self.len(): + key = self.len() - abs(key) + else: + raise IndexError + else: + if key >= self.len(): + raise IndexError + + return self.ip + int(key) + + + + def __contains__(self, item): + """Called to implement membership test operators. + + Should return true if item is in self, false otherwise. Item + can be other IP-objects, strings or ints. + + >>> IP('195.185.1.1').strHex() + '0xc3b90101' + >>> 0xC3B90101 in IP('195.185.1.0/24') + True + >>> '127.0.0.1' in IP('127.0.0.0/24') + True + >>> IP('127.0.0.0/24') in IP('127.0.0.0/25') + False + """ + + if isinstance(item, IP): + if item._ipversion != self._ipversion: + return False + else: + item = IP(item) + if item.ip >= self.ip and item.ip < self.ip + self.len() - item.len() + 1: + return True + else: + return False + + + def overlaps(self, item): + """Check if two IP address ranges overlap. + + Returns 0 if the two ranges don't overlap, 1 if the given + range overlaps at the end and -1 if it does at the beginning. + + >>> IP('192.168.0.0/23').overlaps('192.168.1.0/24') + 1 + >>> IP('192.168.0.0/23').overlaps('192.168.1.255') + 1 + >>> IP('192.168.0.0/23').overlaps('192.168.2.0') + 0 + >>> IP('192.168.1.0/24').overlaps('192.168.0.0/23') + -1 + """ + + if not isinstance(item, IP): + item = IP(item) + if item.ip >= self.ip and item.ip < self.ip + self.len(): + return 1 + elif self.ip >= item.ip and self.ip < item.ip + item.len(): + return -1 + else: + return 0 + + + def __str__(self): + """Dispatch to the prefered String Representation. + + Used to implement str(IP).""" + + return self.strCompressed() + + + def __repr__(self): + """Print a representation of the Object. + + Used to implement repr(IP). Returns a string which evaluates + to an identical Object (without the wantprefixlen stuff - see + module docstring. + + >>> print(repr(IP('10.0.0.0/24'))) + IP('10.0.0.0/24') + """ + + return("IPint('%s')" % (self.strCompressed(1))) + + + def __cmp__(self, other): + """Called by comparison operations. + + Should return a negative integer if self < other, zero if self + == other, a positive integer if self > other. + + Order is first determined by the address family. IPv4 addresses + are always smaller than IPv6 addresses: + + >>> IP('10.0.0.0') < IP('2001:db8::') + 1 + + Then the first address is compared. Lower addresses are + always smaller: + + >>> IP('10.0.0.0') > IP('10.0.0.1') + 0 + >>> IP('10.0.0.0/24') > IP('10.0.0.1') + 0 + >>> IP('10.0.1.0') > IP('10.0.0.0/24') + 1 + >>> IP('10.0.1.0/24') > IP('10.0.0.0/24') + 1 + >>> IP('10.0.1.0/24') > IP('10.0.0.0') + 1 + + Then the prefix length is compared. Shorter prefixes are + considered smaller than longer prefixes: + + >>> IP('10.0.0.0/24') > IP('10.0.0.0') + 0 + >>> IP('10.0.0.0/24') > IP('10.0.0.0/25') + 0 + >>> IP('10.0.0.0/24') > IP('10.0.0.0/23') + 1 + + """ + if not isinstance(other, IPint): + raise TypeError + + # Lower version -> lower result + if self._ipversion != other._ipversion: + return self._ipversion < other._ipversion and -1 or 1 + + # Lower start address -> lower result + if self.ip != other.ip: + return self.ip < other.ip and -1 or 1 + + # Shorter prefix length -> lower result + if self._prefixlen != other._prefixlen: + return self._prefixlen < other._prefixlen and -1 or 1 + + # No differences found + return 0 + + def __eq__(self, other): + if not isinstance(other, IPint): + return False + return self.__cmp__(other) == 0 + + def __ne__(self, other): + return not self.__eq__(other) + + def __lt__(self, other): + return self.__cmp__(other) < 0 + + def __hash__(self): + """Called for the key object for dictionary operations, and by + the built-in function hash(). Should return a 32-bit integer + usable as a hash value for dictionary operations. The only + required property is that objects which compare equal have the + same hash value + + >>> IP('10.0.0.0/24').__hash__() + -167772185 + """ + + thehash = int(-1) + ip = self.ip + while ip > 0: + thehash = thehash ^ (ip & 0x7fffffff) + ip = ip >> 32 + thehash = thehash ^ self._prefixlen + return int(thehash) + + +class IP(IPint): + """Class for handling IP addresses and networks.""" + + def net(self): + """Return the base (first) address of a network as an IP object. + + The same as IP[0]. + + >>> IP('10.0.0.0/8').net() + IP('10.0.0.0') + """ + return IP(IPint.net(self), ipversion=self._ipversion) + + def broadcast(self): + """Return the broadcast (last) address of a network as an IP object. + + The same as IP[-1]. + + >>> IP('10.0.0.0/8').broadcast() + IP('10.255.255.255') + """ + return IP(IPint.broadcast(self)) + + def netmask(self): + """Return netmask as an IP object. + + >>> IP('10.0.0.0/8').netmask() + IP('255.0.0.0') + """ + return IP(IPint.netmask(self), ipversion=self._ipversion) + + def _getIPv4Map(self): + if self._ipversion != 6: + return None + if (self.ip >> 32) != 0xffff: + return None + ipv4 = self.ip & MAX_IPV4_ADDRESS + if self._prefixlen != 128: + ipv4 = '%s/%s' % (ipv4, 32-(128-self._prefixlen)) + return IP(ipv4, ipversion=4) + + def reverseNames(self): + """Return a list with values forming the reverse lookup. + + >>> IP('213.221.113.87/32').reverseNames() + ['87.113.221.213.in-addr.arpa.'] + >>> IP('213.221.112.224/30').reverseNames() + ['224.112.221.213.in-addr.arpa.', '225.112.221.213.in-addr.arpa.', '226.112.221.213.in-addr.arpa.', '227.112.221.213.in-addr.arpa.'] + >>> IP('127.0.0.0/24').reverseNames() + ['0.0.127.in-addr.arpa.'] + >>> IP('127.0.0.0/23').reverseNames() + ['0.0.127.in-addr.arpa.', '1.0.127.in-addr.arpa.'] + >>> IP('127.0.0.0/16').reverseNames() + ['0.127.in-addr.arpa.'] + >>> IP('127.0.0.0/15').reverseNames() + ['0.127.in-addr.arpa.', '1.127.in-addr.arpa.'] + >>> IP('128.0.0.0/8').reverseNames() + ['128.in-addr.arpa.'] + >>> IP('128.0.0.0/7').reverseNames() + ['128.in-addr.arpa.', '129.in-addr.arpa.'] + >>> IP('::1:2').reverseNames() + ['2.0.0.0.1.ip6.arpa.'] + """ + + if self._ipversion == 4: + ret = [] + # TODO: Refactor. Add support for IPint objects + if self.len() < 2**8: + for x in self: + ret.append(x.reverseName()) + elif self.len() < 2**16: + for i in xrange(0, self.len(), 2**8): + ret.append(self[i].reverseName()[2:]) + elif self.len() < 2**24: + for i in xrange(0, self.len(), 2**16): + ret.append(self[i].reverseName()[4:]) + else: + for i in xrange(0, self.len(), 2**24): + ret.append(self[i].reverseName()[6:]) + return ret + elif self._ipversion == 6: + ipv4 = self._getIPv4Map() + if ipv4 is not None: + return ipv4.reverseNames() + s = "%x" % self.ip + if self._prefixlen % 4 != 0: + raise NotImplementedError("can't create IPv6 reverse names at sub nibble level") + s = list(s) + s.reverse() + s = '.'.join(s) + first_nibble_index = int(32 - (self._prefixlen // 4)) * 2 + return ["%s.ip6.arpa." % s[first_nibble_index:]] + else: + raise ValueError("only IPv4 and IPv6 supported") + + def reverseName(self): + """Return the value for reverse lookup/PTR records as RFC 2317 look alike. + + RFC 2317 is an ugly hack which only works for sub-/24 e.g. not + for /23. Do not use it. Better set up a zone for every + address. See reverseName for a way to achieve that. + + >>> print(IP('195.185.1.1').reverseName()) + 1.1.185.195.in-addr.arpa. + >>> print(IP('195.185.1.0/28').reverseName()) + 0-15.1.185.195.in-addr.arpa. + >>> IP('::1:2').reverseName() + '2.0.0.0.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa.' + >>> IP('ff02::/64').reverseName() + '0.0.0.0.0.0.0.0.0.0.0.0.2.0.f.f.ip6.arpa.' + """ + + if self._ipversion == 4: + s = self.strFullsize(0) + s = s.split('.') + s.reverse() + first_byte_index = int(4 - (self._prefixlen // 8)) + if self._prefixlen % 8 != 0: + nibblepart = "%s-%s" % (s[3-(self._prefixlen // 8)], intToIp(self.ip + self.len() - 1, 4).split('.')[-1]) + nibblepart += '.' + else: + nibblepart = "" + + s = '.'.join(s[first_byte_index:]) + return "%s%s.in-addr.arpa." % (nibblepart, s) + + elif self._ipversion == 6: + ipv4 = self._getIPv4Map() + if ipv4 is not None: + return ipv4.reverseName() + s = '%032x' % self.ip + if self._prefixlen % 4 != 0: + nibblepart = "%s-%x" % (s[self._prefixlen:], self.ip + self.len() - 1) + nibblepart += '.' + else: + nibblepart = "" + s = list(s) + s.reverse() + s = '.'.join(s) + first_nibble_index = int(32 - (self._prefixlen // 4)) * 2 + return "%s%s.ip6.arpa." % (nibblepart, s[first_nibble_index:]) + else: + raise ValueError("only IPv4 and IPv6 supported") + + def make_net(self, netmask): + """Transform a single IP address into a network specification by + applying the given netmask. + + Returns a new IP instance. + + >>> print(IP('127.0.0.1').make_net('255.0.0.0')) + 127.0.0.0/8 + """ + if '/' in str(netmask): + raise ValueError("invalid netmask (%s)" % netmask) + return IP('%s/%s' % (self, netmask), make_net=True) + + def __getitem__(self, key): + """Called to implement evaluation of self[key]. + + >>> ip=IP('127.0.0.0/30') + >>> for x in ip: + ... print(str(x)) + ... + 127.0.0.0 + 127.0.0.1 + 127.0.0.2 + 127.0.0.3 + >>> print(str(ip[2])) + 127.0.0.2 + >>> print(str(ip[-1])) + 127.0.0.3 + """ + if isinstance(key, slice): + return [IP(IPint.__getitem__(self, x), ipversion=self._ipversion) for x in xrange(*key.indices(len(self)))] + return IP(IPint.__getitem__(self, key), ipversion=self._ipversion) + + def __repr__(self): + """Print a representation of the Object. + + >>> IP('10.0.0.0/8') + IP('10.0.0.0/8') + """ + + return("IP('%s')" % (self.strCompressed(1))) + + def get_mac(self): + """ + Get the 802.3 MAC address from IPv6 RFC 2464 address, in lower case. + Return None if the address is an IPv4 or not a IPv6 RFC 2464 address. + + >>> IP('fe80::f66d:04ff:fe47:2fae').get_mac() + 'f4:6d:04:47:2f:ae' + """ + if self._ipversion != 6: + return None + if (self.ip & 0x20000ffff000000) != 0x20000fffe000000: + return None + return '%02x:%02x:%02x:%02x:%02x:%02x' % ( + (((self.ip >> 56) & 0xff) & 0xfd), + (self.ip >> 48) & 0xff, + (self.ip >> 40) & 0xff, + (self.ip >> 16) & 0xff, + (self.ip >> 8) & 0xff, + self.ip & 0xff, + ) + + def v46map(self): + """ + Returns the IPv6 mapped address of an IPv4 address, or the corresponding + IPv4 address if the IPv6 address is in the appropriate range. + Raises a ValueError if the IPv6 address is not translatable. See RFC 4291. + + >>> IP('192.168.1.1').v46map() + IP('::ffff:192.168.1.1') + >>> IP('::ffff:192.168.1.1').v46map() + IP('192.168.1.1') + """ + if self._ipversion == 4: + return IP(str(IPV6_MAP_MASK + self.ip) + + "/%s" % (self._prefixlen + 96)) + else: + if self.ip & IPV6_TEST_MAP == IPV6_MAP_MASK: + return IP(str(self.ip - IPV6_MAP_MASK) + + "/%s" % (self._prefixlen - 96)) + raise ValueError("%s cannot be converted to an IPv4 address." + % repr(self)) + +class IPSet(collections.MutableSet): + def __init__(self, iterable=[]): + # Make sure it's iterable, otherwise wrap + if not isinstance(iterable, collections.Iterable): + raise TypeError("'%s' object is not iterable" % type(iterable).__name__) + + # Make sure we only accept IP objects + for prefix in iterable: + if not isinstance(prefix, IP): + raise ValueError('Only IP objects can be added to an IPSet') + + # Store and optimize + self.prefixes = iterable[:] + self.optimize() + + def __contains__(self, ip): + valid_masks = self.prefixtable.keys() + if isinstance(ip, IP): + #Don't dig through more-specific ranges + ip_mask = ip._prefixlen + valid_masks = [x for x in valid_masks if x <= ip_mask] + for mask in sorted(valid_masks): + i = bisect.bisect(self.prefixtable[mask], ip) + # Because of sorting order, a match can only occur in the prefix + # that comes before the result of the search. + if i and ip in self.prefixtable[mask][i - 1]: + return True + + def __iter__(self): + for prefix in self.prefixes: + yield prefix + + def __len__(self): + return self.len() + + def __add__(self, other): + return IPSet(self.prefixes + other.prefixes) + + def __sub__(self, other): + new = IPSet(self.prefixes) + for prefix in other: + new.discard(prefix) + return new + + def __and__(self, other): + left = iter(self.prefixes) + right = iter(other.prefixes) + result = [] + try: + l = next(left) + r = next(right) + while True: + # iterate over prefixes in order, keeping the smaller of the + # two if they overlap + if l in r: + result.append(l) + l = next(left) + continue + elif r in l: + result.append(r) + r = next(right) + continue + if l < r: + l = next(left) + else: + r = next(right) + except StopIteration: + return IPSet(result) + + def __repr__(self): + return '%s([' % self.__class__.__name__ + ', '.join(map(repr, self.prefixes)) + '])' + + def len(self): + return sum(prefix.len() for prefix in self.prefixes) + + def add(self, value): + # Make sure it's iterable, otherwise wrap + if not isinstance(value, collections.Iterable): + value = [value] + + # Check type + for prefix in value: + if not isinstance(prefix, IP): + raise ValueError('Only IP objects can be added to an IPSet') + + # Append and optimize + self.prefixes.extend(value) + self.optimize() + + def discard(self, value): + # Make sure it's iterable, otherwise wrap + if not isinstance(value, collections.Iterable): + value = [value] + + # This is much faster than iterating over the addresses + if isinstance(value, IPSet): + value = value.prefixes + + # Remove + for del_prefix in value: + if not isinstance(del_prefix, IP): + raise ValueError('Only IP objects can be removed from an IPSet') + + # First check if this prefix contains anything in our list + found = False + d = 0 + for i in range(len(self.prefixes)): + if self.prefixes[i - d] in del_prefix: + self.prefixes.pop(i - d) + d = d + 1 + found = True + + if found: + # If the prefix was bigger than an existing prefix, then it's + # certainly not a subset of one, so skip the rest + continue + + # Maybe one of our prefixes contains this prefix + found = False + for i in range(len(self.prefixes)): + if del_prefix in self.prefixes[i]: + self.prefixes[i:i+1] = self.prefixes[i] - del_prefix + break + + self.optimize() + + def isdisjoint(self, other): + left = iter(self.prefixes) + right = iter(other.prefixes) + try: + l = next(left) + r = next(right) + while True: + if l in r or r in l: + return False + if l < r: + l = next(left) + else: + r = next(right) + except StopIteration: + return True + + def optimize(self): + # The algorithm below *depends* on the sort order + self.prefixes.sort() + + # First eliminate all values that are a subset of other values + addrlen = len(self.prefixes) + i = 0 + while i < addrlen: + # Everything that might be inside this prefix follows + # directly behind it + j = i+1 + while j < addrlen and self.prefixes[j] in self.prefixes[i]: + # Mark for deletion by overwriting with None + self.prefixes[j] = None + j += 1 + + # Continue where we left off + i = j + + # Try to merge as many prefixes as possible + run_again = True + while run_again: + # Filter None values. This happens when a subset is eliminated + # above, or when two prefixes are merged below + self.prefixes = [a for a in self.prefixes if a is not None] + + # We'll set run_again to True when we make changes that require + # re-evaluation of the whole list + run_again = False + + # We can merge two prefixes that have the same version, same + # prefix length and differ only on the last bit of the prefix + addrlen = len(self.prefixes) + i = 0 + while i < addrlen-1: + j = i + 1 + + try: + # The next line will throw an exception when merging + # is not possible + self.prefixes[i] += self.prefixes[j] + self.prefixes[j] = None + i = j + 1 + run_again = True + except ValueError: + # Can't be merged, see if position j can be merged + i = j + + # O(n) insertion now by prefix means faster searching on __contains__ + # when lots of ranges with the same length exist + self.prefixtable = {} + for address in self.prefixes: + try: + self.prefixtable[address._prefixlen].append(address) + except KeyError: + self.prefixtable[address._prefixlen] = [address] + +def _parseAddressIPv6(ipstr): + """ + Internal function used by parseAddress() to parse IPv6 address with ':'. + + >>> print(_parseAddressIPv6('::')) + 0 + >>> print(_parseAddressIPv6('::1')) + 1 + >>> print(_parseAddressIPv6('0:0:0:0:0:0:0:1')) + 1 + >>> print(_parseAddressIPv6('0:0:0::0:0:1')) + 1 + >>> print(_parseAddressIPv6('0:0:0:0:0:0:0:0')) + 0 + >>> print(_parseAddressIPv6('0:0:0::0:0:0')) + 0 + + >>> print(_parseAddressIPv6('FEDC:BA98:7654:3210:FEDC:BA98:7654:3210')) + 338770000845734292534325025077361652240 + >>> print(_parseAddressIPv6('1080:0000:0000:0000:0008:0800:200C:417A')) + 21932261930451111902915077091070067066 + >>> print(_parseAddressIPv6('1080:0:0:0:8:800:200C:417A')) + 21932261930451111902915077091070067066 + >>> print(_parseAddressIPv6('1080:0::8:800:200C:417A')) + 21932261930451111902915077091070067066 + >>> print(_parseAddressIPv6('1080::8:800:200C:417A')) + 21932261930451111902915077091070067066 + >>> print(_parseAddressIPv6('FF01:0:0:0:0:0:0:43')) + 338958331222012082418099330867817087043 + >>> print(_parseAddressIPv6('FF01:0:0::0:0:43')) + 338958331222012082418099330867817087043 + >>> print(_parseAddressIPv6('FF01::43')) + 338958331222012082418099330867817087043 + >>> print(_parseAddressIPv6('0:0:0:0:0:0:13.1.68.3')) + 218186755 + >>> print(_parseAddressIPv6('::13.1.68.3')) + 218186755 + >>> print(_parseAddressIPv6('0:0:0:0:0:FFFF:129.144.52.38')) + 281472855454758 + >>> print(_parseAddressIPv6('::FFFF:129.144.52.38')) + 281472855454758 + >>> print(_parseAddressIPv6('1080:0:0:0:8:800:200C:417A')) + 21932261930451111902915077091070067066 + >>> print(_parseAddressIPv6('1080::8:800:200C:417A')) + 21932261930451111902915077091070067066 + >>> print(_parseAddressIPv6('::1:2:3:4:5:6')) + 1208962713947218704138246 + >>> print(_parseAddressIPv6('1:2:3:4:5:6::')) + 5192455318486707404433266432802816 + """ + + # Split string into a list, example: + # '1080:200C::417A' => ['1080', '200C', '417A'] and fill_pos=2 + # and fill_pos is the position of '::' in the list + items = [] + index = 0 + fill_pos = None + while index < len(ipstr): + text = ipstr[index:] + if text.startswith("::"): + if fill_pos is not None: + # Invalid IPv6, eg. '1::2::' + raise ValueError("%r: Invalid IPv6 address: more than one '::'" % ipstr) + fill_pos = len(items) + index += 2 + continue + pos = text.find(':') + if pos == 0: + # Invalid IPv6, eg. '1::2:' + raise ValueError("%r: Invalid IPv6 address" % ipstr) + if pos != -1: + items.append(text[:pos]) + if text[pos:pos+2] == "::": + index += pos + else: + index += pos+1 + + if index == len(ipstr): + # Invalid IPv6, eg. '1::2:' + raise ValueError("%r: Invalid IPv6 address" % ipstr) + else: + items.append(text) + break + + if items and '.' in items[-1]: + # IPv6 ending with IPv4 like '::ffff:192.168.0.1' + if (fill_pos is not None) and not (fill_pos <= len(items)-1): + # Invalid IPv6: 'ffff:192.168.0.1::' + raise ValueError("%r: Invalid IPv6 address: '::' after IPv4" % ipstr) + value = parseAddress(items[-1])[0] + items = items[:-1] + ["%04x" % (value >> 16), "%04x" % (value & 0xffff)] + + # Expand fill_pos to fill with '0' + # ['1','2'] with fill_pos=1 => ['1', '0', '0', '0', '0', '0', '0', '2'] + if fill_pos is not None: + diff = 8 - len(items) + if diff <= 0: + raise ValueError("%r: Invalid IPv6 address: '::' is not needed" % ipstr) + items = items[:fill_pos] + ['0']*diff + items[fill_pos:] + + # Here we have a list of 8 strings + if len(items) != 8: + # Invalid IPv6, eg. '1:2:3' + raise ValueError("%r: Invalid IPv6 address: should have 8 hextets" % ipstr) + + # Convert strings to long integer + value = 0 + index = 0 + for item in items: + try: + item = int(item, 16) + error = not(0 <= item <= 0xffff) + except ValueError: + error = True + if error: + raise ValueError("%r: Invalid IPv6 address: invalid hexlet %r" % (ipstr, item)) + value = (value << 16) + item + index += 1 + return value + +def parseAddress(ipstr): + """ + Parse a string and return the corresponding IP address (as integer) + and a guess of the IP version. + + Following address formats are recognized: + + >>> def testParseAddress(address): + ... ip, version = parseAddress(address) + ... print(("%s (IPv%s)" % (ip, version))) + ... + >>> testParseAddress('0x0123456789abcdef') # IPv4 if <= 0xffffffff else IPv6 + 81985529216486895 (IPv6) + >>> testParseAddress('123.123.123.123') # IPv4 + 2071690107 (IPv4) + >>> testParseAddress('123.123') # 0-padded IPv4 + 2071658496 (IPv4) + >>> testParseAddress('127') + 2130706432 (IPv4) + >>> testParseAddress('255') + 4278190080 (IPv4) + >>> testParseAddress('256') + 256 (IPv4) + >>> testParseAddress('108000000000000000080800200C417A') + 21932261930451111902915077091070067066 (IPv6) + >>> testParseAddress('0x108000000000000000080800200C417A') + 21932261930451111902915077091070067066 (IPv6) + >>> testParseAddress('1080:0000:0000:0000:0008:0800:200C:417A') + 21932261930451111902915077091070067066 (IPv6) + >>> testParseAddress('1080:0:0:0:8:800:200C:417A') + 21932261930451111902915077091070067066 (IPv6) + >>> testParseAddress('1080:0::8:800:200C:417A') + 21932261930451111902915077091070067066 (IPv6) + >>> testParseAddress('::1') + 1 (IPv6) + >>> testParseAddress('::') + 0 (IPv6) + >>> testParseAddress('0:0:0:0:0:FFFF:129.144.52.38') + 281472855454758 (IPv6) + >>> testParseAddress('::13.1.68.3') + 218186755 (IPv6) + >>> testParseAddress('::FFFF:129.144.52.38') + 281472855454758 (IPv6) + """ + + try: + hexval = int(ipstr, 16) + except ValueError: + hexval = None + try: + intval = int(ipstr, 10) + except ValueError: + intval = None + + if ipstr.startswith('0x') and hexval is not None: + if hexval > MAX_IPV6_ADDRESS: + raise ValueError("IP Address can't be larger than %x: %x" % (MAX_IPV6_ADDRESS, hexval)) + if hexval <= MAX_IPV4_ADDRESS: + return (hexval, 4) + else: + return (hexval, 6) + + if ipstr.find(':') != -1: + return (_parseAddressIPv6(ipstr), 6) + + elif len(ipstr) == 32 and hexval is not None: + # assume IPv6 in pure hexadecimal notation + return (hexval, 6) + + elif ipstr.find('.') != -1 or (intval is not None and intval < 256): + # assume IPv4 ('127' gets interpreted as '127.0.0.0') + bytes = ipstr.split('.') + if len(bytes) > 4: + raise ValueError("IPv4 Address with more than 4 bytes") + bytes += ['0'] * (4 - len(bytes)) + bytes = [int(x) for x in bytes] + for x in bytes: + if x > 255 or x < 0: + raise ValueError("%r: single byte must be 0 <= byte < 256" % (ipstr)) + return ((bytes[0] << 24) + (bytes[1] << 16) + (bytes[2] << 8) + bytes[3], 4) + + elif intval is not None: + # we try to interprete it as a decimal digit - + # this ony works for numbers > 255 ... others + # will be interpreted as IPv4 first byte + if intval > MAX_IPV6_ADDRESS: + raise ValueError("IP Address can't be larger than %x: %x" % (MAX_IPV6_ADDRESS, intval)) + if intval <= MAX_IPV4_ADDRESS: + return (intval, 4) + else: + return (intval, 6) + + raise ValueError("IP Address format was invalid: %s" % ipstr) + + +def intToIp(ip, version): + """Transform an integer string into an IP address.""" + + # just to be sure and hoping for Python 2.2 + ip = int(ip) + + if ip < 0: + raise ValueError("IPs can't be negative: %d" % (ip)) + + ret = '' + if version == 4: + if ip > MAX_IPV4_ADDRESS: + raise ValueError("IPv4 Address can't be larger than %x: %x" % (MAX_IPV4_ADDRESS, ip)) + for l in xrange(4): + ret = str(ip & 0xff) + '.' + ret + ip = ip >> 8 + ret = ret[:-1] + elif version == 6: + if ip > MAX_IPV6_ADDRESS: + raise ValueError("IPv6 Address can't be larger than %x: %x" % (MAX_IPV6_ADDRESS, ip)) + l = "%032x" % ip + for x in xrange(1, 33): + ret = l[-x] + ret + if x % 4 == 0: + ret = ':' + ret + ret = ret[1:] + else: + raise ValueError("only IPv4 and IPv6 supported") + + return ret + +def _ipVersionToLen(version): + """Return number of bits in address for a certain IP version. + + >>> _ipVersionToLen(4) + 32 + >>> _ipVersionToLen(6) + 128 + >>> _ipVersionToLen(5) + Traceback (most recent call last): + File "", line 1, in ? + File "IPy.py", line 1076, in _ipVersionToLen + raise ValueError("only IPv4 and IPv6 supported") + ValueError: only IPv4 and IPv6 supported + """ + + if version == 4: + return 32 + elif version == 6: + return 128 + else: + raise ValueError("only IPv4 and IPv6 supported") + + +def _countFollowingZeros(l): + """Return number of elements containing 0 at the beginning of the list.""" + if len(l) == 0: + return 0 + elif l[0] != 0: + return 0 + else: + return 1 + _countFollowingZeros(l[1:]) + + +_BitTable = {'0': '0000', '1': '0001', '2': '0010', '3': '0011', + '4': '0100', '5': '0101', '6': '0110', '7': '0111', + '8': '1000', '9': '1001', 'a': '1010', 'b': '1011', + 'c': '1100', 'd': '1101', 'e': '1110', 'f': '1111'} + +def _intToBin(val): + """Return the binary representation of an integer as string.""" + + if val < 0: + raise ValueError("Only positive values allowed") + s = "%x" % val + ret = '' + for x in s: + ret += _BitTable[x] + # remove leading zeros + while ret[0] == '0' and len(ret) > 1: + ret = ret[1:] + return ret + +def _count1Bits(num): + """Find the highest bit set to 1 in an integer.""" + ret = 0 + while num > 0: + num = num >> 1 + ret += 1 + return ret + +def _count0Bits(num): + """Find the highest bit set to 0 in an integer.""" + + # this could be so easy if _count1Bits(~int(num)) would work as excepted + num = int(num) + if num < 0: + raise ValueError("Only positive Numbers please: %s" % (num)) + ret = 0 + while num > 0: + if num & 1 == 1: + break + num = num >> 1 + ret += 1 + return ret + + +def _checkPrefix(ip, prefixlen, version): + """Check the validity of a prefix + + Checks if the variant part of a prefix only has 0s, and the length is + correct. + + >>> _checkPrefix(0x7f000000, 24, 4) + 1 + >>> _checkPrefix(0x7f000001, 24, 4) + 0 + >>> repr(_checkPrefix(0x7f000001, -1, 4)) + 'None' + >>> repr(_checkPrefix(0x7f000001, 33, 4)) + 'None' + """ + + # TODO: unify this v4/v6/invalid code in a function + bits = _ipVersionToLen(version) + + if prefixlen < 0 or prefixlen > bits: + return None + + if ip == 0: + zbits = bits + 1 + else: + zbits = _count0Bits(ip) + if zbits < bits - prefixlen: + return 0 + else: + return 1 + + +def _checkNetmask(netmask, masklen): + """Checks if a netmask is expressable as a prefixlen.""" + + num = int(netmask) + bits = masklen + + # remove zero bits at the end + while (num & 1) == 0 and bits != 0: + num = num >> 1 + bits -= 1 + if bits == 0: + break + # now check if the rest consists only of ones + while bits > 0: + if (num & 1) == 0: + raise ValueError("Netmask 0x%x can't be expressed as an prefix." % netmask) + num = num >> 1 + bits -= 1 + + +def _checkNetaddrWorksWithPrefixlen(net, prefixlen, version): + """Check if a base addess of a network is compatible with a prefixlen""" + try: + return (net & _prefixlenToNetmask(prefixlen, version) == net) + except ValueError: + return False + + +def _netmaskToPrefixlen(netmask): + """Convert an Integer representing a netmask to a prefixlen. + + E.g. 0xffffff00 (255.255.255.0) returns 24 + """ + + netlen = _count0Bits(netmask) + masklen = _count1Bits(netmask) + _checkNetmask(netmask, masklen) + return masklen - netlen + + +def _prefixlenToNetmask(prefixlen, version): + """Return a mask of n bits as a long integer. + + From 'IP address conversion functions with the builtin socket module' + by Alex Martelli + http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/66517 + """ + if prefixlen == 0: + return 0 + elif prefixlen < 0: + raise ValueError("Prefixlen must be > 0") + return ((2<','>') else: - return '' \ No newline at end of file + return '' + +def is_ip_public(host): + ip_address = get_ip(host) + ip = IP(ip_address) + if ip.iptype() == 'PUBLIC': + return True + + return False + +def get_ip(host): + from plexpy import logger + ip_address = '' + try: + socket.inet_aton(host) + ip_address = host + except socket.error: + try: + ip_address = socket.gethostbyname(host) + logger.debug(u"IP Checker :: Resolved %s to %s." % (host, ip_address)) + except: + logger.error(u"IP Checker :: Bad IP or hostname provided.") + + return ip_address diff --git a/plexpy/webserve.py b/plexpy/webserve.py index a81bf203..bd1485d4 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -14,7 +14,7 @@ # along with PlexPy. If not, see . from plexpy import logger, notifiers, plextv, pmsconnect, common, log_reader, datafactory, graphs, users, libraries -from plexpy.helpers import checked, radio +from plexpy.helpers import checked, radio, get_ip from mako.lookup import TemplateLookup from mako import exceptions @@ -1402,22 +1402,34 @@ class WebInterface(object): def get_server_id(self, hostname=None, port=None, identifier=None, ssl=0, remote=0, **kwargs): from plexpy import http_handler - # Grab our serverId. - # This endpoint always works over plain HTTP even when secure connections on PMS is set to required. + # Attempt to get the pms_identifier from plex.tv if the server is published + # Works for all PMS SSL settings if not identifier and hostname and port: - request_handler = http_handler.HTTPHandler(host=hostname, - port=port, - token=None) - uri = '/identity' - request = request_handler.make_request(uri=uri, - proto='http', - request_type='GET', - output_format='xml', - no_token=True, - timeout=10) - if request: - xml_head = request.getElementsByTagName('MediaContainer')[0] - identifier = xml_head.getAttribute('machineIdentifier') + plex_tv = plextv.PlexTV() + servers = plex_tv.discover() + ip_address = get_ip(hostname) + + for server in servers: + if (server['ip'] == hostname or server['ip'] == ip_address) and server['port'] == port: + identifier = server['clientIdentifier'] + break + + # Fallback to checking /identity endpoint is server is unpublished + # Cannot set SSL settings on the PMS if unpublished so 'http' is okay + if not identifier: + request_handler = http_handler.HTTPHandler(host=hostname, + port=port, + token=None) + uri = '/identity' + request = request_handler.make_request(uri=uri, + proto='http', + request_type='GET', + output_format='xml', + no_token=True, + timeout=10) + if request: + xml_head = request.getElementsByTagName('MediaContainer')[0] + identifier = xml_head.getAttribute('machineIdentifier') if identifier: cherrypy.response.headers['Content-type'] = 'application/json' From ac42563c5ef2ed4bb8b0211c591d4b6f402224a3 Mon Sep 17 00:00:00 2001 From: Tim Van Date: Sun, 7 Feb 2016 22:28:04 +0200 Subject: [PATCH 04/47] Refresh PMS URL when changing is_remote option in settings. --- plexpy/webserve.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plexpy/webserve.py b/plexpy/webserve.py index bd1485d4..7cfbdd41 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -1192,6 +1192,11 @@ class WebInterface(object): (kwargs['pms_ssl'] != plexpy.CONFIG.PMS_SSL): server_changed = True + # If we change the PMS remote setting, make sure we grab the new url. + if 'pms_is_remote' in kwargs and \ + (kwargs['pms_is_remote'] != plexpy.CONFIG.PMS_IS_REMOTE): + server_changed = True + # Remove config with 'hscard-' prefix and change home_stats_cards to list if 'home_stats_cards' in kwargs: for k in kwargs.keys(): From 64191902723f2a7d7750776c132f61b1c0df29fa Mon Sep 17 00:00:00 2001 From: Tim Van Date: Sun, 7 Feb 2016 22:34:38 +0200 Subject: [PATCH 05/47] Revert silly naming bug. --- plexpy/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexpy/config.py b/plexpy/config.py index 984d57bc..45eb1c79 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -25,7 +25,7 @@ _CONFIG_DEFINITIONS = { 'PMS_NAME': (unicode, 'PMS', ''), 'PMS_PORT': (int, 'PMS', 32400), 'PMS_TOKEN': (str, 'PMS', ''), - 'PMS_SSL': (int, 'PMS', 0), + 'PMS_SSL': (int, 'General', 0), 'PMS_URL': (str, 'PMS', ''), 'PMS_USE_BIF': (int, 'PMS', 0), 'PMS_UUID': (str, 'PMS', ''), From 7e9e68ecd8d218188ddd880f0b175f4166e32cdf Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sat, 6 Feb 2016 17:26:35 -0800 Subject: [PATCH 06/47] Fix video media flags for tracks --- data/interfaces/default/info.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/interfaces/default/info.html b/data/interfaces/default/info.html index 6786b307..9a025348 100644 --- a/data/interfaces/default/info.html +++ b/data/interfaces/default/info.html @@ -171,10 +171,10 @@ DOCUMENTATION :: END % endif % if data['media_type'] == 'movie' or data['media_type'] == 'episode' or data['media_type'] == 'track':
- % if data['video_codec']: + % if data['media_type'] != 'track' and data['video_codec']: % endif - % if data['video_resolution']: + % if data['media_type'] != 'track' and data['video_resolution']: % endif % if data['audio_codec']: From 877002961f4bd42adfe7a35282c0e03e090453af Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sun, 7 Feb 2016 12:03:10 -0800 Subject: [PATCH 07/47] Use custom library icons in library statistics --- data/interfaces/default/css/plexpy.css | 6 ++++-- data/interfaces/default/library_stats.html | 6 +++--- plexpy/datafactory.py | 12 ++++++++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/data/interfaces/default/css/plexpy.css b/data/interfaces/default/css/plexpy.css index ad0a0c8d..6c683f76 100644 --- a/data/interfaces/default/css/plexpy.css +++ b/data/interfaces/default/css/plexpy.css @@ -501,7 +501,8 @@ textarea.form-control:focus { .libraries-poster-face { overflow: hidden; float: left; - background-size: contain; + background-size: cover; + background-position: center; height: 40px; width: 40px; /*-webkit-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1); @@ -1717,7 +1718,8 @@ a:hover .item-children-poster { float: left; margin-top: 15px; margin-right: 15px; - background-size: contain; + background-size: cover; + background-position: center; height: 80px; width: 80px; /*-webkit-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1); diff --git a/data/interfaces/default/library_stats.html b/data/interfaces/default/library_stats.html index 048d5e6f..80af7341 100644 --- a/data/interfaces/default/library_stats.html +++ b/data/interfaces/default/library_stats.html @@ -75,13 +75,13 @@ DOCUMENTATION :: END
% endif - % if library['thumb']: + % if library['thumb'].startswith("http"):
-
+
% else:
-
+
% endif diff --git a/plexpy/datafactory.py b/plexpy/datafactory.py index 3be50b84..d4bb7111 100644 --- a/plexpy/datafactory.py +++ b/plexpy/datafactory.py @@ -664,7 +664,8 @@ class DataFactory(object): for id in library_cards: if id.isdigit(): try: - query = 'SELECT section_id, section_name, section_type, thumb, count, parent_count, child_count ' \ + query = 'SELECT section_id, section_name, section_type, thumb AS library_thumb, ' \ + 'custom_thumb_url AS custom_thumb, count, parent_count, child_count ' \ 'FROM library_sections ' \ 'WHERE section_id = %s ' % id result = monitor_db.select(query) @@ -673,10 +674,17 @@ class DataFactory(object): return None for item in result: + if item['custom_thumb'] and item['custom_thumb'] != item['library_thumb']: + library_thumb = item['custom_thumb'] + elif item['library_thumb']: + library_thumb = item['library_thumb'] + else: + library_thumb = common.DEFAULT_COVER_THUMB + library = {'section_id': item['section_id'], 'section_name': item['section_name'], 'section_type': item['section_type'], - 'thumb': item['thumb'], + 'thumb': library_thumb, 'count': item['count'], 'parent_count': item['parent_count'], 'child_count': item['child_count'] From 16756ddb8c872b7c120dde15d4e7b452c5c4665a Mon Sep 17 00:00:00 2001 From: Tim Van Date: Mon, 8 Feb 2016 00:21:40 +0200 Subject: [PATCH 08/47] Don't chose a custom URL when picking a hostname for local SSL configs. --- plexpy/plextv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexpy/plextv.py b/plexpy/plextv.py index df93548e..bb8de76b 100644 --- a/plexpy/plextv.py +++ b/plexpy/plextv.py @@ -86,7 +86,7 @@ def get_real_pms_url(): plexpy.CONFIG.__setattr__('PMS_URL', item['uri']) plexpy.CONFIG.write() logger.info(u"PlexPy PlexTV :: Server URL retrieved.") - if not plexpy.CONFIG.PMS_IS_REMOTE and item['local'] == '1': + if not plexpy.CONFIG.PMS_IS_REMOTE and item['local'] == '1' and 'plex.direct' in item['uri']: plexpy.CONFIG.__setattr__('PMS_URL', item['uri']) plexpy.CONFIG.write() logger.info(u"PlexPy PlexTV :: Server URL retrieved.") From 82b7128c04900b8ce11c581d148fb7996c1cf3f3 Mon Sep 17 00:00:00 2001 From: Tim Van Date: Mon, 8 Feb 2016 01:28:58 +0200 Subject: [PATCH 09/47] Allow secure websocket connections. --- data/interfaces/default/settings.html | 2 +- plexpy/web_socket.py | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index 3e10c49c..84b97e32 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -490,7 +490,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents() Use Websocket (requires restart) [experimental]

Instead of polling the server at regular intervals let the server tell us when something happens.
- This is currently experimental. Encrypted websocket is not currently supported.

+ This is currently experimental.

From 1d9a4e0b99ab43569e8fc70532553cf3b2ce689a Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Mon, 8 Feb 2016 17:34:24 -0800 Subject: [PATCH 12/47] Add view_offset to history grouping logic --- plexpy/activity_processor.py | 7 +++++-- plexpy/datafactory.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/plexpy/activity_processor.py b/plexpy/activity_processor.py index fff24627..0ea6e532 100644 --- a/plexpy/activity_processor.py +++ b/plexpy/activity_processor.py @@ -182,7 +182,7 @@ class ActivityProcessor(object): self.db.action(query=query, args=args) # Check if we should group the session, select the last two rows from the user - query = 'SELECT id, rating_key, user_id, reference_id FROM session_history \ + query = 'SELECT id, rating_key, view_offset, user_id, reference_id FROM session_history \ WHERE user_id = ? ORDER BY id DESC LIMIT 2 ' args = [session['user_id']] @@ -191,6 +191,7 @@ class ActivityProcessor(object): new_session = {'id': result[0]['id'], 'rating_key': result[0]['rating_key'], + 'view_offset': result[0]['view_offset'], 'user_id': result[0]['user_id'], 'reference_id': result[0]['reference_id']} @@ -199,12 +200,14 @@ class ActivityProcessor(object): else: prev_session = {'id': result[1]['id'], 'rating_key': result[1]['rating_key'], + 'view_offset': result[1]['view_offset'], 'user_id': result[1]['user_id'], 'reference_id': result[1]['reference_id']} query = 'UPDATE session_history SET reference_id = ? WHERE id = ? ' # If rating_key is the same in the previous session, then set the reference_id to the previous row, else set the reference_id to the new id - if (prev_session is not None) and (prev_session['rating_key'] == new_session['rating_key']): + if (prev_session is not None) and (prev_session['rating_key'] == new_session['rating_key'] \ + and prev_session['view_offset'] <= new_session['view_offset']): args = [prev_session['reference_id'], new_session['id']] else: args = [new_session['id'], new_session['id']] diff --git a/plexpy/datafactory.py b/plexpy/datafactory.py index d4bb7111..edbeed16 100644 --- a/plexpy/datafactory.py +++ b/plexpy/datafactory.py @@ -58,7 +58,7 @@ class DataFactory(object): 'session_history_metadata.thumb', 'session_history_metadata.parent_thumb', 'session_history_metadata.grandparent_thumb', - '((CASE WHEN view_offset IS NULL THEN 0.1 ELSE view_offset * 1.0 END) / \ + 'MAX((CASE WHEN view_offset IS NULL THEN 0.1 ELSE view_offset * 1.0 END) / \ (CASE WHEN session_history_metadata.duration IS NULL THEN 1.0 \ ELSE session_history_metadata.duration * 1.0 END) * 100) AS percent_complete', 'session_history_media_info.video_decision', From 5c952b1d8624e0fe29359ec92429572231d40307 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Tue, 9 Feb 2016 17:08:44 -0800 Subject: [PATCH 13/47] Fix regression where {stream_duration} not reported --- plexpy/notification_handler.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py index 7b5ec947..a4234a9f 100644 --- a/plexpy/notification_handler.py +++ b/plexpy/notification_handler.py @@ -446,10 +446,9 @@ def build_notify_text(session=None, timeline=None, state=None): transcode_decision = 'Direct Play' if state != 'play': - stream_duration = helpers.convert_seconds_to_minutes( - time.time() - - helpers.cast_to_int(session.get('started', 0)) - - helpers.cast_to_int(session.get('paused_counter', 0))) + stream_duration = int((time.time() - + helpers.cast_to_int(session.get('started', 0)) - + helpers.cast_to_int(session.get('paused_counter', 0))) / 60) else: stream_duration = 0 From 6ebfc516a6ac23db00989685c1bfaa277194c261 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Tue, 9 Feb 2016 17:08:59 -0800 Subject: [PATCH 14/47] Add ETA to current activity --- data/interfaces/default/current_activity.html | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/data/interfaces/default/current_activity.html b/data/interfaces/default/current_activity.html index 5b4ab21e..47be0dcd 100644 --- a/data/interfaces/default/current_activity.html +++ b/data/interfaces/default/current_activity.html @@ -198,6 +198,13 @@ DOCUMENTATION :: END % else: IP: N/A % endif +
+ ETA: + + +
${a['view_offset']}/${a['duration']} From 71131c699e811418265ef8611eba6dc3aab5edd4 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Tue, 9 Feb 2016 17:58:56 -0800 Subject: [PATCH 15/47] Add total duration to libraries and users tables --- data/interfaces/default/css/plexpy.css | 4 +++ data/interfaces/default/js/script.js | 29 +++++++++++++++---- .../interfaces/default/js/tables/libraries.js | 18 +++++++++++- data/interfaces/default/js/tables/users.js | 18 +++++++++++- data/interfaces/default/libraries.html | 6 ++++ data/interfaces/default/users.html | 6 ++++ plexpy/libraries.py | 4 +++ plexpy/users.py | 4 +++ 8 files changed, 82 insertions(+), 7 deletions(-) diff --git a/data/interfaces/default/css/plexpy.css b/data/interfaces/default/css/plexpy.css index 6c683f76..c271be54 100644 --- a/data/interfaces/default/css/plexpy.css +++ b/data/interfaces/default/css/plexpy.css @@ -2180,6 +2180,10 @@ a .home-platforms-instance-list-oval:hover, .refresh-libraries-button { float: right; } +.refresh-users-button, +.refresh-libraries-button { + margin-right: 5px; +} .nav-settings, .nav-settings ul { margin: 0px 0px 20px 0px; diff --git a/data/interfaces/default/js/script.js b/data/interfaces/default/js/script.js index 5d82f90c..1c416319 100644 --- a/data/interfaces/default/js/script.js +++ b/data/interfaces/default/js/script.js @@ -252,13 +252,13 @@ function isPrivateIP(ip_address) { function humanTime(seconds) { if (seconds >= 86400) { - text = '

' + Math.floor(moment.duration(seconds, 'seconds').asDays()) + - '

days

' + Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) + - '

hrs

' + Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + '

mins

'; + text = '

' + Math.floor(moment.duration(seconds, 'seconds').asDays()) + '

days

' + + '

' + Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) + '

hrs

' + + '

' + Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + '

mins

'; return text; } else if (seconds >= 3600) { - text = '

' + Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) + - '

hrs

' + Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + '

mins

'; + text = '

' + Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) + '

hrs

' + + '

' + Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + '

mins

'; return text; } else if (seconds >= 60) { text = '

' + Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + '

mins

'; @@ -269,6 +269,25 @@ function humanTime(seconds) { } } +function humanTimeClean(seconds) { + if (seconds >= 86400) { + text = Math.floor(moment.duration(seconds, 'seconds').asDays()) + ' days ' + + Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) + ' hrs ' + + Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + ' mins'; + return text; + } else if (seconds >= 3600) { + text = Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) + ' hrs ' + + Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + ' mins'; + return text; + } else if (seconds >= 60) { + text = Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + ' mins'; + return text; + } else { + text = '0'; + return text; + } +} + String.prototype.toProperCase = function () { return this.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();}); }; diff --git a/data/interfaces/default/js/tables/libraries.js b/data/interfaces/default/js/tables/libraries.js index 377295ae..46844506 100644 --- a/data/interfaces/default/js/tables/libraries.js +++ b/data/interfaces/default/js/tables/libraries.js @@ -161,12 +161,28 @@ libraries_list_table_options = { $(td).html('n/a'); } }, - "width": "25%", + "width": "18%", "className": "hidden-sm hidden-xs" }, { "targets": [9], "data": "plays", + "createdCell": function (td, cellData, rowData, row, col) { + if (cellData !== null && cellData !== '') { + $(td).html(cellData); + } + }, + "searchable": false, + "width": "7%" + }, + { + "targets": [10], + "data": "duration", + "createdCell": function (td, cellData, rowData, row, col) { + if (cellData !== null && cellData !== '') { + $(td).html(humanTimeClean(cellData)); + } + }, "searchable": false, "width": "10%" } diff --git a/data/interfaces/default/js/tables/users.js b/data/interfaces/default/js/tables/users.js index f7147a17..d1abb6c9 100644 --- a/data/interfaces/default/js/tables/users.js +++ b/data/interfaces/default/js/tables/users.js @@ -165,12 +165,28 @@ users_list_table_options = { $(td).html('n/a'); } }, - "width": "30%", + "width": "23%", "className": "hidden-sm hidden-xs" }, { "targets": [8], "data": "plays", + "createdCell": function (td, cellData, rowData, row, col) { + if (cellData !== null && cellData !== '') { + $(td).html(cellData); + } + }, + "searchable": false, + "width": "7%" + }, + { + "targets": [9], + "data": "duration", + "createdCell": function (td, cellData, rowData, row, col) { + if (cellData !== null && cellData !== '') { + $(td).html(humanTimeClean(cellData)); + } + }, "searchable": false, "width": "10%" } diff --git a/data/interfaces/default/libraries.html b/data/interfaces/default/libraries.html index 1e99697c..41fc9825 100644 --- a/data/interfaces/default/libraries.html +++ b/data/interfaces/default/libraries.html @@ -2,6 +2,7 @@ <%def name="headIncludes()"> + @@ -23,6 +24,7 @@ All Libraries
+ % if config['update_section_ids'] == -1: % else: @@ -48,6 +50,7 @@
+ @@ -79,6 +82,7 @@ <%def name="javascriptIncludes()"> + @@ -96,6 +100,8 @@ } libraries_list_table = $('#libraries_list_table').DataTable(libraries_list_table_options); + var colvis = new $.fn.dataTable.ColVis(libraries_list_table, { buttonText: ' Select columns', buttonClass: 'btn btn-dark', exclude: [0, 1] }); + $(colvis.button()).appendTo('div.colvis-button-bar'); clearSearchButton('libraries_list_table', libraries_list_table); diff --git a/data/interfaces/default/users.html b/data/interfaces/default/users.html index 69c725a2..9b1a9dd1 100644 --- a/data/interfaces/default/users.html +++ b/data/interfaces/default/users.html @@ -2,6 +2,7 @@ <%def name="headIncludes()"> + @@ -12,6 +13,7 @@ All Users
+
+ @@ -67,6 +70,7 @@ <%def name="javascriptIncludes()"> + @@ -84,6 +88,8 @@ } users_list_table = $('#users_list_table').DataTable(users_list_table_options); + var colvis = new $.fn.dataTable.ColVis(users_list_table, { buttonText: ' Select columns', buttonClass: 'btn btn-dark', exclude: [0, 1] }); + $(colvis.button()).appendTo('div.colvis-button-bar'); clearSearchButton('users_list_table', users_list_table); diff --git a/plexpy/libraries.py b/plexpy/libraries.py index cbe2e33e..771f3c7a 100644 --- a/plexpy/libraries.py +++ b/plexpy/libraries.py @@ -133,6 +133,9 @@ class Libraries(object): 'library_sections.custom_thumb_url AS custom_thumb', 'library_sections.art', 'COUNT(session_history.id) AS plays', + 'SUM(CASE WHEN session_history.stopped > 0 THEN (session_history.stopped - session_history.started) \ + ELSE 0 END) - SUM(CASE WHEN session_history.paused_counter IS NULL THEN 0 ELSE \ + session_history.paused_counter END) AS duration', 'MAX(session_history.started) AS last_accessed', 'MAX(session_history.id) AS id', 'session_history_metadata.full_title AS last_played', @@ -200,6 +203,7 @@ class Libraries(object): 'library_thumb': library_thumb, 'library_art': item['art'], 'plays': item['plays'], + 'duration': item['duration'], 'last_accessed': item['last_accessed'], 'id': item['id'], 'last_played': item['last_played'], diff --git a/plexpy/users.py b/plexpy/users.py index 19294285..8f5c5d9c 100644 --- a/plexpy/users.py +++ b/plexpy/users.py @@ -32,6 +32,9 @@ class Users(object): 'users.thumb AS user_thumb', 'users.custom_avatar_url AS custom_thumb', 'COUNT(session_history.id) AS plays', + 'SUM(CASE WHEN session_history.stopped > 0 THEN (session_history.stopped - session_history.started) \ + ELSE 0 END) - SUM(CASE WHEN session_history.paused_counter IS NULL THEN 0 ELSE \ + session_history.paused_counter END) AS duration', 'MAX(session_history.started) AS last_seen', 'MAX(session_history.id) AS id', 'session_history_metadata.full_title AS last_played', @@ -100,6 +103,7 @@ class Users(object): 'friendly_name': item['friendly_name'], 'user_thumb': user_thumb, 'plays': item['plays'], + 'duration': item['duration'], 'last_seen': item['last_seen'], 'last_played': item['last_played'], 'id': item['id'], From 42bfacfb19aa22b72df3f65db53f8fb8567561c0 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Tue, 9 Feb 2016 22:20:17 -0800 Subject: [PATCH 16/47] Add IMDB, TVDB, TMDb, last.fm, and trakt to notification options --- data/interfaces/default/css/plexpy.css | 4 ++ data/interfaces/default/settings.html | 55 ++++++++++++++++++++++---- plexpy/notification_handler.py | 39 ++++++++++++++++++ 3 files changed, 90 insertions(+), 8 deletions(-) diff --git a/data/interfaces/default/css/plexpy.css b/data/interfaces/default/css/plexpy.css index c271be54..a9a6f36e 100644 --- a/data/interfaces/default/css/plexpy.css +++ b/data/interfaces/default/css/plexpy.css @@ -2718,4 +2718,8 @@ table[id^='media_info_child'] table[id^='media_info_child'] thead th { } .selectize-input input[type='text'] { height: 20px; +} +.small-muted { + font-size: small; + color: #777; } \ No newline at end of file diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index 7dabae20..1a8651a8 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -1128,7 +1128,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents() - + @@ -1291,7 +1291,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents() - + @@ -1299,7 +1299,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents() - + @@ -1307,7 +1307,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents() - + @@ -1323,7 +1323,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents() - + @@ -1357,21 +1357,60 @@ available_notification_agents = sorted(notifiers.available_notification_agents() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - +
Last Accessed Last Played Total PlaysTotal Duration
Last Player Last Played Total PlaysTotal Duration
{ip_address}The IP address of the device being used for playback. (PMS 0.9.14 and above)The IP address of the device being used for playback. (PMS 0.9.14 and above)
{stream_duration}
{season_num}The season number for the item if item is episode.The season number for the episode.
{season_num00}
{episode_num}The episode number for the item if item is episode.The episode number for the episode.
{episode_num00}
{track_num}The track number for the item if item is track.The track number for the track.
{track_num00}
{content_rating}The content rating for the item. (e.g. TV-MA, TV-PG, etc.)The content rating for the item. (e.g. TV-MA, TV-PG, etc.)
{directors}{duration} The duration (in minutes) for the item.
{imdb_id}The IMDB ID for the movie. (e.g. tt2488496) +

(PMS agent must be Freebase)

{imdb_url}The IMDB URL for the movie. +

(PMS agent must be Freebase)

{thetvdb_id}The TVDB ID for the TV show. (e.g. 121361) +

(PMS agent must be TheTVDB)

{thetvdb_url}The TVDB URL for the TV show. +

(PMS agent must be TheTVDB)

{themoviedb_id}The TMDb ID for the movie or TV show. (e.g. 15260) +

(PMS agent must be The Movie Database)

{themoviedb_url}The TMDb URL for the movie or TV show. +

(PMS agent must be The Movie Database)

{lastfm_url}The last.fm URL for the album. +

(PMS agent must be Last.fm)

{trakt_url}The trakt.tv URL for the movie or TV show.
{section_id} The unique identifier for the library.
{rating_key}The unique identifier for the item.The unique identifier for the movie, episode, or track.
{parent_rating_key}The unique identifier for the item's parent (season or album).The unique identifier for the season or album.
{grandparent_rating_key}The unique identifier for the item's grandparent (TV show or artist).The unique identifier for the TV show or artist.
diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py index a4234a9f..a5cb0480 100644 --- a/plexpy/notification_handler.py +++ b/plexpy/notification_handler.py @@ -457,6 +457,37 @@ def build_notify_text(session=None, timeline=None, state=None): progress_percent = helpers.get_percent(view_offset, duration) remaining_duration = duration - view_offset + # Get media IDs from guid and build URLs + if 'imdb://' in metadata['guid']: + metadata['imdb_id'] = metadata['guid'].split('imdb://')[1].split('?')[0] + metadata['imdb_url'] = 'https://www.imdb.com/title/' + metadata['imdb_id'] + metadata['trakt_url'] = 'https://trakt.tv/search/imdb/' + metadata['imdb_id'] + + if 'thetvdb://' in metadata['guid']: + metadata['thetvdb_id'] = metadata['guid'].split('thetvdb://')[1].split('/')[0] + metadata['thetvdb_url'] = 'https://thetvdb.com/?tab=series&id=' + metadata['thetvdb_id'] + metadata['trakt_url'] = 'https://trakt.tv/search/tvdb/' + metadata['thetvdb_id'] + '?id_type=show' + + elif 'thetvdbdvdorder://' in metadata['guid']: + metadata['thetvdb_id'] = metadata['guid'].split('thetvdbdvdorder://')[1].split('/')[0] + metadata['thetvdb_url'] = 'https://thetvdb.com/?tab=series&id=' + metadata['thetvdb_id'] + metadata['trakt_url'] = 'https://trakt.tv/search/tvdb/' + metadata['thetvdb_id'] + '?id_type=show' + + if 'themoviedb://' in metadata['guid']: + if metadata['media_type'] == 'movie': + metadata['themoviedb_id'] = metadata['guid'].split('themoviedb://')[1].split('?')[0] + metadata['themoviedb_url'] = 'https://www.themoviedb.org/movie/' + metadata['themoviedb_id'] + metadata['trakt_url'] = 'https://trakt.tv/search/tmdb/' + metadata['themoviedb_id'] + '?id_type=movie' + + elif metadata['media_type'] == 'show' or metadata['media_type'] == 'episode': + metadata['themoviedb_id'] = metadata['guid'].split('themoviedb://')[1].split('/')[0] + metadata['themoviedb_url'] = 'https://www.themoviedb.org/tv/' + metadata['themoviedb_id'] + metadata['trakt_url'] = 'https://trakt.tv/search/tmdb/' + metadata['themoviedb_id'] + '?id_type=show' + + if 'lastfm://' in metadata['guid']: + metadata['lastfm_id'] = metadata['guid'].split('lastfm://')[1].rsplit('/', 1)[0] + metadata['lastfm_url'] = 'https://www.last.fm/music/' + metadata['lastfm_id'] + # Fix metadata params for notify recently added grandparent if state == 'created' and plexpy.CONFIG.NOTIFY_RECENTLY_ADDED_GRANDPARENT: show_name = metadata['title'] @@ -538,6 +569,14 @@ def build_notify_text(session=None, timeline=None, state=None): 'tagline': metadata['tagline'], 'rating': metadata['rating'], 'duration': duration, + 'imdb_id': metadata.get('imdb_id',''), + 'imdb_url': metadata.get('imdb_url',''), + 'thetvdb_id': metadata.get('thetvdb_id',''), + 'thetvdb_url': metadata.get('thetvdb_url',''), + 'themoviedb_id': metadata.get('themoviedb_id',''), + 'themoviedb_url': metadata.get('themoviedb_url',''), + 'lastfm_url': metadata.get('lastfm_url',''), + 'trakt_url': metadata.get('trakt_url',''), 'section_id': metadata['section_id'], 'rating_key': metadata['rating_key'], 'parent_rating_key': metadata['parent_rating_key'], From 9359567a8a8baa87575c8301b6d26b07dfe2bfe8 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Tue, 9 Feb 2016 23:00:10 -0800 Subject: [PATCH 17/47] Add optional subject line to notification agents --- plexpy/config.py | 4 ++++ plexpy/notifiers.py | 56 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/plexpy/config.py b/plexpy/config.py index 984d57bc..9da5a15e 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -85,6 +85,7 @@ _CONFIG_DEFINITIONS = { 'FACEBOOK_APP_SECRET': (str, 'Facebook', ''), 'FACEBOOK_TOKEN': (str, 'Facebook', ''), 'FACEBOOK_GROUP': (str, 'Facebook', ''), + 'FACEBOOK_INCL_SUBJECT': (int, 'Facebook', 1), 'FACEBOOK_ON_PLAY': (int, 'Facebook', 0), 'FACEBOOK_ON_STOP': (int, 'Facebook', 0), 'FACEBOOK_ON_PAUSE': (int, 'Facebook', 0), @@ -304,6 +305,7 @@ _CONFIG_DEFINITIONS = { 'SLACK_HOOK': (str, 'Slack', ''), 'SLACK_CHANNEL': (str, 'Slack', ''), 'SLACK_ICON_EMOJI': (str, 'Slack', ''), + 'SLACK_INCL_SUBJECT': (int, 'Slack', 1), 'SLACK_USERNAME': (str, 'Slack', ''), 'SLACK_ON_PLAY': (int, 'Slack', 0), 'SLACK_ON_STOP': (int, 'Slack', 0), @@ -343,6 +345,7 @@ _CONFIG_DEFINITIONS = { 'TELEGRAM_BOT_TOKEN': (str, 'Telegram', ''), 'TELEGRAM_ENABLED': (int, 'Telegram', 0), 'TELEGRAM_CHAT_ID': (str, 'Telegram', ''), + 'TELEGRAM_INCL_SUBJECT': (int, 'Telegram', 1), 'TELEGRAM_ON_PLAY': (int, 'Telegram', 0), 'TELEGRAM_ON_STOP': (int, 'Telegram', 0), 'TELEGRAM_ON_PAUSE': (int, 'Telegram', 0), @@ -364,6 +367,7 @@ _CONFIG_DEFINITIONS = { 'TWITTER_ACCESS_TOKEN_SECRET': (str, 'Twitter', ''), 'TWITTER_CONSUMER_KEY': (str, 'Twitter', ''), 'TWITTER_CONSUMER_SECRET': (str, 'Twitter', ''), + 'TWITTER_INCL_SUBJECT': (int, 'Twitter', 1), 'TWITTER_ON_PLAY': (int, 'Twitter', 0), 'TWITTER_ON_STOP': (int, 'Twitter', 0), 'TWITTER_ON_PAUSE': (int, 'Twitter', 0), diff --git a/plexpy/notifiers.py b/plexpy/notifiers.py index 092eb9b9..a6274c63 100644 --- a/plexpy/notifiers.py +++ b/plexpy/notifiers.py @@ -1169,12 +1169,16 @@ class TwitterNotifier(object): self.access_token_secret = plexpy.CONFIG.TWITTER_ACCESS_TOKEN_SECRET self.consumer_key = plexpy.CONFIG.TWITTER_CONSUMER_KEY self.consumer_secret = plexpy.CONFIG.TWITTER_CONSUMER_SECRET + self.incl_subject = plexpy.CONFIG.TWITTER_INCL_SUBJECT def notify(self, subject, message): if not subject or not message: return else: - self._send_tweet(subject + ': ' + message) + if self.incl_subject: + self._send_tweet(subject + ': ' + message) + else: + self._send_tweet(message) def test_notify(self): return self._send_tweet("This is a test notification from PlexPy at " + helpers.now()) @@ -1284,6 +1288,12 @@ class TwitterNotifier(object): 'name': 'twitter_access_token_secret', 'description': 'Your Twitter access token secret.', 'input_type': 'text' + }, + {'label': 'Include Subject Line', + 'value': self.incl_subject, + 'name': 'twitter_incl_subject', + 'description': 'Include the subject line in the notifications.', + 'input_type': 'checkbox' } ] @@ -1628,6 +1638,7 @@ class TELEGRAM(object): self.enabled = plexpy.CONFIG.TELEGRAM_ENABLED self.bot_token = plexpy.CONFIG.TELEGRAM_BOT_TOKEN self.chat_id = plexpy.CONFIG.TELEGRAM_CHAT_ID + self.incl_subject = plexpy.CONFIG.TELEGRAM_INCL_SUBJECT def conf(self, options): return cherrypy.config['config'].get('Telegram', options) @@ -1638,8 +1649,13 @@ class TELEGRAM(object): http_handler = HTTPSConnection("api.telegram.org") + if self.incl_subject: + text = event.encode('utf-8') + ': ' + message.encode("utf-8") + else: + text = message.encode("utf-8") + data = {'chat_id': self.chat_id, - 'text': event.encode('utf-8') + ': ' + message.encode("utf-8")} + 'text': text} http_handler.request("POST", "/bot%s/%s" % (self.bot_token, "sendMessage"), @@ -1682,6 +1698,12 @@ class TELEGRAM(object): 'name': 'telegram_chat_id', 'description': 'Your Telegram Chat ID, Group ID, or @channelusername. Contact @myidbot on Telegram to get an ID.', 'input_type': 'text' + }, + {'label': 'Include Subject Line', + 'value': self.incl_subject, + 'name': 'telegram_incl_subject', + 'description': 'Include the subject line in the notifications.', + 'input_type': 'checkbox' } ] @@ -1698,6 +1720,7 @@ class SLACK(object): self.channel = plexpy.CONFIG.SLACK_CHANNEL self.username = plexpy.CONFIG.SLACK_USERNAME self.icon_emoji = plexpy.CONFIG.SLACK_ICON_EMOJI + self.incl_subject = plexpy.CONFIG.SLACK_INCL_SUBJECT def conf(self, options): return cherrypy.config['config'].get('Slack', options) @@ -1707,7 +1730,12 @@ class SLACK(object): return http_handler = HTTPSConnection("hooks.slack.com") - data = {'text': event.encode('utf-8') + ': ' + message.encode("utf-8")} + if self.incl_subject: + text = event.encode('utf-8') + ': ' + message.encode("utf-8") + else: + text = message.encode("utf-8") + + data = {'text': text} if self.channel != '': data['channel'] = self.channel if self.username != '': data['username'] = self.username if self.icon_emoji != '': @@ -1745,10 +1773,10 @@ class SLACK(object): return self.notify('Main Screen Activate', 'Test Message') def return_config_options(self): - config_option = [{'label': 'Slack Hook', + config_option = [{'label': 'Slack Webhook URL', 'value': self.slack_hook, 'name': 'slack_hook', - 'description': 'Your Slack incoming webhook.', + 'description': 'Your Slack incoming webhook URL.', 'input_type': 'text' }, {'label': 'Slack Channel', @@ -1768,6 +1796,12 @@ class SLACK(object): 'description': 'The icon you wish to show, use Slack emoji or image url. Leave blank for webhook integration default.', 'name': 'slack_icon_emoji', 'input_type': 'text' + }, + {'label': 'Include Subject Line', + 'value': self.incl_subject, + 'name': 'slack_incl_subject', + 'description': 'Include the subject line in the notifications.', + 'input_type': 'checkbox' } ] @@ -2038,12 +2072,16 @@ class FacebookNotifier(object): self.app_id = plexpy.CONFIG.FACEBOOK_APP_ID self.app_secret = plexpy.CONFIG.FACEBOOK_APP_SECRET self.group_id = plexpy.CONFIG.FACEBOOK_GROUP + self.incl_subject = plexpy.CONFIG.FACEBOOK_INCL_SUBJECT def notify(self, subject, message): if not subject or not message: return else: - self._post_facebook(subject + ': ' + message) + if self.incl_subject: + self._post_facebook(subject + ': ' + message) + else: + self._post_facebook(message) def test_notify(self): return self._post_facebook(u"PlexPy Notifiers :: This is a test notification from PlexPy at " + helpers.now()) @@ -2143,6 +2181,12 @@ class FacebookNotifier(object): 'name': 'facebook_group', 'description': 'Your Facebook Group ID.', 'input_type': 'text' + }, + {'label': 'Include Subject Line', + 'value': self.incl_subject, + 'name': 'facebook_incl_subject', + 'description': 'Include the subject line in the notifications.', + 'input_type': 'checkbox' } ] From 2fcd55eb602dc407d6f3fde1caecc61dea40cb79 Mon Sep 17 00:00:00 2001 From: Hellowlol Date: Wed, 16 Dec 2015 00:28:15 +0100 Subject: [PATCH 18/47] API2 --- .gitignore | 5 +- lib/profilehooks.py | 732 +++++++++++++++++++++++++++++++++++++++++ plexpy/__init__.py | 12 +- plexpy/api2.py | 495 ++++++++++++++++++++++++++++ plexpy/database.py | 54 ++- plexpy/helpers.py | 60 +++- plexpy/http_handler.py | 2 +- plexpy/pmsconnect.py | 76 ++--- plexpy/webserve.py | 360 +++++++++++++++----- plexpy/webstart.py | 31 +- 10 files changed, 1671 insertions(+), 156 deletions(-) create mode 100644 lib/profilehooks.py create mode 100644 plexpy/api2.py diff --git a/.gitignore b/.gitignore index 3e58c483..cb7eb12f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ cache/* *.csr *.pem +# Mergetool +*.orgin + # OS generated files # ###################### .DS_Store? @@ -32,7 +35,7 @@ Icon? Thumbs.db #Ignore files generated by PyCharm -.idea/* +*.idea/* #Ignore files generated by vi *.swp diff --git a/lib/profilehooks.py b/lib/profilehooks.py new file mode 100644 index 00000000..af599fae --- /dev/null +++ b/lib/profilehooks.py @@ -0,0 +1,732 @@ +""" +Profiling hooks + +This module contains a couple of decorators (`profile` and `coverage`) that +can be used to wrap functions and/or methods to produce profiles and line +coverage reports. There's a third convenient decorator (`timecall`) that +measures the duration of function execution without the extra profiling +overhead. + +Usage example (Python 2.4 or newer):: + + from profilehooks import profile, coverage + + @profile # or @coverage + def fn(n): + if n < 2: return 1 + else: return n * fn(n-1) + + print fn(42) + +Usage example (Python 2.3 or older):: + + from profilehooks import profile, coverage + + def fn(n): + if n < 2: return 1 + else: return n * fn(n-1) + + # Now wrap that function in a decorator + fn = profile(fn) # or coverage(fn) + + print fn(42) + +Reports for all thusly decorated functions will be printed to sys.stdout +on program termination. You can alternatively request for immediate +reports for each call by passing immediate=True to the profile decorator. + +There's also a @timecall decorator for printing the time to sys.stderr +every time a function is called, when you just want to get a rough measure +instead of a detailed (but costly) profile. + +Caveats + + A thread on python-dev convinced me that hotshot produces bogus numbers. + See http://mail.python.org/pipermail/python-dev/2005-November/058264.html + + I don't know what will happen if a decorated function will try to call + another decorated function. All decorators probably need to explicitly + support nested profiling (currently TraceFuncCoverage is the only one + that supports this, while HotShotFuncProfile has support for recursive + functions.) + + Profiling with hotshot creates temporary files (*.prof for profiling, + *.cprof for coverage) in the current directory. These files are not + cleaned up. Exception: when you specify a filename to the profile + decorator (to store the pstats.Stats object for later inspection), + the temporary file will be the filename you specified with '.raw' + appended at the end. + + Coverage analysis with hotshot seems to miss some executions resulting + in lower line counts and some lines errorneously marked as never + executed. For this reason coverage analysis now uses trace.py which is + slower, but more accurate. + +Copyright (c) 2004--2008 Marius Gedminas +Copyright (c) 2007 Hanno Schlichting +Copyright (c) 2008 Florian Schulze + +Released under the MIT licence since December 2006: + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + +(Previously it was distributed under the GNU General Public Licence.) +""" +# $Id: profilehooks.py 29 2010-08-13 16:29:20Z mg $ + +__author__ = "Marius Gedminas (marius@gedmin.as)" +__copyright__ = "Copyright 2004-2009 Marius Gedminas" +__license__ = "MIT" +__version__ = "1.4" +__date__ = "2009-03-31" + + +import atexit +import inspect +import sys +import re + +# For profiling +from profile import Profile +import pstats + +# For hotshot profiling (inaccurate!) +try: + import hotshot + import hotshot.stats +except ImportError: + hotshot = None + +# For trace.py coverage +import trace + +# For hotshot coverage (inaccurate!; uses undocumented APIs; might break) +if hotshot is not None: + import _hotshot + import hotshot.log + +# For cProfile profiling (best) +try: + import cProfile +except ImportError: + cProfile = None + +# For timecall +import time + + +# registry of available profilers +AVAILABLE_PROFILERS = {} + + +def profile(fn=None, skip=0, filename=None, immediate=False, dirs=False, + sort=None, entries=40, + profiler=('cProfile', 'profile', 'hotshot')): + """Mark `fn` for profiling. + + If `skip` is > 0, first `skip` calls to `fn` will not be profiled. + + If `immediate` is False, profiling results will be printed to + sys.stdout on program termination. Otherwise results will be printed + after each call. + + If `dirs` is False only the name of the file will be printed. + Otherwise the full path is used. + + `sort` can be a list of sort keys (defaulting to ['cumulative', + 'time', 'calls']). The following ones are recognized:: + + 'calls' -- call count + 'cumulative' -- cumulative time + 'file' -- file name + 'line' -- line number + 'module' -- file name + 'name' -- function name + 'nfl' -- name/file/line + 'pcalls' -- call count + 'stdname' -- standard name + 'time' -- internal time + + `entries` limits the output to the first N entries. + + `profiler` can be used to select the preferred profiler, or specify a + sequence of them, in order of preference. The default is ('cProfile'. + 'profile', 'hotshot'). + + If `filename` is specified, the profile stats will be stored in the + named file. You can load them pstats.Stats(filename). + + Usage:: + + def fn(...): + ... + fn = profile(fn, skip=1) + + If you are using Python 2.4, you should be able to use the decorator + syntax:: + + @profile(skip=3) + def fn(...): + ... + + or just :: + + @profile + def fn(...): + ... + + """ + if fn is None: # @profile() syntax -- we are a decorator maker + def decorator(fn): + return profile(fn, skip=skip, filename=filename, + immediate=immediate, dirs=dirs, + sort=sort, entries=entries, + profiler=profiler) + return decorator + # @profile syntax -- we are a decorator. + if isinstance(profiler, str): + profiler = [profiler] + for p in profiler: + if p in AVAILABLE_PROFILERS: + profiler_class = AVAILABLE_PROFILERS[p] + break + else: + raise ValueError('only these profilers are available: %s' + % ', '.join(AVAILABLE_PROFILERS)) + fp = profiler_class(fn, skip=skip, filename=filename, + immediate=immediate, dirs=dirs, + sort=sort, entries=entries) + # fp = HotShotFuncProfile(fn, skip=skip, filename=filename, ...) + # or HotShotFuncProfile + # We cannot return fp or fp.__call__ directly as that would break method + # definitions, instead we need to return a plain function. + def new_fn(*args, **kw): + return fp(*args, **kw) + new_fn.__doc__ = fn.__doc__ + new_fn.__name__ = fn.__name__ + new_fn.__dict__ = fn.__dict__ + new_fn.__module__ = fn.__module__ + return new_fn + + +def coverage(fn): + """Mark `fn` for line coverage analysis. + + Results will be printed to sys.stdout on program termination. + + Usage:: + + def fn(...): + ... + fn = coverage(fn) + + If you are using Python 2.4, you should be able to use the decorator + syntax:: + + @coverage + def fn(...): + ... + + """ + fp = TraceFuncCoverage(fn) # or HotShotFuncCoverage + # We cannot return fp or fp.__call__ directly as that would break method + # definitions, instead we need to return a plain function. + def new_fn(*args, **kw): + return fp(*args, **kw) + new_fn.__doc__ = fn.__doc__ + new_fn.__name__ = fn.__name__ + new_fn.__dict__ = fn.__dict__ + new_fn.__module__ = fn.__module__ + return new_fn + + +def coverage_with_hotshot(fn): + """Mark `fn` for line coverage analysis. + + Uses the 'hotshot' module for fast coverage analysis. + + BUG: Produces inaccurate results. + + See the docstring of `coverage` for usage examples. + """ + fp = HotShotFuncCoverage(fn) + # We cannot return fp or fp.__call__ directly as that would break method + # definitions, instead we need to return a plain function. + def new_fn(*args, **kw): + return fp(*args, **kw) + new_fn.__doc__ = fn.__doc__ + new_fn.__name__ = fn.__name__ + new_fn.__dict__ = fn.__dict__ + new_fn.__module__ = fn.__module__ + return new_fn + + +class FuncProfile(object): + """Profiler for a function (uses profile).""" + + # This flag is shared between all instances + in_profiler = False + + Profile = Profile + + def __init__(self, fn, skip=0, filename=None, immediate=False, dirs=False, + sort=None, entries=40): + """Creates a profiler for a function. + + Every profiler has its own log file (the name of which is derived + from the function name). + + FuncProfile registers an atexit handler that prints profiling + information to sys.stderr when the program terminates. + """ + self.fn = fn + self.skip = skip + self.filename = filename + self.immediate = immediate + self.dirs = dirs + self.sort = sort or ('cumulative', 'time', 'calls') + if isinstance(self.sort, str): + self.sort = (self.sort, ) + self.entries = entries + self.reset_stats() + atexit.register(self.atexit) + + def __call__(self, *args, **kw): + """Profile a singe call to the function.""" + self.ncalls += 1 + if self.skip > 0: + self.skip -= 1 + self.skipped += 1 + return self.fn(*args, **kw) + if FuncProfile.in_profiler: + # handle recursive calls + return self.fn(*args, **kw) + # You cannot reuse the same profiler for many calls and accumulate + # stats that way. :-/ + profiler = self.Profile() + try: + FuncProfile.in_profiler = True + return profiler.runcall(self.fn, *args, **kw) + finally: + FuncProfile.in_profiler = False + self.stats.add(profiler) + if self.immediate: + self.print_stats() + self.reset_stats() + + def print_stats(self): + """Print profile information to sys.stdout.""" + funcname = self.fn.__name__ + filename = self.fn.func_code.co_filename + lineno = self.fn.func_code.co_firstlineno + print + print "*** PROFILER RESULTS ***" + print "%s (%s:%s)" % (funcname, filename, lineno) + print "function called %d times" % self.ncalls, + if self.skipped: + print "(%d calls not profiled)" % self.skipped + else: + print + print + stats = self.stats + if self.filename: + stats.dump_stats(self.filename) + if not self.dirs: + stats.strip_dirs() + stats.sort_stats(*self.sort) + stats.print_stats(self.entries) + + def reset_stats(self): + """Reset accumulated profiler statistics.""" + # Note: not using self.Profile, since pstats.Stats() fails then + self.stats = pstats.Stats(Profile()) + self.ncalls = 0 + self.skipped = 0 + + def atexit(self): + """Stop profiling and print profile information to sys.stdout. + + This function is registered as an atexit hook. + """ + if not self.immediate: + self.print_stats() + + +AVAILABLE_PROFILERS['profile'] = FuncProfile + + +if cProfile is not None: + + class CProfileFuncProfile(FuncProfile): + """Profiler for a function (uses cProfile).""" + + Profile = cProfile.Profile + + AVAILABLE_PROFILERS['cProfile'] = CProfileFuncProfile + + +if hotshot is not None: + + class HotShotFuncProfile(object): + """Profiler for a function (uses hotshot).""" + + # This flag is shared between all instances + in_profiler = False + + def __init__(self, fn, skip=0, filename=None): + """Creates a profiler for a function. + + Every profiler has its own log file (the name of which is derived + from the function name). + + HotShotFuncProfile registers an atexit handler that prints + profiling information to sys.stderr when the program terminates. + + The log file is not removed and remains there to clutter the + current working directory. + """ + self.fn = fn + self.filename = filename + if self.filename: + self.logfilename = filename + ".raw" + else: + self.logfilename = fn.__name__ + ".prof" + self.profiler = hotshot.Profile(self.logfilename) + self.ncalls = 0 + self.skip = skip + self.skipped = 0 + atexit.register(self.atexit) + + def __call__(self, *args, **kw): + """Profile a singe call to the function.""" + self.ncalls += 1 + if self.skip > 0: + self.skip -= 1 + self.skipped += 1 + return self.fn(*args, **kw) + if HotShotFuncProfile.in_profiler: + # handle recursive calls + return self.fn(*args, **kw) + try: + HotShotFuncProfile.in_profiler = True + return self.profiler.runcall(self.fn, *args, **kw) + finally: + HotShotFuncProfile.in_profiler = False + + def atexit(self): + """Stop profiling and print profile information to sys.stderr. + + This function is registered as an atexit hook. + """ + self.profiler.close() + funcname = self.fn.__name__ + filename = self.fn.func_code.co_filename + lineno = self.fn.func_code.co_firstlineno + print + print "*** PROFILER RESULTS ***" + print "%s (%s:%s)" % (funcname, filename, lineno) + print "function called %d times" % self.ncalls, + if self.skipped: + print "(%d calls not profiled)" % self.skipped + else: + print + print + stats = hotshot.stats.load(self.logfilename) + # hotshot.stats.load takes ages, and the .prof file eats megabytes, but + # a saved stats object is small and fast + if self.filename: + stats.dump_stats(self.filename) + # it is best to save before strip_dirs + stats.strip_dirs() + stats.sort_stats('cumulative', 'time', 'calls') + stats.print_stats(40) + + AVAILABLE_PROFILERS['hotshot'] = HotShotFuncProfile + + + class HotShotFuncCoverage: + """Coverage analysis for a function (uses _hotshot). + + HotShot coverage is reportedly faster than trace.py, but it appears to + have problems with exceptions; also line counts in coverage reports + are generally lower from line counts produced by TraceFuncCoverage. + Is this my bug, or is it a problem with _hotshot? + """ + + def __init__(self, fn): + """Creates a profiler for a function. + + Every profiler has its own log file (the name of which is derived + from the function name). + + HotShotFuncCoverage registers an atexit handler that prints + profiling information to sys.stderr when the program terminates. + + The log file is not removed and remains there to clutter the + current working directory. + """ + self.fn = fn + self.logfilename = fn.__name__ + ".cprof" + self.profiler = _hotshot.coverage(self.logfilename) + self.ncalls = 0 + atexit.register(self.atexit) + + def __call__(self, *args, **kw): + """Profile a singe call to the function.""" + self.ncalls += 1 + return self.profiler.runcall(self.fn, args, kw) + + def atexit(self): + """Stop profiling and print profile information to sys.stderr. + + This function is registered as an atexit hook. + """ + self.profiler.close() + funcname = self.fn.__name__ + filename = self.fn.func_code.co_filename + lineno = self.fn.func_code.co_firstlineno + print + print "*** COVERAGE RESULTS ***" + print "%s (%s:%s)" % (funcname, filename, lineno) + print "function called %d times" % self.ncalls + print + fs = FuncSource(self.fn) + reader = hotshot.log.LogReader(self.logfilename) + for what, (filename, lineno, funcname), tdelta in reader: + if filename != fs.filename: + continue + if what == hotshot.log.LINE: + fs.mark(lineno) + if what == hotshot.log.ENTER: + # hotshot gives us the line number of the function definition + # and never gives us a LINE event for the first statement in + # a function, so if we didn't perform this mapping, the first + # statement would be marked as never executed + if lineno == fs.firstlineno: + lineno = fs.firstcodelineno + fs.mark(lineno) + reader.close() + print fs + + +class TraceFuncCoverage: + """Coverage analysis for a function (uses trace module). + + HotShot coverage analysis is reportedly faster, but it appears to have + problems with exceptions. + """ + + # Shared between all instances so that nested calls work + tracer = trace.Trace(count=True, trace=False, + ignoredirs=[sys.prefix, sys.exec_prefix]) + + # This flag is also shared between all instances + tracing = False + + def __init__(self, fn): + """Creates a profiler for a function. + + Every profiler has its own log file (the name of which is derived + from the function name). + + TraceFuncCoverage registers an atexit handler that prints + profiling information to sys.stderr when the program terminates. + + The log file is not removed and remains there to clutter the + current working directory. + """ + self.fn = fn + self.logfilename = fn.__name__ + ".cprof" + self.ncalls = 0 + atexit.register(self.atexit) + + def __call__(self, *args, **kw): + """Profile a singe call to the function.""" + self.ncalls += 1 + if TraceFuncCoverage.tracing: + return self.fn(*args, **kw) + try: + TraceFuncCoverage.tracing = True + return self.tracer.runfunc(self.fn, *args, **kw) + finally: + TraceFuncCoverage.tracing = False + + def atexit(self): + """Stop profiling and print profile information to sys.stderr. + + This function is registered as an atexit hook. + """ + funcname = self.fn.__name__ + filename = self.fn.func_code.co_filename + lineno = self.fn.func_code.co_firstlineno + print + print "*** COVERAGE RESULTS ***" + print "%s (%s:%s)" % (funcname, filename, lineno) + print "function called %d times" % self.ncalls + print + fs = FuncSource(self.fn) + for (filename, lineno), count in self.tracer.counts.items(): + if filename != fs.filename: + continue + fs.mark(lineno, count) + print fs + never_executed = fs.count_never_executed() + if never_executed: + print "%d lines were not executed." % never_executed + + +class FuncSource: + """Source code annotator for a function.""" + + blank_rx = re.compile(r"^\s*finally:\s*(#.*)?$") + + def __init__(self, fn): + self.fn = fn + self.filename = inspect.getsourcefile(fn) + self.source, self.firstlineno = inspect.getsourcelines(fn) + self.sourcelines = {} + self.firstcodelineno = self.firstlineno + self.find_source_lines() + + def find_source_lines(self): + """Mark all executable source lines in fn as executed 0 times.""" + strs = trace.find_strings(self.filename) + lines = trace.find_lines_from_code(self.fn.func_code, strs) + self.firstcodelineno = sys.maxint + for lineno in lines: + self.firstcodelineno = min(self.firstcodelineno, lineno) + self.sourcelines.setdefault(lineno, 0) + if self.firstcodelineno == sys.maxint: + self.firstcodelineno = self.firstlineno + + def mark(self, lineno, count=1): + """Mark a given source line as executed count times. + + Multiple calls to mark for the same lineno add up. + """ + self.sourcelines[lineno] = self.sourcelines.get(lineno, 0) + count + + def count_never_executed(self): + """Count statements that were never executed.""" + lineno = self.firstlineno + counter = 0 + for line in self.source: + if self.sourcelines.get(lineno) == 0: + if not self.blank_rx.match(line): + counter += 1 + lineno += 1 + return counter + + def __str__(self): + """Return annotated source code for the function.""" + lines = [] + lineno = self.firstlineno + for line in self.source: + counter = self.sourcelines.get(lineno) + if counter is None: + prefix = ' ' * 7 + elif counter == 0: + if self.blank_rx.match(line): + prefix = ' ' * 7 + else: + prefix = '>' * 6 + ' ' + else: + prefix = '%5d: ' % counter + lines.append(prefix + line) + lineno += 1 + return ''.join(lines) + + +def timecall(fn=None, immediate=True, timer=time.time): + """Wrap `fn` and print its execution time. + + Example:: + + @timecall + def somefunc(x, y): + time.sleep(x * y) + + somefunc(2, 3) + + will print the time taken by somefunc on every call. If you want just + a summary at program termination, use + + @timecall(immediate=False) + + You can also choose a timing method other than the default ``time.time()``, + e.g.: + + @timecall(timer=time.clock) + + """ + if fn is None: # @timecall() syntax -- we are a decorator maker + def decorator(fn): + return timecall(fn, immediate=immediate, timer=timer) + return decorator + # @timecall syntax -- we are a decorator. + fp = FuncTimer(fn, immediate=immediate, timer=timer) + # We cannot return fp or fp.__call__ directly as that would break method + # definitions, instead we need to return a plain function. + def new_fn(*args, **kw): + return fp(*args, **kw) + new_fn.__doc__ = fn.__doc__ + new_fn.__name__ = fn.__name__ + new_fn.__dict__ = fn.__dict__ + new_fn.__module__ = fn.__module__ + return new_fn + + +class FuncTimer(object): + + def __init__(self, fn, immediate, timer): + self.fn = fn + self.ncalls = 0 + self.totaltime = 0 + self.immediate = immediate + self.timer = timer + if not immediate: + atexit.register(self.atexit) + + def __call__(self, *args, **kw): + """Profile a singe call to the function.""" + fn = self.fn + timer = self.timer + self.ncalls += 1 + try: + start = timer() + return fn(*args, **kw) + finally: + duration = timer() - start + self.totaltime += duration + if self.immediate: + funcname = fn.__name__ + filename = fn.func_code.co_filename + lineno = fn.func_code.co_firstlineno + print >> sys.stderr, "\n %s (%s:%s):\n %.3f seconds\n" % ( + funcname, filename, lineno, duration) + def atexit(self): + if not self.ncalls: + return + funcname = self.fn.__name__ + filename = self.fn.func_code.co_filename + lineno = self.fn.func_code.co_firstlineno + print ("\n %s (%s:%s):\n" + " %d calls, %.3f seconds (%.3f seconds per call)\n" % ( + funcname, filename, lineno, self.ncalls, + self.totaltime, self.totaltime / self.ncalls)) diff --git a/plexpy/__init__.py b/plexpy/__init__.py index 806833b6..aff3175d 100644 --- a/plexpy/__init__.py +++ b/plexpy/__init__.py @@ -57,6 +57,7 @@ _INITIALIZED = False started = False DATA_DIR = None +BACKUP_DIR = None CONFIG = None @@ -73,6 +74,7 @@ UMASK = None POLLING_FAILOVER = False + def initialize(config_file): with INIT_LOCK: @@ -82,7 +84,6 @@ def initialize(config_file): global LATEST_VERSION global UMASK global POLLING_FAILOVER - CONFIG = plexpy.config.Config(config_file) assert CONFIG is not None @@ -126,6 +127,12 @@ def initialize(config_file): except OSError as e: logger.error("Could not create cache dir '%s': %s", DATA_DIR, e) + plexpy.BACKUP_DIR = os.path.join(plexpy.PROG_DIR, 'backups') + try: + os.makedirs(plexpy.BACKUP_DIR) + except OSError: + pass + # Initialize the database logger.info('Checking to see if the database has all tables....') try: @@ -186,7 +193,6 @@ def initialize(config_file): _INITIALIZED = True return True - def daemonize(): if threading.activeCount() != 1: logger.warn( @@ -801,6 +807,7 @@ def dbcheck(): conn_db.commit() c_db.close() + def shutdown(restart=False, update=False): cherrypy.engine.exit() SCHED.shutdown(wait=False) @@ -833,6 +840,7 @@ def shutdown(restart=False, update=False): os._exit(0) + def generate_uuid(): logger.debug(u"Generating UUID...") return uuid.uuid4().hex diff --git a/plexpy/api2.py b/plexpy/api2.py new file mode 100644 index 00000000..849cc41a --- /dev/null +++ b/plexpy/api2.py @@ -0,0 +1,495 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of PlexPy. +# +# PlexPy 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. +# +# PlexPy 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 PlexPy. If not, see . + + +import hashlib +import inspect +import json +import os +import random +import re +import time +import traceback + +import cherrypy +import xmltodict + +import database +import logger +import plexpy + + +class API2: + def __init__(self, **kwargs): + self._api_valid_methods = self._api_docs().keys() + self._api_authenticated = False + self._api_out_type = 'json' # default + self._api_msg = None + self._api_debug = None + self._api_cmd = None + self._api_apikey = None + self._api_callback = None # JSONP + self._api_result_type = 'failed' + self._api_profileme = None # For profiling the api call + self._api_kwargs = None # Cleaned kwargs + + def _api_docs(self, md=False): + """ Makes the api docs """ + + docs = {} + for f, _ in inspect.getmembers(self, predicate=inspect.ismethod): + if not f.startswith('_') and not f.startswith('_api'): + if md is True: + docs[f] = inspect.getdoc(getattr(self, f)) if inspect.getdoc(getattr(self, f)) else None + else: + docs[f] = ' '.join(inspect.getdoc(getattr(self, f)).split()) if inspect.getdoc(getattr(self, f)) else None + return docs + + def docs_md(self): + """ Return a API.md to simplify api docs because of the decorator. """ + + return self._api_make_md() + + def docs(self): + """ Returns a dict where commands are keys, docstring are value. """ + + return self._api_docs() + + def _api_validate(self, *args, **kwargs): + """ sets class vars and remove unneeded parameters. """ + + if not plexpy.CONFIG.API_ENABLED: + self._api_msg = 'API not enabled' + + elif not plexpy.CONFIG.API_KEY: + self._api_msg = 'API key not generated' + + elif len(plexpy.CONFIG.API_KEY) != 32: + self._api_msg = 'API key not generated correctly' + + elif 'apikey' not in kwargs: + self._api_msg = 'Parameter apikey is required' + + elif kwargs.get('apikey', '') != plexpy.CONFIG.API_KEY: + self._api_msg = 'Invalid apikey' + + elif 'cmd' not in kwargs: + self._api_msg = 'Parameter cmd is required. Possible commands are: %s' % ', '.join(self._api_valid_methods) + + elif 'cmd' in kwargs and kwargs.get('cmd') not in self._api_valid_methods: + self._api_msg = 'Unknown command: %s. Possible commands are: %s' % (kwargs.get('cmd', ''), ', '.join(self._api_valid_methods)) + + self._api_callback = kwargs.pop('callback', None) + self._api_apikey = kwargs.pop('apikey', None) + self._api_cmd = kwargs.pop('cmd', None) + self._api_debug = kwargs.pop('debug', False) + self._api_profileme = kwargs.pop('profileme', None) + # Allow override for the api. + self._api_out_type = kwargs.pop('out_type', 'json') + + if self._api_apikey == plexpy.CONFIG.API_KEY and plexpy.CONFIG.API_ENABLED and self._api_cmd in self._api_valid_methods: + self._api_authenticated = True + self._api_msg = None + self._api_kwargs = kwargs + elif self._api_cmd in ('get_apikey', 'docs', 'docs_md') and plexpy.CONFIG.API_ENABLED: + self._api_authenticated = True + # Remove the old error msg + self._api_msg = None + self._api_kwargs = kwargs + + logger.debug(u'PlexPy APIv2 :: Cleaned kwargs %s' % self._api_kwargs) + + return self._api_kwargs + + def get_logs(self, sort='', search='', order='desc', regex='', start=0, end=0, **kwargs): + """ + Returns the log + + Args: + sort(string, optional): time, thread, msg, loglevel + search(string, optional): 'string' + order(string, optional): desc, asc + regex(string, optional): 'regexstring' + start(int, optional): int + end(int, optional): int + + + Returns: + ```{"response": + {"msg": "Hey", + "result": "success"}, + "data": [ + {"time": "29-sept.2015", + "thread: "MainThread", + "msg: "Called x from y", + "loglevel": "DEBUG" + } + ] + } + ``` + + """ + + logfile = os.path.join(plexpy.CONFIG.LOG_DIR, 'plexpy.log') + templog = [] + start = int(kwargs.get('start', 0)) + end = int(kwargs.get('end', 0)) + + if regex: + logger.debug(u'PlexPy APIv2 :: Filtering log using regex %s' % regex) + reg = re.compile('u' + regex, flags=re.I) + + for line in open(logfile, 'r').readlines(): + temp_loglevel_and_time = None + + try: + temp_loglevel_and_time = line.split('- ') + loglvl = temp_loglevel_and_time[1].split(' :')[0].strip() + tl_tread = line.split(' :: ') + if loglvl is None: + msg = line.replace('\n', '') + else: + msg = line.split(' : ')[1].replace('\n', '') + thread = tl_tread[1].split(' : ')[0] + except IndexError: + # We assume this is a traceback + tl = (len(templog) - 1) + templog[tl]['msg'] += line.replace('\n', '') + continue + + if len(line) > 1 and temp_loglevel_and_time is not None and loglvl in line: + + d = { + 'time': temp_loglevel_and_time[0], + 'loglevel': loglvl, + 'msg': msg.replace('\n', ''), + 'thread': thread + } + templog.append(d) + + if end > 0 or start > 0: + logger.debug(u'PlexPy APIv2 :: Slicing the log from %s to %s' % (start, end)) + templog = templog[start:end] + + if sort: + logger.debug(u'PlexPy APIv2 :: Sorting log based on %s' % sort) + templog = sorted(templog, key=lambda k: k[sort]) + + if search: + logger.debug(u'PlexPy APIv2 :: Searching log values for %s' % search) + tt = [d for d in templog for k, v in d.items() if search.lower() in v.lower()] + + if len(tt): + templog = tt + + if regex: + tt = [] + for l in templog: + stringdict = ' '.join('{}{}'.format(k, v) for k, v in l.items()) + if reg.search(stringdict): + tt.append(l) + + if len(tt): + templog = tt + + if order == 'desc': + templog = templog[::-1] + + self.data = templog + return templog + + def get_settings(self, key=''): + """ Fetches all settings from the config file + + Args: + key(string, optional): 'Run the it without args to see all args' + + Returns: + json: + ``` + {General: {api_enabled: true, ...} + Advanced: {cache_sizemb: "32", ...}} + ``` + """ + + interface_dir = os.path.join(plexpy.PROG_DIR, 'data/interfaces/') + interface_list = [name for name in os.listdir(interface_dir) if + os.path.isdir(os.path.join(interface_dir, name))] + + conf = plexpy.CONFIG._config + config = {} + + # Truthify the dict + for k, v in conf.iteritems(): + if isinstance(v, dict): + d = {} + for kk, vv in v.iteritems(): + if vv == '0' or vv == '1': + d[kk] = bool(vv) + else: + d[kk] = vv + config[k] = d + if k == 'General': + config[k]['interface'] = interface_dir + config[k]['interface_list'] = interface_list + + if key: + return config.get(key, None) + + return config + + def sql(self, query=''): + """ Query the db with raw sql, makes backup of + the db if the backup is older then 24h + """ + if not query: + return + + # allow the user to shoot them self + # in the foot but not in the head.. + if not len(os.listdir(plexpy.BACKUP_DIR)): + self.backupdb() + else: + # If the backup is less then 24 h old lets make a backup + if any([os.path.getctime(os.path.join(plexpy.BACKUP_DIR, file_)) < + (time.time() - 86400) for file_ in os.listdir(plexpy.BACKUP_DIR)]): + self.backupdb() + + db = database.MonitorDatabase() + rows = db.select(query) + self.data = rows + return rows + + def backupdb(self, cleanup=False): + """ Makes a backup of the db, removes all but the 3 last backups + + Args: + cleanup: (bool, optional) + """ + + data = database.make_backup(cleanup=cleanup) + + if data: + self.result_type = 'success' + else: + self.result_type = 'failed' + + return data + + def restart(self, **kwargs): + """ Restarts plexpy """ + + plexpy.SIGNAL = 'restart' + self.msg = 'Restarting plexpy' + self.result_type = 'success' + + def update(self, **kwargs): + """ Check for updates on Github """ + + plexpy.SIGNAL = 'update' + self.msg = 'Updating plexpy' + self.result_type = 'success' + + def _api_make_md(self): + """ Tries to make a API.md to simplify the api docs """ + + head = '''# API Reference\n +The API is still pretty new and needs some serious cleaning up on the backend but should be reasonably functional. There are no error codes yet. + +## General structure +The API endpoint is `http://ip:port + HTTP_ROOT + /api?apikey=$apikey&cmd=$command` + +Response example +``` +{ + "response": { + "data": [ + { + "loglevel": "INFO", + "msg": "Signal 2 caught, saving and exiting...", + "thread": "MainThread", + "time": "22-sep-2015 01:42:56 " + } + ], + "message": null, + "result": "success" + } +} +``` + +General parameters: + out_type: 'xml', + callback: 'pong', + 'debug': 1 + +## API methods''' + + body = '' + doc = self._api_docs(md=True) + for k in sorted(doc): + v = doc.get(k) + body += '### %s\n' % k + body += '' if not v else v + '\n' + body += '\n\n' + + result = head + '\n\n' + body + return '
' + result + '
' + + def get_apikey(self, username='', password=''): + """ Fetches apikey + + Args: + username(string, optional): Your username + password(string, optional): Your password + + Returns: + string: Apikey, args are required if auth is enabled + makes and saves the apikey it does not exist + """ + + apikey = hashlib.sha224(str(random.getrandbits(256))).hexdigest()[0:32] + if plexpy.CONFIG.HTTP_USERNAME and plexpy.CONFIG.HTTP_PASSWORD: + if username == plexpy.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD: + if plexpy.CONFIG.API_KEY: + self.data = plexpy.CONFIG.API_KEY + else: + self.data = apikey + plexpy.CONFIG.API_KEY = apikey + plexpy.CONFIG.write() + else: + self.msg = 'Authentication is enabled, please add the correct username and password to the parameters' + else: + if plexpy.CONFIG.API_KEY: + self.data = plexpy.CONFIG.API_KEY + else: + # Make a apikey if the doesn't exist + self.data = apikey + plexpy.CONFIG.API_KEY = apikey + plexpy.CONFIG.write() + + return self.data + + def _api_responds(self, result_type='success', data=None, msg=''): + """ Formats the result to a predefined dict so we can hange it the to + the desired output by _api_out_as """ + + if data is None: + data = {} + return {"response": {"result": result_type, "message": msg, "data": data}} + + def _api_out_as(self, out): + """ Formats the response to the desired output """ + + if self._api_cmd == 'docs_md': + return out['response']['data'] + + if self._api_out_type == 'json': + cherrypy.response.headers['Content-Type'] = 'application/json;charset=UTF-8' + try: + if self._api_debug: + out = json.dumps(out, indent=4, sort_keys=True) + else: + out = json.dumps(out) + if self._api_callback is not None: + cherrypy.response.headers['Content-Type'] = 'application/javascript' + # wrap with JSONP call if requested + out = self._api_callback + '(' + out + ');' + # if we fail to generate the output fake an error + except Exception as e: + logger.info(u'PlexPy APIv2 :: ' + traceback.format_exc()) + out['message'] = traceback.format_exc() + out['result'] = 'error' + elif self._api_out_type == 'xml': + cherrypy.response.headers['Content-Type'] = 'application/xml' + try: + out = xmltodict.unparse(out, pretty=True) + except Exception as e: + logger.error(u'PlexPy APIv2 :: Failed to parse xml result') + try: + out['message'] = e + out['result'] = 'error' + out = xmltodict.unparse(out, pretty=True) + + except Exception as e: + logger.error(u'PlexPy APIv2 :: Failed to parse xml result error message %s' % e) + out = ''' + + %s + + error + + ''' % e + + return out + + def _api_run(self, *args, **kwargs): + """ handles the stuff from the handler """ + + result = {} + logger.debug(u'PlexPy APIv2 :: Original kwargs was %s' % kwargs) + + self._api_validate(**kwargs) + + if self._api_cmd and self._api_authenticated: + call = getattr(self, self._api_cmd) + + # Profile is written to console. + if self._api_profileme: + from profilehooks import profile + call = profile(call, immediate=True) + + # We allow this to fail so we get a + # traceback in the browser + if self._api_debug: + result = call(**self._api_kwargs) + else: + try: + result = call(**self._api_kwargs) + except Exception as e: + logger.error(u'PlexPy APIv2 :: Failed to run %s %s %s' % (self._api_cmd, self._api_kwargs, e)) + + ret = None + # The api decorated function can return different result types. + # convert it to a list/dict before we change it to the users + # wanted output + try: + if isinstance(result, (dict, list)): + ret = result + else: + raise + except: + try: + ret = json.loads(result) + except (ValueError, TypeError): + try: + ret = xmltodict.parse(result, attr_prefix='') + except: + pass + + # Fallback if we cant "parse the reponse" + if ret is None: + ret = result + + if ret or self._api_result_type == 'success': + # To allow override for restart etc + # if the call returns some data we are gonna assume its a success + self._api_result_type = 'success' + else: + self._api_result_type = 'error' + + return self._api_out_as(self._api_responds(result_type=self._api_result_type, msg=self._api_msg, data=ret)) diff --git a/plexpy/database.py b/plexpy/database.py index f4bbdcf5..8878e1ee 100644 --- a/plexpy/database.py +++ b/plexpy/database.py @@ -13,20 +13,24 @@ # You should have received a copy of the GNU General Public License # along with PlexPy. If not, see . -from plexpy import logger - -import sqlite3 import os -import plexpy -import time +import sqlite3 +import shutil import threading +import time + +import logger +import plexpy + db_lock = threading.Lock() + def drop_session_db(): monitor_db = MonitorDatabase() monitor_db.action('DROP TABLE sessions') + def clear_history_tables(): logger.debug(u"PlexPy Database :: Deleting all session_history records... No turning back now bub.") monitor_db = MonitorDatabase() @@ -35,10 +39,49 @@ def clear_history_tables(): monitor_db.action('DELETE FROM session_history_metadata') monitor_db.action('VACUUM;') + def db_filename(filename="plexpy.db"): + """ Returns the filepath to the db """ return os.path.join(plexpy.DATA_DIR, filename) + +def make_backup(cleanup=False): + """ Makes a backup of db, removes all but the last 3 backups """ + + backupfolder = plexpy.BACKUP_DIR + backup_file = 'plexpy.backup-%s.db' % int(time.time()) + backup_file_fp = os.path.join(backupfolder, backup_file) + + # In case the user has deleted it manually + if not os.path.exists(backupfolder): + os.makedirs(backupfolder) + + db = MonitorDatabase() + db.connection.execute('begin immediate') + shutil.copyfile(db_filename(), backup_file_fp) + db.connection.rollback() + + if cleanup: + # Delete all backup files except from the last 3. + for root, dirs, files in os.walk(backupfolder): + if len(files) > 3: + all_files = [os.path.join(root, f) for f in files] + backups_sorted_on_age = sorted(all_files, key=os.path.getctime, reverse=True) + for file_ in backups_sorted_on_age[3:]: + try: + os.remove(file_) + except OSError as e: + logger.error('Failed to delete %s from the backup folder %s' % (file_, e)) + + if backup_file in os.listdir(backupfolder): + logger.debug('Successfully backup of the %s to %s in %s' % (db_filename(), backup_file, backupfolder)) + return True + else: + logger.debug('Failed to make backup of %s to %s in %s' % (db_filename(), backup_file, backupfolder)) + return False + + def get_cache_size(): # This will protect against typecasting problems produced by empty string and None settings if not plexpy.CONFIG.CACHE_SIZEMB: @@ -46,6 +89,7 @@ def get_cache_size(): return 0 return int(plexpy.CONFIG.CACHE_SIZEMB) + def dict_factory(cursor, row): d = {} for idx, col in enumerate(cursor.description): diff --git a/plexpy/helpers.py b/plexpy/helpers.py index 578fbf9f..0e1cc17e 100644 --- a/plexpy/helpers.py +++ b/plexpy/helpers.py @@ -13,23 +13,59 @@ # You should have received a copy of the GNU General Public License # along with PlexPy. If not, see . -from operator import itemgetter -from xml.dom import minidom from IPy import IP - -import unicodedata -import plexpy import datetime import fnmatch -import shutil -import time -import sys -import re -import os +from functools import wraps import json -import xmltodict +import os import math +from operator import itemgetter +import re +import shutil import socket +import sys +import time +from xml.dom import minidom +import unicodedata + +import xmltodict +import plexpy +from api2 import API2 + + +def addtoapi(*dargs, **dkwargs): + """ Helper decorator that adds function to the API class. + is used to reuse as much code as possible + + args: + dargs: (string, optional) Used to rename a function + + Example: + @addtoapi("i_was_renamed", "im_a_second_alias") + @addtoapi() + + """ + def rd(function): + @wraps(function) + def wrapper(*args, **kwargs): + return function(*args, **kwargs) + + if dargs: + # To rename the function if it sucks.. and + # allow compat with old api. + for n in dargs: + if function.__doc__ and len(function.__doc__): + function.__doc__ = function.__doc__.strip() + setattr(API2, n, function) + return wrapper + + if function.__doc__ and len(function.__doc__): + function.__doc__ = function.__doc__.strip() + setattr(API2, function.__name__, function) + return wrapper + + return rd def multikeysort(items, columns): comparers = [((itemgetter(col[1:].strip()), -1) if col.startswith('-') else (itemgetter(col.strip()), 1)) for col in columns] @@ -174,7 +210,7 @@ def human_duration(s, sig='dhms'): if sig >= 'dh' and h > 0: h = h + 1 if sig == 'dh' and m >= 30 else h hd_list.append(str(h) + ' hrs') - + if sig >= 'dhm' and m > 0: m = m + 1 if sig == 'dhm' and s >= 30 else m hd_list.append(str(m) + ' mins') diff --git a/plexpy/http_handler.py b/plexpy/http_handler.py index 013512ba..199ad647 100644 --- a/plexpy/http_handler.py +++ b/plexpy/http_handler.py @@ -16,10 +16,10 @@ # You should have received a copy of the GNU General Public License # along with PlexPy. If not, see . -from plexpy import logger, helpers from httplib import HTTPSConnection from httplib import HTTPConnection import ssl +from plexpy import logger, helpers class HTTPHandler(object): diff --git a/plexpy/pmsconnect.py b/plexpy/pmsconnect.py index b31e56e9..05916f88 100644 --- a/plexpy/pmsconnect.py +++ b/plexpy/pmsconnect.py @@ -19,10 +19,11 @@ from urlparse import urlparse import plexpy import urllib2 + def get_server_friendly_name(): logger.info(u"PlexPy Pmsconnect :: Requesting name from server...") server_name = PmsConnect().get_server_pref(pref='FriendlyName') - + # If friendly name is blank if not server_name: servers_info = PmsConnect().get_servers_info() @@ -30,7 +31,7 @@ def get_server_friendly_name(): if server['machine_identifier'] == plexpy.CONFIG.PMS_IDENTIFIER: server_name = server['name'] break - + if server_name and server_name != plexpy.CONFIG.PMS_NAME: plexpy.CONFIG.__setattr__('PMS_NAME', server_name) plexpy.CONFIG.write() @@ -38,6 +39,7 @@ def get_server_friendly_name(): return server_name + def refresh_libraries(): logger.info(u"PlexPy Pmsconnect :: Requesting libraries list refresh...") @@ -71,7 +73,6 @@ def refresh_libraries(): library_keys.append(section['section_id']) - if plexpy.CONFIG.HOME_LIBRARY_CARDS == ['first_run_wizard']: plexpy.CONFIG.__setattr__('HOME_LIBRARY_CARDS', library_keys) plexpy.CONFIG.write() @@ -206,7 +207,7 @@ class PmsConnect(object): proto=self.protocol, request_type='GET', output_format=output_format) - + return request def get_childrens_list(self, rating_key='', output_format=''): @@ -223,7 +224,7 @@ class PmsConnect(object): proto=self.protocol, request_type='GET', output_format=output_format) - + return request def get_server_list(self, output_format=''): @@ -300,7 +301,7 @@ class PmsConnect(object): """ count = '&X-Plex-Container-Size=' + count if count else '' - uri = '/library/sections/' + section_id + '/' + list_type +'?X-Plex-Container-Start=0' + count + sort_type + uri = '/library/sections/' + section_id + '/' + list_type + '?X-Plex-Container-Start=0' + count + sort_type request = self.request_handler.make_request(uri=uri, proto=self.protocol, request_type='GET', @@ -835,7 +836,7 @@ class PmsConnect(object): metadata = self.get_metadata_details(str(child_rating_key), get_media_info) if metadata: metadata_list.append(metadata['metadata']) - + elif get_children and a.getElementsByTagName('Directory'): dir_main = a.getElementsByTagName('Directory') metadata_main = [d for d in dir_main if helpers.get_xml_attr(d, 'ratingKey')] @@ -844,7 +845,7 @@ class PmsConnect(object): metadata = self.get_metadata_children_details(str(child_rating_key), get_children, get_media_info) if metadata: metadata_list.extend(metadata['metadata']) - + output = {'metadata': metadata_list} return output @@ -892,7 +893,7 @@ class PmsConnect(object): metadata['section_type'] = 'track' metadata_list = {'metadata': metadata} - + return metadata_list def get_current_activity(self): @@ -995,7 +996,7 @@ class PmsConnect(object): machine_id = helpers.get_xml_attr(session.getElementsByTagName('Player')[0], 'machineIdentifier') session_output = {'session_key': helpers.get_xml_attr(session, 'sessionKey'), - 'section_id': helpers.get_xml_attr(session, 'librarySectionID'), + 'section_id': helpers.get_xml_attr(session, 'librarySectionID'), 'media_index': helpers.get_xml_attr(session, 'index'), 'parent_media_index': helpers.get_xml_attr(session, 'parentIndex'), 'art': helpers.get_xml_attr(session, 'art'), @@ -1117,7 +1118,7 @@ class PmsConnect(object): if helpers.get_xml_attr(session, 'type') == 'episode': session_output = {'session_key': helpers.get_xml_attr(session, 'sessionKey'), - 'section_id': helpers.get_xml_attr(session, 'librarySectionID'), + 'section_id': helpers.get_xml_attr(session, 'librarySectionID'), 'media_index': helpers.get_xml_attr(session, 'index'), 'parent_media_index': helpers.get_xml_attr(session, 'parentIndex'), 'art': helpers.get_xml_attr(session, 'art'), @@ -1175,7 +1176,7 @@ class PmsConnect(object): elif helpers.get_xml_attr(session, 'type') == 'movie': session_output = {'session_key': helpers.get_xml_attr(session, 'sessionKey'), - 'section_id': helpers.get_xml_attr(session, 'librarySectionID'), + 'section_id': helpers.get_xml_attr(session, 'librarySectionID'), 'media_index': helpers.get_xml_attr(session, 'index'), 'parent_media_index': helpers.get_xml_attr(session, 'parentIndex'), 'art': helpers.get_xml_attr(session, 'art'), @@ -1233,7 +1234,7 @@ class PmsConnect(object): elif helpers.get_xml_attr(session, 'type') == 'clip': session_output = {'session_key': helpers.get_xml_attr(session, 'sessionKey'), - 'section_id': helpers.get_xml_attr(session, 'librarySectionID'), + 'section_id': helpers.get_xml_attr(session, 'librarySectionID'), 'media_index': helpers.get_xml_attr(session, 'index'), 'parent_media_index': helpers.get_xml_attr(session, 'parentIndex'), 'art': helpers.get_xml_attr(session, 'art'), @@ -1324,7 +1325,7 @@ class PmsConnect(object): machine_id = helpers.get_xml_attr(session.getElementsByTagName('Player')[0], 'machineIdentifier') session_output = {'session_key': helpers.get_xml_attr(session, 'sessionKey'), - 'section_id': helpers.get_xml_attr(session, 'librarySectionID'), + 'section_id': helpers.get_xml_attr(session, 'librarySectionID'), 'media_index': helpers.get_xml_attr(session, 'index'), 'parent_media_index': helpers.get_xml_attr(session, 'parentIndex'), 'art': helpers.get_xml_attr(session, 'art'), @@ -1409,7 +1410,7 @@ class PmsConnect(object): children_list = {'children_count': '0', 'children_list': [] } - return parent_list + return children_list result_data = [] @@ -1556,7 +1557,7 @@ class PmsConnect(object): 'title': helpers.get_xml_attr(xml_head[0], 'title1'), 'libraries_list': libraries_list } - + return output def get_library_children_details(self, section_id='', section_type='', list_type='all', count='', rating_key='', get_media_info=False): @@ -1613,15 +1614,15 @@ class PmsConnect(object): if a.getAttribute('size') == '0': logger.debug(u"PlexPy Pmsconnect :: No library data.") childern_list = {'library_count': '0', - 'childern_list': [] - } + 'childern_list': [] + } return childern_list if rating_key: library_count = helpers.get_xml_attr(xml_head[0], 'size') else: library_count = helpers.get_xml_attr(xml_head[0], 'totalSize') - + # Get show/season info from xml_head item_main = [] @@ -1673,7 +1674,7 @@ class PmsConnect(object): output = {'library_count': library_count, 'childern_list': childern_list } - + return output def get_library_details(self): @@ -1788,7 +1789,7 @@ class PmsConnect(object): except Exception as e: logger.warn(u"PlexPy Pmsconnect :: Unable to parse XML for get_search_result_details: %s." % e) return [] - + search_results_count = 0 search_results_list = {'movie': [], 'show': [], @@ -1806,8 +1807,8 @@ class PmsConnect(object): if totalSize == 0: logger.debug(u"PlexPy Pmsconnect :: No search results.") search_results_list = {'results_count': search_results_count, - 'results_list': [] - } + 'results_list': [] + } return search_results_list for a in xml_head: @@ -1912,7 +1913,7 @@ class PmsConnect(object): if a.getAttribute('size'): if a.getAttribute('size') == '0': return {} - + title = helpers.get_xml_attr(a, 'title2') if a.getElementsByTagName('Directory'): @@ -1957,34 +1958,33 @@ class PmsConnect(object): if child_rating_key: key = int(child_index) children.update({key: {'rating_key': int(child_rating_key)}}) - + key = int(parent_index) if match_type == 'index' else parent_title - parents.update({key: + parents.update({key: {'rating_key': int(parent_rating_key), 'children': children} }) - + key = 0 if match_type == 'index' else title - key_list = {key: - {'rating_key': int(rating_key), - 'children': parents }, - 'section_id': section_id, - 'library_name': library_name - } - + key_list = {key: {'rating_key': int(rating_key), + 'children': parents}, + 'section_id': section_id, + 'library_name': library_name + } + return key_list def get_server_response(self): # Refresh Plex remote access port mapping first self.put_refresh_reachability() account_data = self.get_account(output_format='xml') - + try: xml_head = account_data.getElementsByTagName('MyPlex') except Exception as e: logger.warn(u"PlexPy Pmsconnect :: Unable to parse XML for get_server_response: %s." % e) return None - + server_response = {} for a in xml_head: @@ -1993,5 +1993,5 @@ class PmsConnect(object): 'public_address': helpers.get_xml_attr(a, 'publicAddress'), 'public_port': helpers.get_xml_attr(a, 'publicPort') } - - return server_response \ No newline at end of file + + return server_response diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 7cfbdd41..a4fc0459 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -14,7 +14,7 @@ # along with PlexPy. If not, see . from plexpy import logger, notifiers, plextv, pmsconnect, common, log_reader, datafactory, graphs, users, libraries -from plexpy.helpers import checked, radio, get_ip +from plexpy.helpers import checked, addtoapi, get_ip from mako.lookup import TemplateLookup from mako import exceptions @@ -26,6 +26,7 @@ import hashlib import random import json import os +from api2 import API2 try: # pylint:disable=E0611 @@ -101,10 +102,29 @@ class WebInterface(object): return serve_template(templatename="welcome.html", title="Welcome", config=config) @cherrypy.expose - def discover(self, token=''): - """ - Returns the servers that you own as a - list of dicts (formatted for selectize) + @addtoapi() + def discover(self, token): + """ Gets all your servers that are published to plextv + + Returns: + json: + ``` + [{"httpsRequired": "0", + "ip": "10.0.0.97", + "value": "10.0.0.97", + "label": "dude-PC", + "clientIdentifier": "1234", + "local": "1", "port": "32400"}, + {"httpsRequired": "0", + "ip": "85.167.100.100", + "value": "85.167.100.100", + "label": "dude-PC", + "clientIdentifier": "1234", + "local": "0", + "port": "10294"} + ] + ``` + """ # Need to set token so result doesn't return http 401 plexpy.CONFIG.__setattr__('PMS_TOKEN', token) @@ -132,7 +152,10 @@ class WebInterface(object): return serve_template(templatename="index.html", title="Home", config=config) @cherrypy.expose + @addtoapi() def get_date_formats(self): + """ Get the date and time formats used by plexpy """ + if plexpy.CONFIG.DATE_FORMAT: date_format = plexpy.CONFIG.DATE_FORMAT else: @@ -212,7 +235,7 @@ class WebInterface(object): library_cards = plexpy.CONFIG.HOME_LIBRARY_CARDS stats_data = data_factory.get_library_stats(library_cards=library_cards) - + return serve_template(templatename="library_stats.html", title="Library Stats", data=stats_data) @cherrypy.expose @@ -242,6 +265,7 @@ class WebInterface(object): return serve_template(templatename="libraries.html", title="Libraries", config=config) @cherrypy.expose + @addtoapi() def get_library_list(self, **kwargs): library_data = libraries.Libraries() @@ -251,10 +275,37 @@ class WebInterface(object): return json.dumps(library_list) @cherrypy.expose + @addtoapi() + def get_library_sections(self, **kwargs): + """ Get the library sections from pms + + Returns: + json: + ``` + [{"section_id": 1, "section_name": "Movies"}, + {"section_id": 7, "section_name": "Music"}, + {"section_id": 2, "section_name": "TV Shows"} + ] + ``` + + """ + + library_data = libraries.Libraries() + result = library_data.get_sections() + + if result: + cherrypy.response.headers['Content-type'] = 'application/json' + return json.dumps(result) + else: + logger.warn(u"Unable to retrieve data for get_library_sections.") + + @cherrypy.expose + @addtoapi() # should be added manually def refresh_libraries_list(self, **kwargs): threading.Thread(target=pmsconnect.refresh_libraries).start() logger.info(u"Manual libraries list refresh requested.") + @cherrypy.expose def library(self, section_id=None): config = { @@ -284,10 +335,11 @@ class WebInterface(object): else: result = None status_message = 'An error occured.' - + return serve_template(templatename="edit_library.html", title="Edit Library", data=result, status_message=status_message) @cherrypy.expose + @addtoapi() def edit_library(self, section_id=None, **kwargs): custom_thumb = kwargs.get('custom_thumb', '') do_notify = kwargs.get('do_notify', 0) @@ -323,10 +375,10 @@ class WebInterface(object): @cherrypy.expose def get_library_user_stats(self, section_id=None, **kwargs): - + library_data = libraries.Libraries() result = library_data.get_user_stats(section_id=section_id) - + if result: return serve_template(templatename="library_user_stats.html", data=result, title="Player Stats") else: @@ -358,12 +410,13 @@ class WebInterface(object): return serve_template(templatename="library_recently_added.html", data=None, title="Recently Added") @cherrypy.expose + @addtoapi() def get_library_media_info(self, section_id=None, section_type=None, rating_key=None, refresh='', **kwargs): - + if refresh == 'true': - refresh = True + refresh = True else: - refresh = False + refresh = False library_data = libraries.Libraries() result = library_data.get_datatables_media_info(section_id=section_id, @@ -371,16 +424,17 @@ class WebInterface(object): rating_key=rating_key, refresh=refresh, kwargs=kwargs) - + cherrypy.response.headers['Content-type'] = 'application/json' return json.dumps(result) @cherrypy.expose + @addtoapi() def get_media_info_file_sizes(self, section_id=None, rating_key=None): get_file_sizes_hold = plexpy.CONFIG.GET_FILE_SIZES_HOLD section_ids = set(get_file_sizes_hold['section_ids']) rating_keys = set(get_file_sizes_hold['rating_keys']) - + if (section_id and section_id not in section_ids) or (rating_key and rating_key not in rating_keys): if section_id: section_ids.add(section_id) @@ -399,23 +453,25 @@ class WebInterface(object): plexpy.CONFIG.GET_FILE_SIZES_HOLD = {'section_ids': list(section_ids), 'rating_keys': list(rating_keys)} else: result = False - + cherrypy.response.headers['Content-type'] = 'application/json' return json.dumps({'success': result}) @cherrypy.expose + @addtoapi() def get_library_unwatched(self, section_id=None, section_type=None, **kwargs): - + pms_connect = pmsconnect.PmsConnect() result = pms_connect.get_library_children_details(section_id=section_id, - section_type=section_type, - get_media_info=True, - kwargs=kwargs) + section_type=section_type, + get_media_info=True) + # fixed a bug in this one, is this even used? cherrypy.response.headers['Content-type'] = 'application/json' return json.dumps(result) @cherrypy.expose + @addtoapi() def delete_all_library_history(self, section_id, **kwargs): library_data = libraries.Libraries() @@ -430,6 +486,7 @@ class WebInterface(object): return json.dumps({'message': 'no data received'}) @cherrypy.expose + @addtoapi() def delete_library(self, section_id, **kwargs): library_data = libraries.Libraries() @@ -444,6 +501,7 @@ class WebInterface(object): return json.dumps({'message': 'no data received'}) @cherrypy.expose + @addtoapi() def undelete_library(self, section_id=None, section_name=None, **kwargs): library_data = libraries.Libraries() @@ -464,6 +522,7 @@ class WebInterface(object): return json.dumps({'message': 'no data received'}) @cherrypy.expose + @addtoapi() def update_section_ids(self, **kwargs): logger.debug(u"Manual database section_id update called.") @@ -476,6 +535,7 @@ class WebInterface(object): return "Unable to update section_id's in database. See logs for details." @cherrypy.expose + @addtoapi() def delete_datatable_media_info_cache(self, section_id, **kwargs): get_file_sizes_hold = plexpy.CONFIG.GET_FILE_SIZES_HOLD section_ids = set(get_file_sizes_hold['section_ids']) @@ -514,6 +574,7 @@ class WebInterface(object): return serve_template(templatename="users.html", title="Users") @cherrypy.expose + @addtoapi() def get_user_list(self, **kwargs): user_data = users.Users() @@ -523,7 +584,9 @@ class WebInterface(object): return json.dumps(user_list) @cherrypy.expose + @addtoapi() def refresh_users_list(self, **kwargs): + """ Refresh a users list in a own thread """ threading.Thread(target=plextv.refresh_users).start() logger.info(u"Manual users list refresh requested.") @@ -781,6 +844,7 @@ class WebInterface(object): return "Updated graphs config values." @cherrypy.expose + @addtoapi() def get_plays_by_date(self, time_range='30', y_axis='plays', **kwargs): graph = graphs.Graphs() @@ -793,6 +857,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_plays_by_date.") @cherrypy.expose + @addtoapi() def get_plays_by_dayofweek(self, time_range='30', y_axis='plays', **kwargs): graph = graphs.Graphs() @@ -805,6 +870,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_plays_by_dayofweek.") @cherrypy.expose + @addtoapi() def get_plays_by_hourofday(self, time_range='30', y_axis='plays', **kwargs): graph = graphs.Graphs() @@ -817,6 +883,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_plays_by_hourofday.") @cherrypy.expose + @addtoapi() def get_plays_per_month(self, y_axis='plays', **kwargs): graph = graphs.Graphs() @@ -829,6 +896,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_plays_per_month.") @cherrypy.expose + @addtoapi() def get_plays_by_top_10_platforms(self, time_range='30', y_axis='plays', **kwargs): graph = graphs.Graphs() @@ -841,6 +909,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_plays_by_top_10_platforms.") @cherrypy.expose + @addtoapi() def get_plays_by_top_10_users(self, time_range='30', y_axis='plays', **kwargs): graph = graphs.Graphs() @@ -853,6 +922,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_plays_by_top_10_users.") @cherrypy.expose + @addtoapi() def get_plays_by_stream_type(self, time_range='30', y_axis='plays', **kwargs): graph = graphs.Graphs() @@ -865,6 +935,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_plays_by_stream_type.") @cherrypy.expose + @addtoapi() def get_plays_by_source_resolution(self, time_range='30', y_axis='plays', **kwargs): graph = graphs.Graphs() @@ -877,6 +948,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_plays_by_source_resolution.") @cherrypy.expose + @addtoapi() def get_plays_by_stream_resolution(self, time_range='30', y_axis='plays', **kwargs): graph = graphs.Graphs() @@ -889,6 +961,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_plays_by_stream_resolution.") @cherrypy.expose + @addtoapi() def get_stream_type_by_top_10_users(self, time_range='30', y_axis='plays', **kwargs): graph = graphs.Graphs() @@ -901,6 +974,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_stream_type_by_top_10_users.") @cherrypy.expose + @addtoapi() def get_stream_type_by_top_10_platforms(self, time_range='30', y_axis='plays', **kwargs): graph = graphs.Graphs() @@ -993,6 +1067,7 @@ class WebInterface(object): }) @cherrypy.expose + @addtoapi() def get_plex_log(self, window=1000, **kwargs): log_lines = [] try: @@ -1129,7 +1204,7 @@ class WebInterface(object): "buffer_wait": plexpy.CONFIG.BUFFER_WAIT, "group_history_tables": checked(plexpy.CONFIG.GROUP_HISTORY_TABLES) } - + return serve_template(templatename="settings.html", title="Settings", config=config) @cherrypy.expose @@ -1137,15 +1212,15 @@ class WebInterface(object): # Handle the variable config options. Note - keys with False values aren't getting passed checked_configs = [ - "launch_browser", "enable_https", "api_enabled", "freeze_db", "check_github", "get_file_sizes", + "launch_browser", "enable_https", "api_enabled", "freeze_db", "check_github", "get_file_sizes", "grouping_global_history", "grouping_user_history", "grouping_charts", "pms_use_bif", "pms_ssl", "movie_notify_enable", "tv_notify_enable", "music_notify_enable", "monitoring_use_websocket", "tv_notify_on_start", "movie_notify_on_start", "music_notify_on_start", "tv_notify_on_stop", "movie_notify_on_stop", "music_notify_on_stop", - "tv_notify_on_pause", "movie_notify_on_pause", "music_notify_on_pause", + "tv_notify_on_pause", "movie_notify_on_pause", "music_notify_on_pause", "refresh_libraries_on_startup", "refresh_users_on_startup", - "ip_logging_enable", "movie_logging_enable", "tv_logging_enable", "music_logging_enable", - "pms_is_remote", "home_stats_type", "group_history_tables", "notify_consecutive", + "ip_logging_enable", "movie_logging_enable", "tv_logging_enable", "music_logging_enable", + "pms_is_remote", "home_stats_type", "group_history_tables", "notify_consecutive", "notify_recently_added", "notify_recently_added_grandparent", "monitor_remote_access" ] for checked_config in checked_configs: @@ -1231,11 +1306,11 @@ class WebInterface(object): # Get new server URLs for SSL communications. if server_changed: plextv.get_real_pms_url() - + # Get new server friendly name. if server_changed: pmsconnect.get_server_friendly_name() - + # Reconfigure scheduler if intervals changed if reschedule: plexpy.initialize_scheduler() @@ -1286,6 +1361,7 @@ class WebInterface(object): data=this_agent) @cherrypy.expose + @addtoapi('notify') def test_notifier(self, config_id=None, subject='PlexPy', body='Test notification', **kwargs): cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" @@ -1297,7 +1373,7 @@ class WebInterface(object): break else: this_agent = None - + if this_agent: logger.debug(u"Sending test %s notification." % this_agent['name']) notifiers.send_notification(this_agent['id'], subject, body, **kwargs) @@ -1308,7 +1384,8 @@ class WebInterface(object): else: logger.debug(u"Unable to send test notification, no notification agent ID received.") return "No notification agent ID received." - + + @cherrypy.expose def twitterStep1(self): cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" @@ -1373,6 +1450,7 @@ class WebInterface(object): cherrypy.response.status = 200 @cherrypy.expose + @addtoapi() def get_plexwatch_export_data(self, database_path=None, table_name=None, import_ignore_interval=0, **kwargs): from plexpy import plexwatch_import @@ -1404,6 +1482,7 @@ class WebInterface(object): return False @cherrypy.expose + @addtoapi() def get_server_id(self, hostname=None, port=None, identifier=None, ssl=0, remote=0, **kwargs): from plexpy import http_handler @@ -1444,7 +1523,17 @@ class WebInterface(object): return None @cherrypy.expose + @addtoapi() def get_server_pref(self, pref=None, **kwargs): + """ Return a specified server preference. + + Args: + pref(string): 'name of preference' + + Returns: + String: '' + + """ pms_connect = pmsconnect.PmsConnect() result = pms_connect.get_server_pref(pref=pref) @@ -1507,7 +1596,7 @@ class WebInterface(object): result = pms_connect.get_metadata_details(rating_key=rating_key, get_media_info=True) if result: metadata = result['metadata'] - + if metadata: return serve_template(templatename="info.html", data=metadata, title="Info", config=config, source=source) else: @@ -1553,15 +1642,8 @@ class WebInterface(object): return None - - ##### Search ##### - - @cherrypy.expose - def search(self, query=''): - - return serve_template(templatename="search.html", title="Search", query=query) - @cherrypy.expose + @addtoapi('search') def search_results(self, query, **kwargs): pms_connect = pmsconnect.PmsConnect() @@ -1573,6 +1655,10 @@ class WebInterface(object): else: logger.warn(u"Unable to retrieve data for search_results.") + @cherrypy.expose + def search(self, query=''): + return serve_template(templatename="search.html", title="Search", query=query) + @cherrypy.expose def get_search_results_children(self, query, media_type=None, season_index=None, **kwargs): @@ -1582,7 +1668,7 @@ class WebInterface(object): if media_type: result['results_list'] = {media_type: result['results_list'][media_type]} if media_type == 'season' and season_index: - result['results_list']['season'] = [season for season in result['results_list']['season'] + result['results_list']['season'] = [season for season in result['results_list']['season'] if season['media_index'] == season_index] if result: @@ -1591,10 +1677,6 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_search_results_children.") return serve_template(templatename="info_search_results_list.html", data=None, title="Search Result List") - - - ##### Update Metadata ##### - @cherrypy.expose def update_metadata(self, rating_key=None, query=None, update=False, **kwargs): query_string = query @@ -1612,6 +1694,7 @@ class WebInterface(object): return serve_template(templatename="update_metadata.html", query=query, update=update, title="Info") @cherrypy.expose + @addtoapi() def update_metadata_details(self, old_rating_key, new_rating_key, media_type, **kwargs): data_factory = datafactory.DataFactory() pms_connect = pmsconnect.PmsConnect() @@ -1631,11 +1714,21 @@ class WebInterface(object): cherrypy.response.headers['Content-type'] = 'application/json' return json.dumps({'message': 'no data received'}) - - # test code @cherrypy.expose + @addtoapi() def get_new_rating_keys(self, rating_key='', media_type='', **kwargs): + """ + Grap the new rating keys + + Args: + rating_key(string): '', + media_type(string): '' + + Returns: + json: '' + + """ pms_connect = pmsconnect.PmsConnect() result = pms_connect.get_rating_keys_list(rating_key=rating_key, media_type=media_type) @@ -1647,7 +1740,17 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_new_rating_keys.") @cherrypy.expose + @addtoapi() def get_old_rating_keys(self, rating_key='', media_type='', **kwargs): + """ + Grap the old rating keys + Args: + rating_key(string): '', + media_type(string): '' + Returns: + json: '' + + """ data_factory = datafactory.DataFactory() result = data_factory.get_rating_keys_list(rating_key=rating_key, media_type=media_type) @@ -1659,18 +1762,8 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_old_rating_keys.") - ##### API ##### - - @cherrypy.expose - def api(self, *args, **kwargs): - from plexpy.api import Api - - a = Api() - a.checkParams(*args, **kwargs) - - return a.fetchData() - @cherrypy.expose + @addtoapi() def get_pms_sessions_json(self, **kwargs): pms_connect = pmsconnect.PmsConnect() @@ -1696,6 +1789,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_metadata_json.") @cherrypy.expose + @addtoapi('get_metadata') def get_metadata_xml(self, rating_key='', **kwargs): pms_connect = pmsconnect.PmsConnect() @@ -1708,7 +1802,17 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_metadata_xml.") @cherrypy.expose + @addtoapi('get_recently_added') def get_recently_added_json(self, count='0', **kwargs): + """ Get all items that where recelty added to plex + + Args: + count(string): Number of items + + Returns: + dict: of all added items + + """ pms_connect = pmsconnect.PmsConnect() result = pms_connect.get_recently_added(count, 'json') @@ -1720,19 +1824,9 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_recently_added_json.") @cherrypy.expose - def get_episode_list_json(self, rating_key='', **kwargs): - - pms_connect = pmsconnect.PmsConnect() - result = pms_connect.get_episode_list(rating_key, 'json') - - if result: - cherrypy.response.headers['Content-type'] = 'application/json' - return result - else: - logger.warn(u"Unable to retrieve data for get_episode_list_json.") - - @cherrypy.expose + @addtoapi() def get_friends_list(self, **kwargs): + """ Gets the friends list of the server owner for plex.tv """ plex_tv = plextv.PlexTV() result = plex_tv.get_plextv_friends('json') @@ -1744,7 +1838,9 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_friends_list.") @cherrypy.expose + @addtoapi() def get_user_details(self, **kwargs): + """ Get all details about a user from plextv """ plex_tv = plextv.PlexTV() result = plex_tv.get_plextv_user_details('json') @@ -1756,7 +1852,9 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_user_details.") @cherrypy.expose + @addtoapi() def get_server_list(self, **kwargs): + """ Find all servers published on plextv""" plex_tv = plextv.PlexTV() result = plex_tv.get_plextv_server_list('json') @@ -1768,6 +1866,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_server_list.") @cherrypy.expose + @addtoapi() def get_sync_lists(self, machine_id='', **kwargs): plex_tv = plextv.PlexTV() @@ -1780,7 +1879,22 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_sync_lists.") @cherrypy.expose + @addtoapi() def get_servers(self, **kwargs): + """ All servers + + Returns: + json: + ``` + {"MediaContainer": {"@size": "1", "Server": + {"@name": "dude-PC", + "@host": "10.0.0.97", + "@address": "10.0.0.97", + "@port": "32400", + "@machineIdentifier": "1234", + "@version": "0.9.15.2.1663-7efd046"}}} + ``` + """ pms_connect = pmsconnect.PmsConnect() result = pms_connect.get_server_list(output_format='json') @@ -1792,7 +1906,24 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_servers.") @cherrypy.expose + @addtoapi() def get_servers_info(self, **kwargs): + """ Graps info about the server + + Returns: + json: + ``` + [{"port": "32400", + "host": "10.0.0.97", + "version": "0.9.15.2.1663-7efd046", + "name": "dude-PC", + "machine_identifier": "1234" + } + ] + ``` + + + """ pms_connect = pmsconnect.PmsConnect() result = pms_connect.get_servers_info() @@ -1804,6 +1935,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_servers_info.") @cherrypy.expose + @addtoapi() def get_server_friendly_name(self, **kwargs): result = pmsconnect.get_server_friendly_name() @@ -1815,6 +1947,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_server_friendly_name.") @cherrypy.expose + @addtoapi() def get_server_prefs(self, pref=None, **kwargs): if pref: @@ -1830,19 +1963,18 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_server_prefs.") @cherrypy.expose - def get_library_sections(self, **kwargs): - - library_data = libraries.Libraries() - result = library_data.get_sections() - - if result: - cherrypy.response.headers['Content-type'] = 'application/json' - return json.dumps(result) - else: - logger.warn(u"Unable to retrieve data for get_library_sections.") - - @cherrypy.expose + @addtoapi() def get_activity(self, **kwargs): + """ Return processed and validated session list. + + Returns: + json: + ``` + {stream_count: 1, + session: [{dict}] + } + ``` + """ pms_connect = pmsconnect.PmsConnect() result = pms_connect.get_current_activity() @@ -1854,7 +1986,22 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_activity.") @cherrypy.expose + @addtoapi() def get_full_users_list(self, **kwargs): + """ Get a list all users that has access to your server + + Returns: + json: + ``` + [{"username": "Hellowlol", "user_id": "1345", + "thumb": "https://plex.tv/users/123aa/avatar", + "is_allow_sync": null, + "is_restricted": "0", + "is_home_user": "0", + "email": "John.Doe@email.com"}] + ``` + + """ plex_tv = plextv.PlexTV() result = plex_tv.get_full_users_list() @@ -1866,7 +2013,43 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_full_users_list.") @cherrypy.expose + @addtoapi() def get_sync_item(self, sync_id, **kwargs): + """ Return sync item details. + + Args: + sync_id(string): unique sync id for item + output_format(string, optional): 'xml/json' + + Returns: + List: + ``` + {"data": [ + {"username": "username", + "item_downloaded_percent_complete": 100, + "user_id": "134", + "failure": "", + "title": "Some Movie", + "total_size": "747195119", + "root_title": "Movies", + "music_bitrate": "128", + "photo_quality": "49", + "friendly_name": "username", + "device_name": "Username iPad", + "platform": "iOS", + "state": "complete", + "item_downloaded_count": "1", + "content_type": "video", + "metadata_type": "movie", + "video_quality": "49", + "item_count": "1", + "rating_key": "59207", + "item_complete_count": "1", + "sync_id": "1234"} + ] + } + ``` + """ pms_connect = pmsconnect.PmsConnect() result = pms_connect.get_sync_item(sync_id, output_format='json') @@ -1878,6 +2061,7 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_sync_item.") @cherrypy.expose + @addtoapi() def get_sync_transcode_queue(self, **kwargs): pms_connect = pmsconnect.PmsConnect() @@ -1889,10 +2073,8 @@ class WebInterface(object): else: logger.warn(u"Unable to retrieve data for get_sync_transcode_queue.") - - - @cherrypy.expose + @addtoapi() def random_arnold_quotes(self, **kwargs): from random import randint quote_list = ['To crush your enemies, see them driven before you, and to hear the lamentation of their women!', @@ -1921,4 +2103,16 @@ class WebInterface(object): ] random_number = randint(0, len(quote_list) - 1) - return quote_list[int(random_number)] \ No newline at end of file + return quote_list[int(random_number)] + + ### API ### + + @cherrypy.expose + def api(self, *args, **kwargs): + if args and 'v2' in args[0]: + return API2()._api_run(**kwargs) + else: + from plexpy.api import Api + a = Api() + a.checkParams(*args, **kwargs) + return a.fetchData() diff --git a/plexpy/webstart.py b/plexpy/webstart.py index c9a41f09..0c15282c 100644 --- a/plexpy/webstart.py +++ b/plexpy/webstart.py @@ -15,12 +15,13 @@ import os import sys -import cherrypy -import plexpy +import cherrypy from plexpy import logger -from plexpy.webserve import WebInterface +import plexpy from plexpy.helpers import create_https_certificates +from plexpy.webserve import WebInterface + def initialize(options): @@ -35,13 +36,11 @@ def initialize(options): # self-signed ones. if not (https_cert and os.path.exists(https_cert)) or not (https_key and os.path.exists(https_key)): if not create_https_certificates(https_cert, https_key): - logger.warn("Unable to create certificate and key. Disabling " \ - "HTTPS") + logger.warn("Unable to create certificate and key. Disabling HTTPS") enable_https = False if not (os.path.exists(https_cert) and os.path.exists(https_key)): - logger.warn("Disabled HTTPS because of missing certificate and " \ - "key.") + logger.warn("Disabled HTTPS because of missing certificate and key.") enable_https = False options_dict = { @@ -63,13 +62,17 @@ def initialize(options): protocol = "http" logger.info("Starting PlexPy web server on %s://%s:%d/", protocol, - options['http_host'], options['http_port']) + options['http_host'], options['http_port']) cherrypy.config.update(options_dict) conf = { '/': { 'tools.staticdir.root': os.path.join(plexpy.PROG_DIR, 'data'), - 'tools.proxy.on': options['http_proxy'] # pay attention to X-Forwarded-Proto header + 'tools.proxy.on': options['http_proxy'], # pay attention to X-Forwarded-Proto header + 'tools.gzip.on': True, + 'tools.gzip.mime_types': ['text/html', 'text/plain', 'text/css', + 'text/javascript', 'application/json', + 'application/javascript'] }, '/interfaces': { 'tools.staticdir.on': True, @@ -87,15 +90,15 @@ def initialize(options): 'tools.staticdir.on': True, 'tools.staticdir.dir': "js" }, - '/favicon.ico': { - 'tools.staticfile.on': True, - 'tools.staticfile.filename': os.path.join(os.path.abspath( - os.curdir), "images" + os.sep + "favicon.ico") - }, '/cache': { 'tools.staticdir.on': True, 'tools.staticdir.dir': plexpy.CONFIG.CACHE_DIR + }, + '/favicon.ico': { + 'tools.staticfile.on': True, + 'tools.staticfile.filename': os.path.abspath(os.path.join(plexpy.PROG_DIR, 'data/interfaces/default/images/favicon.ico')) } + } if options['http_password']: From 91a55294386c65c5132c221e82fc495c60d6601b Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sun, 14 Feb 2016 11:03:32 -0800 Subject: [PATCH 19/47] Some APIv2 cleanup --- plexpy/api2.py | 2 +- plexpy/config.py | 1 + plexpy/webserve.py | 34 ++++++++++++++-------------------- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/plexpy/api2.py b/plexpy/api2.py index 849cc41a..9c5bb105 100644 --- a/plexpy/api2.py +++ b/plexpy/api2.py @@ -257,7 +257,7 @@ class API2: """ Query the db with raw sql, makes backup of the db if the backup is older then 24h """ - if not query: + if not plexpy.CONFIG.API_SQL or not query: return # allow the user to shoot them self diff --git a/plexpy/config.py b/plexpy/config.py index 9da5a15e..46f39490 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -32,6 +32,7 @@ _CONFIG_DEFINITIONS = { 'TIME_FORMAT': (str, 'General', 'HH:mm'), 'API_ENABLED': (int, 'General', 0), 'API_KEY': (str, 'General', ''), + 'API_SQL': (int, 'General', 0), 'BOXCAR_ENABLED': (int, 'Boxcar', 0), 'BOXCAR_TOKEN': (str, 'Boxcar', ''), 'BOXCAR_SOUND': (str, 'Boxcar', ''), diff --git a/plexpy/webserve.py b/plexpy/webserve.py index a4fc0459..21213ed7 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -304,7 +304,7 @@ class WebInterface(object): def refresh_libraries_list(self, **kwargs): threading.Thread(target=pmsconnect.refresh_libraries).start() logger.info(u"Manual libraries list refresh requested.") - + return True @cherrypy.expose def library(self, section_id=None): @@ -457,19 +457,6 @@ class WebInterface(object): cherrypy.response.headers['Content-type'] = 'application/json' return json.dumps({'success': result}) - @cherrypy.expose - @addtoapi() - def get_library_unwatched(self, section_id=None, section_type=None, **kwargs): - - pms_connect = pmsconnect.PmsConnect() - result = pms_connect.get_library_children_details(section_id=section_id, - section_type=section_type, - get_media_info=True) - # fixed a bug in this one, is this even used? - - cherrypy.response.headers['Content-type'] = 'application/json' - return json.dumps(result) - @cherrypy.expose @addtoapi() def delete_all_library_history(self, section_id, **kwargs): @@ -589,6 +576,7 @@ class WebInterface(object): """ Refresh a users list in a own thread """ threading.Thread(target=plextv.refresh_users).start() logger.info(u"Manual users list refresh requested.") + return True @cherrypy.expose def user(self, user_id=None): @@ -1642,6 +1630,13 @@ class WebInterface(object): return None + + ##### Search ##### + + @cherrypy.expose + def search(self, query=''): + return serve_template(templatename="search.html", title="Search", query=query) + @cherrypy.expose @addtoapi('search') def search_results(self, query, **kwargs): @@ -1655,10 +1650,6 @@ class WebInterface(object): else: logger.warn(u"Unable to retrieve data for search_results.") - @cherrypy.expose - def search(self, query=''): - return serve_template(templatename="search.html", title="Search", query=query) - @cherrypy.expose def get_search_results_children(self, query, media_type=None, season_index=None, **kwargs): @@ -1677,6 +1668,9 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_search_results_children.") return serve_template(templatename="info_search_results_list.html", data=None, title="Search Result List") + + ##### Update Metadata ##### + @cherrypy.expose def update_metadata(self, rating_key=None, query=None, update=False, **kwargs): query_string = query @@ -1763,7 +1757,7 @@ class WebInterface(object): @cherrypy.expose - @addtoapi() + @addtoapi('get_sessions') def get_pms_sessions_json(self, **kwargs): pms_connect = pmsconnect.PmsConnect() @@ -1777,6 +1771,7 @@ class WebInterface(object): return False @cherrypy.expose + @addtoapi('get_metadata') def get_metadata_json(self, rating_key='', **kwargs): pms_connect = pmsconnect.PmsConnect() @@ -1789,7 +1784,6 @@ class WebInterface(object): logger.warn(u"Unable to retrieve data for get_metadata_json.") @cherrypy.expose - @addtoapi('get_metadata') def get_metadata_xml(self, rating_key='', **kwargs): pms_connect = pmsconnect.PmsConnect() From 322f106e7521116c56281b9d1daa38cd8656317f Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sun, 14 Feb 2016 11:35:14 -0800 Subject: [PATCH 20/47] Log JS errors from the WebUI --- data/interfaces/default/js/script.js | 13 +++++++++++++ plexpy/webserve.py | 9 ++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/data/interfaces/default/js/script.js b/data/interfaces/default/js/script.js index 1c416319..9f3ff621 100644 --- a/data/interfaces/default/js/script.js +++ b/data/interfaces/default/js/script.js @@ -391,3 +391,16 @@ function clearSearchButton(tableName, table) { table.search('').draw(); }); } + +// Taken from https://github.com/Hellowlol/HTPC-Manager +window.onerror = function (message, file, line) { + var e = { + 'page': window.location.href, + 'message': message, + 'file': file, + 'line': line + }; + + $.post("log_js_errors", e, function (data) { + }); +}; \ No newline at end of file diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 21213ed7..b3cbd12e 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -1081,7 +1081,14 @@ class WebInterface(object): logger.debug(u"If you read this message, debug logging is available") raise cherrypy.HTTPRedirect("logs") - + @cherrypy.expose + def log_js_errors(self, page, message, file, line): + """ Logs javascript errors from the web interface. """ + logger.error(u"WebUI :: /%s : %s. (%s:%s)" % (page.rpartition('/')[-1], + message, + file.rpartition('/')[-1].partition('?')[0], + line)) + return True ##### Settings ##### From c90dd147bb5cd7a7b2299a1d5d3258db6d7ad726 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sun, 14 Feb 2016 11:39:03 -0800 Subject: [PATCH 21/47] Rename config_id to agent_id --- .../default/notification_config.html | 4 +- data/interfaces/default/settings.html | 8 +- plexpy/notification_handler.py | 34 ++++---- plexpy/notifiers.py | 80 +++++++++---------- plexpy/webserve.py | 24 +++--- 5 files changed, 75 insertions(+), 75 deletions(-) diff --git a/data/interfaces/default/notification_config.html b/data/interfaces/default/notification_config.html index d071ce77..fab610d4 100644 --- a/data/interfaces/default/notification_config.html +++ b/data/interfaces/default/notification_config.html @@ -132,7 +132,7 @@ from plexpy import helpers function reloadModal() { $.ajax({ url: 'get_notification_agent_config', - data: { config_id: '${agent["id"]}' }, + data: { agent_id: '${agent["id"]}' }, cache: false, async: true, complete: function (xhr, status) { @@ -195,7 +195,7 @@ from plexpy import helpers $.ajax({ url: 'test_notifier', data: { - config_id: '${agent["id"]}', + agent_id: '${agent["id"]}', subject: $('#test_subject').val(), body: $('#test_body').val(), script: $('#test_script').val(), diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index 1a8651a8..5b2d5732 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -1721,10 +1721,10 @@ $(document).ready(function() { // Load notification agent config modal $(".toggle-notification-config-modal").click(function() { - var configId = $(this).data('id'); + var agent_id = $(this).data('id'); $.ajax({ url: 'get_notification_agent_config', - data: { config_id: configId }, + data: { agent_id: agent_id }, cache: false, async: true, complete: function(xhr, status) { @@ -1735,10 +1735,10 @@ $(document).ready(function() { // Load notification triggers config modal $(".toggle-notification-triggers-modal").click(function() { - var configId = $(this).data('id'); + var agent_id = $(this).data('id'); $.ajax({ url: 'get_notification_agent_triggers', - data: { config_id: configId }, + data: { agent_id: agent_id }, cache: false, async: true, complete: function(xhr, status) { diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py index a5cb0480..ad64eced 100644 --- a/plexpy/notification_handler.py +++ b/plexpy/notification_handler.py @@ -49,7 +49,7 @@ def notify(stream_data=None, notify_action=None): if agent['on_play'] and notify_action == 'play': # Build and send notification notify_strings = build_notify_text(session=stream_data, state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -62,7 +62,7 @@ def notify(stream_data=None, notify_action=None): and (plexpy.CONFIG.NOTIFY_CONSECUTIVE or progress_percent < plexpy.CONFIG.NOTIFY_WATCHED_PERCENT): # Build and send notification notify_strings = build_notify_text(session=stream_data, state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -74,7 +74,7 @@ def notify(stream_data=None, notify_action=None): and (plexpy.CONFIG.NOTIFY_CONSECUTIVE or progress_percent < 99): # Build and send notification notify_strings = build_notify_text(session=stream_data, state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -86,7 +86,7 @@ def notify(stream_data=None, notify_action=None): and (plexpy.CONFIG.NOTIFY_CONSECUTIVE or progress_percent < 99): # Build and send notification notify_strings = build_notify_text(session=stream_data, state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -97,7 +97,7 @@ def notify(stream_data=None, notify_action=None): elif agent['on_buffer'] and notify_action == 'buffer': # Build and send notification notify_strings = build_notify_text(session=stream_data, state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -113,7 +113,7 @@ def notify(stream_data=None, notify_action=None): if not any(d['agent_id'] == agent['id'] for d in notify_states): # Build and send notification notify_strings = build_notify_text(session=stream_data, state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -128,7 +128,7 @@ def notify(stream_data=None, notify_action=None): if not notify_state['on_watched'] and (notify_state['agent_id'] == agent['id']): # Build and send notification notify_strings = build_notify_text(session=stream_data, state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -143,7 +143,7 @@ def notify(stream_data=None, notify_action=None): if agent['on_play'] and notify_action == 'play': # Build and send notification notify_strings = build_notify_text(session=stream_data, state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -155,7 +155,7 @@ def notify(stream_data=None, notify_action=None): elif agent['on_stop'] and notify_action == 'stop': # Build and send notification notify_strings = build_notify_text(session=stream_data, state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -167,7 +167,7 @@ def notify(stream_data=None, notify_action=None): elif agent['on_pause'] and notify_action == 'pause': # Build and send notification notify_strings = build_notify_text(session=stream_data, state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -179,7 +179,7 @@ def notify(stream_data=None, notify_action=None): elif agent['on_resume'] and notify_action == 'resume': # Build and send notification notify_strings = build_notify_text(session=stream_data, state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -191,7 +191,7 @@ def notify(stream_data=None, notify_action=None): elif agent['on_buffer'] and notify_action == 'buffer': # Build and send notification notify_strings = build_notify_text(session=stream_data, state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -215,7 +215,7 @@ def notify_timeline(timeline_data=None, notify_action=None): if agent['on_created'] and notify_action == 'created': # Build and send notification notify_strings = build_notify_text(timeline=timeline_data, state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -228,7 +228,7 @@ def notify_timeline(timeline_data=None, notify_action=None): if agent['on_extdown'] and notify_action == 'extdown': # Build and send notification notify_strings = build_server_notify_text(state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -236,7 +236,7 @@ def notify_timeline(timeline_data=None, notify_action=None): if agent['on_intdown'] and notify_action == 'intdown': # Build and send notification notify_strings = build_server_notify_text(state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -244,7 +244,7 @@ def notify_timeline(timeline_data=None, notify_action=None): if agent['on_extup'] and notify_action == 'extup': # Build and send notification notify_strings = build_server_notify_text(state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, @@ -252,7 +252,7 @@ def notify_timeline(timeline_data=None, notify_action=None): if agent['on_intup'] and notify_action == 'intup': # Build and send notification notify_strings = build_server_notify_text(state=notify_action) - notifiers.send_notification(config_id=agent['id'], + notifiers.send_notification(agent_id=agent['id'], subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, diff --git a/plexpy/notifiers.py b/plexpy/notifiers.py index a6274c63..fbd6364d 100644 --- a/plexpy/notifiers.py +++ b/plexpy/notifiers.py @@ -358,59 +358,59 @@ def available_notification_agents(): return agents -def get_notification_agent_config(config_id): - if str(config_id).isdigit(): - config_id = int(config_id) +def get_notification_agent_config(agent_id): + if str(agent_id).isdigit(): + agent_id = int(agent_id) - if config_id == 0: + if agent_id == 0: growl = GROWL() return growl.return_config_options() - elif config_id == 1: + elif agent_id == 1: prowl = PROWL() return prowl.return_config_options() - elif config_id == 2: + elif agent_id == 2: xbmc = XBMC() return xbmc.return_config_options() - elif config_id == 3: + elif agent_id == 3: plex = Plex() return plex.return_config_options() - elif config_id == 4: + elif agent_id == 4: nma = NMA() return nma.return_config_options() - elif config_id == 5: + elif agent_id == 5: pushalot = PUSHALOT() return pushalot.return_config_options() - elif config_id == 6: + elif agent_id == 6: pushbullet = PUSHBULLET() return pushbullet.return_config_options() - elif config_id == 7: + elif agent_id == 7: pushover = PUSHOVER() return pushover.return_config_options() - elif config_id == 8: + elif agent_id == 8: osx_notify = OSX_NOTIFY() return osx_notify.return_config_options() - elif config_id == 9: + elif agent_id == 9: boxcar = BOXCAR() return boxcar.return_config_options() - elif config_id == 10: + elif agent_id == 10: email = Email() return email.return_config_options() - elif config_id == 11: + elif agent_id == 11: tweet = TwitterNotifier() return tweet.return_config_options() - elif config_id == 12: + elif agent_id == 12: iftttClient = IFTTT() return iftttClient.return_config_options() - elif config_id == 13: + elif agent_id == 13: telegramClient = TELEGRAM() return telegramClient.return_config_options() - elif config_id == 14: + elif agent_id == 14: slackClient = SLACK() return slackClient.return_config_options() - elif config_id == 15: + elif agent_id == 15: script = Scripts() return script.return_config_options() - elif config_id == 16: + elif agent_id == 16: facebook = FacebookNotifier() return facebook.return_config_options() else: @@ -419,59 +419,59 @@ def get_notification_agent_config(config_id): return [] -def send_notification(config_id, subject, body, **kwargs): - if str(config_id).isdigit(): - config_id = int(config_id) +def send_notification(agent_id, subject, body, **kwargs): + if str(agent_id).isdigit(): + agent_id = int(agent_id) - if config_id == 0: + if agent_id == 0: growl = GROWL() growl.notify(message=body, event=subject) - elif config_id == 1: + elif agent_id == 1: prowl = PROWL() prowl.notify(message=body, event=subject) - elif config_id == 2: + elif agent_id == 2: xbmc = XBMC() xbmc.notify(subject=subject, message=body) - elif config_id == 3: + elif agent_id == 3: plex = Plex() plex.notify(subject=subject, message=body) - elif config_id == 4: + elif agent_id == 4: nma = NMA() nma.notify(subject=subject, message=body) - elif config_id == 5: + elif agent_id == 5: pushalot = PUSHALOT() pushalot.notify(message=body, event=subject) - elif config_id == 6: + elif agent_id == 6: pushbullet = PUSHBULLET() pushbullet.notify(message=body, subject=subject) - elif config_id == 7: + elif agent_id == 7: pushover = PUSHOVER() pushover.notify(message=body, event=subject) - elif config_id == 8: + elif agent_id == 8: osx_notify = OSX_NOTIFY() osx_notify.notify(title=subject, text=body) - elif config_id == 9: + elif agent_id == 9: boxcar = BOXCAR() boxcar.notify(title=subject, message=body) - elif config_id == 10: + elif agent_id == 10: email = Email() email.notify(subject=subject, message=body) - elif config_id == 11: + elif agent_id == 11: tweet = TwitterNotifier() tweet.notify(subject=subject, message=body) - elif config_id == 12: + elif agent_id == 12: iftttClient = IFTTT() iftttClient.notify(subject=subject, message=body) - elif config_id == 13: + elif agent_id == 13: telegramClient = TELEGRAM() telegramClient.notify(message=body, event=subject) - elif config_id == 14: + elif agent_id == 14: slackClient = SLACK() slackClient.notify(message=body, event=subject) - elif config_id == 15: + elif agent_id == 15: scripts = Scripts() scripts.notify(message=body, subject=subject, **kwargs) - elif config_id == 16: + elif agent_id == 16: facebook = FacebookNotifier() facebook.notify(subject=subject, message=body) else: diff --git a/plexpy/webserve.py b/plexpy/webserve.py index b3cbd12e..ce4ecb40 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -1321,12 +1321,12 @@ class WebInterface(object): raise cherrypy.HTTPRedirect("settings") @cherrypy.expose - def get_notification_agent_config(self, config_id, **kwargs): - if config_id.isdigit(): - config = notifiers.get_notification_agent_config(config_id=config_id) + def get_notification_agent_config(self, agent_id, **kwargs): + if agent_id.isdigit(): + config = notifiers.get_notification_agent_config(agent_id=agent_id) agents = notifiers.available_notification_agents() for agent in agents: - if int(config_id) == agent['id']: + if int(agent_id) == agent['id']: this_agent = agent break else: @@ -1340,11 +1340,11 @@ class WebInterface(object): agent=this_agent, data=config, checkboxes=checkboxes) @cherrypy.expose - def get_notification_agent_triggers(self, config_id, **kwargs): - if config_id.isdigit(): + def get_notification_agent_triggers(self, agent_id, **kwargs): + if agent_id.isdigit(): agents = notifiers.available_notification_agents() for agent in agents: - if int(config_id) == agent['id']: + if int(agent_id) == agent['id']: this_agent = agent break else: @@ -1357,13 +1357,13 @@ class WebInterface(object): @cherrypy.expose @addtoapi('notify') - def test_notifier(self, config_id=None, subject='PlexPy', body='Test notification', **kwargs): + def test_notifier(self, agent_id=None, subject='PlexPy', body='Test notification', **kwargs): cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - if config_id.isdigit(): + if agent_id.isdigit(): agents = notifiers.available_notification_agents() for agent in agents: - if int(config_id) == agent['id']: + if int(agent_id) == agent['id']: this_agent = agent break else: @@ -1374,8 +1374,8 @@ class WebInterface(object): notifiers.send_notification(this_agent['id'], subject, body, **kwargs) return "Notification sent." else: - logger.debug(u"Unable to send test notification, invalid notification agent ID %s." % config_id) - return "Invalid notification agent ID %s." % config_id + logger.debug(u"Unable to send test notification, invalid notification agent ID %s." % agent_id) + return "Invalid notification agent ID %s." % agent_id else: logger.debug(u"Unable to send test notification, no notification agent ID received.") return "No notification agent ID received." From 38c9c5a6ea4e6849caf3694dd02e08ed302a4bbb Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sun, 14 Feb 2016 17:51:14 -0800 Subject: [PATCH 22/47] Add configuration and scheduler info to settings page --- data/interfaces/default/css/plexpy.css | 29 +++++ data/interfaces/default/settings.html | 143 ++++++++++++++++++++++++- plexpy/__init__.py | 27 +++-- plexpy/common.py | 12 ++- plexpy/config.py | 2 + plexpy/database.py | 2 +- plexpy/helpers.py | 7 ++ plexpy/webserve.py | 1 + 8 files changed, 205 insertions(+), 18 deletions(-) diff --git a/data/interfaces/default/css/plexpy.css b/data/interfaces/default/css/plexpy.css index a9a6f36e..d4862b84 100644 --- a/data/interfaces/default/css/plexpy.css +++ b/data/interfaces/default/css/plexpy.css @@ -2722,4 +2722,33 @@ table[id^='media_info_child'] table[id^='media_info_child'] thead th { .small-muted { font-size: small; color: #777; +} +.config-info-table, +.config-scheduler-table { + width: 100% +} +.config-info-table td, +.config-info-table th, +.config-scheduler-table td, +.config-scheduler-table th { + padding-bottom: 5px; +} +.config-info-table td:first-child { + width: 150px; +} +.config-scheduler-table td:first-child { + width: 225px; +} +.config-scheduler-table th { + color: #fff; +} +a.no-highlight { + color: #777; +} +a.no-highlight:hover { + color: #fff; +} +.top-line { + border-top: 1px dotted #777; + padding-top: 5px; } \ No newline at end of file diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index 5b2d5732..33f6b94b 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -1,9 +1,14 @@ <%inherit file="base.html"/> <%! +import arrow +import sys import plexpy from plexpy import notifiers, common, versioncheck +from plexpy.helpers import anon_url available_notification_agents = sorted(notifiers.available_notification_agents(), key=lambda k: k['name']) + +scheduled_jobs = [j.id for j in plexpy.SCHED.get_jobs()] %> <%def name="headIncludes()"> @@ -33,7 +38,8 @@ available_notification_agents = sorted(notifiers.available_notification_agents()
% else: -
Unable to retrieve data from database. +
No stats to show.

% endif \ No newline at end of file diff --git a/plexpy/libraries.py b/plexpy/libraries.py index 771f3c7a..4b6f47f5 100644 --- a/plexpy/libraries.py +++ b/plexpy/libraries.py @@ -540,51 +540,27 @@ class Libraries(object): def get_details(self, section_id=None): from plexpy import pmsconnect - monitor_db = database.MonitorDatabase() + default_return = {'section_id': None, + 'section_name': 'Local', + 'section_type': '', + 'library_thumb': common.DEFAULT_COVER_THUMB, + 'library_art': '', + 'count': 0, + 'parent_count': 0, + 'child_count': 0, + 'do_notify': 0, + 'do_notify_created': 0, + 'keep_history': 0 + } - try: - if section_id: - query = 'SELECT section_id, section_name, section_type, count, parent_count, child_count, ' \ - 'thumb AS library_thumb, custom_thumb_url AS custom_thumb, art, ' \ - 'do_notify, do_notify_created, keep_history ' \ - 'FROM library_sections ' \ - 'WHERE section_id = ? ' - result = monitor_db.select(query, args=[section_id]) - else: - result = [] - except Exception as e: - logger.warn(u"PlexPy Libraries :: Unable to execute database query for get_details: %s." % e) - result = [] + if not section_id: + return default_return - if result: - library_details = {} - for item in result: - if item['custom_thumb'] and item['custom_thumb'] != item['library_thumb']: - library_thumb = item['custom_thumb'] - elif item['library_thumb']: - library_thumb = item['library_thumb'] - else: - library_thumb = common.DEFAULT_COVER_THUMB + def get_library_details(section_id=section_id): + monitor_db = database.MonitorDatabase() - library_details = {'section_id': item['section_id'], - 'section_name': item['section_name'], - 'section_type': item['section_type'], - 'library_thumb': library_thumb, - 'library_art': item['art'], - 'count': item['count'], - 'parent_count': item['parent_count'], - 'child_count': item['child_count'], - 'do_notify': item['do_notify'], - 'do_notify_created': item['do_notify_created'], - 'keep_history': item['keep_history'] - } - return library_details - else: - logger.warn(u"PlexPy Libraries :: Unable to retrieve library from local database. Requesting library list refresh.") - # Let's first refresh the libraries list to make sure the library isn't newly added and not in the db yet - pmsconnect.refresh_libraries() try: - if section_id: + if str(section_id).isdigit(): query = 'SELECT section_id, section_name, section_type, count, parent_count, child_count, ' \ 'thumb AS library_thumb, custom_thumb_url AS custom_thumb, art, ' \ 'do_notify, do_notify_created, keep_history ' \ @@ -593,12 +569,12 @@ class Libraries(object): result = monitor_db.select(query, args=[section_id]) else: result = [] - except: + except Exception as e: logger.warn(u"PlexPy Libraries :: Unable to execute database query for get_details: %s." % e) result = [] + library_details = {} if result: - library_details = {} for item in result: if item['custom_thumb'] and item['custom_thumb'] != item['library_thumb']: library_thumb = item['custom_thumb'] @@ -619,22 +595,28 @@ class Libraries(object): 'do_notify_created': item['do_notify_created'], 'keep_history': item['keep_history'] } + return library_details + + library_details = get_library_details(section_id=section_id) + + if library_details: + return library_details + + else: + logger.warn(u"PlexPy Libraries :: Unable to retrieve library from local database. Requesting library list refresh.") + # Let's first refresh the libraries list to make sure the library isn't newly added and not in the db yet + pmsconnect.refresh_libraries() + + library_details = get_library_details(section_id=section_id) + + if library_details: return library_details + else: + logger.warn(u"PlexPy Users :: Unable to retrieve user from local database. Returning 'Local' library.") # If there is no library data we must return something - # Use "Local" user to retain compatibility with PlexWatch database value - return {'section_id': None, - 'section_name': 'Local', - 'section_type': '', - 'library_thumb': common.DEFAULT_COVER_THUMB, - 'library_art': '', - 'count': 0, - 'parent_count': 0, - 'child_count': 0, - 'do_notify': 0, - 'do_notify_created': 0, - 'keep_history': 0 - } + # Use "Local" library to retain compatibility with PlexWatch database value + return default_return def get_watch_time_stats(self, section_id=None): monitor_db = database.MonitorDatabase() diff --git a/plexpy/users.py b/plexpy/users.py index 8f5c5d9c..e4e7ee3c 100644 --- a/plexpy/users.py +++ b/plexpy/users.py @@ -245,58 +245,24 @@ class Users(object): def get_details(self, user_id=None, user=None): from plexpy import plextv - monitor_db = database.MonitorDatabase() - - try: - if str(user_id).isdigit(): - query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \ - 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history ' \ - 'FROM users ' \ - 'WHERE user_id = ? ' - result = monitor_db.select(query, args=[user_id]) - elif user: - query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \ - 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history ' \ - 'FROM users ' \ - 'WHERE username = ? ' - result = monitor_db.select(query, args=[user]) - else: - result = [] - except Exception as e: - logger.warn(u"PlexPy Users :: Unable to execute database query for get_details: %s." % e) - result = [] + default_return = {'user_id': None, + 'username': 'Local', + 'friendly_name': 'Local', + 'user_thumb': common.DEFAULT_USER_THUMB, + 'email': '', + 'is_home_user': 0, + 'is_allow_sync': 0, + 'is_restricted': 0, + 'do_notify': 0, + 'keep_history': 0 + } - if result: - user_details = {} - for item in result: - if item['friendly_name']: - friendly_name = item['friendly_name'] - else: - friendly_name = item['username'] + if not user_id and not user: + return default_return - if item['custom_thumb'] and item['custom_thumb'] != item['user_thumb']: - user_thumb = item['custom_thumb'] - elif item['user_thumb']: - user_thumb = item['user_thumb'] - else: - user_thumb = common.DEFAULT_USER_THUMB + def get_user_details(user_id=user_id, user=user): + monitor_db = database.MonitorDatabase() - user_details = {'user_id': item['user_id'], - 'username': item['username'], - 'friendly_name': friendly_name, - 'user_thumb': user_thumb, - 'email': item['email'], - 'is_home_user': item['is_home_user'], - 'is_allow_sync': item['is_allow_sync'], - 'is_restricted': item['is_restricted'], - 'do_notify': item['do_notify'], - 'keep_history': item['keep_history'] - } - return user_details - else: - logger.warn(u"PlexPy Users :: Unable to retrieve user from local database. Requesting user list refresh.") - # Let's first refresh the user list to make sure the user isn't newly added and not in the db yet - plextv.refresh_users() try: if str(user_id).isdigit(): query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \ @@ -316,8 +282,8 @@ class Users(object): logger.warn(u"PlexPy Users :: Unable to execute database query for get_details: %s." % e) result = [] + user_details = {} if result: - user_details = {} for item in result: if item['friendly_name']: friendly_name = item['friendly_name'] @@ -342,21 +308,28 @@ class Users(object): 'do_notify': item['do_notify'], 'keep_history': item['keep_history'] } + return user_details + + user_details = get_user_details(user_id=user_id, user=user) + + if user_details: + return user_details + + else: + logger.warn(u"PlexPy Users :: Unable to retrieve user from local database. Requesting user list refresh.") + # Let's first refresh the user list to make sure the user isn't newly added and not in the db yet + plextv.refresh_users() + + user_details = get_user_details(user_id=user_id, user=user) + + if user_details: return user_details + else: + logger.warn(u"PlexPy Users :: Unable to retrieve user from local database. Returning 'Local' user.") # If there is no user data we must return something # Use "Local" user to retain compatibility with PlexWatch database value - return {'user_id': None, - 'username': 'Local', - 'friendly_name': 'Local', - 'user_thumb': common.DEFAULT_USER_THUMB, - 'email': '', - 'is_home_user': 0, - 'is_allow_sync': 0, - 'is_restricted': 0, - 'do_notify': 0, - 'keep_history': 0 - } + return default_return def get_watch_time_stats(self, user_id=None): monitor_db = database.MonitorDatabase() diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 64e80e74..aad13469 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -363,9 +363,11 @@ class WebInterface(object): @cherrypy.expose def get_library_watch_time_stats(self, section_id=None, **kwargs): - - library_data = libraries.Libraries() - result = library_data.get_watch_time_stats(section_id=section_id) + if section_id: + library_data = libraries.Libraries() + result = library_data.get_watch_time_stats(section_id=section_id) + else: + result = None if result: return serve_template(templatename="user_watch_time_stats.html", data=result, title="Watch Stats") @@ -375,9 +377,11 @@ class WebInterface(object): @cherrypy.expose def get_library_user_stats(self, section_id=None, **kwargs): - - library_data = libraries.Libraries() - result = library_data.get_user_stats(section_id=section_id) + if section_id: + library_data = libraries.Libraries() + result = library_data.get_user_stats(section_id=section_id) + else: + result = None if result: return serve_template(templatename="library_user_stats.html", data=result, title="Player Stats") @@ -387,9 +391,11 @@ class WebInterface(object): @cherrypy.expose def get_library_recently_watched(self, section_id=None, limit='10', **kwargs): - - library_data = libraries.Libraries() - result = library_data.get_recently_watched(section_id=section_id, limit=limit) + if section_id: + library_data = libraries.Libraries() + result = library_data.get_recently_watched(section_id=section_id, limit=limit) + else: + result = None if result: return serve_template(templatename="user_recently_watched.html", data=result, title="Recently Watched") @@ -399,9 +405,11 @@ class WebInterface(object): @cherrypy.expose def get_library_recently_added(self, section_id=None, limit='10', **kwargs): - - pms_connect = pmsconnect.PmsConnect() - result = pms_connect.get_recently_added_details(section_id=section_id, count=limit) + if section_id: + pms_connect = pmsconnect.PmsConnect() + result = pms_connect.get_recently_added_details(section_id=section_id, count=limit) + else: + result = None if result: return serve_template(templatename="library_recently_added.html", data=result['recently_added'], title="Recently Added") @@ -628,9 +636,11 @@ class WebInterface(object): @cherrypy.expose def get_user_watch_time_stats(self, user=None, user_id=None, **kwargs): - - user_data = users.Users() - result = user_data.get_watch_time_stats(user_id=user_id) + if users_id or user: + user_data = users.Users() + result = user_data.get_watch_time_stats(user_id=user_id) + else: + result = None if result: return serve_template(templatename="user_watch_time_stats.html", data=result, title="Watch Stats") @@ -640,9 +650,11 @@ class WebInterface(object): @cherrypy.expose def get_user_player_stats(self, user=None, user_id=None, **kwargs): - - user_data = users.Users() - result = user_data.get_player_stats(user_id=user_id) + if users_id or user: + user_data = users.Users() + result = user_data.get_player_stats(user_id=user_id) + else: + result = None if result: return serve_template(templatename="user_player_stats.html", data=result, title="Player Stats") @@ -652,9 +664,11 @@ class WebInterface(object): @cherrypy.expose def get_user_recently_watched(self, user=None, user_id=None, limit='10', **kwargs): - - user_data = users.Users() - result = user_data.get_recently_watched(user_id=user_id, limit=limit) + if users_id or user: + user_data = users.Users() + result = user_data.get_recently_watched(user_id=user_id, limit=limit) + else: + result = None if result: return serve_template(templatename="user_recently_watched.html", data=result, title="Recently Watched") From 1c8428c3ea0760c78cdbeee03a76413cedf2bfe8 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Thu, 18 Feb 2016 06:53:07 -0800 Subject: [PATCH 35/47] Add backup back to api --- plexpy/api2.py | 12 ++++++++++++ plexpy/webserve.py | 3 +-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/plexpy/api2.py b/plexpy/api2.py index 71a60e3e..27a09787 100644 --- a/plexpy/api2.py +++ b/plexpy/api2.py @@ -275,6 +275,18 @@ class API2: self.data = rows return rows + def backupdb(self): + """ Creates a manual backup of the plexpy.db file """ + + data = database.make_backup() + + if data: + self.result_type = 'success' + else: + self.result_type = 'failed' + + return data + def restart(self, **kwargs): """ Restarts plexpy """ diff --git a/plexpy/webserve.py b/plexpy/webserve.py index aad13469..0349de6f 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -1354,11 +1354,10 @@ class WebInterface(object): return serve_template(templatename="scheduler_table.html") @cherrypy.expose - @addtoapi() def backup_db(self): """ Creates a manual backup of the plexpy.db file """ - result = database.make_backup(scheduler=False) + result = database.make_backup() if result: cherrypy.response.headers['Content-type'] = 'application/json' From 71d30af582e62008e8683aadd2bf606650a2c5fb Mon Sep 17 00:00:00 2001 From: Tim Van Date: Thu, 18 Feb 2016 18:01:42 +0200 Subject: [PATCH 36/47] Add Plex Media Scanner log files to Log viewer. --- data/interfaces/default/logs.html | 29 ++++++++++++++++++++++++++++- plexpy/log_reader.py | 8 ++++++-- plexpy/webserve.py | 7 ++++++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/data/interfaces/default/logs.html b/data/interfaces/default/logs.html index ecb1d17f..04a2938a 100644 --- a/data/interfaces/default/logs.html +++ b/data/interfaces/default/logs.html @@ -29,6 +29,7 @@ from plexpy import helpers
@@ -57,6 +58,19 @@ from plexpy import helpers
+
+ + + + + + + + + + +
TimestampLevelMessage
+
@@ -98,11 +112,18 @@ from plexpy import helpers function LoadPlexLogs() { plex_log_table_options.ajax = { - "url": "get_plex_log" + "url": "get_plex_log?log_type=server" } plex_log_table = $('#plex_log_table').DataTable(plex_log_table_options); } + function LoadPlexScannerLogs() { + plex_log_table_options.ajax = { + "url": "get_plex_log?log_type=scanner" + } + plex_scanner_log_table = $('#plex_scanner_log_table').DataTable(plex_log_table_options); + } + $("#plexpy-logs-btn").click(function() { $("#clear-logs").show(); LoadPlexPyLogs(); @@ -115,6 +136,12 @@ from plexpy import helpers clearSearchButton('plex_log_table', plex_log_table); }); + $("#plex-scanner-logs-btn").click(function() { + $("#clear-logs").hide(); + LoadPlexScannerLogs(); + clearSearchButton('plex_scanner_log_table', plex_scanner_log_table); + }); + $("#clear-logs").click(function() { var r = confirm("Are you sure you want to clear the PlexPy log?"); if (r == true) { diff --git a/plexpy/log_reader.py b/plexpy/log_reader.py index b8b78329..c251e210 100644 --- a/plexpy/log_reader.py +++ b/plexpy/log_reader.py @@ -18,10 +18,14 @@ import re import os import plexpy -def get_log_tail(window=20, parsed=True): +def get_log_tail(window=20, parsed=True, log_type="server"): if plexpy.CONFIG.PMS_LOGS_FOLDER: - log_file = os.path.join(plexpy.CONFIG.PMS_LOGS_FOLDER, 'Plex Media Server.log') + log_file = "" + if log_type == "server": + log_file = os.path.join(plexpy.CONFIG.PMS_LOGS_FOLDER, 'Plex Media Server.log') + elif log_type == "scanner": + log_file = os.path.join(plexpy.CONFIG.PMS_LOGS_FOLDER, 'Plex Media Scanner.log') else: return [] diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 0349de6f..28759f10 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -1072,8 +1072,13 @@ class WebInterface(object): @addtoapi() def get_plex_log(self, window=1000, **kwargs): log_lines = [] + log_type = "" + + if 'log_type' in kwargs: + log_type = kwargs.get('log_type', "server") + try: - log_lines = {'data': log_reader.get_log_tail(window=window)} + log_lines = {'data': log_reader.get_log_tail(window=window, parsed=True, log_type=log_type)} except: logger.warn(u"Unable to retrieve Plex Logs.") From 932c93e573266f66890b9e0b4eedd81100441aa1 Mon Sep 17 00:00:00 2001 From: Tim Van Date: Thu, 18 Feb 2016 18:06:36 +0200 Subject: [PATCH 37/47] Ensure we default to the server log. --- plexpy/webserve.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 28759f10..18a10015 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -1072,7 +1072,7 @@ class WebInterface(object): @addtoapi() def get_plex_log(self, window=1000, **kwargs): log_lines = [] - log_type = "" + log_type = "server" if 'log_type' in kwargs: log_type = kwargs.get('log_type', "server") From 0fee4fee2ae02cb141806415eea38f59436c62ec Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Thu, 18 Feb 2016 12:03:28 -0800 Subject: [PATCH 38/47] Fix typo from e38e98d9e71ee9587ee928400d43aab44c4ae4b9 --- plexpy/webserve.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 18a10015..d4fe2263 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -636,7 +636,7 @@ class WebInterface(object): @cherrypy.expose def get_user_watch_time_stats(self, user=None, user_id=None, **kwargs): - if users_id or user: + if user_id or user: user_data = users.Users() result = user_data.get_watch_time_stats(user_id=user_id) else: @@ -650,7 +650,7 @@ class WebInterface(object): @cherrypy.expose def get_user_player_stats(self, user=None, user_id=None, **kwargs): - if users_id or user: + if user_id or user: user_data = users.Users() result = user_data.get_player_stats(user_id=user_id) else: @@ -664,7 +664,7 @@ class WebInterface(object): @cherrypy.expose def get_user_recently_watched(self, user=None, user_id=None, limit='10', **kwargs): - if users_id or user: + if user_id or user: user_data = users.Users() result = user_data.get_recently_watched(user_id=user_id, limit=limit) else: From b1ecff3d10a7a48d3d4a4f9cbccf180aa412a65a Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Thu, 18 Feb 2016 18:52:07 -0800 Subject: [PATCH 39/47] Add TV posters to Facebook notifications --- lib/openanything.py | 107 ++++++++++++++++++++++++++++++++++++++++++++ plexpy/config.py | 1 + plexpy/notifiers.py | 78 +++++++++++++++++++++----------- 3 files changed, 160 insertions(+), 26 deletions(-) create mode 100644 lib/openanything.py diff --git a/lib/openanything.py b/lib/openanything.py new file mode 100644 index 00000000..413f576d --- /dev/null +++ b/lib/openanything.py @@ -0,0 +1,107 @@ +'''OpenAnything: a kind and thoughtful library for HTTP web services + +This program is part of 'Dive Into Python', a free Python book for +experienced programmers. Visit http://diveintopython.org/ for the +latest version. +''' + +__author__ = 'Mark Pilgrim (mark@diveintopython.org)' +__version__ = '$Revision: 1.6 $'[11:-2] +__date__ = '$Date: 2004/04/16 21:16:24 $' +__copyright__ = 'Copyright (c) 2004 Mark Pilgrim' +__license__ = 'Python' + +import urllib2, urlparse, gzip +from StringIO import StringIO + +USER_AGENT = 'OpenAnything/%s +http://diveintopython.org/http_web_services/' % __version__ + +class SmartRedirectHandler(urllib2.HTTPRedirectHandler): + def http_error_301(self, req, fp, code, msg, headers): + result = urllib2.HTTPRedirectHandler.http_error_301( + self, req, fp, code, msg, headers) + result.status = code + return result + + def http_error_302(self, req, fp, code, msg, headers): + result = urllib2.HTTPRedirectHandler.http_error_302( + self, req, fp, code, msg, headers) + result.status = code + return result + +class DefaultErrorHandler(urllib2.HTTPDefaultErrorHandler): + def http_error_default(self, req, fp, code, msg, headers): + result = urllib2.HTTPError( + req.get_full_url(), code, msg, headers, fp) + result.status = code + return result + +def openAnything(source, etag=None, lastmodified=None, agent=USER_AGENT): + """URL, filename, or string --> stream + + This function lets you define parsers that take any input source + (URL, pathname to local or network file, or actual data as a string) + and deal with it in a uniform manner. Returned object is guaranteed + to have all the basic stdio read methods (read, readline, readlines). + Just .close() the object when you're done with it. + + If the etag argument is supplied, it will be used as the value of an + If-None-Match request header. + + If the lastmodified argument is supplied, it must be a formatted + date/time string in GMT (as returned in the Last-Modified header of + a previous request). The formatted date/time will be used + as the value of an If-Modified-Since request header. + + If the agent argument is supplied, it will be used as the value of a + User-Agent request header. + """ + + if hasattr(source, 'read'): + return source + + if source == '-': + return sys.stdin + + if urlparse.urlparse(source)[0] == 'http': + # open URL with urllib2 + request = urllib2.Request(source) + request.add_header('User-Agent', agent) + if lastmodified: + request.add_header('If-Modified-Since', lastmodified) + if etag: + request.add_header('If-None-Match', etag) + request.add_header('Accept-encoding', 'gzip') + opener = urllib2.build_opener(SmartRedirectHandler(), DefaultErrorHandler()) + return opener.open(request) + + # try to open with native open function (if source is a filename) + try: + return open(source) + except (IOError, OSError): + pass + + # treat source as string + return StringIO(str(source)) + +def fetch(source, etag=None, lastmodified=None, agent=USER_AGENT): + '''Fetch data and metadata from a URL, file, stream, or string''' + result = {} + f = openAnything(source, etag, lastmodified, agent) + result['data'] = f.read() + if hasattr(f, 'headers'): + # save ETag, if the server sent one + result['etag'] = f.headers.get('ETag') + # save Last-Modified header, if the server sent one + result['lastmodified'] = f.headers.get('Last-Modified') + if f.headers.get('content-encoding') == 'gzip': + # data came back gzip-compressed, decompress it + result['data'] = gzip.GzipFile(fileobj=StringIO(result['data'])).read() + if hasattr(f, 'url'): + result['url'] = f.url + result['status'] = 200 + if hasattr(f, 'status'): + result['status'] = f.status + f.close() + return result + diff --git a/plexpy/config.py b/plexpy/config.py index d76d264a..53e6eed3 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -88,6 +88,7 @@ _CONFIG_DEFINITIONS = { 'FACEBOOK_APP_SECRET': (str, 'Facebook', ''), 'FACEBOOK_TOKEN': (str, 'Facebook', ''), 'FACEBOOK_GROUP': (str, 'Facebook', ''), + 'FACEBOOK_INCL_POSTER': (int, 'Facebook', 1), 'FACEBOOK_INCL_SUBJECT': (int, 'Facebook', 1), 'FACEBOOK_ON_PLAY': (int, 'Facebook', 0), 'FACEBOOK_ON_STOP': (int, 'Facebook', 0), diff --git a/plexpy/notifiers.py b/plexpy/notifiers.py index 047e5ca5..7f71cbfd 100644 --- a/plexpy/notifiers.py +++ b/plexpy/notifiers.py @@ -20,6 +20,7 @@ import cherrypy from email.mime.text import MIMEText import email.utils from httplib import HTTPSConnection, HTTPConnection +import openanything import os import shlex import smtplib @@ -2069,9 +2070,11 @@ class FacebookNotifier(object): def __init__(self): self.redirect_uri = plexpy.CONFIG.FACEBOOK_REDIRECT_URI + self.access_token = plexpy.CONFIG.FACEBOOK_TOKEN self.app_id = plexpy.CONFIG.FACEBOOK_APP_ID self.app_secret = plexpy.CONFIG.FACEBOOK_APP_SECRET self.group_id = plexpy.CONFIG.FACEBOOK_GROUP + self.incl_poster = plexpy.CONFIG.FACEBOOK_INCL_POSTER self.incl_subject = plexpy.CONFIG.FACEBOOK_INCL_SUBJECT def notify(self, subject, message, **kwargs): @@ -2118,39 +2121,24 @@ class FacebookNotifier(object): return True def _post_facebook(self, message=None, **kwargs): - access_token = plexpy.CONFIG.FACEBOOK_TOKEN - group_id = plexpy.CONFIG.FACEBOOK_GROUP - - if group_id: - api = facebook.GraphAPI(access_token=access_token, version='2.5') + if self.group_id: + api = facebook.GraphAPI(access_token=self.access_token, version='2.5') attachment = {} - if 'metadata' in kwargs: + if self.incl_poster and 'metadata' in kwargs: + poster = '' + caption = 'View in Plex Web.' metadata = kwargs['metadata'] - if metadata['media_type'] == 'movie' and metadata['imdb_id']: - uri = 'i=' + metadata['imdb_id'] + if metadata['media_type'] == 'movie' and metadata.get('imdb_id', ''): title = metadata['title'] subtitle = metadata['year'] - elif metadata['media_type'] == 'show': - uri = 't=' + metadata['title'] + '&y=' + metadata['year'] - title = metadata['title'] - subtitle = metadata['year'] - elif metadata['media_type'] == 'episode': - uri = 't=' + metadata['grandparent_title'] - title = metadata['grandparent_title'] + ' - ' + metadata['title'] - subtitle = 'S' + metadata['parent_media_index'] + ' ' + '\xc2\xb7'.decode('utf8') + ' E' + metadata['media_index'] - else: - uri = '' - title = '' - subtitle = '' + uri = '/?i=' + metadata['imdb_id'] - # Get poster using OMDb API - poster = '' - if uri: + # Get poster using OMDb API http_handler = HTTPConnection("www.omdbapi.com") - http_handler.request('GET', '/?' + uri) + http_handler.request('GET', uri) response = http_handler.getresponse() request_status = response.status @@ -2161,16 +2149,47 @@ class FacebookNotifier(object): logger.warn(u"PlexPy Notifiers :: Unable to retrieve IMDB poster: %s" % response.reason) else: logger.warn(u"PlexPy Notifiers :: Unable to retrieve IMDB poster.") - + + elif (metadata['media_type'] == 'show' or metadata['media_type'] == 'episode') \ + and (metadata.get('imdb_id', '') or metadata.get('thetvdb_id', '')): + if metadata['media_type'] == 'show': + title = metadata['title'] + subtitle = metadata['year'] + elif metadata['media_type'] == 'episode': + title = metadata['grandparent_title'] + ' - ' + metadata['title'] + subtitle = 'S' + metadata['parent_media_index'] + ' ' + '\xc2\xb7'.decode('utf8') + \ + ' E' + metadata['media_index'] + + if metadata.get('imdb_id', ''): + uri = '/lookup/shows?imdb=' + metadata['imdb_id'] + elif metadata.get('thetvdb_id', ''): + uri = '/lookup/shows?thetvdb=' + metadata['thetvdb_id'] + + # Get poster using TVmaze API + request = urllib2.Request('http://api.tvmaze.com' + uri) + opener = urllib2.build_opener(openanything.SmartRedirectHandler()) + response = opener.open(request) + request_status = response.status + + if request_status == 301: + data = json.loads(response.read()) + image = data.get('image', '') + poster = image.get('original', image.get('medium','')) + elif request_status >= 400 and request_status < 500: + logger.warn(u"PlexPy Notifiers :: Unable to retrieve TVmaze poster: %s" % response.reason) + else: + logger.warn(u"PlexPy Notifiers :: Unable to retrieve TVmaze poster.") + if poster and poster != 'N/A': attachment['link'] = 'http://app.plex.tv/web/app#!/server/' + plexpy.CONFIG.PMS_IDENTIFIER + \ '/details/%2Flibrary%2Fmetadata%2F' + metadata['rating_key'] attachment['picture'] = poster attachment['name'] = title attachment['description'] = subtitle + attachment['caption'] = caption try: - api.put_wall_post(profile_id=group_id, message=message, attachment=attachment) + api.put_wall_post(profile_id=self.group_id, message=message, attachment=attachment) logger.info(u"PlexPy Notifiers :: Facebook notification sent.") except Exception as e: logger.warn(u"PlexPy Notifiers :: Error sending Facebook post: %s" % e) @@ -2227,6 +2246,13 @@ class FacebookNotifier(object): 'description': 'Your Facebook Group ID.', 'input_type': 'text' }, + {'label': 'Include Poster Image', + 'value': self.incl_poster, + 'name': 'facebook_incl_poster', + 'description': 'Include a poster in the notifications. \ + (PMS agent must be Freebase or TheTVDB. TheMovieDb is currently not supported.)', + 'input_type': 'checkbox' + }, {'label': 'Include Subject Line', 'value': self.incl_subject, 'name': 'facebook_incl_subject', From 0e53252a2789bb3930af458018c0c16f49c99a44 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Thu, 18 Feb 2016 21:09:07 -0800 Subject: [PATCH 40/47] Move get poster to notification handler --- data/interfaces/default/settings.html | 7 ++++ plexpy/notification_handler.py | 48 +++++++++++++++++++++- plexpy/notifiers.py | 59 ++++----------------------- 3 files changed, 63 insertions(+), 51 deletions(-) diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index 75255e69..e08f7eeb 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -1479,6 +1479,13 @@ available_notification_agents = sorted(notifiers.available_notification_agents() {duration} The duration (in minutes) for the item. + + {poster_url} + + A URL for the movie or TV show poster.
+

(PMS agent must be Freebase or TheTVDB)

+ + {imdb_id} The IMDB ID for the movie. (e.g. tt2488496) diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py index a19c36ba..45903513 100644 --- a/plexpy/notification_handler.py +++ b/plexpy/notification_handler.py @@ -14,9 +14,13 @@ # along with PlexPy. If not, see . +import arrow +import json +from httplib import HTTPConnection +import openanything import re import time -import arrow +import urllib2 from plexpy import logger, config, notifiers, database, helpers, plextv, pmsconnect import plexpy @@ -495,6 +499,47 @@ def build_notify_text(session=None, timeline=None, state=None): metadata['lastfm_id'] = metadata['guid'].split('lastfm://')[1].rsplit('/', 1)[0] metadata['lastfm_url'] = 'https://www.last.fm/music/' + metadata['lastfm_id'] + # Get posters (only IMDB and TheTVDB supported) + if metadata['media_type'] == 'movie' and metadata.get('imdb_id', ''): + uri = '/?i=' + metadata['imdb_id'] + + # Get poster using OMDb API + http_handler = HTTPConnection("www.omdbapi.com") + http_handler.request('GET', uri) + response = http_handler.getresponse() + request_status = response.status + + if request_status == 200: + data = json.loads(response.read()) + poster_url = data.get('Poster', '') + metadata['poster_url'] = poster_url if poster_url != 'N/A' else '' + elif request_status >= 400 and request_status < 500: + logger.warn(u"PlexPy Notifiers :: Unable to retrieve IMDB poster: %s" % response.reason) + else: + logger.warn(u"PlexPy Notifiers :: Unable to retrieve IMDB poster.") + + elif (metadata['media_type'] == 'show' or metadata['media_type'] == 'episode') \ + and (metadata.get('imdb_id', '') or metadata.get('thetvdb_id', '')): + if metadata.get('imdb_id', ''): + uri = '/lookup/shows?imdb=' + metadata['imdb_id'] + elif metadata.get('thetvdb_id', ''): + uri = '/lookup/shows?thetvdb=' + metadata['thetvdb_id'] + + # Get poster using TVmaze API + request = urllib2.Request('http://api.tvmaze.com' + uri) + opener = urllib2.build_opener(openanything.SmartRedirectHandler()) + response = opener.open(request) + request_status = response.status + + if request_status == 301: + data = json.loads(response.read()) + image = data.get('image', '') + metadata['poster_url'] = image.get('original', image.get('medium','')) + elif request_status >= 400 and request_status < 500: + logger.warn(u"PlexPy Notifiers :: Unable to retrieve TVmaze poster: %s" % response.reason) + else: + logger.warn(u"PlexPy Notifiers :: Unable to retrieve TVmaze poster.") + # Fix metadata params for notify recently added grandparent if state == 'created' and plexpy.CONFIG.NOTIFY_RECENTLY_ADDED_GRANDPARENT: show_name = metadata['title'] @@ -576,6 +621,7 @@ def build_notify_text(session=None, timeline=None, state=None): 'tagline': metadata['tagline'], 'rating': metadata['rating'], 'duration': duration, + 'poster_url': metadata.get('poster_url',''), 'imdb_id': metadata.get('imdb_id',''), 'imdb_url': metadata.get('imdb_url',''), 'thetvdb_id': metadata.get('thetvdb_id',''), diff --git a/plexpy/notifiers.py b/plexpy/notifiers.py index 7f71cbfd..772b7d84 100644 --- a/plexpy/notifiers.py +++ b/plexpy/notifiers.py @@ -2127,63 +2127,22 @@ class FacebookNotifier(object): attachment = {} if self.incl_poster and 'metadata' in kwargs: - poster = '' - caption = 'View in Plex Web.' metadata = kwargs['metadata'] + poster_url = metadata.get('poster_url','') + caption = 'View in Plex Web.' - if metadata['media_type'] == 'movie' and metadata.get('imdb_id', ''): + if metadata['media_type'] == 'movie' or metadata['media_type'] == 'show': title = metadata['title'] subtitle = metadata['year'] - uri = '/?i=' + metadata['imdb_id'] + elif metadata['media_type'] == 'episode': + title = metadata['grandparent_title'] + ' - ' + metadata['title'] + subtitle = 'S' + metadata['parent_media_index'] + ' ' + '\xc2\xb7'.decode('utf8') + \ + ' E' + metadata['media_index'] - # Get poster using OMDb API - http_handler = HTTPConnection("www.omdbapi.com") - http_handler.request('GET', uri) - response = http_handler.getresponse() - request_status = response.status - - if request_status == 200: - data = json.loads(response.read()) - poster = data.get('Poster', '') - elif request_status >= 400 and request_status < 500: - logger.warn(u"PlexPy Notifiers :: Unable to retrieve IMDB poster: %s" % response.reason) - else: - logger.warn(u"PlexPy Notifiers :: Unable to retrieve IMDB poster.") - - elif (metadata['media_type'] == 'show' or metadata['media_type'] == 'episode') \ - and (metadata.get('imdb_id', '') or metadata.get('thetvdb_id', '')): - if metadata['media_type'] == 'show': - title = metadata['title'] - subtitle = metadata['year'] - elif metadata['media_type'] == 'episode': - title = metadata['grandparent_title'] + ' - ' + metadata['title'] - subtitle = 'S' + metadata['parent_media_index'] + ' ' + '\xc2\xb7'.decode('utf8') + \ - ' E' + metadata['media_index'] - - if metadata.get('imdb_id', ''): - uri = '/lookup/shows?imdb=' + metadata['imdb_id'] - elif metadata.get('thetvdb_id', ''): - uri = '/lookup/shows?thetvdb=' + metadata['thetvdb_id'] - - # Get poster using TVmaze API - request = urllib2.Request('http://api.tvmaze.com' + uri) - opener = urllib2.build_opener(openanything.SmartRedirectHandler()) - response = opener.open(request) - request_status = response.status - - if request_status == 301: - data = json.loads(response.read()) - image = data.get('image', '') - poster = image.get('original', image.get('medium','')) - elif request_status >= 400 and request_status < 500: - logger.warn(u"PlexPy Notifiers :: Unable to retrieve TVmaze poster: %s" % response.reason) - else: - logger.warn(u"PlexPy Notifiers :: Unable to retrieve TVmaze poster.") - - if poster and poster != 'N/A': + if poster_url: attachment['link'] = 'http://app.plex.tv/web/app#!/server/' + plexpy.CONFIG.PMS_IDENTIFIER + \ '/details/%2Flibrary%2Fmetadata%2F' + metadata['rating_key'] - attachment['picture'] = poster + attachment['picture'] = poster_url attachment['name'] = title attachment['description'] = subtitle attachment['caption'] = caption From e2ac8be451acf5efbbfd4d57534659b8b4362c15 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Thu, 18 Feb 2016 22:24:19 -0800 Subject: [PATCH 41/47] Cleanup save settings --- data/interfaces/default/settings.html | 12 ++--- plexpy/webserve.py | 67 +++++++++++---------------- 2 files changed, 31 insertions(+), 48 deletions(-) diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index e08f7eeb..2cc6d635 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -224,7 +224,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents()
  • @@ -475,7 +475,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents() - +

    Plex Logs

    @@ -1381,7 +1381,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents() {media_type} - The type of media (movie, episode, track). + The type of media. (movie, episode, track) {title} @@ -1481,10 +1481,8 @@ available_notification_agents = sorted(notifiers.available_notification_agents() {poster_url} - - A URL for the movie or TV show poster.
    -

    (PMS agent must be Freebase or TheTVDB)

    - + A URL for the movie or TV show poster. +

    (PMS agent must be Freebase or TheTVDB)

    {imdb_id} diff --git a/plexpy/webserve.py b/plexpy/webserve.py index d4fe2263..38a63abc 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -1244,11 +1244,11 @@ class WebInterface(object): for checked_config in checked_configs: if checked_config not in kwargs: # checked items should be zero or one. if they were not sent then the item was not checked - kwargs[checked_config] = 0 + kwargs[checked_config] = '0' # If http password exists in config, do not overwrite when blank value received - if 'http_password' in kwargs: - if kwargs['http_password'] == ' ' and plexpy.CONFIG.HTTP_PASSWORD != '': + if kwargs.get('http_password'): + if kwargs['http_password'].strip() == '' and plexpy.CONFIG.HTTP_PASSWORD != '': kwargs['http_password'] = plexpy.CONFIG.HTTP_PASSWORD for plain_config, use_config in [(x[4:], x) for x in kwargs if x.startswith('use_')]: @@ -1258,50 +1258,34 @@ class WebInterface(object): # Check if we should refresh our data server_changed = False + reschedule = False https_changed = False refresh_libraries = False refresh_users = False - reschedule = False - if 'monitoring_interval' in kwargs and 'refresh_libraries_interval' in kwargs: - if (kwargs['monitoring_interval'] != str(plexpy.CONFIG.MONITORING_INTERVAL)) or \ - (kwargs['refresh_libraries_interval'] != str(plexpy.CONFIG.REFRESH_LIBRARIES_INTERVAL)): - reschedule = True - - if 'monitoring_interval' in kwargs and 'refresh_users_interval' in kwargs: - if (kwargs['monitoring_interval'] != str(plexpy.CONFIG.MONITORING_INTERVAL)) or \ - (kwargs['refresh_users_interval'] != str(plexpy.CONFIG.REFRESH_USERS_INTERVAL)): - reschedule = True - - if 'notify_recently_added' in kwargs and \ - (kwargs['notify_recently_added'] != plexpy.CONFIG.NOTIFY_RECENTLY_ADDED): + # If we change any monitoring settings, make sure we reschedule tasks. + if kwargs.get('monitoring_interval') != str(plexpy.CONFIG.MONITORING_INTERVAL) or \ + kwargs.get('refresh_libraries_interval') != str(plexpy.CONFIG.REFRESH_LIBRARIES_INTERVAL) or \ + kwargs.get('refresh_users_interval') != str(plexpy.CONFIG.REFRESH_USERS_INTERVAL) or \ + kwargs.get('notify_recently_added') != str(plexpy.CONFIG.NOTIFY_RECENTLY_ADDED) or \ + kwargs.get('monitor_remote_access') != str(plexpy.CONFIG.MONITOR_REMOTE_ACCESS): reschedule = True - if 'monitor_remote_access' in kwargs and \ - (kwargs['monitor_remote_access'] != plexpy.CONFIG.MONITOR_REMOTE_ACCESS): - reschedule = True - - # If we change the SSL setting for PMS, make sure we grab the new url. - if 'pms_ssl' in kwargs and \ - (kwargs['pms_ssl'] != plexpy.CONFIG.PMS_SSL): - server_changed = True - - # If we change the PMS remote setting, make sure we grab the new url. - if 'pms_is_remote' in kwargs and \ - (kwargs['pms_is_remote'] != plexpy.CONFIG.PMS_IS_REMOTE): + # If we change the SSL setting for PMS or PMS remote setting, make sure we grab the new url. + if kwargs.get('pms_ssl') != str(plexpy.CONFIG.PMS_SSL) or \ + kwargs.get('pms_is_remote') != str(plexpy.CONFIG.PMS_IS_REMOTE): server_changed = True # If we change the HTTPS setting, make sure we generate a new certificate. - if 'https_create_cert' in kwargs and kwargs['https_create_cert']: - if 'https_create_cert' in kwargs and (kwargs['https_create_cert'] != plexpy.CONFIG.HTTPS_CREATE_CERT) or \ - 'https_domain' in kwargs and (kwargs['https_domain'] != plexpy.CONFIG.HTTPS_DOMAIN) or \ - 'https_ip' in kwargs and (kwargs['https_ip'] != plexpy.CONFIG.HTTPS_IP) or \ - 'https_cert' in kwargs and (kwargs['https_cert'] != plexpy.CONFIG.HTTPS_CERT) or \ - 'https_key' in kwargs and (kwargs['https_key'] != plexpy.CONFIG.HTTPS_KEY): + if kwargs.get('enable_https') and kwargs.get('https_create_cert'): + if kwargs.get('https_domain') != plexpy.CONFIG.HTTPS_DOMAIN or \ + kwargs.get('https_ip') != plexpy.CONFIG.HTTPS_IP or \ + kwargs.get('https_cert') != plexpy.CONFIG.HTTPS_CERT or \ + kwargs.get('https_key') != plexpy.CONFIG.HTTPS_KEY: https_changed = True # Remove config with 'hscard-' prefix and change home_stats_cards to list - if 'home_stats_cards' in kwargs: + if kwargs.get('home_stats_cards', ''): for k in kwargs.keys(): if k.startswith('hscard-'): del kwargs[k] @@ -1311,7 +1295,7 @@ class WebInterface(object): kwargs['home_stats_cards'] = plexpy.CONFIG.HOME_STATS_CARDS # Remove config with 'hlcard-' prefix and change home_library_cards to list - if 'home_library_cards' in kwargs: + if kwargs.get('home_library_cards', ''): for k in kwargs.keys(): if k.startswith('hlcard-'): del kwargs[k] @@ -1320,7 +1304,8 @@ class WebInterface(object): if kwargs['home_library_cards'] == ['first_run_wizard']: refresh_libraries = True - if 'server_changed' in kwargs: + # If we change the server, make sure we grab the new url and refresh libraries and users lists. + if kwargs.get('server_changed'): del kwargs['server_changed'] server_changed = True refresh_users = True @@ -1336,14 +1321,14 @@ class WebInterface(object): plextv.get_real_pms_url() pmsconnect.get_server_friendly_name() - # Generate a new HTTPS certificate - if https_changed: - create_https_certificates(plexpy.CONFIG.HTTPS_CERT, plexpy.CONFIG.HTTPS_KEY) - # Reconfigure scheduler if intervals changed if reschedule: plexpy.initialize_scheduler() + # Generate a new HTTPS certificate + if https_changed: + create_https_certificates(plexpy.CONFIG.HTTPS_CERT, plexpy.CONFIG.HTTPS_KEY) + # Refresh users table if our server IP changes. if refresh_libraries: threading.Thread(target=pmsconnect.refresh_libraries).start() From 3e50e11933ac813ec20417b60fd81a4507155d63 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Thu, 18 Feb 2016 22:26:13 -0800 Subject: [PATCH 42/47] Simplify log_type --- plexpy/webserve.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 38a63abc..bc5edb7a 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -1072,10 +1072,7 @@ class WebInterface(object): @addtoapi() def get_plex_log(self, window=1000, **kwargs): log_lines = [] - log_type = "server" - - if 'log_type' in kwargs: - log_type = kwargs.get('log_type', "server") + log_type = kwargs.get('log_type', 'server') try: log_lines = {'data': log_reader.get_log_tail(window=window, parsed=True, log_type=log_type)} From de86516a0af93d3385054351140ced997fb5c705 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Thu, 18 Feb 2016 22:48:02 -0800 Subject: [PATCH 43/47] Disable monitor remote access checkbox if remote access is disabled * And anonymize URLs --- data/interfaces/default/settings.html | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index 2cc6d635..96ee2494 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -489,7 +489,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents()

    Set the complete folder path where your Plex Server logs are, shortcuts are not recognized.
    - Click here for help. This is required if you enable IP logging (for PMS 0.9.12 and below).

    + Click here for help. This is required if you enable IP logging (for PMS 0.9.12 and below).

    @@ -619,7 +619,8 @@ available_notification_agents = sorted(notifiers.available_notification_agents() -

    Enable to have PlexPy check if remote access to the Plex Media Server goes down. Your server needs to have remote access enabled.

    + +

    Enable to have PlexPy check if remote access to the Plex Media Server goes down.

    @@ -762,11 +763,11 @@ available_notification_agents = sorted(notifiers.available_notification_agents()

    You can set custom formatted text for each type of notification. - Click here for a list of available parameters which can be used. + Click here for a list of available parameters which can be used.

    - You can also add tags to exclude certain text depending on the media type. Click - here to view usage information. + You can also add tags to exclude certain text depending on the media type. + Click here to view usage information.


      @@ -1900,12 +1901,23 @@ $(document).ready(function() { async: true, success: function(data) { if (data !== 'true') { - $("#debugLogCheck").html("Debug logging must be enabled on your Plex Server. More.."); + $("#debugLogCheck").html("Debug logging must be enabled on your Plex Server. Click here for help."); $("#ip_logging_enable").attr("disabled", true); } } }); + $.ajax({ + url: 'get_server_pref', + data: { pref: 'PublishServerOnPlexOnlineKey' }, + async: true, + success: function(data) { + if (data !== 'true') { + $("#remoteAccessCheck").html("Remote access must be enabled on your Plex Server. Click here for help.") + $("#monitor_remote_access").attr("disabled", true); + } + } + }); // Check to see if our logs folder is set before allowing IP logging to be enabled. checkLogsPath(); From eab6365af9f83baaa7a0b06a2b06afabb0f0a667 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Fri, 19 Feb 2016 21:02:48 -0800 Subject: [PATCH 44/47] Disable IP logging checkbox depending on server version --- data/interfaces/default/settings.html | 60 +++++++++++++++++---------- plexpy/webserve.py | 28 +++++++++++-- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index 96ee2494..36b82fb4 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -658,7 +658,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents()

      - Enable this to attempt to log the IP address of the user (for PMS 0.9.12 and below, IP address is automatically logged for PMS 0.9.14 and above). + Enable this to attempt to log the IP address of the user.

      @@ -1896,13 +1896,45 @@ $(document).ready(function() { }) $.ajax({ - url: 'get_server_pref', - data: { pref: 'logDebug' }, + url: 'get_server_identity', async: true, success: function(data) { - if (data !== 'true') { - $("#debugLogCheck").html("Debug logging must be enabled on your Plex Server. Click here for help."); + var version = data.version.split('.') + if (parseInt(version[0]) >= 0 && parseInt(version[1]) >= 9 && parseInt(version[2]) >= 14) { + $("#debugLogCheck").html("IP address is automatically logged for PMS version 0.9.14 and above."); $("#ip_logging_enable").attr("disabled", true); + $("#ip_logging_enable").attr("checked", true); + } else { + $.ajax({ + url: 'get_server_pref', + data: { pref: 'logDebug' }, + async: true, + success: function(data) { + if (data !== 'true') { + $("#debugLogCheck").html("Debug logging must be enabled on your Plex Server. Click here for help."); + $("#ip_logging_enable").attr("disabled", true); + $("#ip_logging_enable").attr("checked", false); + } + } + }); + + // Check to see if our logs folder is set before allowing IP logging to be enabled. + checkLogsPath(); + + $("#pms_logs_folder").change(function() { + checkLogsPath(); + }); + + function checkLogsPath() { + if ($("#pms_logs_folder").val() == '') { + $("#debugLogCheck").html("You must first define your Plex Server Logs folder path under the Plex Media Server tab."); + $("#ip_logging_enable").attr("disabled", true); + $("#ip_logging_enable").attr("checked", false); + } else { + $("#ip_logging_enable").attr("disabled", false); + $("#debugLogCheck").html(""); + } + } } } }); @@ -1913,27 +1945,11 @@ $(document).ready(function() { async: true, success: function(data) { if (data !== 'true') { - $("#remoteAccessCheck").html("Remote access must be enabled on your Plex Server. Click here for help.") + $("#remoteAccessCheck").html("Remote access must be enabled on your Plex Server. Click here for help."); $("#monitor_remote_access").attr("disabled", true); } } }); - // Check to see if our logs folder is set before allowing IP logging to be enabled. - checkLogsPath(); - - $("#pms_logs_folder").change(function() { - checkLogsPath(); - }); - - function checkLogsPath() { - if ($("#pms_logs_folder").val() == '') { - $("#debugLogCheck").html("You must first define your Plex Server Logs folder path under the Plex Media Server tab."); - $("#ip_logging_enable").attr("disabled", true); - } else { - $("#ip_logging_enable").attr("disabled", false); - $("#debugLogCheck").html(""); - } - } var accordion_session = new Accordion($('#accordion-session'), false); var accordion_timeline = new Accordion($('#accordion-timeline'), false); diff --git a/plexpy/webserve.py b/plexpy/webserve.py index bc5edb7a..bb4a78e7 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -1943,7 +1943,7 @@ class WebInterface(object): @cherrypy.expose @addtoapi() def get_servers_info(self, **kwargs): - """ Graps info about the server + """ Grabs list of info about the servers Returns: json: @@ -1956,8 +1956,6 @@ class WebInterface(object): } ] ``` - - """ pms_connect = pmsconnect.PmsConnect() @@ -1969,6 +1967,30 @@ class WebInterface(object): else: logger.warn(u"Unable to retrieve data for get_servers_info.") + @cherrypy.expose + @addtoapi() + def get_server_identity(self, **kwargs): + """ Grabs info about the local server + + Returns: + json: + ``` + [{"machine_identifier": "1234", + "version": "0.9.15.x.xxx-xxxxxxx" + } + ] + ``` + """ + + pms_connect = pmsconnect.PmsConnect() + result = pms_connect.get_server_identity() + + if result: + cherrypy.response.headers['Content-type'] = 'application/json' + return json.dumps(result) + else: + logger.warn(u"Unable to retrieve data for get_server_identity.") + @cherrypy.expose @addtoapi() def get_server_friendly_name(self, **kwargs): From 284ab45a178275c3b2c4e6b0eafe0ec4e8a7e986 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Fri, 19 Feb 2016 23:25:33 -0800 Subject: [PATCH 45/47] Upload Plex posters to Imgur for notifications --- lib/openanything.py | 107 --------------------------------- plexpy/helpers.py | 44 ++++++++++++-- plexpy/notification_handler.py | 74 ++++++++--------------- plexpy/notifiers.py | 39 ++++++++---- 4 files changed, 93 insertions(+), 171 deletions(-) delete mode 100644 lib/openanything.py diff --git a/lib/openanything.py b/lib/openanything.py deleted file mode 100644 index 413f576d..00000000 --- a/lib/openanything.py +++ /dev/null @@ -1,107 +0,0 @@ -'''OpenAnything: a kind and thoughtful library for HTTP web services - -This program is part of 'Dive Into Python', a free Python book for -experienced programmers. Visit http://diveintopython.org/ for the -latest version. -''' - -__author__ = 'Mark Pilgrim (mark@diveintopython.org)' -__version__ = '$Revision: 1.6 $'[11:-2] -__date__ = '$Date: 2004/04/16 21:16:24 $' -__copyright__ = 'Copyright (c) 2004 Mark Pilgrim' -__license__ = 'Python' - -import urllib2, urlparse, gzip -from StringIO import StringIO - -USER_AGENT = 'OpenAnything/%s +http://diveintopython.org/http_web_services/' % __version__ - -class SmartRedirectHandler(urllib2.HTTPRedirectHandler): - def http_error_301(self, req, fp, code, msg, headers): - result = urllib2.HTTPRedirectHandler.http_error_301( - self, req, fp, code, msg, headers) - result.status = code - return result - - def http_error_302(self, req, fp, code, msg, headers): - result = urllib2.HTTPRedirectHandler.http_error_302( - self, req, fp, code, msg, headers) - result.status = code - return result - -class DefaultErrorHandler(urllib2.HTTPDefaultErrorHandler): - def http_error_default(self, req, fp, code, msg, headers): - result = urllib2.HTTPError( - req.get_full_url(), code, msg, headers, fp) - result.status = code - return result - -def openAnything(source, etag=None, lastmodified=None, agent=USER_AGENT): - """URL, filename, or string --> stream - - This function lets you define parsers that take any input source - (URL, pathname to local or network file, or actual data as a string) - and deal with it in a uniform manner. Returned object is guaranteed - to have all the basic stdio read methods (read, readline, readlines). - Just .close() the object when you're done with it. - - If the etag argument is supplied, it will be used as the value of an - If-None-Match request header. - - If the lastmodified argument is supplied, it must be a formatted - date/time string in GMT (as returned in the Last-Modified header of - a previous request). The formatted date/time will be used - as the value of an If-Modified-Since request header. - - If the agent argument is supplied, it will be used as the value of a - User-Agent request header. - """ - - if hasattr(source, 'read'): - return source - - if source == '-': - return sys.stdin - - if urlparse.urlparse(source)[0] == 'http': - # open URL with urllib2 - request = urllib2.Request(source) - request.add_header('User-Agent', agent) - if lastmodified: - request.add_header('If-Modified-Since', lastmodified) - if etag: - request.add_header('If-None-Match', etag) - request.add_header('Accept-encoding', 'gzip') - opener = urllib2.build_opener(SmartRedirectHandler(), DefaultErrorHandler()) - return opener.open(request) - - # try to open with native open function (if source is a filename) - try: - return open(source) - except (IOError, OSError): - pass - - # treat source as string - return StringIO(str(source)) - -def fetch(source, etag=None, lastmodified=None, agent=USER_AGENT): - '''Fetch data and metadata from a URL, file, stream, or string''' - result = {} - f = openAnything(source, etag, lastmodified, agent) - result['data'] = f.read() - if hasattr(f, 'headers'): - # save ETag, if the server sent one - result['etag'] = f.headers.get('ETag') - # save Last-Modified header, if the server sent one - result['lastmodified'] = f.headers.get('Last-Modified') - if f.headers.get('content-encoding') == 'gzip': - # data came back gzip-compressed, decompress it - result['data'] = gzip.GzipFile(fileobj=StringIO(result['data'])).read() - if hasattr(f, 'url'): - result['url'] = f.url - result['status'] = 200 - if hasattr(f, 'status'): - result['status'] = f.status - f.close() - return result - diff --git a/plexpy/helpers.py b/plexpy/helpers.py index c360638c..710449b8 100644 --- a/plexpy/helpers.py +++ b/plexpy/helpers.py @@ -13,23 +13,25 @@ # You should have received a copy of the GNU General Public License # along with PlexPy. If not, see . -from IPy import IP +import base64 import datetime import fnmatch from functools import wraps +from IPy import IP import json -import os import math from operator import itemgetter +import os import re import shutil import socket import sys import time -from xml.dom import minidom import unicodedata - +import urllib, urllib2 +from xml.dom import minidom import xmltodict + import plexpy from api2 import API2 @@ -523,3 +525,37 @@ def anon_url(*url): Return a URL string consisting of the Anonymous redirect URL and an arbitrary number of values appended. """ return '' if None in url else '%s%s' % (plexpy.CONFIG.ANON_REDIRECT, ''.join(str(s) for s in url)) + +def uploadToImgur(imgPath, imgTitle=''): + from plexpy import logger + + client_id = '743b1a443ccd2b0' + img_url = '' + + try: + with open(imgPath, 'rb') as imgFile: + img = imgFile.read() + except IOError as e: + logger.error(u"PlexPy Helpers :: Unable to read image file for Imgur: %s" % e) + return img_url + + headers = {'Authorization': 'Client-ID %s' % client_id} + data = {'type': 'base64', + 'image': base64.b64encode(img)} + if imgTitle: + data['title'] = imgTitle + data['name'] = imgTitle + '.jpg' + + request = urllib2.Request('https://api.imgur.com/3/image', headers=headers, data=urllib.urlencode(data)) + response = urllib2.urlopen(request) + response = json.loads(response.read()) + + if response.get('status') == 200: + logger.debug(u"PlexPy Helpers :: Image uploaded to Imgur.") + img_url = response.get('data').get('link', '') + elif response.get('status') >= 400 and response.get('status') < 500: + logger.warn(u"PlexPy Helpers :: Unable to upload image to Imgur: %s" % response.reason) + else: + logger.warn(u"PlexPy Helpers :: Unable to upload image to Imgur.") + + return img_url \ No newline at end of file diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py index 45903513..fe782f68 100644 --- a/plexpy/notification_handler.py +++ b/plexpy/notification_handler.py @@ -15,12 +15,10 @@ import arrow -import json -from httplib import HTTPConnection -import openanything +import os import re import time -import urllib2 +import urllib from plexpy import logger, config, notifiers, database, helpers, plextv, pmsconnect import plexpy @@ -157,7 +155,8 @@ def notify(stream_data=None, notify_action=None): subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, - script_args=notify_strings[2]) + script_args=notify_strings[2], + metadata=notify_strings[3]) # Set the notification state in the db set_notify_state(session=stream_data, state=notify_action, agent_info=agent) @@ -169,7 +168,8 @@ def notify(stream_data=None, notify_action=None): subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, - script_args=notify_strings[2]) + script_args=notify_strings[2], + metadata=notify_strings[3]) # Set the notification state in the db set_notify_state(session=stream_data, state=notify_action, agent_info=agent) @@ -181,7 +181,8 @@ def notify(stream_data=None, notify_action=None): subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, - script_args=notify_strings[2]) + script_args=notify_strings[2], + metadata=notify_strings[3]) # Set the notification state in the db set_notify_state(session=stream_data, state=notify_action, agent_info=agent) @@ -193,7 +194,8 @@ def notify(stream_data=None, notify_action=None): subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, - script_args=notify_strings[2]) + script_args=notify_strings[2], + metadata=notify_strings[3]) # Set the notification state in the db set_notify_state(session=stream_data, state=notify_action, agent_info=agent) @@ -205,7 +207,8 @@ def notify(stream_data=None, notify_action=None): subject=notify_strings[0], body=notify_strings[1], notify_action=notify_action, - script_args=notify_strings[2]) + script_args=notify_strings[2], + metadata=notify_strings[3]) # Set the notification state in the db set_notify_state(session=stream_data, state=notify_action, agent_info=agent) @@ -499,46 +502,21 @@ def build_notify_text(session=None, timeline=None, state=None): metadata['lastfm_id'] = metadata['guid'].split('lastfm://')[1].rsplit('/', 1)[0] metadata['lastfm_url'] = 'https://www.last.fm/music/' + metadata['lastfm_id'] - # Get posters (only IMDB and TheTVDB supported) - if metadata['media_type'] == 'movie' and metadata.get('imdb_id', ''): - uri = '/?i=' + metadata['imdb_id'] + if metadata['media_type'] == 'movie' or metadata['media_type'] == 'show' or metadata['media_type'] == 'artist': + thumb = metadata['thumb'] + elif metadata['media_type'] == 'episode': + thumb = metadata['grandparent_thumb'] + elif metadata['media_type'] == 'track': + thumb = metadata['parent_thumb'] + else: + thumb = None - # Get poster using OMDb API - http_handler = HTTPConnection("www.omdbapi.com") - http_handler.request('GET', uri) - response = http_handler.getresponse() - request_status = response.status - - if request_status == 200: - data = json.loads(response.read()) - poster_url = data.get('Poster', '') - metadata['poster_url'] = poster_url if poster_url != 'N/A' else '' - elif request_status >= 400 and request_status < 500: - logger.warn(u"PlexPy Notifiers :: Unable to retrieve IMDB poster: %s" % response.reason) - else: - logger.warn(u"PlexPy Notifiers :: Unable to retrieve IMDB poster.") - - elif (metadata['media_type'] == 'show' or metadata['media_type'] == 'episode') \ - and (metadata.get('imdb_id', '') or metadata.get('thetvdb_id', '')): - if metadata.get('imdb_id', ''): - uri = '/lookup/shows?imdb=' + metadata['imdb_id'] - elif metadata.get('thetvdb_id', ''): - uri = '/lookup/shows?thetvdb=' + metadata['thetvdb_id'] - - # Get poster using TVmaze API - request = urllib2.Request('http://api.tvmaze.com' + uri) - opener = urllib2.build_opener(openanything.SmartRedirectHandler()) - response = opener.open(request) - request_status = response.status - - if request_status == 301: - data = json.loads(response.read()) - image = data.get('image', '') - metadata['poster_url'] = image.get('original', image.get('medium','')) - elif request_status >= 400 and request_status < 500: - logger.warn(u"PlexPy Notifiers :: Unable to retrieve TVmaze poster: %s" % response.reason) - else: - logger.warn(u"PlexPy Notifiers :: Unable to retrieve TVmaze poster.") + if thumb: + # Retrieve the poster from Plex and cache to file + urllib.urlretrieve(plexpy.CONFIG.PMS_URL + thumb + '?X-Plex-Token=' + plexpy.CONFIG.PMS_TOKEN, + os.path.join(plexpy.CONFIG.CACHE_DIR, 'cache-poster.jpg')) + # Upload thumb to Imgur and get link + metadata['poster_url'] = helpers.uploadToImgur(os.path.join(plexpy.CONFIG.CACHE_DIR, 'cache-poster.jpg'), full_title) # Fix metadata params for notify recently added grandparent if state == 'created' and plexpy.CONFIG.NOTIFY_RECENTLY_ADDED_GRANDPARENT: diff --git a/plexpy/notifiers.py b/plexpy/notifiers.py index 772b7d84..6831f011 100644 --- a/plexpy/notifiers.py +++ b/plexpy/notifiers.py @@ -19,8 +19,7 @@ import json import cherrypy from email.mime.text import MIMEText import email.utils -from httplib import HTTPSConnection, HTTPConnection -import openanything +from httplib import HTTPSConnection import os import shlex import smtplib @@ -2129,19 +2128,35 @@ class FacebookNotifier(object): if self.incl_poster and 'metadata' in kwargs: metadata = kwargs['metadata'] poster_url = metadata.get('poster_url','') - caption = 'View in Plex Web.' - - if metadata['media_type'] == 'movie' or metadata['media_type'] == 'show': - title = metadata['title'] - subtitle = metadata['year'] - elif metadata['media_type'] == 'episode': - title = metadata['grandparent_title'] + ' - ' + metadata['title'] - subtitle = 'S' + metadata['parent_media_index'] + ' ' + '\xc2\xb7'.decode('utf8') + \ - ' E' + metadata['media_index'] if poster_url: + if metadata['media_type'] == 'movie' or metadata['media_type'] == 'show': + title = metadata['title'] + subtitle = metadata['year'] + rating_key = metadata['rating_key'] + + elif metadata['media_type'] == 'episode': + title = '%s - %s' % (metadata['grandparent_title'], metadata['title']) + subtitle = 'S%s %s E%s' % (metadata['parent_media_index'], + '\xc2\xb7'.decode('utf8'), + metadata['media_index']) + rating_key = metadata['rating_key'] + + elif metadata['media_type'] == 'artist': + title = metadata['title'] + subtitle = '' + rating_key = metadata['rating_key'] + + elif metadata['media_type'] == 'track': + title = '%s - %s' % (metadata['grandparent_title'], metadata['title']) + subtitle = metadata['parent_title'] + rating_key = metadata['parent_rating_key'] + + caption = 'View in Plex Web.' + + # Build Facebook post attachment attachment['link'] = 'http://app.plex.tv/web/app#!/server/' + plexpy.CONFIG.PMS_IDENTIFIER + \ - '/details/%2Flibrary%2Fmetadata%2F' + metadata['rating_key'] + '/details/%2Flibrary%2Fmetadata%2F' + rating_key attachment['picture'] = poster_url attachment['name'] = title attachment['description'] = subtitle From 1f5c60588ecdbd211b2c9316c061a7a30d7fc011 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sat, 20 Feb 2016 08:45:00 -0800 Subject: [PATCH 46/47] Change Facebook help text --- plexpy/notifiers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plexpy/notifiers.py b/plexpy/notifiers.py index 6831f011..44ab261f 100644 --- a/plexpy/notifiers.py +++ b/plexpy/notifiers.py @@ -2223,8 +2223,7 @@ class FacebookNotifier(object): {'label': 'Include Poster Image', 'value': self.incl_poster, 'name': 'facebook_incl_poster', - 'description': 'Include a poster in the notifications. \ - (PMS agent must be Freebase or TheTVDB. TheMovieDb is currently not supported.)', + 'description': 'Include a poster and link in the notifications.', 'input_type': 'checkbox' }, {'label': 'Include Subject Line', From 7afbd98d17bcf3d7c23dc3aa60f036b848a79bbe Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sat, 20 Feb 2016 19:53:21 -0800 Subject: [PATCH 47/47] v1.3.7 --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ plexpy/version.py | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 446f639b..30a3279a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## v1.3.7 (2016-02-20) + +* Fix: Verifying server with SSL enabled. +* Fix: Regression where {stream_duration} reported as 0. +* Fix: Video metadata flags showing up for track info. +* Fix: Custom library icons not applied to Library Statistics. +* Fix: Typos in the Web UI. +* Add: ETA to Current Activity overlay. +* Add: Total duration to Libraries and Users tables. +* Add: {machine_id} to notification options. +* Add: IMDB, TVDB, TMDb, Last.fm, and Trackt IDs/URLs to notification options. +* Add: {poster_url} to notification options using Imgur. +* Add: Poster and link for Facebook notifications. +* Add: Log javascript errors from the Web UI. +* Add: Configuration and Scheduler info to the settings page. +* Add: Schedule background task to backup the PlexPy database. +* Add: URL anonymizer for external links. +* Add: Plex Media Scanner log file to Log viewer. +* Add: API v2 (sill very experimental) (Thanks @Hellowlol) +* Change: Allow secure websocket connections. +* Change: History grouping now accounts for the view offset. +* Change: Subject line can be toggled off for Facebook, Slack, Telegram, and Twitter. +* Change: Create self-signed SSL certificates when enabling HTTPS. +* Change: Revert homepage "Last Played" to "Last Watched". +* Change: Disable monitor remote access checkbox if remote access is not enabled on the PMS. +* Change: Disable IP logging checkbox if PMS version is 0.9.14 or greater. + + ## v1.3.6 (2016-02-03) * Fix: Regression where {duration} not reported in minutes. diff --git a/plexpy/version.py b/plexpy/version.py index 189a4528..0473c553 100644 --- a/plexpy/version.py +++ b/plexpy/version.py @@ -1,2 +1,2 @@ PLEXPY_VERSION = "master" -PLEXPY_RELEASE_VERSION = "1.3.6" +PLEXPY_RELEASE_VERSION = "1.3.7"