Update plexapi to v3.6.0

This commit is contained in:
JonnyWong16 2020-07-31 22:06:07 -07:00
parent 873194b402
commit 6e53743716
No known key found for this signature in database
GPG key ID: B1F1F9807184697A
18 changed files with 1500 additions and 104 deletions

View file

@ -3,9 +3,10 @@ import logging
import os import os
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from platform import uname from platform import uname
from uuid import getnode
from plexapi.config import PlexConfig, reset_base_headers from plexapi.config import PlexConfig, reset_base_headers
from plexapi.utils import SecretsFilter from plexapi.utils import SecretsFilter
from uuid import getnode
# Load User Defined Config # Load User Defined Config
DEFAULT_CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini') DEFAULT_CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini')
@ -14,7 +15,7 @@ CONFIG = PlexConfig(CONFIG_PATH)
# PlexAPI Settings # PlexAPI Settings
PROJECT = 'PlexAPI' PROJECT = 'PlexAPI'
VERSION = '3.3.0' VERSION = '3.6.0'
TIMEOUT = CONFIG.get('plexapi.timeout', 30, int) TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int) X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int)
X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool) X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool)

View file

@ -1,14 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import json import json
import threading import threading
import websocket
from plexapi import log from plexapi import log
class AlertListener(threading.Thread): 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 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 alerts you must call .start() on the object once it's created. When calling
`PlexServer.startAlertListener()`, the thread will be started for you. `PlexServer.startAlertListener()`, the thread will be started for you.
@ -26,9 +26,9 @@ class AlertListener(threading.Thread):
Parameters: Parameters:
server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to. 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 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' key = '/:/websockets/notifications'
@ -40,6 +40,11 @@ class AlertListener(threading.Thread):
self._ws = None self._ws = None
def run(self): def run(self):
try:
import websocket
except ImportError:
log.warning("Can't use the AlertListener without websocket")
return
# create the websocket connection # create the websocket connection
url = self._server.url(self.key, includeToken=True).replace('http', 'ws') url = self._server.url(self.key, includeToken=True).replace('http', 'ws')
log.info('Starting AlertListener: %s', url) log.info('Starting AlertListener: %s', url)
@ -48,15 +53,21 @@ class AlertListener(threading.Thread):
self._ws.run_forever() self._ws.run_forever()
def stop(self): 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()` started again. You must call :func:`plexapi.server.PlexServer.startAlertListener()`
from a PlexServer instance. from a PlexServer instance.
""" """
log.info('Stopping AlertListener.') log.info('Stopping AlertListener.')
self._ws.close() self._ws.close()
def _onMessage(self, ws, message): def _onMessage(self, *args):
""" Called when websocket message is recieved. """ """ 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: try:
data = json.loads(message)['NotificationContainer'] data = json.loads(message)['NotificationContainer']
log.debug('Alert: %s %s %s', *data) log.debug('Alert: %s %s %s', *data)
@ -65,6 +76,12 @@ class AlertListener(threading.Thread):
except Exception as err: # pragma: no cover except Exception as err: # pragma: no cover
log.error('AlertListener Msg Error: %s', err) log.error('AlertListener Msg Error: %s', err)
def _onError(self, ws, err): # pragma: no cover def _onError(self, *args): # pragma: no cover
""" Called when websocket error is recieved. """ """ 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) log.error('AlertListener Error: %s' % err)

View file

@ -284,15 +284,15 @@ class Track(Audio, Playable):
art (str): Track artwork (/library/metadata/<ratingkey>/art/<artid>) art (str): Track artwork (/library/metadata/<ratingkey>/art/<artid>)
chapterSource (TYPE): Unknown chapterSource (TYPE): Unknown
duration (int): Length of this album in seconds. duration (int): Length of this album in seconds.
grandparentArt (str): Artist artowrk. grandparentArt (str): Album artist artwork.
grandparentKey (str): Artist API URL. grandparentKey (str): Album artist API URL.
grandparentRatingKey (str): Unique key identifying artist. grandparentRatingKey (str): Unique key identifying album artist.
grandparentThumb (str): URL to artist thumbnail image. grandparentThumb (str): URL to album artist thumbnail image.
grandparentTitle (str): Name of the artist for this track. grandparentTitle (str): Name of the album artist for this track.
guid (str): Unknown (unique ID). guid (str): Unknown (unique ID).
media (list): List of :class:`~plexapi.media.Media` objects for this track. media (list): List of :class:`~plexapi.media.Media` objects for this track.
moods (list): List of :class:`~plexapi.media.Mood` objects for this track. moods (list): List of :class:`~plexapi.media.Mood` objects for this track.
originalTitle (str): Original track title (if translated). originalTitle (str): Track artist.
parentIndex (int): Album index. parentIndex (int): Album index.
parentKey (str): Album API URL. parentKey (str): Album API URL.
parentRatingKey (int): Unique key identifying album. parentRatingKey (int): Unique key identifying album.

View file

@ -132,6 +132,8 @@ class PlexObject(object):
* __regex: Value matches the specified regular expression. * __regex: Value matches the specified regular expression.
* __startswith: Value starts with specified arg. * __startswith: Value starts with specified arg.
""" """
if ekey is None:
raise BadRequest('ekey was not provided')
if isinstance(ekey, int): if isinstance(ekey, int):
ekey = '/library/metadata/%s' % ekey ekey = '/library/metadata/%s' % ekey
for elem in self._server.query(ekey): for elem in self._server.query(ekey):
@ -140,13 +142,27 @@ class PlexObject(object):
clsname = cls.__name__ if cls else 'None' clsname = cls.__name__ if cls else 'None'
raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs)) raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs))
def fetchItems(self, ekey, cls=None, **kwargs): def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs):
""" Load the specified key to find and build all items with the specified tag """ Load the specified key to find and build all items with the specified tag
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
on how this is used. on how this is used.
Parameters:
container_start (None, int): offset to get a subset of the data
container_size (None, int): How many items in data
""" """
data = self._server.query(ekey) url_kw = {}
if container_start is not None:
url_kw["X-Plex-Container-Start"] = container_start
if container_size is not None:
url_kw["X-Plex-Container-Size"] = container_size
if ekey is None:
raise BadRequest('ekey was not provided')
data = self._server.query(ekey, params=url_kw)
items = self.findItems(data, cls, ekey, **kwargs) items = self.findItems(data, cls, ekey, **kwargs)
librarySectionID = data.attrib.get('librarySectionID') librarySectionID = data.attrib.get('librarySectionID')
if librarySectionID: if librarySectionID:
for item in items: for item in items:
@ -421,6 +437,141 @@ class PlexPartialObject(PlexObject):
'havnt allowed items to be deleted' % self.key) 'havnt allowed items to be deleted' % self.key)
raise raise
def history(self, maxresults=9999999, mindate=None):
""" Get Play History for a media item.
Parameters:
maxresults (int): Only return the specified number of results (optional).
mindate (datetime): Min datetime to return results from.
"""
return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey)
def posters(self):
""" Returns list of available poster objects. :class:`~plexapi.media.Poster`. """
return self.fetchItems('%s/posters' % self.key)
def uploadPoster(self, url=None, filepath=None):
""" Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
if url:
key = '%s/posters?url=%s' % (self.key, quote_plus(url))
self._server.query(key, method=self._server._session.post)
elif filepath:
key = '%s/posters?' % self.key
data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data)
def setPoster(self, poster):
""" Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
poster.select()
def arts(self):
""" Returns list of available art objects. :class:`~plexapi.media.Poster`. """
return self.fetchItems('%s/arts' % self.key)
def uploadArt(self, url=None, filepath=None):
""" Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
if url:
key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url))
self._server.query(key, method=self._server._session.post)
elif filepath:
key = '/library/metadata/%s/arts?' % self.ratingKey
data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data)
def setArt(self, art):
""" Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
art.select()
def unmatch(self):
""" Unmatches metadata match from object. """
key = '/library/metadata/%s/unmatch' % self.ratingKey
self._server.query(key, method=self._server._session.put)
def matches(self, agent=None, title=None, year=None, language=None):
""" Return list of (:class:`~plexapi.media.SearchResult`) metadata matches.
Parameters:
agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.)
title (str): Title of item to search for
year (str): Year of item to search in
language (str) : Language of item to search in
Examples:
1. video.matches()
2. video.matches(title="something", year=2020)
3. video.matches(title="something")
4. video.matches(year=2020)
5. video.matches(title="something", year="")
6. video.matches(title="", year=2020)
7. video.matches(title="", year="")
1. The default behaviour in Plex Web = no params in plexapi
2. Both title and year specified by user
3. Year automatically filled in
4. Title automatically filled in
5. Explicitly searches for title with blank year
6. Explicitly searches for blank title with year
7. I don't know what the user is thinking... return the same result as 1
For 2 to 7, the agent and language is automatically filled in
"""
key = '/library/metadata/%s/matches' % self.ratingKey
params = {'manual': 1}
if agent and not any([title, year, language]):
params['language'] = self.section().language
params['agent'] = utils.getAgentIdentifier(self.section(), agent)
else:
if any(x is not None for x in [agent, title, year, language]):
if title is None:
params['title'] = self.title
else:
params['title'] = title
if year is None:
params['year'] = self.year
else:
params['year'] = year
params['language'] = language or self.section().language
if agent is None:
params['agent'] = self.section().agent
else:
params['agent'] = utils.getAgentIdentifier(self.section(), agent)
key = key + '?' + urlencode(params)
data = self._server.query(key, method=self._server._session.get)
return self.findItems(data, initpath=key)
def fixMatch(self, searchResult=None, auto=False, agent=None):
""" Use match result to update show metadata.
Parameters:
auto (bool): True uses first match from matches
False allows user to provide the match
searchResult (:class:`~plexapi.media.SearchResult`): Search result from
~plexapi.base.matches()
agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.)
"""
key = '/library/metadata/%s/match' % self.ratingKey
if auto:
autoMatch = self.matches(agent=agent)
if autoMatch:
searchResult = autoMatch[0]
else:
raise NotFound('No matches found using this agent: (%s:%s)' % (agent, autoMatch))
elif not searchResult:
raise NotFound('fixMatch() requires either auto=True or '
'searchResult=:class:`~plexapi.media.SearchResult`.')
params = {'guid': searchResult.guid,
'name': searchResult.name}
data = key + '?' + urlencode(params)
self._server.query(data, method=self._server._session.put)
# The photo tag cant be built atm. TODO # The photo tag cant be built atm. TODO
# def arts(self): # def arts(self):
# part = '%s/arts' % self.key # part = '%s/arts' % self.key
@ -509,6 +660,14 @@ class Playable(object):
key = '%s/split' % self.key key = '%s/split' % self.key
return self._server.query(key, method=self._server._session.put) return self._server.query(key, method=self._server._session.put)
def merge(self, ratingKeys):
"""Merge duplicate items."""
if not isinstance(ratingKeys, list):
ratingKeys = str(ratingKeys).split(",")
key = '%s/merge?ids=%s' % (self.key, ','.join(ratingKeys))
return self._server.query(key, method=self._server._session.put)
def unmatch(self): def unmatch(self):
"""Unmatch a media file.""" """Unmatch a media file."""
key = '%s/unmatch' % self.key key = '%s/unmatch' % self.key
@ -573,7 +732,7 @@ class Playable(object):
time, state) time, state)
self._server.query(key) self._server.query(key)
self.reload() self.reload()
def updateTimeline(self, time, state='stopped', duration=None): def updateTimeline(self, time, state='stopped', duration=None):
""" Set the timeline progress for this video. """ Set the timeline progress for this video.

View file

@ -1,15 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import time import time
import requests
from requests.status_codes import _codes as codes import requests
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils
from plexapi import log, logfilter, utils
from plexapi.base import PlexObject from plexapi.base import PlexObject
from plexapi.compat import ElementTree from plexapi.compat import ElementTree
from plexapi.exceptions import BadRequest, Unsupported from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported
from plexapi.playqueue import PlayQueue from plexapi.playqueue import PlayQueue
from requests.status_codes import _codes as codes
DEFAULT_MTYPE = 'video' DEFAULT_MTYPE = 'video'
@ -159,11 +157,16 @@ class PlexClient(PlexObject):
log.debug('%s %s', method.__name__.upper(), url) log.debug('%s %s', method.__name__.upper(), url)
headers = self._headers(**headers or {}) headers = self._headers(**headers or {})
response = method(url, headers=headers, timeout=timeout, **kwargs) response = method(url, headers=headers, timeout=timeout, **kwargs)
if response.status_code not in (200, 201): if response.status_code not in (200, 201, 204):
codename = codes.get(response.status_code)[0] codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ') errtext = response.text.replace('\n', ' ')
log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)) if response.status_code == 401:
raise Unauthorized(message)
elif response.status_code == 404:
raise NotFound(message)
else:
raise BadRequest(message)
data = response.text.encode('utf8') data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None return ElementTree.fromstring(data) if data.strip() else None
@ -204,10 +207,13 @@ class PlexClient(PlexObject):
return query(key, headers=headers) return query(key, headers=headers)
except ElementTree.ParseError: except ElementTree.ParseError:
# Workaround for players which don't return valid XML on successful commands # 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 ( if self.product in (
'Plexamp', 'Plexamp',
'Plex for Android (TV)', 'Plex for Android (TV)',
'Plex for Android (Mobile)',
'Plex for Samsung',
): ):
return return
raise raise
@ -300,6 +306,8 @@ class PlexClient(PlexObject):
'address': server_url[1].strip('/'), 'address': server_url[1].strip('/'),
'port': server_url[-1], 'port': server_url[-1],
'key': media.key, 'key': media.key,
'protocol': server_url[0],
'token': media._server.createToken()
}, **params)) }, **params))
# ------------------- # -------------------
@ -465,6 +473,18 @@ class PlexClient(PlexObject):
server_url = media._server._baseurl.split(':') server_url = media._server._baseurl.split(':')
server_port = server_url[-1].strip('/') server_port = server_url[-1].strip('/')
if hasattr(media, "playlistType"):
mediatype = media.playlistType
else:
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': if self.product != 'OpenPHT':
try: try:
self.sendCommand('timeline/subscribe', port=server_port, protocol='http') self.sendCommand('timeline/subscribe', port=server_port, protocol='http')
@ -481,7 +501,8 @@ class PlexClient(PlexObject):
'port': server_port, 'port': server_port,
'offset': offset, 'offset': offset,
'key': media.key, 'key': media.key,
'token': media._server._token, 'token': media._server.createToken(),
'type': mediatype,
'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID, 'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID,
}, **params)) }, **params))
@ -527,9 +548,9 @@ class PlexClient(PlexObject):
# ------------------- # -------------------
# Timeline Commands # Timeline Commands
def timeline(self): def timeline(self, wait=1):
""" Poll the current timeline and return the XML response. """ """ Poll the current timeline and return the XML response. """
return self.sendCommand('timeline/poll', wait=1) return self.sendCommand('timeline/poll', wait=wait)
def isPlayingMedia(self, includePaused=False): def isPlayingMedia(self, includePaused=False):
""" Returns True if any media is currently playing. """ Returns True if any media is currently playing.
@ -538,7 +559,7 @@ class PlexClient(PlexObject):
includePaused (bool): Set True to treat currently paused items includePaused (bool): Set True to treat currently paused items
as playing (optional; default True). as playing (optional; default True).
""" """
for mediatype in self.timeline(): for mediatype in self.timeline(wait=0):
if mediatype.get('state') == 'playing': if mediatype.get('state') == 'playing':
return True return True
if includePaused and mediatype.get('state') == 'paused': if includePaused and mediatype.get('state') == 'paused':

View file

@ -25,9 +25,9 @@ except ImportError:
from urllib import quote from urllib import quote
try: try:
from urllib.parse import quote_plus from urllib.parse import quote_plus, quote
except ImportError: except ImportError:
from urllib import quote_plus from urllib import quote_plus, quote
try: try:
from urllib.parse import unquote from urllib.parse import unquote
@ -44,11 +44,6 @@ try:
except ImportError: except ImportError:
from xml.etree import ElementTree from xml.etree import ElementTree
try:
from unittest.mock import patch, MagicMock
except ImportError:
from mock import patch, MagicMock
def makedirs(name, mode=0o777, exist_ok=False): def makedirs(name, mode=0o777, exist_ok=False):
""" Mimicks os.makedirs() from Python 3. """ """ Mimicks os.makedirs() from Python 3. """

View file

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

148
lib/plexapi/gdm.py Normal file
View file

@ -0,0 +1,148 @@
"""
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

@ -1,9 +1,10 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from plexapi import X_PLEX_CONTAINER_SIZE, log, utils from plexapi import X_PLEX_CONTAINER_SIZE, log, utils
from plexapi.base import PlexObject from plexapi.base import PlexObject
from plexapi.compat import unquote, urlencode, quote_plus from plexapi.compat import quote, quote_plus, unquote, urlencode
from plexapi.media import MediaTag
from plexapi.exceptions import BadRequest, NotFound from plexapi.exceptions import BadRequest, NotFound
from plexapi.media import MediaTag
from plexapi.settings import Setting
class Library(PlexObject): class Library(PlexObject):
@ -294,6 +295,17 @@ class Library(PlexObject):
part += urlencode(kwargs) part += urlencode(kwargs)
return self._server.query(part, method=self._server._session.post) return self._server.query(part, method=self._server._session.post)
def history(self, maxresults=9999999, mindate=None):
""" Get Play History for all library Sections for the owner.
Parameters:
maxresults (int): Only return the specified number of results (optional).
mindate (datetime): Min datetime to return results from.
"""
hist = []
for section in self.sections():
hist.extend(section.history(maxresults=maxresults, mindate=mindate))
return hist
class LibrarySection(PlexObject): class LibrarySection(PlexObject):
""" Base class for a single library section. """ Base class for a single library section.
@ -320,6 +332,8 @@ class LibrarySection(PlexObject):
type (str): Type of content section represents (movie, artist, photo, show). type (str): Type of content section represents (movie, artist, photo, show).
updatedAt (datetime): Datetime this library section was last updated. updatedAt (datetime): Datetime this library section was last updated.
uuid (str): Unique id for this section (32258d7c-3e6c-4ac5-98ad-bad7a3b78c63) uuid (str): Unique id for this section (32258d7c-3e6c-4ac5-98ad-bad7a3b78c63)
totalSize (int): Total number of item in the library
""" """
ALLOWED_FILTERS = () ALLOWED_FILTERS = ()
ALLOWED_SORT = () ALLOWED_SORT = ()
@ -343,6 +357,51 @@ class LibrarySection(PlexObject):
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.uuid = data.attrib.get('uuid') self.uuid = data.attrib.get('uuid')
# Private attrs as we dont want a reload.
self._total_size = None
def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs):
""" Load the specified key to find and build all items with the specified tag
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
on how this is used.
Parameters:
container_start (None, int): offset to get a subset of the data
container_size (None, int): How many items in data
"""
url_kw = {}
if container_start is not None:
url_kw["X-Plex-Container-Start"] = container_start
if container_size is not None:
url_kw["X-Plex-Container-Size"] = container_size
if ekey is None:
raise BadRequest('ekey was not provided')
data = self._server.query(ekey, params=url_kw)
if '/all' in ekey:
# totalSize is only included in the xml response
# if container size is used.
total_size = data.attrib.get("totalSize") or data.attrib.get("size")
self._total_size = utils.cast(int, total_size)
items = self.findItems(data, cls, ekey, **kwargs)
librarySectionID = data.attrib.get('librarySectionID')
if librarySectionID:
for item in items:
item.librarySectionID = librarySectionID
return items
@property
def totalSize(self):
if self._total_size is None:
part = '/library/sections/%s/all?X-Plex-Container-Start=0&X-Plex-Container-Size=1' % self.key
data = self._server.query(part)
self._total_size = int(data.attrib.get("totalSize"))
return self._total_size
def delete(self): def delete(self):
""" Delete a library section. """ """ Delete a library section. """
@ -354,13 +413,18 @@ class LibrarySection(PlexObject):
log.error(msg) log.error(msg)
raise raise
def edit(self, **kwargs): def reload(self, key=None):
return self._server.library.section(self.title)
def edit(self, agent=None, **kwargs):
""" Edit a library (Note: agent is required). See :class:`~plexapi.library.Library` for example usage. """ Edit a library (Note: agent is required). See :class:`~plexapi.library.Library` for example usage.
Parameters: Parameters:
kwargs (dict): Dict of settings to edit. kwargs (dict): Dict of settings to edit.
""" """
part = '/library/sections/%s?%s' % (self.key, urlencode(kwargs)) if not agent:
agent = self.agent
part = '/library/sections/%s?agent=%s&%s' % (self.key, agent, urlencode(kwargs))
self._server.query(part, method=self._server._session.put) self._server.query(part, method=self._server._session.put)
# Reload this way since the self.key dont have a full path, but is simply a id. # Reload this way since the self.key dont have a full path, but is simply a id.
@ -374,7 +438,7 @@ class LibrarySection(PlexObject):
Parameters: Parameters:
title (str): Title of the item to return. title (str): Title of the item to return.
""" """
key = '/library/sections/%s/all' % self.key key = '/library/sections/%s/all?title=%s' % (self.key, quote(title, safe=''))
return self.fetchItem(key, title__iexact=title) return self.fetchItem(key, title__iexact=title)
def all(self, sort=None, **kwargs): def all(self, sort=None, **kwargs):
@ -390,6 +454,17 @@ class LibrarySection(PlexObject):
key = '/library/sections/%s/all%s' % (self.key, sortStr) key = '/library/sections/%s/all%s' % (self.key, sortStr)
return self.fetchItems(key, **kwargs) return self.fetchItems(key, **kwargs)
def agents(self):
""" Returns a list of available `:class:`~plexapi.media.Agent` for this library section.
"""
return self._server.agents(utils.searchType(self.type))
def settings(self):
""" Returns a list of all library settings. """
key = '/library/sections/%s/prefs' % self.key
data = self._server.query(key)
return self.findItems(data, cls=Setting)
def onDeck(self): def onDeck(self):
""" Returns a list of media items on deck from this library section. """ """ Returns a list of media items on deck from this library section. """
key = '/library/sections/%s/onDeck' % self.key key = '/library/sections/%s/onDeck' % self.key
@ -464,9 +539,9 @@ class LibrarySection(PlexObject):
key = '/library/sections/%s/%s%s' % (self.key, category, utils.joinArgs(args)) key = '/library/sections/%s/%s%s' % (self.key, category, utils.joinArgs(args))
return self.fetchItems(key, cls=FilterChoice) return self.fetchItems(key, cls=FilterChoice)
def search(self, title=None, sort=None, maxresults=999999, libtype=None, **kwargs): def search(self, title=None, sort=None, maxresults=None,
""" Search the library. If there are many results, they will be fetched from the server libtype=None, container_start=0, container_size=X_PLEX_CONTAINER_SIZE, **kwargs):
in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only looking for the first <num> """ Search the library. The http requests will be batched in container_size. If you're only looking for the first <num>
results, it would be wise to set the maxresults option to that amount so this functions results, it would be wise to set the maxresults option to that amount so this functions
doesn't iterate over all results on the server. doesn't iterate over all results on the server.
@ -477,6 +552,8 @@ class LibrarySection(PlexObject):
maxresults (int): Only return the specified number of results (optional). maxresults (int): Only return the specified number of results (optional).
libtype (str): Filter results to a spcifiec libtype (movie, show, episode, artist, libtype (str): Filter results to a spcifiec libtype (movie, show, episode, artist,
album, track; optional). album, track; optional).
container_start (int): default 0
container_size (int): default X_PLEX_CONTAINER_SIZE in your config file.
**kwargs (dict): Any of the available filters for the current library section. Partial string **kwargs (dict): Any of the available filters for the current library section. Partial string
matches allowed. Multiple matches OR together. Negative filtering also possible, just add an matches allowed. Multiple matches OR together. Negative filtering also possible, just add an
exclamation mark to the end of filter name, e.g. `resolution!=1x1`. exclamation mark to the end of filter name, e.g. `resolution!=1x1`.
@ -508,15 +585,37 @@ class LibrarySection(PlexObject):
args['sort'] = self._cleanSearchSort(sort) args['sort'] = self._cleanSearchSort(sort)
if libtype is not None: if libtype is not None:
args['type'] = utils.searchType(libtype) args['type'] = utils.searchType(libtype)
# iterate over the results
results, subresults = [], '_init' results = []
args['X-Plex-Container-Start'] = 0 subresults = []
args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults) offset = container_start
while subresults and maxresults > len(results):
if maxresults is not None:
container_size = min(container_size, maxresults)
while True:
key = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args)) key = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args))
subresults = self.fetchItems(key) subresults = self.fetchItems(key, container_start=container_start,
results += subresults[:maxresults - len(results)] container_size=container_size)
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size'] if not len(subresults):
if offset > self.totalSize:
log.info("container_start is higher then the number of items in the library")
break
results.extend(subresults)
# self.totalSize is not used as a condition in the while loop as
# this require a additional http request.
# self.totalSize is updated from .fetchItems
wanted_number_of_items = self.totalSize - offset
if maxresults is not None:
wanted_number_of_items = min(maxresults, wanted_number_of_items)
container_size = min(container_size, maxresults - len(results))
if wanted_number_of_items <= len(results):
break
container_start += container_size
return results return results
def _cleanSearchFilter(self, category, value, libtype=None): def _cleanSearchFilter(self, category, value, libtype=None):
@ -543,7 +642,7 @@ class LibrarySection(PlexObject):
matches = [k for t, k in lookup.items() if item in t] matches = [k for t, k in lookup.items() if item in t]
if matches: map(result.add, matches); continue if matches: map(result.add, matches); continue
# nothing matched; use raw item value # nothing matched; use raw item value
log.warning('Filter value not listed, using raw item value: %s' % item) log.debug('Filter value not listed, using raw item value: %s' % item)
result.add(item) result.add(item)
return ','.join(result) return ','.join(result)
@ -633,6 +732,14 @@ class LibrarySection(PlexObject):
return myplex.sync(client=client, clientId=clientId, sync_item=sync_item) return myplex.sync(client=client, clientId=clientId, sync_item=sync_item)
def history(self, maxresults=9999999, mindate=None):
""" Get Play History for this library Section for the owner.
Parameters:
maxresults (int): Only return the specified number of results (optional).
mindate (datetime): Min datetime to return results from.
"""
return self._server.history(maxresults=maxresults, mindate=mindate, librarySectionID=self.key, accountID=1)
class MovieSection(LibrarySection): class MovieSection(LibrarySection):
""" Represents a :class:`~plexapi.library.LibrarySection` section containing movies. """ Represents a :class:`~plexapi.library.LibrarySection` section containing movies.
@ -869,7 +976,7 @@ class PhotoSection(LibrarySection):
TYPE (str): 'photo' TYPE (str): 'photo'
""" """
ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure', 'device', 'resolution', 'place', ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure', 'device', 'resolution', 'place',
'originallyAvailableAt', 'addedAt', 'title', 'userRating') 'originallyAvailableAt', 'addedAt', 'title', 'userRating', 'tag', 'year')
ALLOWED_SORT = ('addedAt',) ALLOWED_SORT = ('addedAt',)
TAG = 'Directory' TAG = 'Directory'
TYPE = 'photo' TYPE = 'photo'
@ -968,6 +1075,7 @@ class Hub(PlexObject):
self.size = utils.cast(int, data.attrib.get('size')) self.size = utils.cast(int, data.attrib.get('size'))
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.key = data.attrib.get('key')
self.items = self.findItems(data) self.items = self.findItems(data)
def __len__(self): def __len__(self):
@ -979,9 +1087,11 @@ class Collections(PlexObject):
TAG = 'Directory' TAG = 'Directory'
TYPE = 'collection' TYPE = 'collection'
_include = "?includeExternalMedia=1&includePreferences=1"
def _loadData(self, data): def _loadData(self, data):
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self._details_key = "/library/metadata/%s%s" % (self.ratingKey, self._include)
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
@ -1051,5 +1161,43 @@ class Collections(PlexObject):
part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key) part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key)
return self._server.query(part, method=self._server._session.put) return self._server.query(part, method=self._server._session.put)
def posters(self):
""" Returns list of available poster objects. :class:`~plexapi.media.Poster`. """
return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey)
def uploadPoster(self, url=None, filepath=None):
""" Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
if url:
key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url))
self._server.query(key, method=self._server._session.post)
elif filepath:
key = '/library/metadata/%s/posters?' % self.ratingKey
data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data)
def setPoster(self, poster):
""" Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
poster.select()
def arts(self):
""" Returns list of available art objects. :class:`~plexapi.media.Poster`. """
return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey)
def uploadArt(self, url=None, filepath=None):
""" Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
if url:
key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url))
self._server.query(key, method=self._server._session.post)
elif filepath:
key = '/library/metadata/%s/arts?' % self.ratingKey
data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data)
def setArt(self, art):
""" Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
art.select()
# def edit(self, **kwargs): # def edit(self, **kwargs):
# TODO # TODO

View file

@ -1,5 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from plexapi import log, utils
import xml
from plexapi import compat, log, settings, utils
from plexapi.base import PlexObject from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest from plexapi.exceptions import BadRequest
from plexapi.utils import cast from plexapi.utils import cast
@ -143,7 +146,7 @@ class MediaPart(PlexObject):
def setDefaultSubtitleStream(self, stream): def setDefaultSubtitleStream(self, stream):
""" Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart. """ Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart.
Parameters: Parameters:
stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default. stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default.
""" """
@ -349,6 +352,118 @@ class TranscodeSession(PlexObject):
self.width = cast(int, data.attrib.get('width')) 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): Place item after specified playQueueItemID. '-1' is the active conversion.
Example:
Move 5th conversion Item to active conversion
conversions[4].move('-1')
Move 4th conversion Item to 3rd 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): class MediaTag(PlexObject):
""" Base class for media tags used for filtering and searching your library """ 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 items or navigating the metadata of media items in your library. Tags are
@ -419,6 +534,25 @@ class Label(MediaTag):
FILTER = 'label' FILTER = 'label'
@utils.registerPlexObject
class Tag(MediaTag):
""" Represents a single tag media tag.
Attributes:
TAG (str): 'tag'
FILTER (str): 'tag'
"""
TAG = 'Tag'
FILTER = 'tag'
def _loadData(self, data):
self._data = data
self.id = cast(int, data.attrib.get('id', 0))
self.filter = data.attrib.get('filter')
self.tag = data.attrib.get('tag')
self.title = self.tag
@utils.registerPlexObject @utils.registerPlexObject
class Country(MediaTag): class Country(MediaTag):
""" Represents a single Country media tag. """ Represents a single Country media tag.
@ -483,6 +617,14 @@ class Poster(PlexObject):
self.selected = data.attrib.get('selected') self.selected = data.attrib.get('selected')
self.thumb = data.attrib.get('thumb') self.thumb = data.attrib.get('thumb')
def select(self):
key = self._initpath[:-1]
data = '%s?url=%s' % (key, compat.quote_plus(self.ratingKey))
try:
self._server.query(data, method=self._server._session.put)
except xml.etree.ElementTree.ParseError:
pass
@utils.registerPlexObject @utils.registerPlexObject
class Producer(MediaTag): class Producer(MediaTag):
@ -565,3 +707,74 @@ class Field(PlexObject):
self._data = data self._data = data
self.name = data.attrib.get('name') self.name = data.attrib.get('name')
self.locked = cast(bool, data.attrib.get('locked')) self.locked = cast(bool, data.attrib.get('locked'))
@utils.registerPlexObject
class SearchResult(PlexObject):
""" Represents a single SearchResult.
Attributes:
TAG (str): 'SearchResult'
"""
TAG = 'SearchResult'
def __repr__(self):
name = self._clean(self.firstAttr('name'))
score = self._clean(self.firstAttr('score'))
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, name, score] if p])
def _loadData(self, data):
self._data = data
self.guid = data.attrib.get('guid')
self.lifespanEnded = data.attrib.get('lifespanEnded')
self.name = data.attrib.get('name')
self.score = cast(int, data.attrib.get('score'))
self.year = data.attrib.get('year')
@utils.registerPlexObject
class Agent(PlexObject):
""" Represents a single Agent.
Attributes:
TAG (str): 'Agent'
"""
TAG = 'Agent'
def __repr__(self):
uid = self._clean(self.firstAttr('shortIdentifier'))
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p])
def _loadData(self, data):
self._data = data
self.hasAttribution = data.attrib.get('hasAttribution')
self.hasPrefs = data.attrib.get('hasPrefs')
self.identifier = data.attrib.get('identifier')
self.primary = data.attrib.get('primary')
self.shortIdentifier = self.identifier.rsplit('.', 1)[1]
if 'mediaType' in self._initpath:
self.name = data.attrib.get('name')
self.languageCode = []
for code in data:
self.languageCode += [code.attrib.get('code')]
else:
self.mediaTypes = [AgentMediaType(server=self._server, data=d) for d in data]
def _settings(self):
key = '/:/plugins/%s/prefs' % self.identifier
data = self._server.query(key)
return self.findItems(data, cls=settings.Setting)
class AgentMediaType(Agent):
def __repr__(self):
uid = self._clean(self.firstAttr('name'))
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p])
def _loadData(self, data):
self.mediaType = cast(int, data.attrib.get('mediaType'))
self.name = data.attrib.get('name')
self.languageCode = []
for code in data:
self.languageCode += [code.attrib.get('code')]

View file

@ -1,18 +1,21 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import copy import copy
import requests import threading
import time 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 import requests
from plexapi import log, logfilter, utils 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.base import PlexObject
from plexapi.exceptions import BadRequest, NotFound from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.client import PlexClient from plexapi.client import PlexClient
from plexapi.compat import ElementTree from plexapi.compat import ElementTree
from plexapi.library import LibrarySection from plexapi.library import LibrarySection
from plexapi.server import PlexServer from plexapi.server import PlexServer
from plexapi.sync import SyncList, SyncItem from plexapi.sonos import PlexSonosClient
from plexapi.sync import SyncItem, SyncList
from plexapi.utils import joinArgs from plexapi.utils import joinArgs
from requests.status_codes import _codes as codes
class MyPlexAccount(PlexObject): class MyPlexAccount(PlexObject):
@ -73,6 +76,12 @@ class MyPlexAccount(PlexObject):
REQUESTS = 'https://plex.tv/api/invites/requests' # get REQUESTS = 'https://plex.tv/api/invites/requests' # get
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
# Hub sections
VOD = 'https://vod.provider.plex.tv/' # get
WEBSHOWS = 'https://webshows.provider.plex.tv/' # get
NEWS = 'https://news.provider.plex.tv/' # get
PODCASTS = 'https://podcasts.provider.plex.tv/' # get
MUSIC = 'https://music.provider.plex.tv/' # get
# Key may someday switch to the following url. For now the current value works. # Key may someday switch to the following url. For now the current value works.
# https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId} # https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId}
key = 'https://plex.tv/users/account' key = 'https://plex.tv/users/account'
@ -80,6 +89,8 @@ class MyPlexAccount(PlexObject):
def __init__(self, username=None, password=None, token=None, session=None, timeout=None): def __init__(self, username=None, password=None, token=None, session=None, timeout=None):
self._token = token self._token = token
self._session = session or requests.Session() self._session = session or requests.Session()
self._sonos_cache = []
self._sonos_cache_timestamp = 0
data, initpath = self._signin(username, password, timeout) data, initpath = self._signin(username, password, timeout)
super(MyPlexAccount, self).__init__(self, data, initpath) super(MyPlexAccount, self).__init__(self, data, initpath)
@ -175,7 +186,13 @@ class MyPlexAccount(PlexObject):
if response.status_code not in (200, 201, 204): # pragma: no cover if response.status_code not in (200, 201, 204): # pragma: no cover
codename = codes.get(response.status_code)[0] codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ') 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)
elif response.status_code == 404:
raise NotFound(message)
else:
raise BadRequest(message)
data = response.text.encode('utf8') data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None return ElementTree.fromstring(data) if data.strip() else None
@ -195,6 +212,24 @@ class MyPlexAccount(PlexObject):
data = self.query(MyPlexResource.key) data = self.query(MyPlexResource.key)
return [MyPlexResource(self, elem) for elem in data] return [MyPlexResource(self, elem) for elem in data]
def sonos_speakers(self):
if 'companions_sonos' not in self.subscriptionFeatures:
return []
t = time.time()
if t - self._sonos_cache_timestamp > 60:
self._sonos_cache_timestamp = t
data = self.query('https://sonos.plex.tv/resources')
self._sonos_cache = [PlexSonosClient(self, elem) for elem in data]
return self._sonos_cache
def sonos_speaker(self, name):
return [x for x in self.sonos_speakers() if x.title == name][0]
def sonos_speaker_by_id(self, identifier):
return [x for x in self.sonos_speakers() if x.machineIdentifier == identifier][0]
def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False,
allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None):
""" Share library content with the specified user. """ Share library content with the specified user.
@ -384,8 +419,8 @@ class MyPlexAccount(PlexObject):
params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds}} params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds}}
url = self.FRIENDSERVERS.format(machineId=machineId, serverId=serverId) url = self.FRIENDSERVERS.format(machineId=machineId, serverId=serverId)
else: else:
params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds, params = {'server_id': machineId,
'invited_id': user.id}} 'shared_server': {'library_section_ids': sectionIds, 'invited_id': user.id}}
url = self.FRIENDINVITE.format(machineId=machineId) url = self.FRIENDINVITE.format(machineId=machineId)
# Remove share sections, add shares to user without shares, or update shares # Remove share sections, add shares to user without shares, or update shares
if not user_servers or sectionIds: if not user_servers or sectionIds:
@ -429,7 +464,7 @@ class MyPlexAccount(PlexObject):
return user return user
elif (user.username and user.email and user.id and username.lower() in 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 return user
raise NotFound('Unable to find user %s' % username) raise NotFound('Unable to find user %s' % username)
@ -600,6 +635,54 @@ class MyPlexAccount(PlexObject):
raise 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))
return response.json()['token'] return response.json()['token']
def history(self, maxresults=9999999, mindate=None):
""" Get Play History for all library sections on all servers for the owner.
Parameters:
maxresults (int): Only return the specified number of results (optional).
mindate (datetime): Min datetime to return results from.
"""
servers = [x for x in self.resources() if x.provides == 'server' and x.owned]
hist = []
for server in servers:
conn = server.connect()
hist.extend(conn.history(maxresults=maxresults, mindate=mindate, accountID=1))
return hist
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})
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})
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})
elem = ElementTree.fromstring(req.text)
return self.findItems(elem)
def podcasts(self):
""" Returns a list of Podcasts Hub items :class:`~plexapi.library.Hub`
"""
req = requests.get(self.PODCASTS + 'hubs/', headers={'X-Plex-Token': self._token})
elem = ElementTree.fromstring(req.text)
return self.findItems(elem)
def tidal(self):
""" Returns a list of tidal Hub items :class:`~plexapi.library.Hub`
"""
req = requests.get(self.MUSIC + 'hubs/', headers={'X-Plex-Token': self._token})
elem = ElementTree.fromstring(req.text)
return self.findItems(elem)
class MyPlexUser(PlexObject): class MyPlexUser(PlexObject):
""" This object represents non-signed in users such as friends and linked """ This object represents non-signed in users such as friends and linked
@ -654,6 +737,8 @@ class MyPlexUser(PlexObject):
self.title = data.attrib.get('title', '') self.title = data.attrib.get('title', '')
self.username = data.attrib.get('username', '') self.username = data.attrib.get('username', '')
self.servers = self.findItems(data, MyPlexServerShare) self.servers = self.findItems(data, MyPlexServerShare)
for server in self.servers:
server.accountID = self.id
def get_token(self, machineIdentifier): def get_token(self, machineIdentifier):
try: try:
@ -663,6 +748,29 @@ class MyPlexUser(PlexObject):
except Exception: except Exception:
log.exception('Failed to get access token for %s' % self.title) log.exception('Failed to get access token for %s' % self.title)
def server(self, name):
""" Returns the :class:`~plexapi.myplex.MyPlexServerShare` that matches the name specified.
Parameters:
name (str): Name of the server to return.
"""
for server in self.servers:
if name.lower() == server.name.lower():
return server
raise NotFound('Unable to find server %s' % name)
def history(self, maxresults=9999999, mindate=None):
""" Get all Play History for a user in all shared servers.
Parameters:
maxresults (int): Only return the specified number of results (optional).
mindate (datetime): Min datetime to return results from.
"""
hist = []
for server in self.servers:
hist.extend(server.history(maxresults=maxresults, mindate=mindate))
return hist
class Section(PlexObject): class Section(PlexObject):
""" This refers to a shared section. The raw xml for the data presented here """ This refers to a shared section. The raw xml for the data presented here
@ -689,6 +797,16 @@ class Section(PlexObject):
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.shared = utils.cast(bool, data.attrib.get('shared')) self.shared = utils.cast(bool, data.attrib.get('shared'))
def history(self, maxresults=9999999, mindate=None):
""" Get all Play History for a user for this section in this shared server.
Parameters:
maxresults (int): Only return the specified number of results (optional).
mindate (datetime): Min datetime to return results from.
"""
server = self._server._server.resource(self._server.name).connect()
return server.history(maxresults=maxresults, mindate=mindate,
accountID=self._server.accountID, librarySectionID=self.sectionKey)
class MyPlexServerShare(PlexObject): class MyPlexServerShare(PlexObject):
""" Represents a single user's server reference. Used for library sharing. """ Represents a single user's server reference. Used for library sharing.
@ -711,6 +829,7 @@ class MyPlexServerShare(PlexObject):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
self._data = data self._data = data
self.id = utils.cast(int, data.attrib.get('id')) self.id = utils.cast(int, data.attrib.get('id'))
self.accountID = utils.cast(int, data.attrib.get('accountID'))
self.serverId = utils.cast(int, data.attrib.get('serverId')) self.serverId = utils.cast(int, data.attrib.get('serverId'))
self.machineIdentifier = data.attrib.get('machineIdentifier') self.machineIdentifier = data.attrib.get('machineIdentifier')
self.name = data.attrib.get('name') self.name = data.attrib.get('name')
@ -720,7 +839,21 @@ class MyPlexServerShare(PlexObject):
self.owned = utils.cast(bool, data.attrib.get('owned')) self.owned = utils.cast(bool, data.attrib.get('owned'))
self.pending = utils.cast(bool, data.attrib.get('pending')) self.pending = utils.cast(bool, data.attrib.get('pending'))
def section(self, name):
""" Returns the :class:`~plexapi.myplex.Section` that matches the name specified.
Parameters:
name (str): Name of the section to return.
"""
for section in self.sections():
if name.lower() == section.title.lower():
return section
raise NotFound('Unable to find section %s' % name)
def sections(self): def sections(self):
""" Returns a list of all :class:`~plexapi.myplex.Section` objects shared with this user.
"""
url = MyPlexAccount.FRIENDSERVERS.format(machineId=self.machineIdentifier, serverId=self.id) url = MyPlexAccount.FRIENDSERVERS.format(machineId=self.machineIdentifier, serverId=self.id)
data = self._server.query(url) data = self._server.query(url)
sections = [] sections = []
@ -731,6 +864,15 @@ class MyPlexServerShare(PlexObject):
return sections return sections
def history(self, maxresults=9999999, mindate=None):
""" Get all Play History for a user in this shared server.
Parameters:
maxresults (int): Only return the specified number of results (optional).
mindate (datetime): Min datetime to return results from.
"""
server = self._server.resource(self.name).connect()
return server.history(maxresults=maxresults, mindate=mindate, accountID=self.accountID)
class MyPlexResource(PlexObject): class MyPlexResource(PlexObject):
""" This object represents resources connected to your Plex server that can provide """ This object represents resources connected to your Plex server that can provide
@ -932,6 +1074,186 @@ class MyPlexDevice(PlexObject):
return self._server.syncItems(client=self) return self._server.syncItems(client=self)
class MyPlexPinLogin(object):
"""
MyPlex PIN login class which supports getting the four character PIN which the user must
enter on https://plex.tv/link to authenticate the client and provide an access token to
create a :class:`~plexapi.myplex.MyPlexAccount` instance.
This helper class supports a polling, threaded and callback approach.
- The polling approach expects the developer to periodically check if the PIN login was
successful using :func:`plexapi.myplex.MyPlexPinLogin.checkLogin`.
- The threaded approach expects the developer to call
:func:`plexapi.myplex.MyPlexPinLogin.run` and then at a later time call
:func:`plexapi.myplex.MyPlexPinLogin.waitForLogin` to wait for and check the result.
- The callback approach is an extension of the threaded approach and expects the developer
to pass the `callback` parameter to the call to :func:`plexapi.myplex.MyPlexPinLogin.run`.
The callback will be called when the thread waiting for the PIN login to succeed either
finishes or expires. The parameter passed to the callback is the received authentication
token or `None` if the login expired.
Parameters:
session (requests.Session, optional): Use your own session object if you want to
cache the http responses from PMS
requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT).
Attributes:
PINS (str): 'https://plex.tv/pins.xml'
CHECKPINS (str): 'https://plex.tv/pins/{pinid}.xml'
POLLINTERVAL (int): 1
finished (bool): Whether the pin login has finished or not.
expired (bool): Whether the pin login has expired or not.
token (str): Token retrieved through the pin login.
pin (str): Pin to use for the login on https://plex.tv/link.
"""
PINS = 'https://plex.tv/pins.xml' # get
CHECKPINS = 'https://plex.tv/pins/{pinid}.xml' # get
POLLINTERVAL = 1
def __init__(self, session=None, requestTimeout=None):
super(MyPlexPinLogin, self).__init__()
self._session = session or requests.Session()
self._requestTimeout = requestTimeout or TIMEOUT
self._loginTimeout = None
self._callback = None
self._thread = None
self._abort = False
self._id = None
self.finished = False
self.expired = False
self.token = None
self.pin = self._getPin()
def run(self, callback=None, timeout=None):
""" Starts the thread which monitors the PIN login state.
Parameters:
callback (Callable[str]): Callback called with the received authentication token (optional).
timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional).
Raises:
:class:`RuntimeError`: if the thread is already running.
:class:`RuntimeError`: if the PIN login for the current PIN has expired.
"""
if self._thread and not self._abort:
raise RuntimeError('MyPlexPinLogin thread is already running')
if self.expired:
raise RuntimeError('MyPlexPinLogin has expired')
self._loginTimeout = timeout
self._callback = callback
self._abort = False
self.finished = False
self._thread = threading.Thread(target=self._pollLogin, name='plexapi.myplex.MyPlexPinLogin')
self._thread.start()
def waitForLogin(self):
""" Waits for the PIN login to succeed or expire.
Parameters:
callback (Callable[str]): Callback called with the received authentication token (optional).
timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional).
Returns:
`True` if the PIN login succeeded or `False` otherwise.
"""
if not self._thread or self._abort:
return False
self._thread.join()
if self.expired or not self.token:
return False
return True
def stop(self):
""" Stops the thread monitoring the PIN login state. """
if not self._thread or self._abort:
return
self._abort = True
self._thread.join()
def checkLogin(self):
""" Returns `True` if the PIN login has succeeded. """
if self._thread:
return False
try:
return self._checkLogin()
except Exception:
self.expired = True
self.finished = True
return False
def _getPin(self):
if self.pin:
return self.pin
url = self.PINS
response = self._query(url, self._session.post)
if not response:
return None
self._id = response.find('id').text
self.pin = response.find('code').text
return self.pin
def _checkLogin(self):
if not self._id:
return False
if self.token:
return True
url = self.CHECKPINS.format(pinid=self._id)
response = self._query(url)
if not response:
return False
token = response.find('auth_token').text
if not token:
return False
self.token = token
self.finished = True
return True
def _pollLogin(self):
try:
start = time.time()
while not self._abort and (not self._loginTimeout or (time.time() - start) < self._loginTimeout):
try:
result = self._checkLogin()
except Exception:
self.expired = True
break
if result:
break
time.sleep(self.POLLINTERVAL)
if self.token and self._callback:
self._callback(self.token)
finally:
self.finished = True
def _query(self, url, method=None):
method = method or self._session.get
log.debug('%s %s', method.__name__.upper(), url)
headers = BASE_HEADERS.copy()
response = method(url, headers=headers, timeout=self._requestTimeout)
if not response.ok: # 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))
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
def _connect(cls, url, token, timeout, results, i, job_is_done_event=None): def _connect(cls, url, token, timeout, results, i, job_is_done_event=None):
""" Connects to the specified cls with url and token. Stores the connection """ Connects to the specified cls with url and token. Stores the connection
information to results[i] in a threadsafe way. information to results[i] in a threadsafe way.

