Merge branch 'master' into unmatch_match

This commit is contained in:
blacktwin 2020-04-12 22:37:50 -04:00 committed by GitHub
commit bef40a74f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 426 additions and 28 deletions

View file

@ -10,8 +10,6 @@ services:
- docker
python:
- 2.7
- 3.4
- 3.6
env:

View file

@ -132,6 +132,8 @@ class PlexObject(object):
* __regex: Value matches the specified regular expression.
* __startswith: Value starts with specified arg.
"""
if ekey is None:
raise BadRequest('ekey was not provided')
if isinstance(ekey, int):
ekey = '/library/metadata/%s' % ekey
for elem in self._server.query(ekey):
@ -145,6 +147,8 @@ class PlexObject(object):
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
on how this is used.
"""
if ekey is None:
raise BadRequest('ekey was not provided')
data = self._server.query(ekey)
items = self.findItems(data, cls, ekey, **kwargs)
librarySectionID = data.attrib.get('librarySectionID')

View file

@ -7,7 +7,7 @@ from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
from plexapi import log, logfilter, utils
from plexapi.base import PlexObject
from plexapi.compat import ElementTree
from plexapi.exceptions import BadRequest, Unsupported
from plexapi.exceptions import BadRequest, Unauthorized, Unsupported
from plexapi.playqueue import PlayQueue
@ -162,8 +162,11 @@ class PlexClient(PlexObject):
if response.status_code not in (200, 201):
codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ')
log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext))
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
if response.status_code == 401:
raise Unauthorized(message)
else:
raise BadRequest(message)
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
@ -204,10 +207,13 @@ class PlexClient(PlexObject):
return query(key, headers=headers)
except ElementTree.ParseError:
# Workaround for players which don't return valid XML on successful commands
# - Plexamp: `b'OK'`
# - Plexamp, Plex for Android: `b'OK'`
# - Plex for Samsung: `b'<?xml version="1.0"?><Response code="200" status="OK">'`
if self.product in (
'Plexamp',
'Plex for Android (TV)',
'Plex for Android (Mobile)',
'Plex for Samsung',
):
return
raise
@ -469,10 +475,15 @@ class PlexClient(PlexObject):
if hasattr(media, "playlistType"):
mediatype = media.playlistType
elif media.listType == "audio":
mediatype = "music"
else:
mediatype = "video"
if isinstance(media, PlayQueue):
mediatype = media.items[0].listType
else:
mediatype = media.listType
# mediatype must be in ["video", "music", "photo"]
if mediatype == "audio":
mediatype = "music"
if self.product != 'OpenPHT':
try:

View file

@ -25,9 +25,9 @@ except ImportError:
from urllib import quote
try:
from urllib.parse import quote_plus
from urllib.parse import quote_plus, quote
except ImportError:
from urllib import quote_plus
from urllib import quote_plus, quote
try:
from urllib.parse import unquote

View file

@ -26,6 +26,6 @@ class Unsupported(PlexApiException):
pass
class Unauthorized(PlexApiException):
""" Invalid username or password. """
class Unauthorized(BadRequest):
""" Invalid username/password or token. """
pass

147
plexapi/gdm.py Normal file
View file

