Update PlexAPI to 3.3.0

This commit is contained in:
JonnyWong16 2020-03-24 22:10:43 -07:00
parent 2fa62f71e1
commit c55c00a19e
17 changed files with 1559 additions and 135 deletions

View file

@ -14,13 +14,14 @@ CONFIG = PlexConfig(CONFIG_PATH)
# PlexAPI Settings
PROJECT = 'PlexAPI'
VERSION = '3.0.6'
VERSION = '3.3.0'
TIMEOUT = CONFIG.get('plexapi.timeout', 30, 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)
# Plex Header Configuation
X_PLEX_PROVIDES = CONFIG.get('header.provides', 'controller')
X_PLEX_PLATFORM = CONFIG.get('header.platorm', uname()[0])
X_PLEX_PLATFORM = CONFIG.get('header.platform', CONFIG.get('header.platorm', uname()[0]))
X_PLEX_PLATFORM_VERSION = CONFIG.get('header.platform_version', uname()[2])
X_PLEX_PRODUCT = CONFIG.get('header.product', PROJECT)
X_PLEX_VERSION = CONFIG.get('header.version', VERSION)

View file

@ -12,6 +12,18 @@ class AlertListener(threading.Thread):
alerts you must call .start() on the object once it's created. When calling
`PlexServer.startAlertListener()`, the thread will be started for you.
Known `state`-values for timeline entries, with identifier=`com.plexapp.plugins.library`:
:0: The item was created
:1: Reporting progress on item processing
:2: Matching the item
:3: Downloading the metadata
:4: Processing downloaded metadata
:5: The item processed
:9: The item deleted
When metadata agent is not set for the library processing ends with state=1.
Parameters:
server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to.
callback (func): Callback function to call on recieved messages. The callback function

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.compat import quote_plus
class Audio(PlexPartialObject):
@ -23,6 +24,9 @@ class Audio(PlexPartialObject):
updatedAt (datatime): Datetime this item was updated.
viewCount (int): Count of times this item was accessed.
"""
METADATA_TYPE = 'track'
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
@ -57,6 +61,46 @@ class Audio(PlexPartialObject):
""" Returns the full URL for this audio item. Typically used for getting a specific track. """
return self._server.url(part, includeToken=True) if part else None
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
return self.title
def sync(self, bitrate, client=None, clientId=None, limit=None, title=None):
""" Add current audio (artist, album or track) as sync item for specified device.
See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions.
Parameters:
bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the
module :mod:`plexapi.sync`.
client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see
:func:`plexapi.myplex.MyPlexAccount.sync`.
clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`.
limit (int): maximum count of items to sync, unlimited if `None`.
title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be
generated from metadata of current media.
Returns:
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
"""
from plexapi.sync import SyncItem, Policy, MediaSettings
myplex = self._server.myPlexAccount()
sync_item = SyncItem(self._server, None)
sync_item.title = title if title else self._defaultSyncTitle()
sync_item.rootTitle = self.title
sync_item.contentType = self.listType
sync_item.metadataType = self.METADATA_TYPE
sync_item.machineIdentifier = self._server.machineIdentifier
section = self._server.library.sectionByID(self.librarySectionID)
sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key))
sync_item.policy = Policy.create(limit)
sync_item.mediaSettings = MediaSettings.createMusic(bitrate)
return myplex.sync(sync_item, client=client, clientId=clientId)
@utils.registerPlexObject
class Artist(Audio):
@ -124,12 +168,12 @@ class Artist(Audio):
""" Alias of :func:`~plexapi.audio.Artist.track`. """
return self.track(title)
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Downloads all tracks for this artist to the specified location.
Parameters:
savepath (str): Title of the track to return.
keep_orginal_name (bool): Set True to keep the original filename as stored in
keep_original_name (bool): Set True to keep the original filename as stored in
the Plex server. False will create a new filename with the format
"<Atrist> - <Album> <Track>".
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will
@ -140,7 +184,7 @@ class Artist(Audio):
filepaths = []
for album in self.albums():
for track in album.tracks():
filepaths += track.download(savepath, keep_orginal_name, **kwargs)
filepaths += track.download(savepath, keep_original_name, **kwargs)
return filepaths
@ -207,12 +251,12 @@ class Album(Audio):
""" Return :func:`~plexapi.audio.Artist` of this album. """
return self.fetchItem(self.parentKey)
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Downloads all tracks for this artist to the specified location.
Parameters:
savepath (str): Title of the track to return.
keep_orginal_name (bool): Set True to keep the original filename as stored in
keep_original_name (bool): Set True to keep the original filename as stored in
the Plex server. False will create a new filename with the format
"<Atrist> - <Album> <Track>".
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will
@ -222,9 +266,13 @@ class Album(Audio):
"""
filepaths = []
for track in self.tracks():
filepaths += track.download(savepath, keep_orginal_name, **kwargs)
filepaths += track.download(savepath, keep_original_name, **kwargs)
return filepaths
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
return '%s - %s' % (self.parentTitle, self.title)
@utils.registerPlexObject
class Track(Audio, Playable):
@ -302,3 +350,7 @@ class Track(Audio, Playable):
def artist(self):
""" Return this track's :class:`~plexapi.audio.Artist`. """
return self.fetchItem(self.grandparentKey)
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title)

View file

@ -6,6 +6,7 @@ from plexapi.compat import quote_plus, urlencode
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
from plexapi.utils import tag_helper
DONT_RELOAD_FOR_KEYS = ['key', 'session']
OPERATORS = {
'exact': lambda v, q: v == q,
'iexact': lambda v, q: v.lower() == q.lower(),
@ -145,7 +146,12 @@ class PlexObject(object):
on how this is used.
"""
data = self._server.query(ekey)
return self.findItems(data, cls, ekey, **kwargs)
items = self.findItems(data, cls, ekey, **kwargs)
librarySectionID = data.attrib.get('librarySectionID')
if librarySectionID:
for item in items:
item.librarySectionID = librarySectionID
return items
def findItems(self, data, cls=None, initpath=None, **kwargs):
""" Load the specified data to find and build all items with the specified tag
@ -273,7 +279,8 @@ class PlexPartialObject(PlexObject):
# Dragons inside.. :-/
value = super(PlexPartialObject, self).__getattribute__(attr)
# Check a few cases where we dont want to reload
if attr == 'key' or attr.startswith('_'): return value
if attr in DONT_RELOAD_FOR_KEYS: return value
if attr.startswith('_'): return value
if value not in (None, []): return value
if self.isFullObject(): return value
# Log the reload.
@ -447,6 +454,7 @@ class Playable(object):
self.transcodeSessions = self.findItems(data, etag='TranscodeSession') # session
self.session = self.findItems(data, etag='Session') # session
self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history
self.accountID = utils.cast(int, data.attrib.get('accountID')) # history
self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist
def isFullObject(self):
@ -466,7 +474,7 @@ class Playable(object):
offset, copyts, protocol, mediaIndex, platform.
Raises:
Unsupported: When the item doesn't support fetching a stream URL.
:class:`plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL.
"""
if self.TYPE not in ('movie', 'episode', 'track'):
raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE)
@ -514,13 +522,13 @@ class Playable(object):
"""
client.playMedia(self)
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Downloads this items media to the specified location. Returns a list of
filepaths that have been saved to disk.
Parameters:
savepath (str): Title of the track to return.
keep_orginal_name (bool): Set True to keep the original filename as stored in
keep_original_name (bool): Set True to keep the original filename as stored in
the Plex server. False will create a new filename with the format
"<Artist> - <Album> <Track>".
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will
@ -532,7 +540,7 @@ class Playable(object):
locations = [i for i in self.iterParts() if i]
for location in locations:
filename = location.file
if keep_orginal_name is False:
if keep_original_name is False:
filename = '%s.%s' % (self._prettyfilename(), location.container)
# So this seems to be a alot slower but allows transcode.
if kwargs:
@ -565,6 +573,24 @@ class Playable(object):
time, state)
self._server.query(key)
self.reload()
def updateTimeline(self, time, state='stopped', duration=None):
""" Set the timeline progress for this video.
Parameters:
time (int): milliseconds watched
state (string): state of the video, default 'stopped'
duration (int): duration of the item
"""
durationStr = '&duration='
if duration is not None:
durationStr = durationStr + str(duration)
else:
durationStr = durationStr + str(self.duration)
key = '/:/timeline?ratingKey=%s&key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s%s'
key %= (self.ratingKey, self.key, time, state, durationStr)
self._server.query(key)
self.reload()
@utils.registerPlexObject

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
import time
import requests
from requests.status_codes import _codes as codes
@ -70,6 +70,7 @@ class PlexClient(PlexObject):
self._session = session or server_session or requests.Session()
self._proxyThroughServer = False
self._commandId = 0
self._last_call = 0
if not any([data, initpath, baseurl, token]):
self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433')
self._token = logfilter.add_secret(CONFIG.get('auth.client_token'))
@ -139,7 +140,7 @@ class PlexClient(PlexObject):
value (bool): Enable or disable proxying (optional, default True).
Raises:
:class:`~plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server.
:class:`plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server.
"""
if server:
self._server = server
@ -177,22 +178,39 @@ class PlexClient(PlexObject):
**params (dict): Additional GET parameters to include with the command.
Raises:
:class:`~plexapi.exceptions.Unsupported`: When we detect the client
doesn't support this capability.
:class:`plexapi.exceptions.Unsupported`: When we detect the client doesn't support this capability.
"""
command = command.strip('/')
controller = command.split('/')[0]
headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier}
if controller not in self.protocolCapabilities:
log.debug('Client %s doesnt support %s controller.'
'What your trying might not work' % (self.title, controller))
proxy = self._proxyThroughServer if proxy is None else proxy
query = self._server.query if proxy else self.query
# Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244
t = time.time()
if t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'):
url = '/player/timeline/poll?wait=0&commandID=%s' % self._nextCommandId()
query(url, headers=headers)
self._last_call = t
params['commandID'] = self._nextCommandId()
key = '/player/%s%s' % (command, utils.joinArgs(params))
headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier}
proxy = self._proxyThroughServer if proxy is None else proxy
if proxy:
return self._server.query(key, headers=headers)
return self.query(key, headers=headers)
try:
return query(key, headers=headers)
except ElementTree.ParseError:
# Workaround for players which don't return valid XML on successful commands
# - Plexamp: `b'OK'`
if self.product in (
'Plexamp',
'Plex for Android (TV)',
):
return
raise
def url(self, key, includeToken=False):
""" Build a URL string with proper token argument. Token will be appended to the URL
@ -272,7 +290,7 @@ class PlexClient(PlexObject):
**params (dict): Additional GET parameters to include with the command.
Raises:
:class:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
:class:`plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
"""
if not self._server:
raise Unsupported('A server must be specified before using this command.')
@ -440,15 +458,16 @@ class PlexClient(PlexObject):
also: https://github.com/plexinc/plex-media-player/wiki/Remote-control-API#modified-commands
Raises:
:class:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
:class:`plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
"""
if not self._server:
raise Unsupported('A server must be specified before using this command.')
server_url = media._server._baseurl.split(':')
server_port = server_url[-1].strip('/')
if self.product != 'OpenPHT':
try:
self.sendCommand('timeline/subscribe', port=server_url[1].strip('/'), protocol='http')
self.sendCommand('timeline/subscribe', port=server_port, protocol='http')
except: # noqa: E722
# some clients dont need or like this and raises http 400.
# We want to include the exception in the log,
@ -459,7 +478,7 @@ class PlexClient(PlexObject):
self.sendCommand('playback/playMedia', **dict({
'machineIdentifier': self._server.machineIdentifier,
'address': server_url[1].strip('/'),
'port': server_url[-1],
'port': server_port,
'offset': offset,
'key': media.key,
'token': media._server._token,

View file

@ -2,6 +2,7 @@
# Python 2/3 compatability
# Always try Py3 first
import os
import sys
from sys import version_info
ustr = str
@ -43,6 +44,11 @@ try:
except ImportError:
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):
""" Mimicks os.makedirs() from Python 3. """
@ -51,3 +57,67 @@ def makedirs(name, mode=0o777, exist_ok=False):
except OSError:
if not os.path.isdir(name) or not exist_ok:
raise
def which(cmd, mode=os.F_OK | os.X_OK, path=None):
"""Given a command, mode, and a PATH string, return the path which
conforms to the given mode on the PATH, or None if there is no such
file.
`mode` defaults to os.F_OK | os.X_OK. `path` defaults to the result
of os.environ.get("PATH"), or can be overridden with a custom search
path.
Copied from https://hg.python.org/cpython/file/default/Lib/shutil.py
"""
# Check that a given file can be accessed with the correct mode.
# Additionally check that `file` is not a directory, as on Windows
# directories pass the os.access check.
def _access_check(fn, mode):
return (os.path.exists(fn) and os.access(fn, mode)
and not os.path.isdir(fn))
# If we're given a path with a directory part, look it up directly rather
# than referring to PATH directories. This includes checking relative to the
# current directory, e.g. ./script
if os.path.dirname(cmd):
if _access_check(cmd, mode):
return cmd
return None
if path is None:
path = os.environ.get("PATH", os.defpath)
if not path:
return None
path = path.split(os.pathsep)
if sys.platform == "win32":
# The current directory takes precedence on Windows.
if not os.curdir in path:
path.insert(0, os.curdir)
# PATHEXT is necessary to check on Windows.
pathext = os.environ.get("PATHEXT", "").split(os.pathsep)
# See if the given file matches any of the expected path extensions.
# This will allow us to short circuit when given "python.exe".
# If it does match, only test that one, otherwise we have to try
# others.
if any(cmd.lower().endswith(ext.lower()) for ext in pathext):
files = [cmd]
else:
files = [cmd + ext for ext in pathext]
else:
# On other platforms you don't have things like PATHEXT to tell you
# what file suffixes are executable, so just pass on cmd as-is.
files = [cmd]
seen = set()
for dir in path:
normdir = os.path.normcase(dir)
if not normdir in seen:
seen.add(normdir)
for thefile in files:
name = os.path.join(dir, thefile)
if _access_check(name, mode):
return name
return None

View file

@ -60,4 +60,5 @@ def reset_base_headers():
'X-Plex-Device': plexapi.X_PLEX_DEVICE,
'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME,
'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER,
'X-Plex-Sync-Version': '2',
}

View file

@ -377,9 +377,17 @@ class LibrarySection(PlexObject):
key = '/library/sections/%s/all' % self.key
return self.fetchItem(key, title__iexact=title)
def all(self, **kwargs):
""" Returns a list of media from this library section. """
key = '/library/sections/%s/all' % self.key
def all(self, sort=None, **kwargs):
""" Returns a list of media from this library section.
Parameters:
sort (string): The sort string
"""
sortStr = ''
if sort is not None:
sortStr = '?sort=' + sort
key = '/library/sections/%s/all%s' % (self.key, sortStr)
return self.fetchItems(key, **kwargs)
def onDeck(self):
@ -443,7 +451,7 @@ class LibrarySection(PlexObject):
**kwargs (dict): Additional kwargs to narrow down the choices.
Raises:
:class:`~plexapi.exceptions.BadRequest`: Cannot include kwarg equal to specified category.
:class:`plexapi.exceptions.BadRequest`: Cannot include kwarg equal to specified category.
"""
# TODO: Should this be moved to base?
if category in kwargs:
@ -470,8 +478,8 @@ class LibrarySection(PlexObject):
libtype (str): Filter results to a spcifiec libtype (movie, show, episode, artist,
album, track; optional).
**kwargs (dict): Any of the available filters for the current library section. Partial string
matches allowed. Multiple matches OR together. All inputs will be compared with the
available options and a warning logged if the option does not appear valid.
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`.
* unwatched: Display or hide unwatched content (True, False). [all]
* duplicate: Display or hide duplicate items (True, False). [movie]
@ -486,6 +494,9 @@ class LibrarySection(PlexObject):
* resolution: List of video resolutions to search within ([resolution_or_key, ...]). [movie]
* studio: List of studios to search within ([studio_or_key, ...]). [music]
* year: List of years to search within ([yyyy, ...]). [all]
Raises:
:class:`plexapi.exceptions.BadRequest`: when applying unknown filter
"""
# cleanup the core arguments
args = {}
@ -510,7 +521,10 @@ class LibrarySection(PlexObject):
def _cleanSearchFilter(self, category, value, libtype=None):
# check a few things before we begin
if category not in self.ALLOWED_FILTERS:
if category.endswith('!'):
if category[:-1] not in self.ALLOWED_FILTERS:
raise BadRequest('Unknown filter category: %s' % category[:-1])
elif category not in self.ALLOWED_FILTERS:
raise BadRequest('Unknown filter category: %s' % category)
if category in self.BOOLEAN_FILTERS:
return '1' if value else '0'
@ -543,6 +557,82 @@ class LibrarySection(PlexObject):
raise BadRequest('Unknown sort dir: %s' % sdir)
return '%s:%s' % (lookup[scol], sdir)
def sync(self, policy, mediaSettings, client=None, clientId=None, title=None, sort=None, libtype=None,
**kwargs):
""" Add current library section as sync item for specified device.
See description of :func:`~plexapi.library.LibrarySection.search()` for details about filtering / sorting
and :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions.
Parameters:
policy (:class:`plexapi.sync.Policy`): policy of syncing the media (how many items to sync and process
watched media or not), generated automatically when method
called on specific LibrarySection object.
mediaSettings (:class:`plexapi.sync.MediaSettings`): Transcoding settings used for the media, generated
automatically when method called on specific
LibrarySection object.
client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see
:func:`plexapi.myplex.MyPlexAccount.sync`.
clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`.
title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be
generated from metadata of current media.
sort (str): formatted as `column:dir`; column can be any of {`addedAt`, `originallyAvailableAt`,
`lastViewedAt`, `titleSort`, `rating`, `mediaHeight`, `duration`}. dir can be `asc` or
`desc`.
libtype (str): Filter results to a specific libtype (`movie`, `show`, `episode`, `artist`, `album`,
`track`).
Returns:
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
Raises:
:class:`plexapi.exceptions.BadRequest`: when the library is not allowed to sync
Example:
.. code-block:: python
from plexapi import myplex
from plexapi.sync import Policy, MediaSettings, VIDEO_QUALITY_3_MBPS_720p
c = myplex.MyPlexAccount()
target = c.device('Plex Client')
sync_items_wd = c.syncItems(target.clientIdentifier)
srv = c.resource('Server Name').connect()
section = srv.library.section('Movies')
policy = Policy('count', unwatched=True, value=1)
media_settings = MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)
section.sync(target, policy, media_settings, title='Next best movie', sort='rating:desc')
"""
from plexapi.sync import SyncItem
if not self.allowSync:
raise BadRequest('The requested library is not allowed to sync')
args = {}
for category, value in kwargs.items():
args[category] = self._cleanSearchFilter(category, value, libtype)
if sort is not None:
args['sort'] = self._cleanSearchSort(sort)
if libtype is not None:
args['type'] = utils.searchType(libtype)
myplex = self._server.myPlexAccount()
sync_item = SyncItem(self._server, None)
sync_item.title = title if title else self.title
sync_item.rootTitle = self.title
sync_item.contentType = self.CONTENT_TYPE
sync_item.metadataType = self.METADATA_TYPE
sync_item.machineIdentifier = self._server.machineIdentifier
key = '/library/sections/%s/all' % self.key
sync_item.location = 'library://%s/directory/%s' % (self.uuid, quote_plus(key + utils.joinArgs(args)))
sync_item.policy = policy
sync_item.mediaSettings = mediaSettings
return myplex.sync(client=client, clientId=clientId, sync_item=sync_item)
class MovieSection(LibrarySection):
""" Represents a :class:`~plexapi.library.LibrarySection` section containing movies.
@ -559,11 +649,53 @@ class MovieSection(LibrarySection):
"""
ALLOWED_FILTERS = ('unwatched', 'duplicate', 'year', 'decade', 'genre', 'contentRating',
'collection', 'director', 'actor', 'country', 'studio', 'resolution',
'guid', 'label')
'guid', 'label', 'writer', 'producer', 'subtitleLanguage', 'audioLanguage',
'lastViewedAt', 'viewCount', 'addedAt')
ALLOWED_SORT = ('addedAt', 'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating',
'mediaHeight', 'duration')
TAG = 'Directory'
TYPE = 'movie'
METADATA_TYPE = 'movie'
CONTENT_TYPE = 'video'
def collection(self, **kwargs):
""" Returns a list of collections from this library section. """
return self.search(libtype='collection', **kwargs)
def sync(self, videoQuality, limit=None, unwatched=False, **kwargs):
""" Add current Movie library section as sync item for specified device.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
:func:`plexapi.library.LibrarySection.sync()` for details on syncing libraries and possible exceptions.
Parameters:
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in
:mod:`plexapi.sync` module.
limit (int): maximum count of movies to sync, unlimited if `None`.
unwatched (bool): if `True` watched videos wouldn't be synced.
Returns:
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
Example:
.. code-block:: python
from plexapi import myplex
from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p
c = myplex.MyPlexAccount()
target = c.device('Plex Client')
sync_items_wd = c.syncItems(target.clientIdentifier)
srv = c.resource('Server Name').connect()
section = srv.library.section('Movies')
section.sync(VIDEO_QUALITY_3_MBPS_720p, client=target, limit=1, unwatched=True,
title='Next best movie', sort='rating:desc')
"""
from plexapi.sync import Policy, MediaSettings
kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality)
kwargs['policy'] = Policy.create(limit, unwatched)
return super(MovieSection, self).sync(**kwargs)
class ShowSection(LibrarySection):
@ -578,11 +710,17 @@ class ShowSection(LibrarySection):
TYPE (str): 'show'
"""
ALLOWED_FILTERS = ('unwatched', 'year', 'genre', 'contentRating', 'network', 'collection',
'guid', 'duplicate', 'label')
'guid', 'duplicate', 'label', 'show.title', 'show.year', 'show.userRating',
'show.viewCount', 'show.lastViewedAt', 'show.actor', 'show.addedAt', 'episode.title',
'episode.originallyAvailableAt', 'episode.resolution', 'episode.subtitleLanguage',
'episode.unwatched', 'episode.addedAt', 'episode.userRating', 'episode.viewCount',
'episode.lastViewedAt')
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'originallyAvailableAt', 'titleSort',
'rating', 'unwatched')
TAG = 'Directory'
TYPE = 'show'
METADATA_TYPE = 'episode'
CONTENT_TYPE = 'video'
def searchShows(self, **kwargs):
""" Search for a show. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
@ -600,6 +738,45 @@ class ShowSection(LibrarySection):
"""
return self.search(sort='addedAt:desc', libtype=libtype, maxresults=maxresults)
def collection(self, **kwargs):
""" Returns a list of collections from this library section. """
return self.search(libtype='collection', **kwargs)
def sync(self, videoQuality, limit=None, unwatched=False, **kwargs):
""" Add current Show library section as sync item for specified device.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
:func:`plexapi.library.LibrarySection.sync()` for details on syncing libraries and possible exceptions.
Parameters:
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in
:mod:`plexapi.sync` module.
limit (int): maximum count of episodes to sync, unlimited if `None`.
unwatched (bool): if `True` watched videos wouldn't be synced.
Returns:
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
Example:
.. code-block:: python
from plexapi import myplex
from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p
c = myplex.MyPlexAccount()
target = c.device('Plex Client')
sync_items_wd = c.syncItems(target.clientIdentifier)
srv = c.resource('Server Name').connect()
section = srv.library.section('TV-Shows')
section.sync(VIDEO_QUALITY_3_MBPS_720p, client=target, limit=1, unwatched=True,
title='Next unwatched episode')
"""
from plexapi.sync import Policy, MediaSettings
kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality)
kwargs['policy'] = Policy.create(limit, unwatched)
return super(ShowSection, self).sync(**kwargs)
class MusicSection(LibrarySection):
""" Represents a :class:`~plexapi.library.LibrarySection` section containing music artists.
@ -612,11 +789,19 @@ class MusicSection(LibrarySection):
TAG (str): 'Directory'
TYPE (str): 'artist'
"""
ALLOWED_FILTERS = ('genre', 'country', 'collection', 'mood')
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort')
ALLOWED_FILTERS = ('genre', 'country', 'collection', 'mood', 'year', 'track.userRating', 'artist.title',
'artist.userRating', 'artist.genre', 'artist.country', 'artist.collection', 'artist.addedAt',
'album.title', 'album.userRating', 'album.genre', 'album.decade', 'album.collection',
'album.viewCount', 'album.lastViewedAt', 'album.studio', 'album.addedAt', 'track.title',
'track.userRating', 'track.viewCount', 'track.lastViewedAt', 'track.skipCount',
'track.lastSkippedAt')
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort', 'userRating')
TAG = 'Directory'
TYPE = 'artist'
CONTENT_TYPE = 'audio'
METADATA_TYPE = 'track'
def albums(self):
""" Returns a list of :class:`~plexapi.audio.Album` objects in this section. """
key = '/library/sections/%s/albums' % self.key
@ -634,31 +819,104 @@ class MusicSection(LibrarySection):
""" Search for a track. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
return self.search(libtype='track', **kwargs)
def collection(self, **kwargs):
""" Returns a list of collections from this library section. """
return self.search(libtype='collection', **kwargs)
def sync(self, bitrate, limit=None, **kwargs):
""" Add current Music library section as sync item for specified device.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
:func:`plexapi.library.LibrarySection.sync()` for details on syncing libraries and possible exceptions.
Parameters:
bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the
module :mod:`plexapi.sync`.
limit (int): maximum count of tracks to sync, unlimited if `None`.
Returns:
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
Example:
.. code-block:: python
from plexapi import myplex
from plexapi.sync import AUDIO_BITRATE_320_KBPS
c = myplex.MyPlexAccount()
target = c.device('Plex Client')
sync_items_wd = c.syncItems(target.clientIdentifier)
srv = c.resource('Server Name').connect()
section = srv.library.section('Music')
section.sync(AUDIO_BITRATE_320_KBPS, client=target, limit=100, sort='addedAt:desc',
title='New music')
"""
from plexapi.sync import Policy, MediaSettings
kwargs['mediaSettings'] = MediaSettings.createMusic(bitrate)
kwargs['policy'] = Policy.create(limit)
return super(MusicSection, self).sync(**kwargs)
class PhotoSection(LibrarySection):
""" Represents a :class:`~plexapi.library.LibrarySection` section containing photos.
Attributes:
ALLOWED_FILTERS (list<str>): List of allowed search filters. ('all', 'iso',
'make', 'lens', 'aperture', 'exposure')
'make', 'lens', 'aperture', 'exposure', 'device', 'resolution')
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt')
TAG (str): 'Directory'
TYPE (str): 'photo'
"""
ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure')
ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure', 'device', 'resolution', 'place',
'originallyAvailableAt', 'addedAt', 'title', 'userRating')
ALLOWED_SORT = ('addedAt',)
TAG = 'Directory'
TYPE = 'photo'
CONTENT_TYPE = 'photo'
METADATA_TYPE = 'photo'
def searchAlbums(self, title, **kwargs):
""" Search for an album. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
key = '/library/sections/%s/all?type=14' % self.key
return self.fetchItems(key, title=title)
return self.search(libtype='photoalbum', title=title, **kwargs)
def searchPhotos(self, title, **kwargs):
""" Search for a photo. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
key = '/library/sections/%s/all?type=13' % self.key
return self.fetchItems(key, title=title)
return self.search(libtype='photo', title=title, **kwargs)
def sync(self, resolution, limit=None, **kwargs):
""" Add current Music library section as sync item for specified device.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
:func:`plexapi.library.LibrarySection.sync()` for details on syncing libraries and possible exceptions.
Parameters:
resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the
module :mod:`plexapi.sync`.
limit (int): maximum count of tracks to sync, unlimited if `None`.
Returns:
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
Example:
.. code-block:: python
from plexapi import myplex
from plexapi.sync import PHOTO_QUALITY_HIGH
c = myplex.MyPlexAccount()
target = c.device('Plex Client')
sync_items_wd = c.syncItems(target.clientIdentifier)
srv = c.resource('Server Name').connect()
section = srv.library.section('Photos')
section.sync(PHOTO_QUALITY_HIGH, client=target, limit=100, sort='addedAt:desc',
title='Fresh photos')
"""
from plexapi.sync import Policy, MediaSettings
kwargs['mediaSettings'] = MediaSettings.createPhoto(resolution)
kwargs['policy'] = Policy.create(limit)
return super(PhotoSection, self).sync(**kwargs)
class FilterChoice(PlexObject):
@ -714,3 +972,84 @@ class Hub(PlexObject):
def __len__(self):
return self.size
@utils.registerPlexObject
class Collections(PlexObject):
TAG = 'Directory'
TYPE = 'collection'
def _loadData(self, data):
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.key = data.attrib.get('key')
self.type = data.attrib.get('type')
self.title = data.attrib.get('title')
self.subtype = data.attrib.get('subtype')
self.summary = data.attrib.get('summary')
self.index = utils.cast(int, data.attrib.get('index'))
self.thumb = data.attrib.get('thumb')
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.childCount = utils.cast(int, data.attrib.get('childCount'))
self.minYear = utils.cast(int, data.attrib.get('minYear'))
self.maxYear = utils.cast(int, data.attrib.get('maxYear'))
self.collectionMode = data.attrib.get('collectionMode')
self.collectionSort = data.attrib.get('collectionSort')
@property
def children(self):
return self.fetchItems(self.key)
def __len__(self):
return self.childCount
def delete(self):
part = '/library/metadata/%s' % self.ratingKey
return self._server.query(part, method=self._server._session.delete)
def modeUpdate(self, mode=None):
""" Update Collection Mode
Parameters:
mode: default (Library default)
hide (Hide Collection)
hideItems (Hide Items in this Collection)
showItems (Show this Collection and its Items)
Example:
collection = 'plexapi.library.Collections'
collection.updateMode(mode="hide")
"""
mode_dict = {'default': '-2',
'hide': '0',
'hideItems': '1',
'showItems': '2'}
key = mode_dict.get(mode)
if key is None:
raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict)))
part = '/library/metadata/%s/prefs?collectionMode=%s' % (self.ratingKey, key)
return self._server.query(part, method=self._server._session.put)
def sortUpdate(self, sort=None):
""" Update Collection Sorting
Parameters:
sort: realease (Order Collection by realease dates)
alpha (Order Collection Alphabetically)
Example:
colleciton = 'plexapi.library.Collections'
collection.updateSort(mode="alpha")
"""
sort_dict = {'release': '0',
'alpha': '1'}
key = sort_dict.get(sort)
if key is None:
raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict)))
part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key)
return self._server.query(part, method=self._server._session.put)
# def edit(self, **kwargs):
# TODO

View file

@ -25,9 +25,12 @@ class Media(PlexObject):
id (int): Plex ID of this media item (ex: 46184).
has64bitOffsets (bool): True if video has 64 bit offsets (?).
optimizedForStreaming (bool): True if video is optimized for streaming.
target (str): Media version target name.
title (str): Media version title.
videoCodec (str): Video codec used within the video (ex: ac3).
videoFrameRate (str): Video frame rate (ex: 24p).
videoResolution (str): Video resolution (ex: sd).
videoProfile (str): Video profile (ex: high).
width (int): Width of the video in pixels (ex: 608).
parts (list<:class:`~plexapi.media.MediaPart`>): List of MediaParts in this video.
"""
@ -46,8 +49,11 @@ class Media(PlexObject):
self.id = cast(int, data.attrib.get('id'))
self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets'))
self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming'))
self.target = data.attrib.get('target')
self.title = data.attrib.get('title')
self.videoCodec = data.attrib.get('videoCodec')
self.videoFrameRate = data.attrib.get('videoFrameRate')
self.videoProfile = data.attrib.get('videoProfile')
self.videoResolution = data.attrib.get('videoResolution')
self.width = cast(int, data.attrib.get('width'))
self.parts = self.findItems(data, MediaPart)
@ -79,6 +85,8 @@ class MediaPart(PlexObject):
key (str): Key used to access this media part (ex: /library/parts/46618/1389985872/file.avi).
size (int): Size of this file in bytes (ex: 733884416).
streams (list<:class:`~plexapi.media.MediaPartStream`>): List of streams in this media part.
exists (bool): Determine if file exists
accessible (bool): Determine if file is accessible
"""
TAG = 'Part'
@ -92,7 +100,14 @@ class MediaPart(PlexObject):
self.indexes = data.attrib.get('indexes')
self.key = data.attrib.get('key')
self.size = cast(int, data.attrib.get('size'))
self.decision = data.attrib.get('decision')
self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming'))
self.syncItemId = cast(int, data.attrib.get('syncItemId'))
self.syncState = data.attrib.get('syncState')
self.videoProfile = data.attrib.get('videoProfile')
self.streams = self._buildStreams(data)
self.exists = cast(bool, data.attrib.get('exists'))
self.accessible = cast(bool, data.attrib.get('accessible'))
def _buildStreams(self, data):
streams = []
@ -114,6 +129,35 @@ class MediaPart(PlexObject):
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """
return [stream for stream in self.streams if stream.streamType == SubtitleStream.STREAMTYPE]
def setDefaultAudioStream(self, stream):
""" Set the default :class:`~plexapi.media.AudioStream` for this MediaPart.
Parameters:
stream (:class:`~plexapi.media.AudioStream`): AudioStream to set as default
"""
if isinstance(stream, AudioStream):
key = "/library/parts/%d?audioStreamID=%d&allParts=1" % (self.id, stream.id)
else:
key = "/library/parts/%d?audioStreamID=%d&allParts=1" % (self.id, stream)
self._server.query(key, method=self._server._session.put)
def setDefaultSubtitleStream(self, stream):
""" Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart.
Parameters:
stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default.
"""
if isinstance(stream, SubtitleStream):
key = "/library/parts/%d?subtitleStreamID=%d&allParts=1" % (self.id, stream.id)
else:
key = "/library/parts/%d?subtitleStreamID=%d&allParts=1" % (self.id, stream)
self._server.query(key, method=self._server._session.put)
def resetDefaultSubtitleStream(self):
""" Set default subtitle of this MediaPart to 'none'. """
key = "/library/parts/%d?subtitleStreamID=0&allParts=1" % (self.id)
self._server.query(key, method=self._server._session.put)
class MediaPartStream(PlexObject):
""" Base class for media streams. These consist of video, audio and subtitles.
@ -245,6 +289,7 @@ class SubtitleStream(MediaPartStream):
Attributes:
TAG (str): 'Stream'
STREAMTYPE (int): 3
forced (bool): True if this is a forced subtitle
format (str): Subtitle format (ex: srt).
key (str): Key of this subtitle stream (ex: /library/streams/212284).
title (str): Title of this subtitle stream.
@ -255,6 +300,7 @@ class SubtitleStream(MediaPartStream):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
super(SubtitleStream, self)._loadData(data)
self.forced = cast(bool, data.attrib.get('forced', '0'))
self.format = data.attrib.get('format')
self.key = data.attrib.get('key')
self.title = data.attrib.get('title')
@ -421,6 +467,23 @@ class Mood(MediaTag):
FILTER = 'mood'
@utils.registerPlexObject
class Poster(PlexObject):
""" Represents a Poster.
Attributes:
TAG (str): 'Photo'
"""
TAG = 'Photo'
def _loadData(self, data):
self._data = data
self.key = data.attrib.get('key')
self.ratingKey = data.attrib.get('ratingKey')
self.selected = data.attrib.get('selected')
self.thumb = data.attrib.get('thumb')
@utils.registerPlexObject
class Producer(MediaTag):
""" Represents a single Producer media tag.

View file

@ -3,7 +3,7 @@ import copy
import requests
import time
from requests.status_codes import _codes as codes
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_IDENTIFIER, X_PLEX_ENABLE_FAST_CONNECT
from plexapi import log, logfilter, utils
from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest, NotFound
@ -11,6 +11,7 @@ from plexapi.client import PlexClient
from plexapi.compat import ElementTree
from plexapi.library import LibrarySection
from plexapi.server import PlexServer
from plexapi.sync import SyncList, SyncItem
from plexapi.utils import joinArgs
@ -61,9 +62,12 @@ class MyPlexAccount(PlexObject):
_session (obj): Requests session object used to access this client.
"""
FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data
HOMEUSERCREATE = 'https://plex.tv/api/home/users?title={title}' # post with data
EXISTINGUSER = 'https://plex.tv/api/home/users?invitedEmail={username}' # post with data
FRIENDSERVERS = 'https://plex.tv/api/servers/{machineId}/shared_servers/{serverId}' # put with data
PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get
FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete
REMOVEHOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete
REMOVEINVITE = 'https://plex.tv/api/invites/requested/{userId}?friend=0&server=1&home=0' # delete
REQUESTED = 'https://plex.tv/api/invites/requested' # get
REQUESTS = 'https://plex.tv/api/invites/requests' # get
@ -112,13 +116,31 @@ class MyPlexAccount(PlexObject):
self.title = data.attrib.get('title')
self.username = data.attrib.get('username')
self.uuid = data.attrib.get('uuid')
subscription = data.find('subscription')
self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active'))
self.subscriptionStatus = subscription.attrib.get('status')
self.subscriptionPlan = subscription.attrib.get('plan')
self.subscriptionFeatures = []
for feature in subscription.iter('feature'):
self.subscriptionFeatures.append(feature.attrib.get('id'))
roles = data.find('roles')
self.roles = []
if roles:
for role in roles.iter('role'):
self.roles.append(role.attrib.get('id'))
entitlements = data.find('entitlements')
self.entitlements = []
for entitlement in entitlements.iter('entitlement'):
self.entitlements.append(entitlement.attrib.get('id'))
# TODO: Fetch missing MyPlexAccount attributes
self.subscriptionActive = None # renamed on server
self.subscriptionStatus = None # renamed on server
self.subscriptionPlan = None # renmaed on server
self.subscriptionFeatures = None # renamed on server
self.roles = None
self.entitlements = None
self.profile_settings = None
self.services = None
self.joined_at = None
def device(self, name):
""" Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified.
@ -153,7 +175,6 @@ class MyPlexAccount(PlexObject):
if response.status_code not in (200, 201, 204): # pragma: no cover
codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ')
log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
@ -175,13 +196,14 @@ class MyPlexAccount(PlexObject):
return [MyPlexResource(self, elem) for elem in data]
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.
Parameters:
user (str): MyPlexUser, username, email of the user to be added.
server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share.
sections ([Section]): Library sections, names or ids to be shared (default None shares all sections).
sections ([Section]): Library sections, names or ids to be shared (default None).
[Section] must be defined in order to update shared sections.
allowSync (Bool): Set True to allow user to sync content.
allowCameraUpload (Bool): Set True to allow user to upload photos.
allowChannels (Bool): Set True to allow user to utilize installed channels.
@ -211,6 +233,102 @@ class MyPlexAccount(PlexObject):
url = self.FRIENDINVITE.format(machineId=machineId)
return self.query(url, self._session.post, json=params, headers=headers)
def createHomeUser(self, user, server, sections=None, allowSync=False, allowCameraUpload=False,
allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None):
""" Share library content with the specified user.
Parameters:
user (str): MyPlexUser, username, email of the user to be added.
server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share.
sections ([Section]): Library sections, names or ids to be shared (default None shares all sections).
allowSync (Bool): Set True to allow user to sync content.
allowCameraUpload (Bool): Set True to allow user to upload photos.
allowChannels (Bool): Set True to allow user to utilize installed channels.
filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered.
ex: {'label':['foo']}
"""
machineId = server.machineIdentifier if isinstance(server, PlexServer) else server
sectionIds = self._getSectionIds(server, sections)
headers = {'Content-Type': 'application/json'}
url = self.HOMEUSERCREATE.format(title=user)
# UserID needs to be created and referenced when adding sections
user_creation = self.query(url, self._session.post, headers=headers)
userIds = {}
for elem in user_creation.findall("."):
# Find userID
userIds['id'] = elem.attrib.get('id')
log.debug(userIds)
params = {
'server_id': machineId,
'shared_server': {'library_section_ids': sectionIds, 'invited_id': userIds['id']},
'sharing_settings': {
'allowSync': ('1' if allowSync else '0'),
'allowCameraUpload': ('1' if allowCameraUpload else '0'),
'allowChannels': ('1' if allowChannels else '0'),
'filterMovies': self._filterDictToStr(filterMovies or {}),
'filterTelevision': self._filterDictToStr(filterTelevision or {}),
'filterMusic': self._filterDictToStr(filterMusic or {}),
},
}
url = self.FRIENDINVITE.format(machineId=machineId)
library_assignment = self.query(url, self._session.post, json=params, headers=headers)
return user_creation, library_assignment
def createExistingUser(self, user, server, sections=None, allowSync=False, allowCameraUpload=False,
allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None):
""" Share library content with the specified user.
Parameters:
user (str): MyPlexUser, username, email of the user to be added.
server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share.
sections ([Section]): Library sections, names or ids to be shared (default None shares all sections).
allowSync (Bool): Set True to allow user to sync content.
allowCameraUpload (Bool): Set True to allow user to upload photos.
allowChannels (Bool): Set True to allow user to utilize installed channels.
filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered.
ex: {'label':['foo']}
"""
headers = {'Content-Type': 'application/json'}
# If user already exists, carry over sections and settings.
if isinstance(user, MyPlexUser):
username = user.username
elif user in [_user.username for _user in self.users()]:
username = self.user(user).username
else:
# If user does not already exists, treat request as new request and include sections and settings.
newUser = user
url = self.EXISTINGUSER.format(username=newUser)
user_creation = self.query(url, self._session.post, headers=headers)
machineId = server.machineIdentifier if isinstance(server, PlexServer) else server
sectionIds = self._getSectionIds(server, sections)
params = {
'server_id': machineId,
'shared_server': {'library_section_ids': sectionIds, 'invited_email': newUser},
'sharing_settings': {
'allowSync': ('1' if allowSync else '0'),
'allowCameraUpload': ('1' if allowCameraUpload else '0'),
'allowChannels': ('1' if allowChannels else '0'),
'filterMovies': self._filterDictToStr(filterMovies or {}),
'filterTelevision': self._filterDictToStr(filterTelevision or {}),
'filterMusic': self._filterDictToStr(filterMusic or {}),
},
}
url = self.FRIENDINVITE.format(machineId=machineId)
library_assignment = self.query(url, self._session.post, json=params, headers=headers)
return user_creation, library_assignment
url = self.EXISTINGUSER.format(username=username)
return self.query(url, self._session.post, headers=headers)
def removeFriend(self, user):
""" Remove the specified user from all sharing.
@ -222,6 +340,16 @@ class MyPlexAccount(PlexObject):
url = url.format(userId=user.id)
return self.query(url, self._session.delete)
def removeHomeUser(self, user):
""" Remove the specified managed user from home.
Parameters:
user (str): MyPlexUser, username, email of the user to be removed from home.
"""
user = self.user(user)
url = self.REMOVEHOMEUSER.format(userId=user.id)
return self.query(url, self._session.delete)
def updateFriend(self, user, server, sections=None, removeSections=False, allowSync=None, allowCameraUpload=None,
allowChannels=None, filterMovies=None, filterTelevision=None, filterMusic=None):
""" Update the specified user's share settings.
@ -229,7 +357,8 @@ class MyPlexAccount(PlexObject):
Parameters:
user (str): MyPlexUser, username, email of the user to be added.
server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share.
sections: ([Section]): Library sections, names or ids to be shared (default None shares all sections).
sections: ([Section]): Library sections, names or ids to be shared (default None).
[Section] must be defined in order to update shared sections.
removeSections (Bool): Set True to remove all shares. Supersedes sections.
allowSync (Bool): Set True to allow user to sync content.
allowCameraUpload (Bool): Set True to allow user to upload photos.
@ -244,7 +373,7 @@ class MyPlexAccount(PlexObject):
# Update friend servers
response_filters = ''
response_servers = ''
user = self.user(user.username if isinstance(user, MyPlexUser) else user)
user = user if isinstance(user, MyPlexUser) else self.user(user)
machineId = server.machineIdentifier if isinstance(server, PlexServer) else server
sectionIds = self._getSectionIds(machineId, sections)
headers = {'Content-Type': 'application/json'}
@ -256,10 +385,10 @@ class MyPlexAccount(PlexObject):
url = self.FRIENDSERVERS.format(machineId=machineId, serverId=serverId)
else:
params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds,
"invited_id": user.id}}
'invited_id': user.id}}
url = self.FRIENDINVITE.format(machineId=machineId)
# Remove share sections, add shares to user without shares, or update shares
if sectionIds:
if not user_servers or sectionIds:
if removeSections is True:
response_servers = self.query(url, self._session.delete, json=params, headers=headers)
elif 'invited_id' in params.get('shared_server', ''):
@ -289,7 +418,7 @@ class MyPlexAccount(PlexObject):
return response_servers, response_filters
def user(self, username):
""" Returns the :class:`~myplex.MyPlexUser` that matches the email or username specified.
""" Returns the :class:`~plexapi.myplex.MyPlexUser` that matches the email or username specified.
Parameters:
username (str): Username, email or id of the user to return.
@ -357,7 +486,8 @@ class MyPlexAccount(PlexObject):
def setWebhooks(self, urls):
log.info('Setting webhooks: %s' % urls)
data = self.query(self.WEBHOOKS, self._session.post, data={'urls[]': urls})
data = {'urls[]': urls} if len(urls) else {'urls': ''}
data = self.query(self.WEBHOOKS, self._session.post, data=data)
self._webhooks = self.listAttrs(data, 'url', etag='webhook')
return self._webhooks
@ -376,7 +506,99 @@ class MyPlexAccount(PlexObject):
if library is not None:
params['optOutLibraryStats'] = int(library)
url = 'https://plex.tv/api/v2/user/privacy'
return self.query(url, method=self._session.put, params=params)
return self.query(url, method=self._session.put, data=params)
def syncItems(self, client=None, clientId=None):
""" Returns an instance of :class:`plexapi.sync.SyncList` for specified client.
Parameters:
client (:class:`~plexapi.myplex.MyPlexDevice`): a client to query SyncItems for.
clientId (str): an identifier of a client to query SyncItems for.
If both `client` and `clientId` provided the client would be preferred.
If neither `client` nor `clientId` provided the clientId would be set to current clients`s identifier.
"""
if client:
clientId = client.clientIdentifier
elif clientId is None:
clientId = X_PLEX_IDENTIFIER
data = self.query(SyncList.key.format(clientId=clientId))
return SyncList(self, data)
def sync(self, sync_item, client=None, clientId=None):
""" Adds specified sync item for the client. It's always easier to use methods defined directly in the media
objects, e.g. :func:`plexapi.video.Video.sync`, :func:`plexapi.audio.Audio.sync`.
Parameters:
client (:class:`~plexapi.myplex.MyPlexDevice`): a client for which you need to add SyncItem to.
clientId (str): an identifier of a client for which you need to add SyncItem to.
sync_item (:class:`plexapi.sync.SyncItem`): prepared SyncItem object with all fields set.
If both `client` and `clientId` provided the client would be preferred.
If neither `client` nor `clientId` provided the clientId would be set to current clients`s identifier.
Returns:
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
Raises:
:class:`plexapi.exceptions.BadRequest`: when client with provided clientId wasn`t found.
:class:`plexapi.exceptions.BadRequest`: provided client doesn`t provides `sync-target`.
"""
if not client and not clientId:
clientId = X_PLEX_IDENTIFIER
if not client:
for device in self.devices():
if device.clientIdentifier == clientId:
client = device
break
if not client:
raise BadRequest('Unable to find client by clientId=%s', clientId)
if 'sync-target' not in client.provides:
raise BadRequest('Received client doesn`t provides sync-target')
params = {
'SyncItem[title]': sync_item.title,
'SyncItem[rootTitle]': sync_item.rootTitle,
'SyncItem[metadataType]': sync_item.metadataType,
'SyncItem[machineIdentifier]': sync_item.machineIdentifier,
'SyncItem[contentType]': sync_item.contentType,
'SyncItem[Policy][scope]': sync_item.policy.scope,
'SyncItem[Policy][unwatched]': str(int(sync_item.policy.unwatched)),
'SyncItem[Policy][value]': str(sync_item.policy.value if hasattr(sync_item.policy, 'value') else 0),
'SyncItem[Location][uri]': sync_item.location,
'SyncItem[MediaSettings][audioBoost]': str(sync_item.mediaSettings.audioBoost),
'SyncItem[MediaSettings][maxVideoBitrate]': str(sync_item.mediaSettings.maxVideoBitrate),
'SyncItem[MediaSettings][musicBitrate]': str(sync_item.mediaSettings.musicBitrate),
'SyncItem[MediaSettings][photoQuality]': str(sync_item.mediaSettings.photoQuality),
'SyncItem[MediaSettings][photoResolution]': sync_item.mediaSettings.photoResolution,
'SyncItem[MediaSettings][subtitleSize]': str(sync_item.mediaSettings.subtitleSize),
'SyncItem[MediaSettings][videoQuality]': str(sync_item.mediaSettings.videoQuality),
'SyncItem[MediaSettings][videoResolution]': sync_item.mediaSettings.videoResolution,
}
url = SyncList.key.format(clientId=client.clientIdentifier)
data = self.query(url, method=self._session.post, headers={
'Content-type': 'x-www-form-urlencoded',
}, params=params)
return SyncItem(self, data, None, clientIdentifier=client.clientIdentifier)
def claimToken(self):
""" Returns a str, a new "claim-token", which you can use to register your new Plex Server instance to your
account.
See: https://hub.docker.com/r/plexinc/pms-docker/, https://www.plex.tv/claim/
"""
response = self._session.get('https://plex.tv/api/claim/token.json', headers=self._headers(), timeout=TIMEOUT)
if response.status_code not in (200, 201, 204): # pragma: no cover
codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ')
raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
return response.json()['token']
class MyPlexUser(PlexObject):
@ -405,6 +627,7 @@ class MyPlexUser(PlexObject):
thumb (str): Link to the users avatar.
title (str): Seems to be an aliad for username.
username (str): User's username.
servers: Servers shared between user and friend
"""
TAG = 'User'
key = 'https://plex.tv/api/users/'
@ -577,7 +800,7 @@ class MyPlexResource(PlexObject):
HTTP or HTTPS connection.
Raises:
:class:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
:class:`plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
"""
# Sort connections from (https, local) to (http, remote)
# Only check non-local connections unless we own the resource
@ -684,7 +907,7 @@ class MyPlexDevice(PlexObject):
at least one connection was successful, the PlexClient object is built and returned.
Raises:
:class:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device.
:class:`plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device.
"""
cls = PlexServer if 'server' in self.provides else PlexClient
listargs = [[cls, url, self.token, timeout] for url in self.connections]
@ -697,16 +920,40 @@ class MyPlexDevice(PlexObject):
key = 'https://plex.tv/devices/%s.xml' % self.id
self._server.query(key, self._server._session.delete)
def syncItems(self):
""" Returns an instance of :class:`plexapi.sync.SyncList` for current device.
def _connect(cls, url, token, timeout, results, i):
Raises:
:class:`plexapi.exceptions.BadRequest`: when the device doesn`t provides `sync-target`.
"""
if 'sync-target' not in self.provides:
raise BadRequest('Requested syncList for device which do not provides sync-target')
return self._server.syncItems(client=self)
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
information to results[i] in a threadsafe way.
Arguments:
cls: the class which is responsible for establishing connection, basically it's
:class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer`
url (str): url which should be passed as `baseurl` argument to cls.__init__()
token (str): authentication token which should be passed as `baseurl` argument to cls.__init__()
timeout (int): timeout which should be passed as `baseurl` argument to cls.__init__()
results (list): pre-filled list for results
i (int): index of current job, should be less than len(results)
job_is_done_event (:class:`~threading.Event`): is X_PLEX_ENABLE_FAST_CONNECT is True then the
event would be set as soon the connection is established
"""
starttime = time.time()
try:
device = cls(baseurl=url, token=token, timeout=timeout)
runtime = int(time.time() - starttime)
results[i] = (url, token, device, runtime)
if X_PLEX_ENABLE_FAST_CONNECT and job_is_done_event:
job_is_done_event.set()
except Exception as err:
runtime = int(time.time() - starttime)
log.error('%s: %s', url, err)