View file

@ -117,6 +117,7 @@ class Photo(PlexPartialObject):
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
self.media = self.findItems(data, media.Media) self.media = self.findItems(data, media.Media)
self.tag = self.findItems(data, media.Tag)
def photoalbum(self): def photoalbum(self):
""" Return this photo's :class:`~plexapi.photo.Photoalbum`. """ """ Return this photo's :class:`~plexapi.photo.Photoalbum`. """

View file

@ -268,3 +268,41 @@ class Playlist(PlexPartialObject, Playable):
raise Unsupported('Unsupported playlist content') raise Unsupported('Unsupported playlist content')
return myplex.sync(sync_item, client=client, clientId=clientId) return myplex.sync(sync_item, client=client, clientId=clientId)
def posters(self):
""" Returns list of available poster objects. :class:`~plexapi.media.Poster`. """
return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey)
def uploadPoster(self, url=None, filepath=None):
""" Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
if url:
key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url))
self._server.query(key, method=self._server._session.post)
elif filepath:
key = '/library/metadata/%s/posters?' % self.ratingKey
data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data)
def setPoster(self, poster):
""" Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
poster.select()
def arts(self):
""" Returns list of available art objects. :class:`~plexapi.media.Poster`. """
return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey)
def uploadArt(self, url=None, filepath=None):
""" Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
if url:
key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url))
self._server.query(key, method=self._server._session.post)
elif filepath:
key = '/library/metadata/%s/arts?' % self.ratingKey
data = open(filepath, 'rb').read()
self._server.query(key, method=self._server._session.post, data=data)
def setArt(self, art):
""" Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
art.select()