@ -0,0 +1,147 @@
"""
Support for discovery using GDM (Good Day Mate), multicast protocol by Plex.
# Licensed Apache 2.0
# From https://github.com/home-assistant/netdisco/netdisco/gdm.py
Inspired by
hippojay's plexGDM:
https://github.com/hippojay/script.plexbmc.helper/resources/lib/plexgdm.py
iBaa's PlexConnect: https://github.com/iBaa/PlexConnect/PlexAPI.py
"""
import socket
import struct
class GDM:
"""Base class to discover GDM services."""
def __init__(self):
self.entries = []
self.last_scan = None
def scan(self, scan_for_clients=False):
"""Scan the network."""
self.update(scan_for_clients)
def all(self):
"""Return all found entries.
Will scan for entries if not scanned recently.
"""
self.scan()
return list(self.entries)
def find_by_content_type(self, value):
"""Return a list of entries that match the content_type."""
self.scan()
return [entry for entry in self.entries
if value in entry['data']['Content_Type']]
def find_by_data(self, values):
"""Return a list of entries that match the search parameters."""
self.scan()
return [entry for entry in self.entries
if all(item in entry['data'].items()
for item in values.items())]
def update(self, scan_for_clients):
"""Scan for new GDM services.
Examples of the dict list assigned to self.entries by this function:
Server:
[{'data': {
'Content-Type': 'plex/media-server',
'Host': '53f4b5b6023d41182fe88a99b0e714ba.plex.direct',
'Name': 'myfirstplexserver',
'Port': '32400',
'Resource-Identifier': '646ab0aa8a01c543e94ba975f6fd6efadc36b7',
'Updated-At': '1585769946',
'Version': '1.18.8.2527-740d4c206',
},
'from': ('10.10.10.100', 32414)}]
Clients:
[{'data': {'Content-Type': 'plex/media-player',
'Device-Class': 'stb',
'Name': 'plexamp',
'Port': '36000',
'Product': 'Plexamp',
'Protocol': 'plex',
'Protocol-Capabilities': 'timeline,playback,playqueues,playqueues-creation',
'Protocol-Version': '1',
'Resource-Identifier': 'b6e57a3f-e0f8-494f-8884-f4b58501467e',
'Version': '1.1.0',
},
'from': ('10.10.10.101', 32412)}]
"""
gdm_msg = 'M-SEARCH * HTTP/1.0'.encode('ascii')
gdm_timeout = 1
self.entries = []
known_responses = []
# setup socket for discovery -> multicast message
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(gdm_timeout)
# Set the time-to-live for messages for local network
sock.setsockopt(socket.IPPROTO_IP,
socket.IP_MULTICAST_TTL,
struct.pack("B", gdm_timeout))
if scan_for_clients:
# setup socket for broadcast to Plex clients
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
gdm_ip = '255.255.255.255'
gdm_port = 32412
else:
# setup socket for multicast to Plex server(s)
gdm_ip = '239.0.0.250'
gdm_port = 32414
try:
# Send data to the multicast group
sock.sendto(gdm_msg, (gdm_ip, gdm_port))
# Look for responses from all recipients
while True:
try:
bdata, host = sock.recvfrom(1024)
data = bdata.decode('utf-8')
if '200 OK' in data.splitlines()[0]:
ddata = {k: v.strip() for (k, v) in (
line.split(':') for line in
data.splitlines() if ':' in line)}
identifier = ddata.get('Resource-Identifier')
if identifier and identifier in known_responses:
continue
known_responses.append(identifier)
self.entries.append({'data': ddata,
'from': host})
except socket.timeout:
break
finally:
sock.close()
def main():
"""Test GDM discovery."""
from pprint import pprint
gdm = GDM()
pprint("Scanning GDM for servers...")
gdm.scan()
pprint(gdm.entries)
pprint("Scanning GDM for clients...")
gdm.scan(scan_for_clients=True)
pprint(gdm.entries)
if __name__ == "__main__":
main()

View file

