mirror of
https://github.com/pkkid/python-plexapi
synced 2024-11-21 19:23:05 +00:00
Merge remote-tracking branch 'origin/new_hubs' into new_hubs
This commit is contained in:
commit
a47f67d3df
16 changed files with 533 additions and 54 deletions
|
@ -10,8 +10,6 @@ services:
|
|||
- docker
|
||||
|
||||
python:
|
||||
- 2.7
|
||||
- 3.4
|
||||
- 3.6
|
||||
|
||||
env:
|
||||
|
@ -23,8 +21,10 @@ env:
|
|||
before_install:
|
||||
- pip install --upgrade pip
|
||||
- pip install --upgrade setuptools
|
||||
- pip install --upgrade pytest pytest-cov coveralls
|
||||
install:
|
||||
- pip install .
|
||||
- python -c "import plexapi; print('installation ok')"
|
||||
- pip install --upgrade pytest pytest-cov coveralls
|
||||
- pip install -r requirements_dev.txt
|
||||
- '[ -z "${PLEXAPI_AUTH_MYPLEX_USERNAME}" ] && PYTHONPATH="$PWD:$PYTHONPATH" python -u tools/plex-bootstraptest.py
|
||||
--destination plex --advertise-ip=127.0.0.1 --bootstrap-timeout 540 --docker-tag $PLEX_CONTAINER_TAG --unclaimed ||
|
||||
|
|
|
@ -6,9 +6,9 @@ from plexapi import log
|
|||
|
||||
|
||||
class AlertListener(threading.Thread):
|
||||
""" Creates a websocket connection to the PlexServer to optionally recieve alert notifications.
|
||||
""" Creates a websocket connection to the PlexServer to optionally receive alert notifications.
|
||||
These often include messages from Plex about media scans as well as updates to currently running
|
||||
Transcode Sessions. This class implements threading.Thread, therfore to start monitoring
|
||||
Transcode Sessions. This class implements threading.Thread, therefore to start monitoring
|
||||
alerts you must call .start() on the object once it's created. When calling
|
||||
`PlexServer.startAlertListener()`, the thread will be started for you.
|
||||
|
||||
|
@ -26,9 +26,9 @@ class AlertListener(threading.Thread):
|
|||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to.
|
||||
callback (func): Callback function to call on recieved messages. The callback function
|
||||
callback (func): Callback function to call on received messages. The callback function
|
||||
will be sent a single argument 'data' which will contain a dictionary of data
|
||||
recieved from the server. :samp:`def my_callback(data): ...`
|
||||
received from the server. :samp:`def my_callback(data): ...`
|
||||
"""
|
||||
key = '/:/websockets/notifications'
|
||||
|
||||
|
@ -48,15 +48,21 @@ class AlertListener(threading.Thread):
|
|||
self._ws.run_forever()
|
||||
|
||||
def stop(self):
|
||||
""" Stop the AlertListener thread. Once the notifier is stopped, it cannot be diractly
|
||||
""" Stop the AlertListener thread. Once the notifier is stopped, it cannot be directly
|
||||
started again. You must call :func:`plexapi.server.PlexServer.startAlertListener()`
|
||||
from a PlexServer instance.
|
||||
"""
|
||||
log.info('Stopping AlertListener.')
|
||||
self._ws.close()
|
||||
|
||||
def _onMessage(self, ws, message):
|
||||
""" Called when websocket message is recieved. """
|
||||
def _onMessage(self, *args):
|
||||
""" Called when websocket message is received.
|
||||
In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp
|
||||
object and the message as a STR. Current releases appear to only return the message.
|
||||
We are assuming the last argument in the tuple is the message.
|
||||
This is to support compatibility with current and previous releases of websocket-client.
|
||||
"""
|
||||
message = args[-1]
|
||||
try:
|
||||
data = json.loads(message)['NotificationContainer']
|
||||
log.debug('Alert: %s %s %s', *data)
|
||||
|
@ -65,6 +71,12 @@ class AlertListener(threading.Thread):
|
|||
except Exception as err: # pragma: no cover
|
||||
log.error('AlertListener Msg Error: %s', err)
|
||||
|
||||
def _onError(self, ws, err): # pragma: no cover
|
||||
""" Called when websocket error is recieved. """
|
||||
def _onError(self, *args): # pragma: no cover
|
||||
""" Called when websocket error is received.
|
||||
In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp
|
||||
object and the error. Current releases appear to only return the error.
|
||||
We are assuming the last argument in the tuple is the message.
|
||||
This is to support compatibility with current and previous releases of websocket-client.
|
||||
"""
|
||||
err = args[-1]
|
||||
log.error('AlertListener Error: %s' % err)
|
||||
|
|
|
@ -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')
|
||||
|
@ -429,7 +433,6 @@ class PlexPartialObject(PlexObject):
|
|||
"""
|
||||
return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey)
|
||||
|
||||
|
||||
# The photo tag cant be built atm. TODO
|
||||
# def arts(self):
|
||||
# part = '%s/arts' % self.key
|
||||
|
@ -582,7 +585,7 @@ class Playable(object):
|
|||
time, state)
|
||||
self._server.query(key)
|
||||
self.reload()
|
||||
|
||||
|
||||
def updateTimeline(self, time, state='stopped', duration=None):
|
||||
""" Set the timeline progress for this video.
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
147
plexapi/gdm.py
Normal 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()
|
115
plexapi/media.py
115
plexapi/media.py
|
@ -349,6 +349,121 @@ 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.
|
||||
Optimized items are optimized and queued conversions items."""
|
||||
TAG = 'Item'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.id = data.attrib.get('id')
|
||||
self.composite = data.attrib.get('composite')
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
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.
|
||||
Conversions are items queued for optimization or being actively optimized."""
|
||||
TAG = 'Video'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.addedAt = data.attrib.get('addedAt')
|
||||
self.art = data.attrib.get('art')
|
||||
self.chapterSource = data.attrib.get('chapterSource')
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.duration = data.attrib.get('duration')
|
||||
self.generatorID = data.attrib.get('generatorID')
|
||||
self.generatorType = data.attrib.get('generatorType')
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.key = data.attrib.get('key')
|
||||
self.lastViewedAt = data.attrib.get('lastViewedAt')
|
||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.originallyAvailableAt = data.attrib.get('originallyAvailableAt')
|
||||
self.playQueueItemID = data.attrib.get('playQueueItemID')
|
||||
self.playlistID = data.attrib.get('playlistID')
|
||||
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
|
||||
self.rating = data.attrib.get('rating')
|
||||
self.ratingKey = data.attrib.get('ratingKey')
|
||||
self.studio = data.attrib.get('studio')
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.tagline = data.attrib.get('tagline')
|
||||
self.target = data.attrib.get('target')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = data.attrib.get('updatedAt')
|
||||
self.userID = data.attrib.get('userID')
|
||||
self.username = data.attrib.get('username')
|
||||
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
|
||||
items or navigating the metadata of media items in your library. Tags are
|
||||
|
|
|
@ -1,18 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import copy
|
||||
import requests
|
||||
import time
|
||||
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
|
||||
|
||||
import requests
|
||||
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT,
|
||||
X_PLEX_IDENTIFIER, 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
|
||||
from plexapi.server import PlexServer
|
||||
from plexapi.sync import SyncList, SyncItem
|
||||
from plexapi.sync import SyncItem, SyncList
|
||||
from plexapi.utils import joinArgs
|
||||
from requests.status_codes import _codes as codes
|
||||
|
||||
|
||||
class MyPlexAccount(PlexObject):
|
||||
|
@ -181,7 +182,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
|
||||
|
||||
|
@ -390,8 +395,7 @@ class MyPlexAccount(PlexObject):
|
|||
params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds}}
|
||||
url = self.FRIENDSERVERS.format(machineId=machineId, serverId=serverId)
|
||||
else:
|
||||
params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds,
|
||||
'invited_id': user.id}}
|
||||
params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds, 'invited_id': user.id}}
|
||||
url = self.FRIENDINVITE.format(machineId=machineId)
|
||||
# Remove share sections, add shares to user without shares, or update shares
|
||||
if not user_servers or sectionIds:
|
||||
|
@ -435,7 +439,7 @@ class MyPlexAccount(PlexObject):
|
|||
return user
|
||||
|
||||
elif (user.username and user.email and user.id and username.lower() in
|
||||
(user.username.lower(), user.email.lower(), str(user.id))):
|
||||
(user.username.lower(), user.email.lower(), str(user.id))):
|
||||
return user
|
||||
|
||||
raise NotFound('Unable to find user %s' % username)
|
||||
|
@ -622,21 +626,21 @@ class MyPlexAccount(PlexObject):
|
|||
def videoOnDemand(self):
|
||||
""" Returns a list of VOD Hub items :class:`~plexapi.library.Hub`
|
||||
"""
|
||||
req = requests.get(self.VOD + 'hubs/', headers={'X-Plex-Token':self._token})
|
||||
req = requests.get(self.VOD + 'hubs/', headers={'X-Plex-Token': self._token})
|
||||
elem = ElementTree.fromstring(req.text)
|
||||
return self.findItems(elem)
|
||||
|
||||
def webShows(self):
|
||||
""" Returns a list of Webshow Hub items :class:`~plexapi.library.Hub`
|
||||
"""
|
||||
req = requests.get(self.WEBSHOWS + 'hubs/', headers={'X-Plex-Token':self._token})
|
||||
"""
|
||||
req = requests.get(self.WEBSHOWS + 'hubs/', headers={'X-Plex-Token': self._token})
|
||||
elem = ElementTree.fromstring(req.text)
|
||||
return self.findItems(elem)
|
||||
|
||||
def news(self):
|
||||
""" Returns a list of News Hub items :class:`~plexapi.library.Hub`
|
||||
"""
|
||||
req = requests.get(self.NEWS + 'hubs/sections/all', headers={'X-Plex-Token':self._token})
|
||||
"""
|
||||
req = requests.get(self.NEWS + 'hubs/sections/all', headers={'X-Plex-Token': self._token})
|
||||
elem = ElementTree.fromstring(req.text)
|
||||
return self.findItems(elem)
|
||||
|
||||
|
|
|
@ -7,12 +7,13 @@ 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
|
||||
from plexapi.playqueue import PlayQueue
|
||||
from plexapi.utils import cast
|
||||
from plexapi.media import Optimized, Conversion
|
||||
|
||||
# Need these imports to populate utils.PLEXOBJECTS
|
||||
from plexapi import (audio as _audio, video as _video, # noqa: F401
|
||||
|
@ -372,6 +373,36 @@ class PlexServer(PlexObject):
|
|||
"""
|
||||
return self.fetchItem('/playlists', title=title)
|
||||
|
||||
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.fetchItem('%s/items/%s/items' % (backgroundProcessing.key, optimizedID))
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
by encoding the response to utf-8 and parsing the returned XML into and
|
||||
|
@ -386,8 +417,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
|
||||
|
||||
|
@ -481,6 +515,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
|
||||
|
|
|
@ -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.
|
||||
|
@ -281,7 +357,7 @@ class Movie(Playable, Video):
|
|||
else:
|
||||
self._server.url('%s?download=1' % location.key)
|
||||
filepath = utils.download(url, self._server._token, filename=name,
|
||||
savepath=savepath, session=self._server._session)
|
||||
savepath=savepath, session=self._server._session)
|
||||
if filepath:
|
||||
filepaths.append(filepath)
|
||||
return filepaths
|
||||
|
@ -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. """
|
||||
|
@ -655,9 +731,8 @@ class Episode(Playable, Video):
|
|||
|
||||
@utils.registerPlexObject
|
||||
class Clip(Playable, Video):
|
||||
""" Represents a single Clip.
|
||||
""" Represents a single Clip."""
|
||||
|
||||
"""
|
||||
TAG = 'Video'
|
||||
TYPE = 'clip'
|
||||
METADATA_TYPE = 'clip'
|
||||
|
@ -676,4 +751,4 @@ class Clip(Playable, Video):
|
|||
self.thumbAspectRatio = data.attrib.get('thumbAspectRatio')
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.year = data.attrib.get('year')
|
||||
self.year = data.attrib.get('year')
|
||||
|
|
|
@ -4,4 +4,5 @@
|
|||
#---------------------------------------------------------
|
||||
requests
|
||||
tqdm
|
||||
websocket-client==0.48.0
|
||||
websocket-client
|
||||
mock; python_version < '3.3'
|
||||
|
|
|
@ -14,7 +14,7 @@ requests
|
|||
sphinx
|
||||
sphinxcontrib-napoleon
|
||||
tqdm
|
||||
websocket-client==0.48.0
|
||||
websocket-client
|
||||
|
||||
# Installing sphinx-rtd-theme directly from github above is used until a point release
|
||||
# above 0.4.3 is released. https://github.com/readthedocs/sphinx_rtd_theme/issues/739
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -124,13 +124,14 @@ def create_section(server, section, opts):
|
|||
start = time.time()
|
||||
bar = tqdm(desc='Scanning section ' + section['name'], total=expected_media_count)
|
||||
notifier = server.startAlertListener(alert_callback)
|
||||
time.sleep(3)
|
||||
add_library_section(server, section)
|
||||
while bar.n < bar.total:
|
||||
if runtime >= 120:
|
||||
print('Metadata scan taking too long, but will continue anyway..')
|
||||
break
|
||||
time.sleep(3)
|
||||
runtime = time.time() - start
|
||||
runtime = int(time.time() - start)
|
||||
bar.close()
|
||||
notifier.stop()
|
||||
|
||||
|
|
Loading…
Reference in a new issue