View file

@ -7,12 +7,13 @@ from plexapi.alert import AlertListener
from plexapi.base import PlexObject from plexapi.base import PlexObject
from plexapi.client import PlexClient from plexapi.client import PlexClient
from plexapi.compat import ElementTree, urlencode 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.library import Library, Hub
from plexapi.settings import Settings from plexapi.settings import Settings
from plexapi.playlist import Playlist from plexapi.playlist import Playlist
from plexapi.playqueue import PlayQueue from plexapi.playqueue import PlayQueue
from plexapi.utils import cast from plexapi.utils import cast
from plexapi.media import Optimized, Conversion
# Need these imports to populate utils.PLEXOBJECTS # Need these imports to populate utils.PLEXOBJECTS
from plexapi import (audio as _audio, video as _video, # noqa: F401 from plexapi import (audio as _audio, video as _video, # noqa: F401
@ -183,8 +184,18 @@ class PlexServer(PlexObject):
data = self.query(Account.key) data = self.query(Account.key)
return Account(self, data) return Account(self, data)
def agents(self, mediaType=None):
""" Returns the `:class:`~plexapi.media.Agent` objects this server has available. """
key = '/system/agents'
if mediaType:
key += '?mediaType=%s' % mediaType
return self.fetchItems(key)
def createToken(self, type='delegation', scope='all'): def createToken(self, type='delegation', scope='all'):
"""Create a temp access token for the server.""" """Create a temp access token for the server."""
if not self._token:
# Handle unclaimed servers
return None
q = self.query('/security/token?type=%s&scope=%s' % (type, scope)) q = self.query('/security/token?type=%s&scope=%s' % (type, scope))
return q.attrib.get('token') return q.attrib.get('token')
@ -322,7 +333,7 @@ class PlexServer(PlexObject):
# figure out what method this is.. # figure out what method this is..
return self.query(part, method=self._session.put) return self.query(part, method=self._session.put)
def history(self, maxresults=9999999, mindate=None): def history(self, maxresults=9999999, mindate=None, ratingKey=None, accountID=None, librarySectionID=None):
""" Returns a list of media items from watched history. If there are many results, they will """ Returns a list of media items from watched history. If there are many results, they will
be fetched from the server in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only be fetched from the server in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only
looking for the first <num> results, it would be wise to set the maxresults option to that looking for the first <num> results, it would be wise to set the maxresults option to that
@ -332,9 +343,18 @@ class PlexServer(PlexObject):
maxresults (int): Only return the specified number of results (optional). maxresults (int): Only return the specified number of results (optional).
mindate (datetime): Min datetime to return results from. This really helps speed mindate (datetime): Min datetime to return results from. This really helps speed
up the result listing. For example: datetime.now() - timedelta(days=7) up the result listing. For example: datetime.now() - timedelta(days=7)
ratingKey (int/str) Request history for a specific ratingKey item.
accountID (int/str) Request history for a specific account ID.
librarySectionID (int/str) Request history for a specific library section ID.
""" """
results, subresults = [], '_init' results, subresults = [], '_init'
args = {'sort': 'viewedAt:desc'} args = {'sort': 'viewedAt:desc'}
if ratingKey:
args['metadataItemID'] = ratingKey
if accountID:
args['accountID'] = accountID
if librarySectionID:
args['librarySectionID'] = librarySectionID
if mindate: if mindate:
args['viewedAt>'] = int(mindate.timestamp()) args['viewedAt>'] = int(mindate.timestamp())
args['X-Plex-Container-Start'] = 0 args['X-Plex-Container-Start'] = 0
@ -363,6 +383,36 @@ class PlexServer(PlexObject):
""" """
return self.fetchItem('/playlists', title=title) 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): 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 """ 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 by encoding the response to utf-8 and parsing the returned XML into and
@ -377,8 +427,13 @@ class PlexServer(PlexObject):
if response.status_code not in (200, 201): if response.status_code not in (200, 201):
codename = codes.get(response.status_code)[0] codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ') errtext = response.text.replace('\n', ' ')
log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)) if response.status_code == 401:
raise Unauthorized(message)
elif response.status_code == 404:
raise NotFound(message)
else:
raise BadRequest(message)
data = response.text.encode('utf8') data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None return ElementTree.fromstring(data) if data.strip() else None
@ -472,6 +527,25 @@ class PlexServer(PlexObject):
self.refreshSynclist() self.refreshSynclist()
self.refreshContent() 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): class Account(PlexObject):
""" Contains the locally cached MyPlex account information. The properties provided don't """ Contains the locally cached MyPlex account information. The properties provided don't