@ -349,9 +349,30 @@ class TranscodeSession(PlexObject):
self.width = cast(int, data.attrib.get('width'))
@utils.registerPlexObject
class TranscodeJob(PlexObject):
""" Represents an Optimizing job.
TrancodeJobs are the process for optimizing conversions.
Active or paused optimization items. Usually one item as a time"""
TAG = 'TranscodeJob'
def _loadData(self, data):
self._data = data
self.generatorID = data.attrib.get('generatorID')
self.key = data.attrib.get('key')
self.progress = data.attrib.get('progress')
self.ratingKey = data.attrib.get('ratingKey')
self.size = data.attrib.get('size')
self.targetTagID = data.attrib.get('targetTagID')
self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
@utils.registerPlexObject
class Optimized(PlexObject):
""" Represents a Optimized item. """
""" Represents a Optimized item.
Optimized items are optimized and queued conversions items."""
TAG = 'Item'
def _loadData(self, data):
@ -363,10 +384,26 @@ class Optimized(PlexObject):
self.target = data.attrib.get('target')
self.targetTagID = data.attrib.get('targetTagID')
def remove(self):
""" Remove an Optimized item"""
key = '%s/%s' % (self._initpath, self.id)
self._server.query(key, method=self._server._session.delete)
def rename(self, title):
""" Rename an Optimized item"""
key = '%s/%s?Item[title]=%s' % (self._initpath, self.id, title)
self._server.query(key, method=self._server._session.put)
def reprocess(self, ratingKey):
""" Reprocess a removed Conversion item that is still a listed Optimize item"""
key = '%s/%s/%s/enable' % (self._initpath, self.id, ratingKey)
self._server.query(key, method=self._server._session.put)
@utils.registerPlexObject
class Conversion(PlexObject):
""" Represents a Conversion item. """
""" Represents a Conversion item.
Conversions are items queued for optimization or being actively optimized."""
TAG = 'Video'
def _loadData(self, data):
@ -403,6 +440,29 @@ class Conversion(PlexObject):
self.viewOffset = data.attrib.get('viewOffset')
self.year = data.attrib.get('year')
def remove(self):
""" Remove Conversion from queue """
key = '/playlists/%s/items/%s/%s/disable' % (self.playlistID, self.generatorID, self.ratingKey)
self._server.query(key, method=self._server._session.put)
def move(self, after):
""" Move Conversion items position in queue
after (int): Positional integer to move item
-1 Active conversion
OR
Use another conversion items playQueueItemID to move in front of
Example:
Move 5th conversion Item to active conversion
conversions[4].move('-1')
Move 4th conversion Item to 2nd in conversion queue
conversions[3].move(conversions[1].playQueueItemID)
"""
key = '%s/items/%s/move?after=%s' % (self._initpath, self.playQueueItemID, after)
self._server.query(key, method=self._server._session.put)
class MediaTag(PlexObject):
""" Base class for media tags used for filtering and searching your library

View file

@ -6,7 +6,7 @@ from requests.status_codes import _codes as codes
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_IDENTIFIER, X_PLEX_ENABLE_FAST_CONNECT
from plexapi import log, logfilter, utils
from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest, NotFound
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.client import PlexClient
from plexapi.compat import ElementTree
from plexapi.library import LibrarySection
@ -175,7 +175,11 @@ class MyPlexAccount(PlexObject):
if response.status_code not in (200, 201, 204): # pragma: no cover
codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ')
raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
if response.status_code == 401:
raise Unauthorized(message)
else:
raise BadRequest(message)
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None

View file

@ -7,7 +7,7 @@ from plexapi.alert import AlertListener
from plexapi.base import PlexObject
from plexapi.client import PlexClient
from plexapi.compat import ElementTree, urlencode
from plexapi.exceptions import BadRequest, NotFound
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.library import Library, Hub
from plexapi.settings import Settings
from plexapi.playlist import Playlist
@ -380,16 +380,35 @@ class PlexServer(PlexObject):
"""
return self.fetchItem('/playlists', title=title)
def optimizedItems(self):
def optimizedItems(self, removeAll=None):
""" Returns list of all :class:`~plexapi.media.Optimized` objects connected to server. """
if removeAll is True:
key = '/playlists/generators?type=42'
self.query(key, method=self._server._session.delete)
else:
backgroundProcessing = self.fetchItem('/playlists?type=42')
return self.fetchItems('%s/items' % backgroundProcessing.key, cls=Optimized)
def optimizedItem(self, optimizedID):
""" Returns single queued optimized item :class:`~plexapi.media.Video` object.
Allows for using optimized item ID to connect back to source item.
"""
backgroundProcessing = self.fetchItem('/playlists?type=42')
return self.fetchItems('%s/items' % backgroundProcessing.key, cls=Optimized)
return self.fetchItem('%s/items/%s/items' % (backgroundProcessing.key, optimizedID))
def conversions(self):
def conversions(self, pause=None):
""" Returns list of all :class:`~plexapi.media.Conversion` objects connected to server. """
if pause is True:
self.query('/:/prefs?BackgroundQueueIdlePaused=1', method=self._server._session.put)
elif pause is False:
self.query('/:/prefs?BackgroundQueueIdlePaused=0', method=self._server._session.put)
else:
return self.fetchItems('/playQueues/1', cls=Conversion)
return self.fetchItems('/playQueues/1', cls=Conversion)
def currentBackgroundProcess(self):
""" Returns list of all :class:`~plexapi.media.TranscodeJob` objects running or paused on server. """
return self.fetchItems('/status/sessions/background')
def query(self, key, method=None, headers=None, timeout=None, **kwargs):
""" Main method used to handle HTTPS requests to the Plex server. This method helps
@ -405,8 +424,11 @@ class PlexServer(PlexObject):
if response.status_code not in (200, 201):
codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ')
log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext))
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
if response.status_code == 401:
raise Unauthorized(message)
else:
raise BadRequest(message)
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
@ -500,6 +522,25 @@ class PlexServer(PlexObject):
self.refreshSynclist()
self.refreshContent()
def _allowMediaDeletion(self, toggle=False):
""" Toggle allowMediaDeletion.
Parameters:
toggle (bool): True enables Media Deletion
False or None disable Media Deletion (Default)
"""
if self.allowMediaDeletion and toggle is False:
log.debug('Plex is currently allowed to delete media. Toggling off.')
elif self.allowMediaDeletion and toggle is True:
log.debug('Plex is currently allowed to delete media. Toggle set to allow, exiting.')
raise BadRequest('Plex is currently allowed to delete media. Toggle set to allow, exiting.')
elif self.allowMediaDeletion is None and toggle is True:
log.debug('Plex is currently not allowed to delete media. Toggle set to allow.')
else:
log.debug('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.')
raise BadRequest('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.')
value = 1 if toggle is True else 0
return self.query('/:/prefs?allowMediaDeletion=%s' % value, self._session.put)
class Account(PlexObject):
""" Contains the locally cached MyPlex account information. The properties provided don't