View file

@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
from plexapi import media, utils
from plexapi.base import PlexPartialObject
from plexapi.exceptions import NotFound
from plexapi.exceptions import NotFound, BadRequest
from plexapi.compat import quote_plus
@utils.registerPlexObject
@ -96,6 +97,7 @@ class Photo(PlexPartialObject):
"""
TAG = 'Photo'
TYPE = 'photo'
METADATA_TYPE = 'photo'
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
@ -122,4 +124,45 @@ class Photo(PlexPartialObject):
def section(self):
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
return self._server.library.sectionByID(self.photoalbum().librarySectionID)
if hasattr(self, 'librarySectionID'):
return self._server.library.sectionByID(self.librarySectionID)
elif self.parentKey:
return self._server.library.sectionByID(self.photoalbum().librarySectionID)
else:
raise BadRequest('Unable to get section for photo, can`t find librarySectionID')
def sync(self, resolution, client=None, clientId=None, limit=None, title=None):
""" Add current photo as sync item for specified device.
See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions.
Parameters:
resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the
module :mod:`plexapi.sync`.
client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see
:func:`plexapi.myplex.MyPlexAccount.sync`.
clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`.
limit (int): maximum count of items to sync, unlimited if `None`.
title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be
generated from metadata of current photo.
Returns:
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
"""
from plexapi.sync import SyncItem, Policy, MediaSettings
myplex = self._server.myPlexAccount()
sync_item = SyncItem(self._server, None)
sync_item.title = title if title else self.title
sync_item.rootTitle = self.title
sync_item.contentType = self.listType
sync_item.metadataType = self.METADATA_TYPE
sync_item.machineIdentifier = self._server.machineIdentifier
section = self.section()
sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key))
sync_item.policy = Policy.create(limit)
sync_item.mediaSettings = MediaSettings.createPhoto(resolution)
return myplex.sync(sync_item, client=client, clientId=clientId)

View file

@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
from plexapi import utils
from plexapi.base import PlexPartialObject, Playable
from plexapi.exceptions import BadRequest
from plexapi.exceptions import BadRequest, Unsupported
from plexapi.library import LibrarySection
from plexapi.playqueue import PlayQueue
from plexapi.utils import cast, toDatetime
from plexapi.compat import quote_plus
@utils.registerPlexObject
@ -32,11 +34,35 @@ class Playlist(PlexPartialObject, Playable):
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
self.updatedAt = toDatetime(data.attrib.get('updatedAt'))
self.allowSync = cast(bool, data.attrib.get('allowSync'))
self._items = None # cache for self.items
def __len__(self): # pragma: no cover
return len(self.items())
@property
def metadataType(self):
if self.isVideo:
return 'movie'
elif self.isAudio:
return 'track'
elif self.isPhoto:
return 'photo'
else:
raise Unsupported('Unexpected playlist type')
@property
def isVideo(self):
return self.playlistType == 'video'
@property
def isAudio(self):
return self.playlistType == 'audio'
@property
def isPhoto(self):
return self.playlistType == 'photo'
def __contains__(self, other): # pragma: no cover
return any(i.key == other.key for i in self.items())
@ -102,9 +128,9 @@ class Playlist(PlexPartialObject, Playable):
return PlayQueue.create(self._server, self, *args, **kwargs)
@classmethod
def create(cls, server, title, items):
def _create(cls, server, title, items):
""" Create a playlist. """
if not isinstance(items, (list, tuple)):
if items and not isinstance(items, (list, tuple)):
items = [items]
ratingKeys = []
for item in items:
@ -122,6 +148,61 @@ class Playlist(PlexPartialObject, Playable):
data = server.query(key, method=server._session.post)[0]
return cls(server, data, initpath=key)
@classmethod
def create(cls, server, title, items=None, section=None, limit=None, smart=False, **kwargs):
"""Create a playlist.
Parameters:
server (:class:`~plexapi.server.PlexServer`): Server your connected to.
title (str): Title of the playlist.
items (Iterable): Iterable of objects that should be in the playlist.
section (:class:`~plexapi.library.LibrarySection`, str):
limit (int): default None.
smart (bool): default False.
**kwargs (dict): is passed to the filters. For a example see the search method.
Returns:
:class:`plexapi.playlist.Playlist`: an instance of created Playlist.
"""
if smart:
return cls._createSmart(server, title, section, limit, **kwargs)
else:
return cls._create(server, title, items)
@classmethod
def _createSmart(cls, server, title, section, limit=None, **kwargs):
""" Create a Smart playlist. """
if not isinstance(section, LibrarySection):
section = server.library.section(section)
sectionType = utils.searchType(section.type)
sectionId = section.key
uuid = section.uuid
uri = 'library://%s/directory//library/sections/%s/all?type=%s' % (uuid,
sectionId,
sectionType)
if limit:
uri = uri + '&limit=%s' % str(limit)
for category, value in kwargs.items():
sectionChoices = section.listChoices(category)
for choice in sectionChoices:
if str(choice.title).lower() == str(value).lower():
uri = uri + '&%s=%s' % (category.lower(), str(choice.key))
uri = uri + '&sourceType=%s' % sectionType
key = '/playlists%s' % utils.joinArgs({
'uri': uri,
'type': section.CONTENT_TYPE,
'title': title,
'smart': 1,
})
data = server.query(key, method=server._session.post)[0]
return cls(server, data, initpath=key)
def copyToUser(self, user):
""" Copy playlist to another user account. """
from plexapi.server import PlexServer
@ -132,3 +213,58 @@ class Playlist(PlexPartialObject, Playable):
# Login to your server using your friends credentials.
user_server = PlexServer(self._server._baseurl, token)
return self.create(user_server, self.title, self.items())
def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None,
unwatched=False, title=None):
""" Add current playlist as sync item for specified device.
See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions.
Parameters:
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in
:mod:`plexapi.sync` module. Used only when playlist contains video.
photoResolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in
the module :mod:`plexapi.sync`. Used only when playlist contains photos.
audioBitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values
from the module :mod:`plexapi.sync`. Used only when playlist contains audio.
client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see
:func:`plexapi.myplex.MyPlexAccount.sync`.
clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`.
limit (int): maximum count of items to sync, unlimited if `None`.
unwatched (bool): if `True` watched videos wouldn't be synced.
title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be
generated from metadata of current photo.
Raises:
:class:`plexapi.exceptions.BadRequest`: when playlist is not allowed to sync.
:class:`plexapi.exceptions.Unsupported`: when playlist content is unsupported.
Returns:
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
"""
if not self.allowSync:
raise BadRequest('The playlist is not allowed to sync')
from plexapi.sync import SyncItem, Policy, MediaSettings
myplex = self._server.myPlexAccount()
sync_item = SyncItem(self._server, None)
sync_item.title = title if title else self.title
sync_item.rootTitle = self.title
sync_item.contentType = self.playlistType
sync_item.metadataType = self.metadataType
sync_item.machineIdentifier = self._server.machineIdentifier
sync_item.location = 'playlist:///%s' % quote_plus(self.guid)
sync_item.policy = Policy.create(limit, unwatched)
if self.isVideo:
sync_item.mediaSettings = MediaSettings.createVideo(videoQuality)
elif self.isAudio:
sync_item.mediaSettings = MediaSettings.createMusic(audioBitrate)
elif self.isPhoto:
sync_item.mediaSettings = MediaSettings.createPhoto(photoResolution)
else:
raise Unsupported('Unsupported playlist content')
return myplex.sync(sync_item, client=client, clientId=clientId)

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
import requests
from requests.status_codes import _codes as codes
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE
from plexapi import log, logfilter, utils
from plexapi.alert import AlertListener
from plexapi.base import PlexObject
@ -93,6 +93,7 @@ class PlexServer(PlexObject):
def __init__(self, baseurl=None, token=None, session=None, timeout=None):
self._baseurl = baseurl or CONFIG.get('auth.server_baseurl', 'http://localhost:32400')
self._baseurl = self._baseurl.rstrip('/')
self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
self._session = session or requests.Session()
@ -182,6 +183,18 @@ class PlexServer(PlexObject):
data = self.query(Account.key)
return Account(self, data)
def createToken(self, type='delegation', scope='all'):
"""Create a temp access token for the server."""
q = self.query('/security/token?type=%s&scope=%s' % (type, scope))
return q.attrib.get('token')
def systemAccounts(self):
""" Returns the :class:`~plexapi.server.SystemAccounts` objects this server contains. """
accounts = []
for elem in self.query('/accounts'):
accounts.append(SystemAccount(self, data=elem))
return accounts
def myPlexAccount(self):
""" Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same
token to access this server. If you are not the owner of this PlexServer
@ -232,7 +245,7 @@ class PlexServer(PlexObject):
name (str): Name of the client to return.
Raises:
:class:`~plexapi.exceptions.NotFound`: Unknown client name
:class:`plexapi.exceptions.NotFound`: Unknown client name
"""
for client in self.clients():
if client and client.title == name:
@ -240,14 +253,14 @@ class PlexServer(PlexObject):
raise NotFound('Unknown client name: %s' % name)
def createPlaylist(self, title, items):
def createPlaylist(self, title, items=None, section=None, limit=None, smart=None, **kwargs):
""" Creates and returns a new :class:`~plexapi.playlist.Playlist`.
Parameters:
title (str): Title of the playlist to be created.
items (list<Media>): List of media items to include in the playlist.
"""
return Playlist.create(self, title, items)
return Playlist.create(self, title, items=items, limit=limit, section=section, smart=smart, **kwargs)
def createPlayQueue(self, item, **kwargs):
""" Creates and returns a new :class:`~plexapi.playqueue.PlayQueue`.
@ -290,12 +303,14 @@ class PlexServer(PlexObject):
part = '/updater/check?download=%s' % (1 if download else 0)
if force:
self.query(part, method=self._session.put)
return self.fetchItem('/updater/status')
releases = self.fetchItems('/updater/status')
if len(releases):
return releases[0]
def isLatest(self):
""" Check if the installed version of PMS is the latest. """
release = self.check_for_update(force=True)
return bool(release.version == self.version)
return release is None
def installUpdate(self):
""" Install the newest version of Plex Media Server. """
@ -307,9 +322,29 @@ class PlexServer(PlexObject):
# figure out what method this is..
return self.query(part, method=self._session.put)
def history(self):
""" Returns a list of media items from watched history. """
return self.fetchItems('/status/sessions/history/all')
def history(self, maxresults=9999999, mindate=None):
""" 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
looking for the first <num> 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.
Parameters:
maxresults (int): Only return the specified number of results (optional).
mindate (datetime): Min datetime to return results from. This really helps speed
up the result listing. For example: datetime.now() - timedelta(days=7)
"""
results, subresults = [], '_init'
args = {'sort': 'viewedAt:desc'}
if mindate:
args['viewedAt>'] = int(mindate.timestamp())
args['X-Plex-Container-Start'] = 0
args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
while subresults and maxresults > len(results):
key = '/status/sessions/history/all%s' % utils.joinArgs(args)
subresults = self.fetchItems(key)
results += subresults[:maxresults - len(results)]
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
return results
def playlists(self):
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects saved on the server. """
@ -324,7 +359,7 @@ class PlexServer(PlexObject):
title (str): Title of the playlist to return.
Raises:
:class:`~plexapi.exceptions.NotFound`: Invalid playlist title
:class:`plexapi.exceptions.NotFound`: Invalid playlist title
"""
return self.fetchItem('/playlists', title=title)
@ -391,7 +426,7 @@ class PlexServer(PlexObject):
callback (func): Callback function to call on recieved messages.
raises:
:class:`~plexapi.exception.Unsupported`: Websocket-client not installed.
:class:`plexapi.exception.Unsupported`: Websocket-client not installed.
"""
notifier = AlertListener(self, callback)
notifier.start()
@ -422,6 +457,21 @@ class PlexServer(PlexObject):
return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token)
return '%s%s' % (self._baseurl, key)
def refreshSynclist(self):
""" Force PMS to download new SyncList from Plex.tv. """
return self.query('/sync/refreshSynclists', self._session.put)
def refreshContent(self):
""" Force PMS to refresh content for known SyncLists. """
return self.query('/sync/refreshContent', self._session.put)
def refreshSync(self):
""" Calls :func:`~plexapi.server.PlexServer.refreshSynclist` and
:func:`~plexapi.server.PlexServer.refreshContent`, just like the Plex Web UI does when you click 'refresh'.
"""
self.refreshSynclist()
self.refreshContent()
class Account(PlexObject):
""" Contains the locally cached MyPlex account information. The properties provided don't
@ -469,3 +519,14 @@ class Account(PlexObject):
self.subscriptionFeatures = utils.toList(data.attrib.get('subscriptionFeatures'))
self.subscriptionActive = cast(bool, data.attrib.get('subscriptionActive'))
self.subscriptionState = data.attrib.get('subscriptionState')
class SystemAccount(PlexObject):
""" Minimal api to list system accounts. """
key = '/accounts'
def _loadData(self, data):
self._data = data
self.accountID = cast(int, data.attrib.get('id'))
self.accountKey = data.attrib.get('key')
self.name = data.attrib.get('name')