View file

@ -124,8 +124,8 @@ class Setting(PlexObject):
self.enumValues = self._getEnumValues(data) self.enumValues = self._getEnumValues(data)
def _cast(self, value): def _cast(self, value):
""" Cast the specifief value to the type of this setting. """ """ Cast the specific value to the type of this setting. """
if self.type != 'text': if self.type != 'enum':
value = utils.cast(self.TYPES.get(self.type)['cast'], value) value = utils.cast(self.TYPES.get(self.type)['cast'], value)
return value return value

116
lib/plexapi/sonos.py Normal file
View file

@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
import requests
from plexapi import CONFIG, X_PLEX_IDENTIFIER
from plexapi.client import PlexClient
from plexapi.exceptions import BadRequest
from plexapi.playqueue import PlayQueue
class PlexSonosClient(PlexClient):
""" Class for interacting with a Sonos speaker via the Plex API. This class
makes requests to an external Plex API which then forwards the
Sonos-specific commands back to your Plex server & Sonos speakers. Use
of this feature requires an active Plex Pass subscription and Sonos
speakers linked to your Plex account. It also requires remote access to
be working properly.
More details on the Sonos integration are avaialble here:
https://support.plex.tv/articles/218237558-requirements-for-using-plex-for-sonos/
The Sonos API emulates the Plex player control API closely:
https://github.com/plexinc/plex-media-player/wiki/Remote-control-API
Parameters:
account (:class:`~plexapi.myplex.PlexAccount`): PlexAccount instance this
Sonos speaker is associated with.
data (ElementTree): Response from Plex Sonos API used to build this client.
Attributes:
deviceClass (str): "speaker"
lanIP (str): Local IP address of speaker.
machineIdentifier (str): Unique ID for this device.
platform (str): "Sonos"
platformVersion (str): Build version of Sonos speaker firmware.
product (str): "Sonos"
protocol (str): "plex"
protocolCapabilities (list<str>): List of client capabilities (timeline, playback,
playqueues, provider-playback)
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
session (:class:`~requests.Session`): Session object used for connection.
title (str): Name of this Sonos speaker.
token (str): X-Plex-Token used for authenication
_baseurl (str): Address of public Plex Sonos API endpoint.
_commandId (int): Counter for commands sent to Plex API.
_token (str): Token associated with linked Plex account.
_session (obj): Requests session object used to access this client.
"""
def __init__(self, account, data):
self._data = data
self.deviceClass = data.attrib.get("deviceClass")
self.machineIdentifier = data.attrib.get("machineIdentifier")
self.product = data.attrib.get("product")
self.platform = data.attrib.get("platform")
self.platformVersion = data.attrib.get("platformVersion")
self.protocol = data.attrib.get("protocol")
self.protocolCapabilities = data.attrib.get("protocolCapabilities")
self.lanIP = data.attrib.get("lanIP")
self.title = data.attrib.get("title")
self._baseurl = "https://sonos.plex.tv"
self._commandId = 0
self._token = account._token
self._session = account._session or requests.Session()
# Dummy values for PlexClient inheritance
self._last_call = 0
self._proxyThroughServer = False
self._showSecrets = CONFIG.get("log.show_secrets", "").lower() == "true"
def playMedia(self, media, offset=0, **params):
if hasattr(media, "playlistType"):
mediatype = media.playlistType
else:
if isinstance(media, PlayQueue):
mediatype = media.items[0].listType
else:
mediatype = media.listType
if mediatype == "audio":
mediatype = "music"
else:
raise BadRequest("Sonos currently only supports music for playback")
server_protocol, server_address, server_port = media._server._baseurl.split(":")
server_address = server_address.strip("/")
server_port = server_port.strip("/")
playqueue = (
media
if isinstance(media, PlayQueue)
else media._server.createPlayQueue(media)
)
self.sendCommand(
"playback/playMedia",
**dict(
{
"type": "music",
"providerIdentifier": "com.plexapp.plugins.library",
"containerKey": "/playQueues/{}?own=1".format(
playqueue.playQueueID
),
"key": media.key,
"offset": offset,
"machineIdentifier": media._server.machineIdentifier,
"protocol": server_protocol,
"address": server_address,
"port": server_port,
"token": media._server.createToken(),
"commandID": self._nextCommandId(),
"X-Plex-Client-Identifier": X_PLEX_IDENTIFIER,
"X-Plex-Token": media._server._token,
"X-Plex-Target-Client-Identifier": self.machineIdentifier,
},
**params
)
)

View file

@ -2,16 +2,21 @@
import logging import logging
import os import os
import re import re
import requests
import time import time
import zipfile import zipfile
from datetime import datetime from datetime import datetime
from getpass import getpass from getpass import getpass
from threading import Thread, Event from threading import Event, Thread
from tqdm import tqdm
import requests
from plexapi import compat from plexapi import compat
from plexapi.exceptions import NotFound from plexapi.exceptions import NotFound
try:
from tqdm import tqdm
except ImportError:
tqdm = None
log = logging.getLogger('plexapi') log = logging.getLogger('plexapi')
# Search Types - Plex uses these to filter specific media types when searching. # Search Types - Plex uses these to filter specific media types when searching.
@ -59,7 +64,7 @@ def registerPlexObject(cls):
def cast(func, value): def cast(func, value):
""" Cast the specified value to the specified type (returned by func). Currently this """ Cast the specified value to the specified type (returned by func). Currently this
only support int, float, bool. Should be extended if needed. only support str, int, float, bool. Should be extended if needed.
Parameters: Parameters:
func (func): Calback function to used cast to type (int, bool, float). func (func): Calback function to used cast to type (int, bool, float).
@ -67,7 +72,13 @@ def cast(func, value):
""" """
if value is not None: if value is not None:
if func == bool: if func == bool:
return bool(int(value)) if value in (1, True, "1", "true"):
return True
elif value in (0, False, "0", "false"):
return False
else:
raise ValueError(value)
elif func in (int, float): elif func in (int, float):
try: try:
return func(value) return func(value)
@ -89,7 +100,7 @@ def joinArgs(args):
arglist = [] arglist = []
for key in sorted(args, key=lambda x: x.lower()): for key in sorted(args, key=lambda x: x.lower()):
value = compat.ustr(args[key]) value = compat.ustr(args[key])
arglist.append('%s=%s' % (key, compat.quote(value))) arglist.append('%s=%s' % (key, compat.quote(value, safe='')))
return '?%s' % '&'.join(arglist) return '?%s' % '&'.join(arglist)
@ -287,17 +298,17 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4
# save the file to disk # save the file to disk
log.info('Downloading: %s', fullpath) log.info('Downloading: %s', fullpath)
if showstatus: # pragma: no cover if showstatus and tqdm: # pragma: no cover
total = int(response.headers.get('content-length', 0)) total = int(response.headers.get('content-length', 0))
bar = tqdm(unit='B', unit_scale=True, total=total, desc=filename) bar = tqdm(unit='B', unit_scale=True, total=total, desc=filename)
with open(fullpath, 'wb') as handle: with open(fullpath, 'wb') as handle:
for chunk in response.iter_content(chunk_size=chunksize): for chunk in response.iter_content(chunk_size=chunksize):
handle.write(chunk) handle.write(chunk)
if showstatus: if showstatus and tqdm:
bar.update(len(chunk)) bar.update(len(chunk))
if showstatus: # pragma: no cover if showstatus and tqdm: # pragma: no cover
bar.close() bar.close()
# check we want to unzip the contents # check we want to unzip the contents
if fullpath.endswith('zip') and unpack: if fullpath.endswith('zip') and unpack:
@ -375,3 +386,15 @@ def choose(msg, items, attr): # pragma: no cover
except (ValueError, IndexError): except (ValueError, IndexError):
pass pass
def getAgentIdentifier(section, agent):
""" Return the full agent identifier from a short identifier, name, or confirm full identifier. """
agents = []
for ag in section.agents():
identifiers = [ag.identifier, ag.shortIdentifier, ag.name]
if agent in identifiers:
return ag.identifier
agents += identifiers
raise NotFound('Couldnt find "%s" in agents list (%s)' %
(agent, ', '.join(agents)))

