Skip to content

Commit bef40a7

Browse files
authored
Merge branch 'master' into unmatch_match
2 parents d7d2d5a + 0a77c74 commit bef40a7

File tree

12 files changed

+426
-28
lines changed

12 files changed

+426
-28
lines changed

.travis.yml

Lines changed: 0 additions & 2 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:

plexapi/base.py

Lines changed: 4 additions & 0 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')

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

plexapi/media.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,9 +349,30 @@ def _loadData(self, data):
349349
self.width = cast(int, data.attrib.get('width'))
350350

351351

352+
@utils.registerPlexObject
353+
class TranscodeJob(PlexObject):
354+
""" Represents an Optimizing job.
355+
TrancodeJobs are the process for optimizing conversions.
356+
Active or paused optimization items. Usually one item as a time"""
357+
TAG = 'TranscodeJob'
358+
359+
def _loadData(self, data):
360+
self._data = data
361+
self.generatorID = data.attrib.get('generatorID')
362+
self.key = data.attrib.get('key')
363+
self.progress = data.attrib.get('progress')
364+
self.ratingKey = data.attrib.get('ratingKey')
365+
self.size = data.attrib.get('size')
366+
self.targetTagID = data.attrib.get('targetTagID')
367+
self.thumb = data.attrib.get('thumb')
368+
self.title = data.attrib.get('title')
369+
self.type = data.attrib.get('type')
370+
371+
352372
@utils.registerPlexObject
353373
class Optimized(PlexObject):
354-
""" Represents a Optimized item. """
374+
""" Represents a Optimized item.
375+
Optimized items are optimized and queued conversions items."""
355376
TAG = 'Item'
356377

357378
def _loadData(self, data):
@@ -363,10 +384,26 @@ def _loadData(self, data):
363384
self.target = data.attrib.get('target')
364385
self.targetTagID = data.attrib.get('targetTagID')
365386

387+
def remove(self):
388+
""" Remove an Optimized item"""
389+
key = '%s/%s' % (self._initpath, self.id)
390+
self._server.query(key, method=self._server._session.delete)
391+
392+
def rename(self, title):
393+
""" Rename an Optimized item"""
394+
key = '%s/%s?Item[title]=%s' % (self._initpath, self.id, title)
395+
self._server.query(key, method=self._server._session.put)
396+
397+
def reprocess(self, ratingKey):
398+
""" Reprocess a removed Conversion item that is still a listed Optimize item"""
399+
key = '%s/%s/%s/enable' % (self._initpath, self.id, ratingKey)
400+
self._server.query(key, method=self._server._session.put)
401+
366402

367403
@utils.registerPlexObject
368404
class Conversion(PlexObject):
369-
""" Represents a Conversion item. """
405+
""" Represents a Conversion item.
406+
Conversions are items queued for optimization or being actively optimized."""
370407
TAG = 'Video'
371408

372409
def _loadData(self, data):
@@ -403,6 +440,29 @@ def _loadData(self, data):
403440
self.viewOffset = data.attrib.get('viewOffset')
404441
self.year = data.attrib.get('year')
405442

443+
def remove(self):
444+
""" Remove Conversion from queue """
445+
key = '/playlists/%s/items/%s/%s/disable' % (self.playlistID, self.generatorID, self.ratingKey)
446+
self._server.query(key, method=self._server._session.put)
447+
448+
def move(self, after):
449+
""" Move Conversion items position in queue
450+
after (int): Positional integer to move item
451+
-1 Active conversion
452+
OR
453+
Use another conversion items playQueueItemID to move in front of
454+
455+
Example:
456+
Move 5th conversion Item to active conversion
457+
conversions[4].move('-1')
458+
459+
Move 4th conversion Item to 2nd in conversion queue
460+
conversions[3].move(conversions[1].playQueueItemID)
461+
"""
462+
463+
key = '%s/items/%s/move?after=%s' % (self._initpath, self.playQueueItemID, after)
464+
self._server.query(key, method=self._server._session.put)
465+
406466

407467
class MediaTag(PlexObject):
408468
""" Base class for media tags used for filtering and searching your library

plexapi/myplex.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_IDENTIFIER, X_PLEX_ENABLE_FAST_CONNECT
77
from plexapi import log, logfilter, utils
88
from plexapi.base import PlexObject
9-
from plexapi.exceptions import BadRequest, NotFound
9+
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
1010
from plexapi.client import PlexClient
1111
from plexapi.compat import ElementTree
1212
from plexapi.library import LibrarySection
@@ -175,7 +175,11 @@ def query(self, url, method=None, headers=None, timeout=None, **kwargs):
175175
if response.status_code not in (200, 201, 204): # pragma: no cover
176176
codename = codes.get(response.status_code)[0]
177177
errtext = response.text.replace('\n', ' ')
178-
raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
178+
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
179+
if response.status_code == 401:
180+
raise Unauthorized(message)
181+
else:
182+
raise BadRequest(message)
179183
data = response.text.encode('utf8')
180184
return ElementTree.fromstring(data) if data.strip() else None
181185

0 commit comments

Comments
 (0)