View file

@ -99,13 +99,14 @@ class Setting(PlexObject):
group (str): Group name this setting is categorized as.
enumValues (list,dict): List or dictionary of valis values for this setting.
"""
_bool_cast = lambda x: True if x == 'true' else False
_bool_cast = lambda x: True if x == 'true' or x == '1' else False
_bool_str = lambda x: str(x).lower()
_str = lambda x: str(x).encode('utf-8')
TYPES = {
'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str},
'double': {'type': float, 'cast': float, 'tostr': string_type},
'int': {'type': int, 'cast': int, 'tostr': string_type},
'text': {'type': string_type, 'cast': string_type, 'tostr': string_type},
'double': {'type': float, 'cast': float, 'tostr': _str},
'int': {'type': int, 'cast': int, 'tostr': _str},
'text': {'type': string_type, 'cast': _str, 'tostr': _str},
}
def _loadData(self, data):

View file

@ -1,42 +1,312 @@
# -*- coding: utf-8 -*-
"""
You can work with Mobile Sync on other devices straight away, but if you'd like to use your app as a `sync-target` (when
you can set items to be synced to your app) you need to init some variables.
.. code-block:: python
def init_sync():
import plexapi
plexapi.X_PLEX_PROVIDES = 'sync-target'
plexapi.BASE_HEADERS['X-Plex-Sync-Version'] = '2'
plexapi.BASE_HEADERS['X-Plex-Provides'] = plexapi.X_PLEX_PROVIDES
# mimic iPhone SE
plexapi.X_PLEX_PLATFORM = 'iOS'
plexapi.X_PLEX_PLATFORM_VERSION = '11.4.1'
plexapi.X_PLEX_DEVICE = 'iPhone'
plexapi.BASE_HEADERS['X-Plex-Platform'] = plexapi.X_PLEX_PLATFORM
plexapi.BASE_HEADERS['X-Plex-Platform-Version'] = plexapi.X_PLEX_PLATFORM_VERSION
plexapi.BASE_HEADERS['X-Plex-Device'] = plexapi.X_PLEX_DEVICE
You have to fake platform/device/model because transcoding profiles are hardcoded in Plex, and you obviously have
to explicitly specify that your app supports `sync-target`.
"""
import requests
from plexapi import utils
from plexapi.exceptions import NotFound
import plexapi
from plexapi.base import PlexObject
from plexapi.exceptions import NotFound, BadRequest
class SyncItem(object):
""" Sync Item. This doesn't current work. """
def __init__(self, device, data, servers=None):
self._device = device
self._servers = servers
self._loadData(data)
class SyncItem(PlexObject):
"""
Represents single sync item, for specified server and client. When you saying in the UI to sync "this" to "that"
you're basically creating a sync item.
Attributes:
id (int): unique id of the item.
clientIdentifier (str): an identifier of Plex Client device, to which the item is belongs.
machineIdentifier (str): the id of server which holds all this content.
version (int): current version of the item. Each time you modify the item (e.g. by changing amount if media to
sync) the new version is created.
rootTitle (str): the title of library/media from which the sync item was created. E.g.:
* when you create an item for an episode 3 of season 3 of show Example, the value would be `Title of
Episode 3`
* when you create an item for a season 3 of show Example, the value would be `Season 3`
* when you set to sync all your movies in library named "My Movies" to value would be `My Movies`.
title (str): the title which you've set when created the sync item.
metadataType (str): the type of media which hides inside, can be `episode`, `movie`, etc.
contentType (str): basic type of the content: `video` or `audio`.
status (:class:`~plexapi.sync.Status`): current status of the sync.
mediaSettings (:class:`~plexapi.sync.MediaSettings`): media transcoding settings used for the item.
policy (:class:`~plexapi.sync.Policy`): the policy of which media to sync.
location (str): plex-style library url with all required filters / sorting.
"""
TAG = 'SyncItem'
def __init__(self, server, data, initpath=None, clientIdentifier=None):
super(SyncItem, self).__init__(server, data, initpath)
self.clientIdentifier = clientIdentifier
def _loadData(self, data):
self._data = data
self.id = utils.cast(int, data.attrib.get('id'))
self.version = utils.cast(int, data.attrib.get('version'))
self.id = plexapi.utils.cast(int, data.attrib.get('id'))
self.version = plexapi.utils.cast(int, data.attrib.get('version'))
self.rootTitle = data.attrib.get('rootTitle')
self.title = data.attrib.get('title')
self.metadataType = data.attrib.get('metadataType')
self.contentType = data.attrib.get('contentType')
self.machineIdentifier = data.find('Server').get('machineIdentifier')
self.status = data.find('Status').attrib.copy()
self.MediaSettings = data.find('MediaSettings').attrib.copy()
self.policy = data.find('Policy').attrib.copy()
self.location = data.find('Location').attrib.copy()
self.status = Status(**data.find('Status').attrib)
self.mediaSettings = MediaSettings(**data.find('MediaSettings').attrib)
self.policy = Policy(**data.find('Policy').attrib)
self.location = data.find('Location').attrib.get('uri', '')
def server(self):
server = list(filter(lambda x: x.machineIdentifier == self.machineIdentifier, self._servers))
if 0 == len(server):
""" Returns :class:`plexapi.myplex.MyPlexResource` with server of current item. """
server = [s for s in self._server.resources() if s.clientIdentifier == self.machineIdentifier]
if len(server) == 0:
raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier)
return server[0]
def getMedia(self):
""" Returns list of :class:`~plexapi.base.Playable` which belong to this sync item. """
server = self.server().connect()
key = '/sync/items/%s' % self.id
return server.fetchItems(key)
def markAsDone(self, sync_id):
server = self.server().connect()
url = '/sync/%s/%s/files/%s/downloaded' % (
self._device.clientIdentifier, server.machineIdentifier, sync_id)
server.query(url, method=requests.put)
def markDownloaded(self, media):
""" Mark the file as downloaded (by the nature of Plex it will be marked as downloaded within
any SyncItem where it presented).
Parameters:
media (base.Playable): the media to be marked as downloaded.
"""
url = '/sync/%s/item/%s/downloaded' % (self.clientIdentifier, media.ratingKey)
media._server.query(url, method=requests.put)
def delete(self):
""" Removes current SyncItem """
url = SyncList.key.format(clientId=self.clientIdentifier)
url += '/' + str(self.id)
self._server.query(url, self._server._session.delete)
class SyncList(PlexObject):
""" Represents a Mobile Sync state, specific for single client, within one SyncList may be presented
items from different servers.
Attributes:
clientId (str): an identifier of the client.
items (List<:class:`~plexapi.sync.SyncItem`>): list of registered items to sync.
"""
key = 'https://plex.tv/devices/{clientId}/sync_items'
TAG = 'SyncList'
def _loadData(self, data):
self._data = data
self.clientId = data.attrib.get('clientIdentifier')
self.items = []
syncItems = data.find('SyncItems')
if syncItems:
for sync_item in syncItems.iter('SyncItem'):
item = SyncItem(self._server, sync_item, clientIdentifier=self.clientId)
self.items.append(item)
class Status(object):
""" Represents a current status of specific :class:`~plexapi.sync.SyncItem`.
Attributes:
failureCode: unknown, never got one yet.
failure: unknown.
state (str): server-side status of the item, can be `completed`, `pending`, empty, and probably something
else.
itemsCount (int): total items count.
itemsCompleteCount (int): count of transcoded and/or downloaded items.
itemsDownloadedCount (int): count of downloaded items.
itemsReadyCount (int): count of transcoded items, which can be downloaded.
totalSize (int): total size in bytes of complete items.
itemsSuccessfulCount (int): unknown, in my experience it always was equal to `itemsCompleteCount`.
"""
def __init__(self, itemsCount, itemsCompleteCount, state, totalSize, itemsDownloadedCount, itemsReadyCount,
itemsSuccessfulCount, failureCode, failure):
self.itemsDownloadedCount = plexapi.utils.cast(int, itemsDownloadedCount)
self.totalSize = plexapi.utils.cast(int, totalSize)
self.itemsReadyCount = plexapi.utils.cast(int, itemsReadyCount)
self.failureCode = failureCode
self.failure = failure
self.itemsSuccessfulCount = plexapi.utils.cast(int, itemsSuccessfulCount)
self.state = state
self.itemsCompleteCount = plexapi.utils.cast(int, itemsCompleteCount)
self.itemsCount = plexapi.utils.cast(int, itemsCount)
def __repr__(self):
return '<%s>:%s' % (self.__class__.__name__, dict(
itemsCount=self.itemsCount,
itemsCompleteCount=self.itemsCompleteCount,
itemsDownloadedCount=self.itemsDownloadedCount,
itemsReadyCount=self.itemsReadyCount,
itemsSuccessfulCount=self.itemsSuccessfulCount
))
class MediaSettings(object):
""" Transcoding settings used for all media within :class:`~plexapi.sync.SyncItem`.
Attributes:
audioBoost (int): unknown.
maxVideoBitrate (int|str): maximum bitrate for video, may be empty string.
musicBitrate (int|str): maximum bitrate for music, may be an empty string.
photoQuality (int): photo quality on scale 0 to 100.
photoResolution (str): maximum photo resolution, formatted as WxH (e.g. `1920x1080`).
videoResolution (str): maximum video resolution, formatted as WxH (e.g. `1280x720`, may be empty).
subtitleSize (int|str): unknown, usually equals to 0, may be empty string.
videoQuality (int): video quality on scale 0 to 100.
"""
def __init__(self, maxVideoBitrate=4000, videoQuality=100, videoResolution='1280x720', audioBoost=100,
musicBitrate=192, photoQuality=74, photoResolution='1920x1080', subtitleSize=''):
self.audioBoost = plexapi.utils.cast(int, audioBoost)
self.maxVideoBitrate = plexapi.utils.cast(int, maxVideoBitrate) if maxVideoBitrate != '' else ''
self.musicBitrate = plexapi.utils.cast(int, musicBitrate) if musicBitrate != '' else ''
self.photoQuality = plexapi.utils.cast(int, photoQuality) if photoQuality != '' else ''
self.photoResolution = photoResolution
self.videoResolution = videoResolution
self.subtitleSize = subtitleSize
self.videoQuality = plexapi.utils.cast(int, videoQuality) if videoQuality != '' else ''
@staticmethod
def createVideo(videoQuality):
""" Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided video quality value.
Parameters:
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in this module.
Raises:
:class:`plexapi.exceptions.BadRequest`: when provided unknown video quality.
"""
if videoQuality == VIDEO_QUALITY_ORIGINAL:
return MediaSettings('', '', '')
elif videoQuality < len(VIDEO_QUALITIES['bitrate']):
return MediaSettings(VIDEO_QUALITIES['bitrate'][videoQuality],
VIDEO_QUALITIES['videoQuality'][videoQuality],
VIDEO_QUALITIES['videoResolution'][videoQuality])
else:
raise BadRequest('Unexpected video quality')
@staticmethod
def createMusic(bitrate):
""" Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided music quality value
Parameters:
bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the
module
"""
return MediaSettings(musicBitrate=bitrate)
@staticmethod
def createPhoto(resolution):
""" Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided photo quality value.
Parameters:
resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the
module.
Raises:
:class:`plexapi.exceptions.BadRequest` when provided unknown video quality.
"""
if resolution in PHOTO_QUALITIES:
return MediaSettings(photoQuality=PHOTO_QUALITIES[resolution], photoResolution=resolution)
else:
raise BadRequest('Unexpected photo quality')
class Policy(object):
""" Policy of syncing the media (how many items to sync and process watched media or not).
Attributes:
scope (str): type of limitation policy, can be `count` or `all`.
value (int): amount of media to sync, valid only when `scope=count`.
unwatched (bool): True means disallow to sync watched media.
"""
def __init__(self, scope, unwatched, value=0):
self.scope = scope
self.unwatched = plexapi.utils.cast(bool, unwatched)
self.value = plexapi.utils.cast(int, value)
@staticmethod
def create(limit=None, unwatched=False):
""" Creates a :class:`~plexapi.sync.Policy` object for provided options and automatically sets proper `scope`
value.
Parameters:
limit (int): limit items by count.
unwatched (bool): if True then watched items wouldn't be synced.
Returns:
:class:`~plexapi.sync.Policy`.
"""
scope = 'all'
if limit is None:
limit = 0
else:
scope = 'count'
return Policy(scope, unwatched, limit)
VIDEO_QUALITIES = {
'bitrate': [64, 96, 208, 320, 720, 1500, 2e3, 3e3, 4e3, 8e3, 1e4, 12e3, 2e4],
'videoResolution': ['220x128', '220x128', '284x160', '420x240', '576x320', '720x480', '1280x720', '1280x720',
'1280x720', '1920x1080', '1920x1080', '1920x1080', '1920x1080'],
'videoQuality': [10, 20, 30, 30, 40, 60, 60, 75, 100, 60, 75, 90, 100],
}
VIDEO_QUALITY_0_2_MBPS = 2
VIDEO_QUALITY_0_3_MBPS = 3
VIDEO_QUALITY_0_7_MBPS = 4
VIDEO_QUALITY_1_5_MBPS_480p = 5
VIDEO_QUALITY_2_MBPS_720p = 6
VIDEO_QUALITY_3_MBPS_720p = 7
VIDEO_QUALITY_4_MBPS_720p = 8
VIDEO_QUALITY_8_MBPS_1080p = 9
VIDEO_QUALITY_10_MBPS_1080p = 10
VIDEO_QUALITY_12_MBPS_1080p = 11
VIDEO_QUALITY_20_MBPS_1080p = 12
VIDEO_QUALITY_ORIGINAL = -1
AUDIO_BITRATE_96_KBPS = 96
AUDIO_BITRATE_128_KBPS = 128
AUDIO_BITRATE_192_KBPS = 192
AUDIO_BITRATE_320_KBPS = 320
PHOTO_QUALITIES = {
'720x480': 24,
'1280x720': 49,
'1920x1080': 74,
'3840x2160': 99,
}
PHOTO_QUALITY_HIGHEST = PHOTO_QUALITY_2160p = '3840x2160'
PHOTO_QUALITY_HIGH = PHOTO_QUALITY_1080p = '1920x1080'
PHOTO_QUALITY_MEDIUM = PHOTO_QUALITY_720p = '1280x720'
PHOTO_QUALITY_LOW = PHOTO_QUALITY_480p = '720x480'

