mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-06 05:01:14 -07:00
Update PlexAPI to 3.3.0
This commit is contained in:
parent
2fa62f71e1
commit
c55c00a19e
17 changed files with 1559 additions and 135 deletions
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue