Skip to content

Commit a47f67d

Browse files
committed
Merge remote-tracking branch 'origin/new_hubs' into new_hubs
2 parents efad7eb + 4cbccab commit a47f67d

File tree

16 files changed

+533
-54
lines changed

16 files changed

+533
-54
lines changed

.travis.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ services:
1010
- docker
1111

1212
python:
13-
- 2.7
14-
- 3.4
1513
- 3.6
1614

1715
env:
@@ -23,8 +21,10 @@ env:
2321
before_install:
2422
- pip install --upgrade pip
2523
- pip install --upgrade setuptools
26-
- pip install --upgrade pytest pytest-cov coveralls
2724
install:
25+
- pip install .
26+
- python -c "import plexapi; print('installation ok')"
27+
- pip install --upgrade pytest pytest-cov coveralls
2828
- pip install -r requirements_dev.txt
2929
- '[ -z "${PLEXAPI_AUTH_MYPLEX_USERNAME}" ] && PYTHONPATH="$PWD:$PYTHONPATH" python -u tools/plex-bootstraptest.py
3030
--destination plex --advertise-ip=127.0.0.1 --bootstrap-timeout 540 --docker-tag $PLEX_CONTAINER_TAG --unclaimed ||

plexapi/alert.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77

88
class AlertListener(threading.Thread):
9-
""" Creates a websocket connection to the PlexServer to optionally recieve alert notifications.
9+
""" Creates a websocket connection to the PlexServer to optionally receive alert notifications.
1010
These often include messages from Plex about media scans as well as updates to currently running
11-
Transcode Sessions. This class implements threading.Thread, therfore to start monitoring
11+
Transcode Sessions. This class implements threading.Thread, therefore to start monitoring
1212
alerts you must call .start() on the object once it's created. When calling
1313
`PlexServer.startAlertListener()`, the thread will be started for you.
1414
@@ -26,9 +26,9 @@ class AlertListener(threading.Thread):
2626
2727
Parameters:
2828
server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to.
29-
callback (func): Callback function to call on recieved messages. The callback function
29+
callback (func): Callback function to call on received messages. The callback function
3030
will be sent a single argument 'data' which will contain a dictionary of data
31-
recieved from the server. :samp:`def my_callback(data): ...`
31+
received from the server. :samp:`def my_callback(data): ...`
3232
"""
3333
key = '/:/websockets/notifications'
3434

@@ -48,15 +48,21 @@ def run(self):
4848
self._ws.run_forever()
4949

5050
def stop(self):
51-
""" Stop the AlertListener thread. Once the notifier is stopped, it cannot be diractly
51+
""" Stop the AlertListener thread. Once the notifier is stopped, it cannot be directly
5252
started again. You must call :func:`plexapi.server.PlexServer.startAlertListener()`
5353
from a PlexServer instance.
5454
"""
5555
log.info('Stopping AlertListener.')
5656
self._ws.close()
5757

58-
def _onMessage(self, ws, message):
59-
""" Called when websocket message is recieved. """
58+
def _onMessage(self, *args):
59+
""" Called when websocket message is received.
60+
In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp
61+
object and the message as a STR. Current releases appear to only return the message.
62+
We are assuming the last argument in the tuple is the message.
63+
This is to support compatibility with current and previous releases of websocket-client.
64+
"""
65+
message = args[-1]
6066
try:
6167
data = json.loads(message)['NotificationContainer']
6268
log.debug('Alert: %s %s %s', *data)
@@ -65,6 +71,12 @@ def _onMessage(self, ws, message):
6571
except Exception as err: # pragma: no cover
6672
log.error('AlertListener Msg Error: %s', err)
6773

68-
def _onError(self, ws, err): # pragma: no cover
69-
""" Called when websocket error is recieved. """
74+
def _onError(self, *args): # pragma: no cover
75+
""" Called when websocket error is received.
76+
In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp
77+
object and the error. Current releases appear to only return the error.
78+
We are assuming the last argument in the tuple is the message.
79+
This is to support compatibility with current and previous releases of websocket-client.
80+
"""
81+
err = args[-1]
7082
log.error('AlertListener Error: %s' % err)