View file

@ -7,15 +7,18 @@ import time
import zipfile
from datetime import datetime
from getpass import getpass
from threading import Thread
from threading import Thread, Event
from tqdm import tqdm
from plexapi import compat
from plexapi.exceptions import NotFound
log = logging.getLogger('plexapi')
# Search Types - Plex uses these to filter specific media types when searching.
# Library Types - Populated at runtime
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4,
'artist': 8, 'album': 9, 'track': 10, 'photo': 14}
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, 'trailer': 5, 'comic': 6, 'person': 7,
'artist': 8, 'album': 9, 'track': 10, 'picture': 11, 'clip': 12, 'photo': 13, 'photoalbum': 14,
'playlist': 15, 'playlistFolder': 16, 'collection': 18, 'userPlaylistItem': 1001}
PLEXOBJECTS = {}
@ -129,10 +132,10 @@ def searchType(libtype):
""" Returns the integer value of the library string type.
Parameters:
libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track)
libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track,
collection)
Raises:
NotFound: Unknown libtype
:class:`plexapi.exceptions.NotFound`: Unknown libtype
"""
libtype = compat.ustr(libtype)
if libtype in [compat.ustr(v) for v in SEARCHTYPES.values()]:
@ -144,22 +147,26 @@ def searchType(libtype):
def threaded(callback, listargs):
""" Returns the result of <callback> for each set of \*args in listargs. Each call
to <callback. is called concurrently in their own separate threads.
to <callback> is called concurrently in their own separate threads.
Parameters:
callback (func): Callback function to apply to each set of \*args.
listargs (list): List of lists; \*args to pass each thread.
"""
threads, results = [], []
job_is_done_event = Event()
for args in listargs:
args += [results, len(results)]
results.append(None)
threads.append(Thread(target=callback, args=args))
threads.append(Thread(target=callback, args=args, kwargs=dict(job_is_done_event=job_is_done_event)))
threads[-1].setDaemon(True)
threads[-1].start()
for thread in threads:
thread.join()
return results
while not job_is_done_event.is_set():
if all([not t.is_alive() for t in threads]):
break
time.sleep(0.05)
return [r for r in results if r is not None]
def toDatetime(value, format=None):
@ -171,8 +178,17 @@ def toDatetime(value, format=None):
"""
if value and value is not None:
if format:
value = datetime.strptime(value, format)
try:
value = datetime.strptime(value, format)
except ValueError:
log.info('Failed to parse %s to datetime, defaulting to None', value)
return None
else:
# https://bugs.python.org/issue30684
# And platform support for before epoch seems to be flaky.
# TODO check for others errors too.
if int(value) <= 0:
value = 86400
value = datetime.fromtimestamp(int(value))
return value
@ -242,8 +258,6 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4
>>> download(a_episode.getStreamURL(), a_episode.location)
/path/to/file
"""
from plexapi import log
# fetch the data to be saved
session = session or requests.Session()
headers = {'X-Plex-Token': token}