View file

@ -2,7 +2,8 @@
from plexapi import media, utils from plexapi import media, utils
from plexapi.exceptions import BadRequest, NotFound from plexapi.exceptions import BadRequest, NotFound
from plexapi.base import Playable, PlexPartialObject from plexapi.base import Playable, PlexPartialObject
from plexapi.compat import quote_plus from plexapi.compat import quote_plus, urlencode
import os
class Video(PlexPartialObject): class Video(PlexPartialObject):
@ -89,10 +90,112 @@ class Video(PlexPartialObject):
""" Returns str, default title for a new syncItem. """ """ Returns str, default title for a new syncItem. """
return self.title return self.title
def posters(self): def subtitleStreams(self):
""" Returns list of available poster objects. :class:`~plexapi.media.Poster`:""" """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """
streams = []
return self.fetchItems('%s/posters' % self.key, cls=media.Poster) parts = self.iterParts()
for part in parts:
streams += part.subtitleStreams()
return streams
def uploadSubtitles(self, filepath):
""" Upload Subtitle file for video. """
url = '%s/subtitles' % self.key
filename = os.path.basename(filepath)
subFormat = os.path.splitext(filepath)[1][1:]
with open(filepath, 'rb') as subfile:
params = {'title': filename,
'format': subFormat
}
headers = {'Accept': 'text/plain, */*'}
self._server.query(url, self._server._session.post, data=subfile, params=params, headers=headers)
def removeSubtitles(self, streamID=None, streamTitle=None):
""" Remove Subtitle from movie's subtitles listing.
Note: If subtitle file is located inside video directory it will bbe deleted.
Files outside of video directory are not effected.
"""
for stream in self.subtitleStreams():
if streamID == stream.id or streamTitle == stream.title:
self._server.query(stream.key, self._server._session.delete)
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): 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. """ Add current video (movie, tv-show, season or episode) as sync item for specified device.
@ -224,14 +327,6 @@ class Movie(Playable, Video):
""" """
return [part.file for part in self.iterParts() if part] return [part.file for part in self.iterParts() if part]
def subtitleStreams(self):
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """
streams = []
for elem in self.media:
for part in elem.parts:
streams += part.subtitleStreams()
return streams
def _prettyfilename(self): def _prettyfilename(self):
# This is just for compat. # This is just for compat.
return self.title return self.title
@ -257,7 +352,7 @@ class Movie(Playable, Video):
else: else:
self._server.url('%s?download=1' % location.key) self._server.url('%s?download=1' % location.key)
filepath = utils.download(url, self._server._token, filename=name, filepath = utils.download(url, self._server._token, filename=name,
savepath=savepath, session=self._server._session) savepath=savepath, session=self._server._session)
if filepath: if filepath:
filepaths.append(filepath) filepaths.append(filepath)
return filepaths return filepaths
@ -481,7 +576,7 @@ class Season(Video):
def show(self): def show(self):
""" Return this seasons :func:`~plexapi.video.Show`.. """ """ Return this seasons :func:`~plexapi.video.Show`.. """
return self.fetchItem(self.parentKey) return self.fetchItem(int(self.parentRatingKey))
def watched(self): def watched(self):
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """ """ Returns list of watched :class:`~plexapi.video.Episode` objects. """
@ -622,8 +717,33 @@ class Episode(Playable, Video):
def show(self): def show(self):
"""" Return this episodes :func:`~plexapi.video.Show`.. """ """" Return this episodes :func:`~plexapi.video.Show`.. """
return self.fetchItem(self.grandparentKey) return self.fetchItem(int(self.grandparentRatingKey))
def _defaultSyncTitle(self): def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """ """ Returns str, default title for a new syncItem. """
return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title) return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title)
@utils.registerPlexObject
class Clip(Playable, Video):
""" Represents a single Clip."""
TAG = 'Video'
TYPE = 'clip'
METADATA_TYPE = 'clip'
def _loadData(self, data):
self._data = data
self.addedAt = data.attrib.get('addedAt')
self.duration = data.attrib.get('duration')
self.guid = data.attrib.get('guid')
self.key = data.attrib.get('key')
self.originallyAvailableAt = data.attrib.get('originallyAvailableAt')
self.ratingKey = data.attrib.get('ratingKey')
self.skipDetails = utils.cast(int, data.attrib.get('skipDetails'))
self.subtype = data.attrib.get('subtype')
self.thumb = data.attrib.get('thumb')
self.thumbAspectRatio = data.attrib.get('thumbAspectRatio')
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
self.year = data.attrib.get('year')