plexapi/base.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ def fetchItem(self, ekey, cls=None, **kwargs):
132132
* __regex: Value matches the specified regular expression.
133133
* __startswith: Value starts with specified arg.
134134
"""
135+
if ekey is None:
136+
raise BadRequest('ekey was not provided')
135137
if isinstance(ekey, int):
136138
ekey = '/library/metadata/%s' % ekey
137139
for elem in self._server.query(ekey):
@@ -145,6 +147,8 @@ def fetchItems(self, ekey, cls=None, **kwargs):
145147
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
146148
on how this is used.
147149
"""
150+
if ekey is None:
151+
raise BadRequest('ekey was not provided')
148152
data = self._server.query(ekey)
149153
items = self.findItems(data, cls, ekey, **kwargs)
150154
librarySectionID = data.attrib.get('librarySectionID')
@@ -429,7 +433,6 @@ def history(self, maxresults=9999999, mindate=None):
429433
"""
430434
return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey)
431435

432-
433436
# The photo tag cant be built atm. TODO
434437
# def arts(self):
435438
# part = '%s/arts' % self.key
@@ -582,7 +585,7 @@ def updateProgress(self, time, state='stopped'):
582585
time, state)
583586
self._server.query(key)
584587
self.reload()
585-
588+
586589
def updateTimeline(self, time, state='stopped', duration=None):
587590
""" Set the timeline progress for this video.
588591

plexapi/client.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from plexapi import log, logfilter, utils
88
from plexapi.base import PlexObject
99
from plexapi.compat import ElementTree
10-
from plexapi.exceptions import BadRequest, Unsupported
10+
from plexapi.exceptions import BadRequest, Unauthorized, Unsupported
1111
from plexapi.playqueue import PlayQueue
1212

1313

@@ -162,8 +162,11 @@ def query(self, path, method=None, headers=None, timeout=None, **kwargs):
162162
if response.status_code not in (200, 201):
163163
codename = codes.get(response.status_code)[0]
164164
errtext = response.text.replace('\n', ' ')
165-
log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
166-
raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext))
165+
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
166+
if response.status_code == 401:
167+
raise Unauthorized(message)
168+
else:
169+
raise BadRequest(message)
167170
data = response.text.encode('utf8')
168171
return ElementTree.fromstring(data) if data.strip() else None
169172

@@ -204,10 +207,13 @@ def sendCommand(self, command, proxy=None, **params):
204207
return query(key, headers=headers)
205208
except ElementTree.ParseError:
206209
# Workaround for players which don't return valid XML on successful commands
207-
# - Plexamp: `b'OK'`
210+
# - Plexamp, Plex for Android: `b'OK'`
211+
# - Plex for Samsung: `b'<?xml version="1.0"?><Response code="200" status="OK">'`
208212
if self.product in (
209213
'Plexamp',
210214
'Plex for Android (TV)',
215+
'Plex for Android (Mobile)',
216+
'Plex for Samsung',
211217
):
212218
return
213219
raise
@@ -469,10 +475,15 @@ def playMedia(self, media, offset=0, **params):
469475

470476
if hasattr(media, "playlistType"):
471477
mediatype = media.playlistType
472-
elif media.listType == "audio":
473-
mediatype = "music"
474478
else:
475-
mediatype = "video"
479+
if isinstance(media, PlayQueue):
480+
mediatype = media.items[0].listType
481+
else:
482+
mediatype = media.listType
483+
484+
# mediatype must be in ["video", "music", "photo"]
485+
if mediatype == "audio":
486+
mediatype = "music"
476487

477488
if self.product != 'OpenPHT':
478489
try:

plexapi/compat.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@
2525
from urllib import quote
2626

2727
try:
28-
from urllib.parse import quote_plus
28+
from urllib.parse import quote_plus, quote
2929
except ImportError:
30-
from urllib import quote_plus
30+
from urllib import quote_plus, quote
3131

3232
try:
3333
from urllib.parse import unquote

plexapi/exceptions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,6 @@ class Unsupported(PlexApiException):
2626
pass
2727

2828

29-
class Unauthorized(PlexApiException):
30-
""" Invalid username or password. """
29+
class Unauthorized(BadRequest):
30+
""" Invalid username/password or token. """
3131
pass