View file

@ -2,6 +2,7 @@
from plexapi import media, utils
from plexapi.exceptions import BadRequest, NotFound
from plexapi.base import Playable, PlexPartialObject
from plexapi.compat import quote_plus
class Video(PlexPartialObject):
@ -77,6 +78,59 @@ class Video(PlexPartialObject):
self._server.query(key)
self.reload()
def rate(self, rate):
""" Rate video. """
key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rate)
self._server.query(key)
self.reload()
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
return self.title
def posters(self):
""" Returns list of available poster objects. :class:`~plexapi.media.Poster`:"""
return self.fetchItems('%s/posters' % self.key, cls=media.Poster)
def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None):
""" Add current video (movie, tv-show, season or episode) as sync item for specified device.
See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions.
Parameters:
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in
:mod:`plexapi.sync` module.
client (:class:`plexapi.myplex.MyPlexDevice`): sync destination, see
:func:`plexapi.myplex.MyPlexAccount.sync`.
clientId (str): sync destination, see :func:`plexapi.myplex.MyPlexAccount.sync`.
limit (int): maximum count of items to sync, unlimited if `None`.
unwatched (bool): if `True` watched videos wouldn't be synced.
title (str): descriptive title for the new :class:`plexapi.sync.SyncItem`, if empty the value would be
generated from metadata of current media.
Returns:
:class:`plexapi.sync.SyncItem`: an instance of created syncItem.
"""
from plexapi.sync import SyncItem, Policy, MediaSettings
myplex = self._server.myPlexAccount()
sync_item = SyncItem(self._server, None)
sync_item.title = title if title else self._defaultSyncTitle()
sync_item.rootTitle = self.title
sync_item.contentType = self.listType
sync_item.metadataType = self.METADATA_TYPE
sync_item.machineIdentifier = self._server.machineIdentifier
section = self._server.library.sectionByID(self.librarySectionID)
sync_item.location = 'library://%s/item/%s' % (section.uuid, quote_plus(self.key))
sync_item.policy = Policy.create(limit, unwatched)
sync_item.mediaSettings = MediaSettings.createVideo(videoQuality)
return myplex.sync(sync_item, client=client, clientId=clientId)
@utils.registerPlexObject
class Movie(Playable, Video):
@ -116,6 +170,7 @@ class Movie(Playable, Video):
"""
TAG = 'Video'
TYPE = 'movie'
METADATA_TYPE = 'movie'
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeConcerts=1&includePreferences=1')
@ -181,12 +236,12 @@ class Movie(Playable, Video):
# This is just for compat.
return self.title
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Download video files to specified directory.
Parameters:
savepath (str): Defaults to current working dir.
keep_orginal_name (bool): True to keep the original file name otherwise
keep_original_name (bool): True to keep the original file name otherwise
a friendlier is generated.
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`.
"""
@ -194,7 +249,7 @@ class Movie(Playable, Video):
locations = [i for i in self.iterParts() if i]
for location in locations:
name = location.file
if not keep_orginal_name:
if not keep_original_name:
title = self.title.replace(' ', '.')
name = '%s.%s' % (title, location.container)
if kwargs is not None:
@ -219,6 +274,7 @@ class Show(Video):
banner (str): Key to banner artwork (/library/metadata/<ratingkey>/art/<artid>)
childCount (int): Unknown.
contentRating (str) Content rating (PG-13; NR; TV-G).
collections (List<:class:`~plexapi.media.Collection`>): List of collections this media belongs.
duration (int): Duration of show in milliseconds.
guid (str): Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
index (int): Plex index (?)
@ -236,6 +292,7 @@ class Show(Video):
"""
TAG = 'Directory'
TYPE = 'show'
METADATA_TYPE = 'episode'
def __iter__(self):
for season in self.seasons():
@ -250,6 +307,7 @@ class Show(Video):
self.banner = data.attrib.get('banner')
self.childCount = utils.cast(int, data.attrib.get('childCount'))
self.contentRating = data.attrib.get('contentRating')
self.collections = self.findItems(data, media.Collection)
self.duration = utils.cast(int, data.attrib.get('duration'))
self.guid = data.attrib.get('guid')
self.index = data.attrib.get('index')
@ -279,7 +337,7 @@ class Show(Video):
def seasons(self, **kwargs):
""" Returns a list of :class:`~plexapi.video.Season` objects. """
key = '/library/metadata/%s/children' % self.ratingKey
key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey
return self.fetchItems(key, **kwargs)
def season(self, title=None):
@ -288,9 +346,9 @@ class Show(Video):
Parameters:
title (str or int): Title or Number of the season to return.
"""
if isinstance(title, int):
title = 'Season %s' % title
key = '/library/metadata/%s/children' % self.ratingKey
if isinstance(title, int):
return self.fetchItem(key, etag='Directory', index__iexact=str(title))
return self.fetchItem(key, etag='Directory', title__iexact=title)
def episodes(self, **kwargs):
@ -307,13 +365,13 @@ class Show(Video):
episode (int): Episode number (default:None; required if title not specified).
Raises:
BadRequest: If season and episode is missing.
NotFound: If the episode is missing.
:class:`plexapi.exceptions.BadRequest`: If season and episode is missing.
:class:`plexapi.exceptions.NotFound`: If the episode is missing.
"""
if title:
key = '/library/metadata/%s/allLeaves' % self.ratingKey
return self.fetchItem(key, title__iexact=title)
elif season and episode:
elif season is not None and episode:
results = [i for i in self.episodes() if i.seasonNumber == season and i.index == episode]
if results:
return results[0]
@ -332,18 +390,18 @@ class Show(Video):
""" Alias to :func:`~plexapi.video.Show.episode()`. """
return self.episode(title, season, episode)
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Download video files to specified directory.
Parameters:
savepath (str): Defaults to current working dir.
keep_orginal_name (bool): True to keep the original file name otherwise
keep_original_name (bool): True to keep the original file name otherwise
a friendlier is generated.
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`.
"""
filepaths = []
for episode in self.episodes():
filepaths += episode.download(savepath, keep_orginal_name, **kwargs)
filepaths += episode.download(savepath, keep_original_name, **kwargs)
return filepaths
@ -363,6 +421,7 @@ class Season(Video):
"""
TAG = 'Directory'
TYPE = 'season'
METADATA_TYPE = 'episode'
def __iter__(self):
for episode in self.episodes():
@ -414,7 +473,7 @@ class Season(Video):
key = '/library/metadata/%s/children' % self.ratingKey
if title:
return self.fetchItem(key, title=title)
return self.fetchItem(key, seasonNumber=self.index, index=episode)
return self.fetchItem(key, parentIndex=self.index, index=episode)
def get(self, title=None, episode=None):
""" Alias to :func:`~plexapi.video.Season.episode()`. """
@ -432,20 +491,24 @@ class Season(Video):
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
return self.episodes(watched=False)
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Download video files to specified directory.
Parameters:
savepath (str): Defaults to current working dir.
keep_orginal_name (bool): True to keep the original file name otherwise
keep_original_name (bool): True to keep the original file name otherwise
a friendlier is generated.
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`.
"""
filepaths = []
for episode in self.episodes():
filepaths += episode.download(savepath, keep_orginal_name, **kwargs)
filepaths += episode.download(savepath, keep_original_name, **kwargs)
return filepaths
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
return '%s - %s' % (self.parentTitle, self.title)
@utils.registerPlexObject
class Episode(Playable, Video):
@ -482,6 +545,8 @@ class Episode(Playable, Video):
"""
TAG = 'Video'
TYPE = 'episode'
METADATA_TYPE = 'episode'
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeConcerts=1&includePreferences=1')
@ -558,3 +623,7 @@ class Episode(Playable, Video):
def show(self):
"""" Return this episodes :func:`~plexapi.video.Show`.. """
return self.fetchItem(self.grandparentKey)
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title)