View file

@ -2,7 +2,7 @@
from plexapi import media, utils
from plexapi.exceptions import BadRequest, NotFound
from plexapi.base import Playable, PlexPartialObject
from plexapi.compat import quote_plus
from plexapi.compat import quote_plus, urlencode
import os
@ -126,6 +126,82 @@ class Video(PlexPartialObject):
return self.fetchItems('%s/posters' % self.key, cls=media.Poster)
def optimize(self, title=None, target="", targetTagID=None, locationID=-1, policyScope='all',
policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None):
""" Optimize item
locationID (int): -1 in folder with orginal items
2 library path
target (str): custom quality name.
if none provided use "Custom: {deviceProfile}"
targetTagID (int): Default quality settings
1 Mobile
2 TV
3 Original Quality
deviceProfile (str): Android, IOS, Universal TV, Universal Mobile, Windows Phone,
Windows, Xbox One
Example:
Optimize for Mobile
item.optimize(targetTagID="Mobile") or item.optimize(targetTagID=1")
Optimize for Android 10 MBPS 1080p
item.optimize(deviceProfile="Android", videoQuality=10)
Optimize for IOS Original Quality
item.optimize(deviceProfile="IOS", videoQuality=-1)
* see sync.py VIDEO_QUALITIES for additional information for using videoQuality
"""
tagValues = [1, 2, 3]
tagKeys = ["Mobile", "TV", "Original Quality"]
tagIDs = tagKeys + tagValues
if targetTagID not in tagIDs and (deviceProfile is None or videoQuality is None):
raise BadRequest('Unexpected or missing quality profile.')
if isinstance(targetTagID, str):
tagIndex = tagKeys.index(targetTagID)
targetTagID = tagValues[tagIndex]
if title is None:
title = self.title
backgroundProcessing = self.fetchItem('/playlists?type=42')
key = '%s/items?' % backgroundProcessing.key
params = {
'Item[type]': 42,
'Item[target]': target,
'Item[targetTagID]': targetTagID if targetTagID else '',
'Item[locationID]': locationID,
'Item[Policy][scope]': policyScope,
'Item[Policy][value]': policyValue,
'Item[Policy][unwatched]': policyUnwatched
}
if deviceProfile:
params['Item[Device][profile]'] = deviceProfile
if videoQuality:
from plexapi.sync import MediaSettings
mediaSettings = MediaSettings.createVideo(videoQuality)
params['Item[MediaSettings][videoQuality]'] = mediaSettings.videoQuality
params['Item[MediaSettings][videoResolution]'] = mediaSettings.videoResolution
params['Item[MediaSettings][maxVideoBitrate]'] = mediaSettings.maxVideoBitrate
params['Item[MediaSettings][audioBoost]'] = ''
params['Item[MediaSettings][subtitleSize]'] = ''
params['Item[MediaSettings][musicBitrate]'] = ''
params['Item[MediaSettings][photoQuality]'] = ''
titleParam = {'Item[title]': title}
section = self._server.library.sectionByID(self.librarySectionID)
params['Item[Location][uri]'] = 'library://' + section.uuid + '/item/' + \
quote_plus(self.key + '?includeExternalMedia=1')
data = key + urlencode(params) + '&' + urlencode(titleParam)
return self._server.query(data, method=self._server._session.put)
def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None):
""" Add current video (movie, tv-show, season or episode) as sync item for specified device.
See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions.
@ -505,7 +581,7 @@ class Season(Video):
def show(self):
""" Return this seasons :func:`~plexapi.video.Show`.. """
return self.fetchItem(self.parentKey)
return self.fetchItem(int(self.parentRatingKey))
def watched(self):
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
@ -646,7 +722,7 @@ class Episode(Playable, Video):
def show(self):
"""" Return this episodes :func:`~plexapi.video.Show`.. """
return self.fetchItem(self.grandparentKey)
return self.fetchItem(int(self.grandparentRatingKey))
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """

View file

@ -269,3 +269,41 @@ def test_server_downloadLogs(tmpdir, plex):
def test_server_downloadDatabases(tmpdir, plex):
plex.downloadDatabases(savepath=str(tmpdir), unpack=True)
assert len(tmpdir.listdir()) > 1
def test_server_allowMediaDeletion(account):
plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken)
# Check server current allowMediaDeletion setting
if plex.allowMediaDeletion:
# If allowed then test disallowed
plex._allowMediaDeletion(False)
time.sleep(1)
plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken)
assert plex.allowMediaDeletion is None
# Test redundant toggle
with pytest.raises(BadRequest):
plex._allowMediaDeletion(False)
plex._allowMediaDeletion(True)
time.sleep(1)
plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken)
assert plex.allowMediaDeletion is True
# Test redundant toggle
with pytest.raises(BadRequest):
plex._allowMediaDeletion(True)
else:
# If disallowed then test allowed
plex._allowMediaDeletion(True)
time.sleep(1)
plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken)
assert plex.allowMediaDeletion is True
# Test redundant toggle
with pytest.raises(BadRequest):
plex._allowMediaDeletion(True)
plex._allowMediaDeletion(False)
time.sleep(1)
plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken)
assert plex.allowMediaDeletion is None
# Test redundant toggle
with pytest.raises(BadRequest):
plex._allowMediaDeletion(False)

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
import pytest
from datetime import datetime
from time import sleep
from plexapi.exceptions import BadRequest, NotFound
from . import conftest as utils
@ -685,4 +686,22 @@ def test_video_exists_accessible(movie, episode):
episode.reload()
assert episode.media[0].parts[0].exists is True
assert episode.media[0].parts[0].accessible is True
@pytest.mark.skip(reason='broken? assert len(plex.conversions()) == 1 may fail on some builds')
def test_video_optimize(movie, plex):
plex.optimizedItems(removeAll=True)
movie.optimize(targetTagID=1)
plex.conversions(pause=True)
sleep(1)
assert len(plex.optimizedItems()) == 1
assert len(plex.conversions()) == 1
conversion = plex.conversions()[0]
conversion.remove()
assert len(plex.conversions()) == 0
assert len(plex.optimizedItems()) == 1
optimized = plex.optimizedItems()[0]
video = plex.optimizedItem(optimizedID=optimized.id)
assert movie.key == video.key
plex.optimizedItems(removeAll=True)
assert len(plex.optimizedItems()) == 0