plexapi/gdm.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""
2+
Support for discovery using GDM (Good Day Mate), multicast protocol by Plex.
3+
4+
# Licensed Apache 2.0
5+
# From https://github.com/home-assistant/netdisco/netdisco/gdm.py
6+
7+
Inspired by
8+
hippojay's plexGDM:
9+
https://github.com/hippojay/script.plexbmc.helper/resources/lib/plexgdm.py
10+
iBaa's PlexConnect: https://github.com/iBaa/PlexConnect/PlexAPI.py
11+
"""
12+
import socket
13+
import struct
14+
15+
16+
class GDM:
17+
"""Base class to discover GDM services."""
18+
19+
def __init__(self):
20+
self.entries = []
21+
self.last_scan = None
22+
23+
def scan(self, scan_for_clients=False):
24+
"""Scan the network."""
25+
self.update(scan_for_clients)
26+
27+
def all(self):
28+
"""Return all found entries.
29+
30+
Will scan for entries if not scanned recently.
31+
"""
32+
self.scan()
33+
return list(self.entries)
34+
35+
def find_by_content_type(self, value):
36+
"""Return a list of entries that match the content_type."""
37+
self.scan()
38+
return [entry for entry in self.entries
39+
if value in entry['data']['Content_Type']]
40+
41+
def find_by_data(self, values):
42+
"""Return a list of entries that match the search parameters."""
43+
self.scan()
44+
return [entry for entry in self.entries
45+
if all(item in entry['data'].items()
46+
for item in values.items())]
47+
48+
def update(self, scan_for_clients):
49+
"""Scan for new GDM services.
50+
51+
Examples of the dict list assigned to self.entries by this function:
52+
53+
Server:
54+
[{'data': {
55+
'Content-Type': 'plex/media-server',
56+
'Host': '53f4b5b6023d41182fe88a99b0e714ba.plex.direct',
57+
'Name': 'myfirstplexserver',
58+
'Port': '32400',
59+
'Resource-Identifier': '646ab0aa8a01c543e94ba975f6fd6efadc36b7',
60+
'Updated-At': '1585769946',
61+
'Version': '1.18.8.2527-740d4c206',
62+
},
63+
'from': ('10.10.10.100', 32414)}]
64+
65+
Clients:
66+
[{'data': {'Content-Type': 'plex/media-player',
67+
'Device-Class': 'stb',
68+
'Name': 'plexamp',
69+
'Port': '36000',
70+
'Product': 'Plexamp',
71+
'Protocol': 'plex',
72+
'Protocol-Capabilities': 'timeline,playback,playqueues,playqueues-creation',
73+
'Protocol-Version': '1',
74+
'Resource-Identifier': 'b6e57a3f-e0f8-494f-8884-f4b58501467e',
75+
'Version': '1.1.0',
76+
},
77+
'from': ('10.10.10.101', 32412)}]
78+
"""
79+
80+
gdm_msg = 'M-SEARCH * HTTP/1.0'.encode('ascii')
81+
gdm_timeout = 1
82+
83+
self.entries = []
84+
known_responses = []
85+
86+
# setup socket for discovery -> multicast message
87+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
88+
sock.settimeout(gdm_timeout)
89+
90+
# Set the time-to-live for messages for local network
91+
sock.setsockopt(socket.IPPROTO_IP,
92+
socket.IP_MULTICAST_TTL,
93+
struct.pack("B", gdm_timeout))
94+
95+
if scan_for_clients:
96+
# setup socket for broadcast to Plex clients
97+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
98+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
99+
gdm_ip = '255.255.255.255'
100+
gdm_port = 32412
101+
else:
102+
# setup socket for multicast to Plex server(s)
103+
gdm_ip = '239.0.0.250'
104+
gdm_port = 32414
105+
106+
try:
107+
# Send data to the multicast group
108+
sock.sendto(gdm_msg, (gdm_ip, gdm_port))
109+
110+
# Look for responses from all recipients
111+
while True:
112+
try:
113+
bdata, host = sock.recvfrom(1024)
114+
data = bdata.decode('utf-8')
115+
if '200 OK' in data.splitlines()[0]:
116+
ddata = {k: v.strip() for (k, v) in (
117+
line.split(':') for line in
118+
data.splitlines() if ':' in line)}
119+
identifier = ddata.get('Resource-Identifier')
120+
if identifier and identifier in known_responses:
121+
continue
122+
known_responses.append(identifier)
123+
self.entries.append({'data': ddata,
124+
'from': host})
125+
except socket.timeout:
126+
break
127+
finally:
128+
sock.close()
129+
130+
131+
def main():
132+
"""Test GDM discovery."""
133+
from pprint import pprint
134+
135+
gdm = GDM()
136+
137+
pprint("Scanning GDM for servers...")
138+
gdm.scan()
139+
pprint(gdm.entries)
140+
141+
pprint("Scanning GDM for clients...")
142+
gdm.scan(scan_for_clients=True)
143+
pprint(gdm.entries)
144+
145+
146+
if __name__ == "__main__":
147+
main()

0 commit comments

Comments
 (0)