mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-07 05:31:15 -07:00
Add plexapi v3.0.6
This commit is contained in:
parent
b144ded87b
commit
8aa34321c9
19 changed files with 5539 additions and 0 deletions
49
lib/plexapi/__init__.py
Normal file
49
lib/plexapi/__init__.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from platform import uname
|
||||
from plexapi.config import PlexConfig, reset_base_headers
|
||||
from plexapi.utils import SecretsFilter
|
||||
from uuid import getnode
|
||||
|
||||
# Load User Defined Config
|
||||
DEFAULT_CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini')
|
||||
CONFIG_PATH = os.environ.get('PLEXAPI_CONFIG_PATH', DEFAULT_CONFIG_PATH)
|
||||
CONFIG = PlexConfig(CONFIG_PATH)
|
||||
|
||||
# PlexAPI Settings
|
||||
PROJECT = 'PlexAPI'
|
||||
VERSION = '3.0.6'
|
||||
TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
|
||||
X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int)
|
||||
|
||||
# Plex Header Configuation
|
||||
X_PLEX_PROVIDES = CONFIG.get('header.provides', 'controller')
|
||||
X_PLEX_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)
|
||||
X_PLEX_DEVICE = CONFIG.get('header.device', X_PLEX_PLATFORM)
|
||||
X_PLEX_DEVICE_NAME = CONFIG.get('header.device_name', uname()[1])
|
||||
X_PLEX_IDENTIFIER = CONFIG.get('header.identifier', str(hex(getnode())))
|
||||
BASE_HEADERS = reset_base_headers()
|
||||
|
||||
# Logging Configuration
|
||||
log = logging.getLogger('plexapi')
|
||||
logfile = CONFIG.get('log.path')
|
||||
logformat = CONFIG.get('log.format', '%(asctime)s %(module)12s:%(lineno)-4s %(levelname)-9s %(message)s')
|
||||
loglevel = CONFIG.get('log.level', 'INFO').upper()
|
||||
loghandler = logging.NullHandler()
|
||||
|
||||
if logfile: # pragma: no cover
|
||||
logbackups = CONFIG.get('log.backup_count', 3, int)
|
||||
logbytes = CONFIG.get('log.rotate_bytes', 512000, int)
|
||||
loghandler = RotatingFileHandler(os.path.expanduser(logfile), 'a', logbytes, logbackups)
|
||||
|
||||
loghandler.setFormatter(logging.Formatter(logformat))
|
||||
log.addHandler(loghandler)
|
||||
log.setLevel(loglevel)
|
||||
logfilter = SecretsFilter()
|
||||
if CONFIG.get('log.show_secrets', '').lower() != 'true':
|
||||
log.addFilter(logfilter)
|
58
lib/plexapi/alert.py
Normal file
58
lib/plexapi/alert.py
Normal file
|
@ -0,0 +1,58 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import threading
|
||||
import websocket
|
||||
from plexapi import log
|
||||
|
||||
|
||||
class AlertListener(threading.Thread):
|
||||
""" Creates a websocket connection to the PlexServer to optionally recieve alert notifications.
|
||||
These often include messages from Plex about media scans as well as updates to currently running
|
||||
Transcode Sessions. This class implements threading.Thread, therfore to start monitoring
|
||||
alerts you must call .start() on the object once it's created. When calling
|
||||
`PlexServer.startAlertListener()`, the thread will be started for you.
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to.
|
||||
callback (func): Callback function to call on recieved messages. The callback function
|
||||
will be sent a single argument 'data' which will contain a dictionary of data
|
||||
recieved from the server. :samp:`def my_callback(data): ...`
|
||||
"""
|
||||
key = '/:/websockets/notifications'
|
||||
|
||||
def __init__(self, server, callback=None):
|
||||
super(AlertListener, self).__init__()
|
||||
self.daemon = True
|
||||
self._server = server
|
||||
self._callback = callback
|
||||
self._ws = None
|
||||
|
||||
def run(self):
|
||||
# create the websocket connection
|
||||
url = self._server.url(self.key, includeToken=True).replace('http', 'ws')
|
||||
log.info('Starting AlertListener: %s', url)
|
||||
self._ws = websocket.WebSocketApp(url, on_message=self._onMessage,
|
||||
on_error=self._onError)
|
||||
self._ws.run_forever()
|
||||
|
||||
def stop(self):
|
||||
""" Stop the AlertListener thread. Once the notifier is stopped, it cannot be diractly
|
||||
started again. You must call :func:`plexapi.server.PlexServer.startAlertListener()`
|
||||
from a PlexServer instance.
|
||||
"""
|
||||
log.info('Stopping AlertListener.')
|
||||
self._ws.close()
|
||||
|
||||
def _onMessage(self, ws, message):
|
||||
""" Called when websocket message is recieved. """
|
||||
try:
|
||||
data = json.loads(message)['NotificationContainer']
|
||||
log.debug('Alert: %s %s %s', *data)
|
||||
if self._callback:
|
||||
self._callback(data)
|
||||
except Exception as err: # pragma: no cover
|
||||
log.error('AlertListener Msg Error: %s', err)
|
||||
|
||||
def _onError(self, ws, err): # pragma: no cover
|
||||
""" Called when websocket error is recieved. """
|
||||
log.error('AlertListener Error: %s' % err)
|
304
lib/plexapi/audio.py
Normal file
304
lib/plexapi/audio.py
Normal file
|
@ -0,0 +1,304 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from plexapi import media, utils
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
|
||||
|
||||
class Audio(PlexPartialObject):
|
||||
""" Base class for audio :class:`~plexapi.audio.Artist`, :class:`~plexapi.audio.Album`
|
||||
and :class:`~plexapi.audio.Track` objects.
|
||||
|
||||
Attributes:
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
index (sting): Index Number (often the track number).
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
lastViewedAt (datetime): Datetime item was last accessed.
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
listType (str): Hardcoded as 'audio' (useful for search filters).
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
summary (str): Summary of the artist, track, or album.
|
||||
thumb (str): URL to thumbnail image.
|
||||
title (str): Artist, Album or Track title. (Jason Mraz, We Sing, Lucky, etc.)
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'artist', 'album', or 'track'.
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
viewCount (int): Count of times this item was accessed.
|
||||
"""
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.listType = 'audio'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.index = data.attrib.get('index')
|
||||
self.key = data.attrib.get('key')
|
||||
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
|
||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
|
||||
|
||||
@property
|
||||
def thumbUrl(self):
|
||||
""" Return url to for the thumbnail image. """
|
||||
key = self.firstAttr('thumb', 'parentThumb', 'granparentThumb')
|
||||
return self._server.url(key, includeToken=True) if key else None
|
||||
|
||||
@property
|
||||
def artUrl(self):
|
||||
""" Return the first art url starting on the most specific for that item."""
|
||||
art = self.firstAttr('art', 'grandparentArt')
|
||||
return self._server.url(art, includeToken=True) if art else None
|
||||
|
||||
def url(self, part):
|
||||
""" 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
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Artist(Audio):
|
||||
""" Represents a single audio artist.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'artist'
|
||||
art (str): Artist artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
countries (list): List of :class:`~plexapi.media.Country` objects this artist respresents.
|
||||
genres (list): List of :class:`~plexapi.media.Genre` objects this artist respresents.
|
||||
guid (str): Unknown (unique ID; com.plexapp.agents.plexmusic://gracenote/artist/05517B8701668D28?lang=en)
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
location (str): Filepath this artist is found on disk.
|
||||
similar (list): List of :class:`~plexapi.media.Similar` artists.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'artist'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
self.art = data.attrib.get('art')
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.locations = self.listAttrs(data, 'path', etag='Location')
|
||||
self.countries = self.findItems(data, media.Country)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.similar = self.findItems(data, media.Similar)
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
|
||||
def __iter__(self):
|
||||
for album in self.albums():
|
||||
yield album
|
||||
|
||||
def album(self, title):
|
||||
""" Returns the :class:`~plexapi.audio.Album` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the album to return.
|
||||
"""
|
||||
key = '%s/children' % self.key
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
|
||||
def albums(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Album` objects by this artist. """
|
||||
key = '%s/children' % self.key
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def track(self, title):
|
||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the track to return.
|
||||
"""
|
||||
key = '%s/allLeaves' % self.key
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
|
||||
def tracks(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects by this artist. """
|
||||
key = '%s/allLeaves' % self.key
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def get(self, title):
|
||||
""" Alias of :func:`~plexapi.audio.Artist.track`. """
|
||||
return self.track(title)
|
||||
|
||||
def download(self, savepath=None, keep_orginal_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
|
||||
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
|
||||
be returned and the additional arguments passed in will be sent to that
|
||||
function. If kwargs is not specified, the media items will be downloaded
|
||||
and saved to disk.
|
||||
"""
|
||||
filepaths = []
|
||||
for album in self.albums():
|
||||
for track in album.tracks():
|
||||
filepaths += track.download(savepath, keep_orginal_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Album(Audio):
|
||||
""" Represents a single audio album.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'album'
|
||||
art (str): Album artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
genres (list): List of :class:`~plexapi.media.Genre` objects this album respresents.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
originallyAvailableAt (datetime): Datetime this album was released.
|
||||
parentKey (str): API URL of this artist.
|
||||
parentRatingKey (int): Unique key identifying artist.
|
||||
parentThumb (str): URL to artist thumbnail image.
|
||||
parentTitle (str): Name of the artist for this album.
|
||||
studio (str): Studio that released this album.
|
||||
year (int): Year this album was released.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'album'
|
||||
|
||||
def __iter__(self):
|
||||
for track in self.tracks:
|
||||
yield track
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
self.art = data.attrib.get('art')
|
||||
self.key = self.key.replace('/children', '') # fixes bug #50
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = data.attrib.get('parentRatingKey')
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.studio = data.attrib.get('studio')
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
|
||||
def track(self, title):
|
||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the track to return.
|
||||
"""
|
||||
key = '%s/children' % self.key
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
|
||||
def tracks(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects in this album. """
|
||||
key = '%s/children' % self.key
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def get(self, title):
|
||||
""" Alias of :func:`~plexapi.audio.Album.track`. """
|
||||
return self.track(title)
|
||||
|
||||
def artist(self):
|
||||
""" Return :func:`~plexapi.audio.Artist` of this album. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def download(self, savepath=None, keep_orginal_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
|
||||
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
|
||||
be returned and the additional arguments passed in will be sent to that
|
||||
function. If kwargs is not specified, the media items will be downloaded
|
||||
and saved to disk.
|
||||
"""
|
||||
filepaths = []
|
||||
for track in self.tracks():
|
||||
filepaths += track.download(savepath, keep_orginal_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Track(Audio, Playable):
|
||||
""" Represents a single audio track.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'track'
|
||||
art (str): Track artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
chapterSource (TYPE): Unknown
|
||||
duration (int): Length of this album in seconds.
|
||||
grandparentArt (str): Artist artowrk.
|
||||
grandparentKey (str): Artist API URL.
|
||||
grandparentRatingKey (str): Unique key identifying artist.
|
||||
grandparentThumb (str): URL to artist thumbnail image.
|
||||
grandparentTitle (str): Name of the artist for this track.
|
||||
guid (str): Unknown (unique ID).
|
||||
media (list): List of :class:`~plexapi.media.Media` objects for this track.
|
||||
moods (list): List of :class:`~plexapi.media.Mood` objects for this track.
|
||||
originalTitle (str): Original track title (if translated).
|
||||
parentIndex (int): Album index.
|
||||
parentKey (str): Album API URL.
|
||||
parentRatingKey (int): Unique key identifying album.
|
||||
parentThumb (str): URL to album thumbnail image.
|
||||
parentTitle (str): Name of the album for this track.
|
||||
primaryExtraKey (str): Unknown
|
||||
ratingCount (int): Unknown
|
||||
userRating (float): Rating of this track (0.0 - 10.0) equaling (0 stars - 5 stars)
|
||||
viewOffset (int): Unknown
|
||||
year (int): Year this track was released.
|
||||
sessionKey (int): Session Key (active sessions only).
|
||||
usernames (str): Username of person playing this track (active sessions only).
|
||||
player (str): :class:`~plexapi.client.PlexClient` for playing track (active sessions only).
|
||||
transcodeSessions (None): :class:`~plexapi.media.TranscodeSession` for playing
|
||||
track (active sessions only).
|
||||
"""
|
||||
TAG = 'Track'
|
||||
TYPE = 'track'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
Playable._loadData(self, data)
|
||||
self.art = data.attrib.get('art')
|
||||
self.chapterSource = data.attrib.get('chapterSource')
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.grandparentArt = data.attrib.get('grandparentArt')
|
||||
self.grandparentKey = data.attrib.get('grandparentKey')
|
||||
self.grandparentRatingKey = data.attrib.get('grandparentRatingKey')
|
||||
self.grandparentThumb = data.attrib.get('grandparentThumb')
|
||||
self.grandparentTitle = data.attrib.get('grandparentTitle')
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.originalTitle = data.attrib.get('originalTitle')
|
||||
self.parentIndex = data.attrib.get('parentIndex')
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = data.attrib.get('parentRatingKey')
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
|
||||
self.ratingCount = utils.cast(int, data.attrib.get('ratingCount'))
|
||||
self.userRating = utils.cast(float, data.attrib.get('userRating', 0))
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.moods = self.findItems(data, media.Mood)
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return '%s - %s %s' % (self.grandparentTitle, self.parentTitle, self.title)
|
||||
|
||||
def album(self):
|
||||
""" Return this track's :class:`~plexapi.audio.Album`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def artist(self):
|
||||
""" Return this track's :class:`~plexapi.audio.Artist`. """
|
||||
return self.fetchItem(self.grandparentKey)
|
581
lib/plexapi/base.py
Normal file
581
lib/plexapi/base.py
Normal file
|
@ -0,0 +1,581 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
|
||||
from plexapi import log, utils
|
||||
from plexapi.compat import quote_plus, urlencode
|
||||
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
|
||||
from plexapi.utils import tag_helper
|
||||
|
||||
OPERATORS = {
|
||||
'exact': lambda v, q: v == q,
|
||||
'iexact': lambda v, q: v.lower() == q.lower(),
|
||||
'contains': lambda v, q: q in v,
|
||||
'icontains': lambda v, q: q.lower() in v.lower(),
|
||||
'ne': lambda v, q: v != q,
|
||||
'in': lambda v, q: v in q,
|
||||
'gt': lambda v, q: v > q,
|
||||
'gte': lambda v, q: v >= q,
|
||||
'lt': lambda v, q: v < q,
|
||||
'lte': lambda v, q: v <= q,
|
||||
'startswith': lambda v, q: v.startswith(q),
|
||||
'istartswith': lambda v, q: v.lower().startswith(q),
|
||||
'endswith': lambda v, q: v.endswith(q),
|
||||
'iendswith': lambda v, q: v.lower().endswith(q),
|
||||
'exists': lambda v, q: v is not None if q else v is None,
|
||||
'regex': lambda v, q: re.match(q, v),
|
||||
'iregex': lambda v, q: re.match(q, v, flags=re.IGNORECASE),
|
||||
}
|
||||
|
||||
|
||||
class PlexObject(object):
|
||||
""" Base class for all Plex objects.
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
"""
|
||||
TAG = None # xml element tag
|
||||
TYPE = None # xml element type
|
||||
key = None # plex relative url
|
||||
|
||||
def __init__(self, server, data, initpath=None):
|
||||
self._server = server
|
||||
self._data = data
|
||||
self._initpath = initpath or self.key
|
||||
self._details_key = ''
|
||||
if data is not None:
|
||||
self._loadData(data)
|
||||
|
||||
def __repr__(self):
|
||||
uid = self._clean(self.firstAttr('_baseurl', 'key', 'id', 'playQueueID', 'uri'))
|
||||
name = self._clean(self.firstAttr('title', 'name', 'username', 'product', 'tag', 'value'))
|
||||
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p])
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
# dont overwrite an attr with None unless its a private variable
|
||||
if value is not None or attr.startswith('_') or attr not in self.__dict__:
|
||||
self.__dict__[attr] = value
|
||||
|
||||
def _clean(self, value):
|
||||
""" Clean attr value for display in __repr__. """
|
||||
if value:
|
||||
value = str(value).replace('/library/metadata/', '')
|
||||
value = value.replace('/children', '')
|
||||
return value.replace(' ', '-')[:20]
|
||||
|
||||
def _buildItem(self, elem, cls=None, initpath=None):
|
||||
""" Factory function to build objects based on registered PLEXOBJECTS. """
|
||||
# cls is specified, build the object and return
|
||||
initpath = initpath or self._initpath
|
||||
if cls is not None:
|
||||
return cls(self._server, elem, initpath)
|
||||
# cls is not specified, try looking it up in PLEXOBJECTS
|
||||
etype = elem.attrib.get('type', elem.attrib.get('streamType'))
|
||||
ehash = '%s.%s' % (elem.tag, etype) if etype else elem.tag
|
||||
ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag))
|
||||
# log.debug('Building %s as %s', elem.tag, ecls.__name__)
|
||||
if ecls is not None:
|
||||
return ecls(self._server, elem, initpath)
|
||||
raise UnknownType("Unknown library type <%s type='%s'../>" % (elem.tag, etype))
|
||||
|
||||
def _buildItemOrNone(self, elem, cls=None, initpath=None):
|
||||
""" Calls :func:`~plexapi.base.PlexObject._buildItem()` but returns
|
||||
None if elem is an unknown type.
|
||||
"""
|
||||
try:
|
||||
return self._buildItem(elem, cls, initpath)
|
||||
except UnknownType:
|
||||
return None
|
||||
|
||||
def fetchItem(self, ekey, cls=None, **kwargs):
|
||||
""" Load the specified key to find and build the first item with the
|
||||
specified tag and attrs. If no tag or attrs are specified then
|
||||
the first item in the result set is returned.
|
||||
|
||||
Parameters:
|
||||
ekey (str or int): Path in Plex to fetch items from. If an int is passed
|
||||
in, the key will be translated to /library/metadata/<key>. This allows
|
||||
fetching an item only knowing its key-id.
|
||||
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
|
||||
items to be fetched, passing this in will help the parser ensure
|
||||
it only returns those items. By default we convert the xml elements
|
||||
with the best guess PlexObjects based on tag and type attrs.
|
||||
etag (str): Only fetch items with the specified tag.
|
||||
**kwargs (dict): Optionally add attribute filters on the items to fetch. For
|
||||
example, passing in viewCount=0 will only return matching items. Filtering
|
||||
is done before the Python objects are built to help keep things speedy.
|
||||
Note: Because some attribute names are already used as arguments to this
|
||||
function, such as 'tag', you may still reference the attr tag byappending
|
||||
an underscore. For example, passing in _tag='foobar' will return all items
|
||||
where tag='foobar'. Also Note: Case very much matters when specifying kwargs
|
||||
-- Optionally, operators can be specified by append it
|
||||
to the end of the attribute name for more complex lookups. For example,
|
||||
passing in viewCount__gte=0 will return all items where viewCount >= 0.
|
||||
Available operations include:
|
||||
|
||||
* __contains: Value contains specified arg.
|
||||
* __endswith: Value ends with specified arg.
|
||||
* __exact: Value matches specified arg.
|
||||
* __exists (bool): Value is or is not present in the attrs.
|
||||
* __gt: Value is greater than specified arg.
|
||||
* __gte: Value is greater than or equal to specified arg.
|
||||
* __icontains: Case insensative value contains specified arg.
|
||||
* __iendswith: Case insensative value ends with specified arg.
|
||||
* __iexact: Case insensative value matches specified arg.
|
||||
* __in: Value is in a specified list or tuple.
|
||||
* __iregex: Case insensative value matches the specified regular expression.
|
||||
* __istartswith: Case insensative value starts with specified arg.
|
||||
* __lt: Value is less than specified arg.
|
||||
* __lte: Value is less than or equal to specified arg.
|
||||
* __regex: Value matches the specified regular expression.
|
||||
* __startswith: Value starts with specified arg.
|
||||
"""
|
||||
if isinstance(ekey, int):
|
||||
ekey = '/library/metadata/%s' % ekey
|
||||
for elem in self._server.query(ekey):
|
||||
if self._checkAttrs(elem, **kwargs):
|
||||
return self._buildItem(elem, cls, ekey)
|
||||
clsname = cls.__name__ if cls else 'None'
|
||||
raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs))
|
||||
|
||||
def fetchItems(self, ekey, cls=None, **kwargs):
|
||||
""" Load the specified key to find and build all items with the specified tag
|
||||
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
||||
on how this is used.
|
||||
"""
|
||||
data = self._server.query(ekey)
|
||||
return self.findItems(data, cls, ekey, **kwargs)
|
||||
|
||||
def findItems(self, data, cls=None, initpath=None, **kwargs):
|
||||
""" Load the specified data to find and build all items with the specified tag
|
||||
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
||||
on how this is used.
|
||||
"""
|
||||
# filter on cls attrs if specified
|
||||
if cls and cls.TAG and 'tag' not in kwargs:
|
||||
kwargs['etag'] = cls.TAG
|
||||
if cls and cls.TYPE and 'type' not in kwargs:
|
||||
kwargs['type'] = cls.TYPE
|
||||
# loop through all data elements to find matches
|
||||
items = []
|
||||
for elem in data:
|
||||
if self._checkAttrs(elem, **kwargs):
|
||||
item = self._buildItemOrNone(elem, cls, initpath)
|
||||
if item is not None:
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
def firstAttr(self, *attrs):
|
||||
""" Return the first attribute in attrs that is not None. """
|
||||
for attr in attrs:
|
||||
value = self.__dict__.get(attr)
|
||||
if value is not None:
|
||||
return value
|
||||
|
||||
def listAttrs(self, data, attr, **kwargs):
|
||||
results = []
|
||||
for elem in data:
|
||||
kwargs['%s__exists' % attr] = True
|
||||
if self._checkAttrs(elem, **kwargs):
|
||||
results.append(elem.attrib.get(attr))
|
||||
return results
|
||||
|
||||
def reload(self, key=None):
|
||||
""" Reload the data for this object from self.key. """
|
||||
key = key or self._details_key or self.key
|
||||
if not key:
|
||||
raise Unsupported('Cannot reload an object not built from a URL.')
|
||||
self._initpath = key
|
||||
data = self._server.query(key)
|
||||
self._loadData(data[0])
|
||||
return self
|
||||
|
||||
def _checkAttrs(self, elem, **kwargs):
|
||||
attrsFound = {}
|
||||
for attr, query in kwargs.items():
|
||||
attr, op, operator = self._getAttrOperator(attr)
|
||||
values = self._getAttrValue(elem, attr)
|
||||
# special case query in (None, 0, '') to include missing attr
|
||||
if op == 'exact' and not values and query in (None, 0, ''):
|
||||
return True
|
||||
# return if attr were looking for is missing
|
||||
attrsFound[attr] = False
|
||||
for value in values:
|
||||
value = self._castAttrValue(op, query, value)
|
||||
if operator(value, query):
|
||||
attrsFound[attr] = True
|
||||
break
|
||||
# log.debug('Checking %s for %s found: %s', elem.tag, kwargs, attrsFound)
|
||||
return all(attrsFound.values())
|
||||
|
||||
def _getAttrOperator(self, attr):
|
||||
for op, operator in OPERATORS.items():
|
||||
if attr.endswith('__%s' % op):
|
||||
attr = attr.rsplit('__', 1)[0]
|
||||
return attr, op, operator
|
||||
# default to exact match
|
||||
return attr, 'exact', OPERATORS['exact']
|
||||
|
||||
def _getAttrValue(self, elem, attrstr, results=None):
|
||||
# log.debug('Fetching %s in %s', attrstr, elem.tag)
|
||||
parts = attrstr.split('__', 1)
|
||||
attr = parts[0]
|
||||
attrstr = parts[1] if len(parts) == 2 else None
|
||||
if attrstr:
|
||||
results = [] if results is None else results
|
||||
for child in [c for c in elem if c.tag.lower() == attr.lower()]:
|
||||
results += self._getAttrValue(child, attrstr, results)
|
||||
return [r for r in results if r is not None]
|
||||
# check were looking for the tag
|
||||
if attr.lower() == 'etag':
|
||||
return [elem.tag]
|
||||
# loop through attrs so we can perform case-insensative match
|
||||
for _attr, value in elem.attrib.items():
|
||||
if attr.lower() == _attr.lower():
|
||||
return [value]
|
||||
return []
|
||||
|
||||
def _castAttrValue(self, op, query, value):
|
||||
if op == 'exists':
|
||||
return value
|
||||
if isinstance(query, bool):
|
||||
return bool(int(value))
|
||||
if isinstance(query, int) and '.' in value:
|
||||
return float(value)
|
||||
if isinstance(query, int):
|
||||
return int(value)
|
||||
if isinstance(query, float):
|
||||
return float(value)
|
||||
return value
|
||||
|
||||
def _loadData(self, data):
|
||||
raise NotImplementedError('Abstract method not implemented.')
|
||||
|
||||
|
||||
class PlexPartialObject(PlexObject):
|
||||
""" Not all objects in the Plex listings return the complete list of elements
|
||||
for the object. This object will allow you to assume each object is complete,
|
||||
and if the specified value you request is None it will fetch the full object
|
||||
automatically and update itself.
|
||||
"""
|
||||
|
||||
def __eq__(self, other):
|
||||
return other is not None and self.key == other.key
|
||||
|
||||
def __hash__(self):
|
||||
return hash(repr(self))
|
||||
|
||||
def __iter__(self):
|
||||
yield self
|
||||
|
||||
def __getattribute__(self, attr):
|
||||
# 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 value not in (None, []): return value
|
||||
if self.isFullObject(): return value
|
||||
# Log the reload.
|
||||
clsname = self.__class__.__name__
|
||||
title = self.__dict__.get('title', self.__dict__.get('name'))
|
||||
objname = "%s '%s'" % (clsname, title) if title else clsname
|
||||
log.debug("Reloading %s for attr '%s'" % (objname, attr))
|
||||
# Reload and return the value
|
||||
self.reload()
|
||||
return super(PlexPartialObject, self).__getattribute__(attr)
|
||||
|
||||
def analyze(self):
|
||||
""" Tell Plex Media Server to performs analysis on it this item to gather
|
||||
information. Analysis includes:
|
||||
|
||||
* Gather Media Properties: All of the media you add to a Library has
|
||||
properties that are useful to know–whether it's a video file, a
|
||||
music track, or one of your photos (container, codec, resolution, etc).
|
||||
* Generate Default Artwork: Artwork will automatically be grabbed from a
|
||||
video file. A background image will be pulled out as well as a
|
||||
smaller image to be used for poster/thumbnail type purposes.
|
||||
* Generate Video Preview Thumbnails: Video preview thumbnails are created,
|
||||
if you have that feature enabled. Video preview thumbnails allow
|
||||
graphical seeking in some Apps. It's also used in the Plex Web App Now
|
||||
Playing screen to show a graphical representation of where playback
|
||||
is. Video preview thumbnails creation is a CPU-intensive process akin
|
||||
to transcoding the file.
|
||||
"""
|
||||
key = '/%s/analyze' % self.key.lstrip('/')
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def isFullObject(self):
|
||||
""" Retruns True if this is already a full object. A full object means all attributes
|
||||
were populated from the api path representing only this item. For example, the
|
||||
search result for a movie often only contain a portion of the attributes a full
|
||||
object (main url) for that movie contain.
|
||||
"""
|
||||
return not self.key or self.key == self._initpath
|
||||
|
||||
def isPartialObject(self):
|
||||
""" Returns True if this is not a full object. """
|
||||
return not self.isFullObject()
|
||||
|
||||
def edit(self, **kwargs):
|
||||
""" Edit an object.
|
||||
|
||||
Parameters:
|
||||
kwargs (dict): Dict of settings to edit.
|
||||
|
||||
Example:
|
||||
{'type': 1,
|
||||
'id': movie.ratingKey,
|
||||
'collection[0].tag.tag': 'Super',
|
||||
'collection.locked': 0}
|
||||
"""
|
||||
if 'id' not in kwargs:
|
||||
kwargs['id'] = self.ratingKey
|
||||
if 'type' not in kwargs:
|
||||
kwargs['type'] = utils.searchType(self.type)
|
||||
|
||||
part = '/library/sections/%s/all?%s' % (self.librarySectionID,
|
||||
urlencode(kwargs))
|
||||
self._server.query(part, method=self._server._session.put)
|
||||
|
||||
def _edit_tags(self, tag, items, locked=True, remove=False):
|
||||
""" Helper to edit and refresh a tags.
|
||||
|
||||
Parameters:
|
||||
tag (str): tag name
|
||||
items (list): list of tags to add
|
||||
locked (bool): lock this field.
|
||||
remove (bool): If this is active remove the tags in items.
|
||||
"""
|
||||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
value = getattr(self, tag + 's')
|
||||
existing_cols = [t.tag for t in value if t and remove is False]
|
||||
d = tag_helper(tag, existing_cols + items, locked, remove)
|
||||
self.edit(**d)
|
||||
self.refresh()
|
||||
|
||||
def addCollection(self, collections):
|
||||
""" Add a collection(s).
|
||||
|
||||
Parameters:
|
||||
collections (list): list of strings
|
||||
"""
|
||||
self._edit_tags('collection', collections)
|
||||
|
||||
def removeCollection(self, collections):
|
||||
""" Remove a collection(s). """
|
||||
self._edit_tags('collection', collections, remove=True)
|
||||
|
||||
def addLabel(self, labels):
|
||||
""" Add a label(s). """
|
||||
self._edit_tags('label', labels)
|
||||
|
||||
def removeLabel(self, labels):
|
||||
""" Remove a label(s). """
|
||||
self._edit_tags('label', labels, remove=True)
|
||||
|
||||
def addGenre(self, genres):
|
||||
""" Add a genre(s). """
|
||||
self._edit_tags('genre', genres)
|
||||
|
||||
def removeGenre(self, genres):
|
||||
""" Remove a genre(s). """
|
||||
self._edit_tags('genre', genres, remove=True)
|
||||
|
||||
def refresh(self):
|
||||
""" Refreshing a Library or individual item causes the metadata for the item to be
|
||||
refreshed, even if it already has metadata. You can think of refreshing as
|
||||
"update metadata for the requested item even if it already has some". You should
|
||||
refresh a Library or individual item if:
|
||||
|
||||
* You've changed the Library Metadata Agent.
|
||||
* You've added "Local Media Assets" (such as artwork, theme music, external
|
||||
subtitle files, etc.)
|
||||
* You want to freshen the item posters, summary, etc.
|
||||
* There's a problem with the poster image that's been downloaded.
|
||||
* Items are missing posters or other downloaded information. This is possible if
|
||||
the refresh process is interrupted (the Server is turned off, internet
|
||||
connection dies, etc).
|
||||
"""
|
||||
key = '%s/refresh' % self.key
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def section(self):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
|
||||
return self._server.library.sectionByID(self.librarySectionID)
|
||||
|
||||
def delete(self):
|
||||
""" Delete a media element. This has to be enabled under settings > server > library in plex webui. """
|
||||
try:
|
||||
return self._server.query(self.key, method=self._server._session.delete)
|
||||
except BadRequest: # pragma: no cover
|
||||
log.error('Failed to delete %s. This could be because you '
|
||||
'havnt allowed items to be deleted' % self.key)
|
||||
raise
|
||||
|
||||
# The photo tag cant be built atm. TODO
|
||||
# def arts(self):
|
||||
# part = '%s/arts' % self.key
|
||||
# return self.fetchItem(part)
|
||||
|
||||
# def poster(self):
|
||||
# part = '%s/posters' % self.key
|
||||
# return self.fetchItem(part, etag='Photo')
|
||||
|
||||
|
||||
class Playable(object):
|
||||
""" This is a general place to store functions specific to media that is Playable.
|
||||
Things were getting mixed up a bit when dealing with Shows, Season, Artists,
|
||||
Albums which are all not playable.
|
||||
|
||||
Attributes:
|
||||
sessionKey (int): Active session key.
|
||||
usernames (str): Username of the person playing this item (for active sessions).
|
||||
players (:class:`~plexapi.client.PlexClient`): Client objects playing this item (for active sessions).
|
||||
session (:class:`~plexapi.media.Session`): Session object, for a playing media file.
|
||||
transcodeSessions (:class:`~plexapi.media.TranscodeSession`): Transcode Session object
|
||||
if item is being transcoded (None otherwise).
|
||||
viewedAt (datetime): Datetime item was last viewed (history).
|
||||
playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items).
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey')) # session
|
||||
self.usernames = self.listAttrs(data, 'title', etag='User') # session
|
||||
self.players = self.findItems(data, etag='Player') # session
|
||||
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.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist
|
||||
|
||||
def isFullObject(self):
|
||||
""" Retruns True if this is already a full object. A full object means all attributes
|
||||
were populated from the api path representing only this item. For example, the
|
||||
search result for a movie often only contain a portion of the attributes a full
|
||||
object (main url) for that movie contain.
|
||||
"""
|
||||
return self._details_key == self._initpath or not self.key
|
||||
|
||||
def getStreamURL(self, **params):
|
||||
""" Returns a stream url that may be used by external applications such as VLC.
|
||||
|
||||
Parameters:
|
||||
**params (dict): optional parameters to manipulate the playback when accessing
|
||||
the stream. A few known parameters include: maxVideoBitrate, videoResolution
|
||||
offset, copyts, protocol, mediaIndex, platform.
|
||||
|
||||
Raises:
|
||||
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)
|
||||
mvb = params.get('maxVideoBitrate')
|
||||
vr = params.get('videoResolution', '')
|
||||
params = {
|
||||
'path': self.key,
|
||||
'offset': params.get('offset', 0),
|
||||
'copyts': params.get('copyts', 1),
|
||||
'protocol': params.get('protocol'),
|
||||
'mediaIndex': params.get('mediaIndex', 0),
|
||||
'X-Plex-Platform': params.get('platform', 'Chrome'),
|
||||
'maxVideoBitrate': max(mvb, 64) if mvb else None,
|
||||
'videoResolution': vr if re.match('^\d+x\d+$', vr) else None
|
||||
}
|
||||
# remove None values
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video'
|
||||
# sort the keys since the randomness fucks with my tests..
|
||||
sorted_params = sorted(params.items(), key=lambda val: val[0])
|
||||
return self._server.url('/%s/:/transcode/universal/start.m3u8?%s' %
|
||||
(streamtype, urlencode(sorted_params)), includeToken=True)
|
||||
|
||||
def iterParts(self):
|
||||
""" Iterates over the parts of this media item. """
|
||||
for item in self.media:
|
||||
for part in item.parts:
|
||||
yield part
|
||||
|
||||
def split(self):
|
||||
"""Split a duplicate."""
|
||||
key = '%s/split' % self.key
|
||||
return self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def unmatch(self):
|
||||
"""Unmatch a media file."""
|
||||
key = '%s/unmatch' % self.key
|
||||
return self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def play(self, client):
|
||||
""" Start playback on the specified client.
|
||||
|
||||
Parameters:
|
||||
client (:class:`~plexapi.client.PlexClient`): Client to start playing on.
|
||||
"""
|
||||
client.playMedia(self)
|
||||
|
||||
def download(self, savepath=None, keep_orginal_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
|
||||
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
|
||||
be returned and the additional arguments passed in will be sent to that
|
||||
function. If kwargs is not specified, the media items will be downloaded
|
||||
and saved to disk.
|
||||
"""
|
||||
filepaths = []
|
||||
locations = [i for i in self.iterParts() if i]
|
||||
for location in locations:
|
||||
filename = location.file
|
||||
if keep_orginal_name is False:
|
||||
filename = '%s.%s' % (self._prettyfilename(), location.container)
|
||||
# So this seems to be a alot slower but allows transcode.
|
||||
if kwargs:
|
||||
download_url = self.getStreamURL(**kwargs)
|
||||
else:
|
||||
download_url = self._server.url('%s?download=1' % location.key)
|
||||
filepath = utils.download(download_url, self._server._token, filename=filename,
|
||||
savepath=savepath, session=self._server._session)
|
||||
if filepath:
|
||||
filepaths.append(filepath)
|
||||
return filepaths
|
||||
|
||||
def stop(self, reason=''):
|
||||
""" Stop playback for a media item. """
|
||||
key = '/status/sessions/terminate?sessionId=%s&reason=%s' % (self.session[0].id, quote_plus(reason))
|
||||
return self._server.query(key)
|
||||
|
||||
def updateProgress(self, time, state='stopped'):
|
||||
""" Set the watched progress for this video.
|
||||
|
||||
Note that setting the time to 0 will not work.
|
||||
Use `markWatched` or `markUnwatched` to achieve
|
||||
that goal.
|
||||
|
||||
Parameters:
|
||||
time (int): milliseconds watched
|
||||
state (string): state of the video, default 'stopped'
|
||||
"""
|
||||
key = '/:/progress?key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s' % (self.ratingKey,
|
||||
time, state)
|
||||
self._server.query(key)
|
||||
self.reload()
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Release(PlexObject):
|
||||
TAG = 'Release'
|
||||
key = '/updater/status'
|
||||
|
||||
def _loadData(self, data):
|
||||
self.download_key = data.attrib.get('key')
|
||||
self.version = data.attrib.get('version')
|
||||
self.added = data.attrib.get('added')
|
||||
self.fixed = data.attrib.get('fixed')
|
||||
self.downloadURL = data.attrib.get('downloadURL')
|
||||
self.state = data.attrib.get('state')
|
527
lib/plexapi/client.py
Normal file
527
lib/plexapi/client.py
Normal file
|
@ -0,0 +1,527 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import requests
|
||||
|
||||
from requests.status_codes import _codes as codes
|
||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
|
||||
from plexapi import log, logfilter, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.compat import ElementTree
|
||||
from plexapi.exceptions import BadRequest, Unsupported
|
||||
from plexapi.playqueue import PlayQueue
|
||||
|
||||
|
||||
DEFAULT_MTYPE = 'video'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class PlexClient(PlexObject):
|
||||
""" Main class for interacting with a Plex client. This class can connect
|
||||
directly to the client and control it or proxy commands through your
|
||||
Plex Server. To better understand the Plex client API's read this page:
|
||||
https://github.com/plexinc/plex-media-player/wiki/Remote-control-API
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional).
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
initpath (str): Path used to generate data.
|
||||
baseurl (str): HTTP URL to connect dirrectly to this client.
|
||||
token (str): X-Plex-Token used for authenication (optional).
|
||||
session (:class:`~requests.Session`): requests.Session object if you want more control (optional).
|
||||
timeout (int): timeout in seconds on initial connect to client (default config.TIMEOUT).
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Player'
|
||||
key (str): '/resources'
|
||||
device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc).
|
||||
deviceClass (str): Device class (pc, phone, etc).
|
||||
machineIdentifier (str): Unique ID for this device.
|
||||
model (str): Unknown
|
||||
platform (str): Unknown
|
||||
platformVersion (str): Description
|
||||
product (str): Client Product (Plex for iOS, etc).
|
||||
protocol (str): Always seems ot be 'plex'.
|
||||
protocolCapabilities (list<str>): List of client capabilities (navigation, playback,
|
||||
timeline, mirror, playqueues).
|
||||
protocolVersion (str): Protocol version (1, future proofing?)
|
||||
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
|
||||
session (:class:`~requests.Session`): Session object used for connection.
|
||||
state (str): Unknown
|
||||
title (str): Name of this client (Johns iPhone, etc).
|
||||
token (str): X-Plex-Token used for authenication
|
||||
vendor (str): Unknown
|
||||
version (str): Device version (4.6.1, etc).
|
||||
_baseurl (str): HTTP address of the client.
|
||||
_token (str): Token used to access this client.
|
||||
_session (obj): Requests session object used to access this client.
|
||||
_proxyThroughServer (bool): Set to True after calling
|
||||
:func:`~plexapi.client.PlexClient.proxyThroughServer()` (default False).
|
||||
"""
|
||||
TAG = 'Player'
|
||||
key = '/resources'
|
||||
|
||||
def __init__(self, server=None, data=None, initpath=None, baseurl=None,
|
||||
token=None, connect=True, session=None, timeout=None):
|
||||
super(PlexClient, self).__init__(server, data, initpath)
|
||||
self._baseurl = baseurl.strip('/') if baseurl else None
|
||||
self._token = logfilter.add_secret(token)
|
||||
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
|
||||
server_session = server._session if server else None
|
||||
self._session = session or server_session or requests.Session()
|
||||
self._proxyThroughServer = False
|
||||
self._commandId = 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'))
|
||||
if connect and self._baseurl:
|
||||
self.connect(timeout=timeout)
|
||||
|
||||
def _nextCommandId(self):
|
||||
self._commandId += 1
|
||||
return self._commandId
|
||||
|
||||
def connect(self, timeout=None):
|
||||
""" Alias of reload as any subsequent requests to this client will be
|
||||
made directly to the device even if the object attributes were initially
|
||||
populated from a PlexServer.
|
||||
"""
|
||||
if not self.key:
|
||||
raise Unsupported('Cannot reload an object not built from a URL.')
|
||||
self._initpath = self.key
|
||||
data = self.query(self.key, timeout=timeout)
|
||||
self._loadData(data[0])
|
||||
return self
|
||||
|
||||
def reload(self):
|
||||
""" Alias to self.connect(). """
|
||||
return self.connect()
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.deviceClass = data.attrib.get('deviceClass')
|
||||
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
||||
self.product = data.attrib.get('product')
|
||||
self.protocol = data.attrib.get('protocol')
|
||||
self.protocolCapabilities = data.attrib.get('protocolCapabilities', '').split(',')
|
||||
self.protocolVersion = data.attrib.get('protocolVersion')
|
||||
self.platform = data.attrib.get('platform')
|
||||
self.platformVersion = data.attrib.get('platformVersion')
|
||||
self.title = data.attrib.get('title') or data.attrib.get('name')
|
||||
# Active session details
|
||||
# Since protocolCapabilities is missing from /sessions we cant really control this player without
|
||||
# creating a client manually.
|
||||
# Add this in next breaking release.
|
||||
# if self._initpath == 'status/sessions':
|
||||
self.device = data.attrib.get('device') # session
|
||||
self.model = data.attrib.get('model') # session
|
||||
self.state = data.attrib.get('state') # session
|
||||
self.vendor = data.attrib.get('vendor') # session
|
||||
self.version = data.attrib.get('version') # session
|
||||
self.local = utils.cast(bool, data.attrib.get('local', 0))
|
||||
self.address = data.attrib.get('address') # session
|
||||
self.remotePublicAddress = data.attrib.get('remotePublicAddress')
|
||||
self.userID = data.attrib.get('userID')
|
||||
|
||||
def _headers(self, **kwargs):
|
||||
""" Returns a dict of all default headers for Client requests. """
|
||||
headers = BASE_HEADERS
|
||||
if self._token:
|
||||
headers['X-Plex-Token'] = self._token
|
||||
headers.update(kwargs)
|
||||
return headers
|
||||
|
||||
def proxyThroughServer(self, value=True, server=None):
|
||||
""" Tells this PlexClient instance to proxy all future commands through the PlexServer.
|
||||
Useful if you do not wish to connect directly to the Client device itself.
|
||||
|
||||
Parameters:
|
||||
value (bool): Enable or disable proxying (optional, default True).
|
||||
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server.
|
||||
"""
|
||||
if server:
|
||||
self._server = server
|
||||
if value is True and not self._server:
|
||||
raise Unsupported('Cannot use client proxy with unknown server.')
|
||||
self._proxyThroughServer = value
|
||||
|
||||
def query(self, path, method=None, headers=None, timeout=None, **kwargs):
|
||||
""" Main method used to handle HTTPS requests to the Plex client. This method helps
|
||||
by encoding the response to utf-8 and parsing the returned XML into and
|
||||
ElementTree object. Returns None if no data exists in the response.
|
||||
"""
|
||||
url = self.url(path)
|
||||
method = method or self._session.get
|
||||
timeout = timeout or TIMEOUT
|
||||
log.debug('%s %s', method.__name__.upper(), url)
|
||||
headers = self._headers(**headers or {})
|
||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
||||
if response.status_code not in (200, 201):
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
|
||||
raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext))
|
||||
data = response.text.encode('utf8')
|
||||
return ElementTree.fromstring(data) if data.strip() else None
|
||||
|
||||
def sendCommand(self, command, proxy=None, **params):
|
||||
""" Convenience wrapper around :func:`~plexapi.client.PlexClient.query()` to more easily
|
||||
send simple commands to the client. Returns an ElementTree object containing
|
||||
the response.
|
||||
|
||||
Parameters:
|
||||
command (str): Command to be sent in for format '<controller>/<command>'.
|
||||
proxy (bool): Set True to proxy this command through the PlexServer.
|
||||
**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.
|
||||
"""
|
||||
command = command.strip('/')
|
||||
controller = command.split('/')[0]
|
||||
if controller not in self.protocolCapabilities:
|
||||
log.debug('Client %s doesnt support %s controller.'
|
||||
'What your trying might not work' % (self.title, controller))
|
||||
|
||||
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)
|
||||
|
||||
def url(self, key, includeToken=False):
|
||||
""" Build a URL string with proper token argument. Token will be appended to the URL
|
||||
if either includeToken is True or CONFIG.log.show_secrets is 'true'.
|
||||
"""
|
||||
if not self._baseurl:
|
||||
raise BadRequest('PlexClient object missing baseurl.')
|
||||
if self._token and (includeToken or self._showSecrets):
|
||||
delim = '&' if '?' in key else '?'
|
||||
return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token)
|
||||
return '%s%s' % (self._baseurl, key)
|
||||
|
||||
# ---------------------
|
||||
# Navigation Commands
|
||||
# These commands navigate around the user-interface.
|
||||
def contextMenu(self):
|
||||
""" Open the context menu on the client. """
|
||||
self.sendCommand('navigation/contextMenu')
|
||||
|
||||
def goBack(self):
|
||||
""" Navigate back one position. """
|
||||
self.sendCommand('navigation/back')
|
||||
|
||||
def goToHome(self):
|
||||
""" Go directly to the home screen. """
|
||||
self.sendCommand('navigation/home')
|
||||
|
||||
def goToMusic(self):
|
||||
""" Go directly to the playing music panel. """
|
||||
self.sendCommand('navigation/music')
|
||||
|
||||
def moveDown(self):
|
||||
""" Move selection down a position. """
|
||||
self.sendCommand('navigation/moveDown')
|
||||
|
||||
def moveLeft(self):
|
||||
""" Move selection left a position. """
|
||||
self.sendCommand('navigation/moveLeft')
|
||||
|
||||
def moveRight(self):
|
||||
""" Move selection right a position. """
|
||||
self.sendCommand('navigation/moveRight')
|
||||
|
||||
def moveUp(self):
|
||||
""" Move selection up a position. """
|
||||
self.sendCommand('navigation/moveUp')
|
||||
|
||||
def nextLetter(self):
|
||||
""" Jump to next letter in the alphabet. """
|
||||
self.sendCommand('navigation/nextLetter')
|
||||
|
||||
def pageDown(self):
|
||||
""" Move selection down a full page. """
|
||||
self.sendCommand('navigation/pageDown')
|
||||
|
||||
def pageUp(self):
|
||||
""" Move selection up a full page. """
|
||||
self.sendCommand('navigation/pageUp')
|
||||
|
||||
def previousLetter(self):
|
||||
""" Jump to previous letter in the alphabet. """
|
||||
self.sendCommand('navigation/previousLetter')
|
||||
|
||||
def select(self):
|
||||
""" Select element at the current position. """
|
||||
self.sendCommand('navigation/select')
|
||||
|
||||
def toggleOSD(self):
|
||||
""" Toggle the on screen display during playback. """
|
||||
self.sendCommand('navigation/toggleOSD')
|
||||
|
||||
def goToMedia(self, media, **params):
|
||||
""" Navigate directly to the specified media page.
|
||||
|
||||
Parameters:
|
||||
media (:class:`~plexapi.media.Media`): Media object to navigate to.
|
||||
**params (dict): Additional GET parameters to include with the command.
|
||||
|
||||
Raises:
|
||||
: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(':')
|
||||
self.sendCommand('mirror/details', **dict({
|
||||
'machineIdentifier': self._server.machineIdentifier,
|
||||
'address': server_url[1].strip('/'),
|
||||
'port': server_url[-1],
|
||||
'key': media.key,
|
||||
}, **params))
|
||||
|
||||
# -------------------
|
||||
# Playback Commands
|
||||
# Most of the playback commands take a mandatory mtype {'music','photo','video'} argument,
|
||||
# to specify which media type to apply the command to, (except for playMedia). This
|
||||
# is in case there are multiple things happening (e.g. music in the background, photo
|
||||
# slideshow in the foreground).
|
||||
def pause(self, mtype=DEFAULT_MTYPE):
|
||||
""" Pause the currently playing media type.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/pause', type=mtype)
|
||||
|
||||
def play(self, mtype=DEFAULT_MTYPE):
|
||||
""" Start playback for the specified media type.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/play', type=mtype)
|
||||
|
||||
def refreshPlayQueue(self, playQueueID, mtype=DEFAULT_MTYPE):
|
||||
""" Refresh the specified Playqueue.
|
||||
|
||||
Parameters:
|
||||
playQueueID (str): Playqueue ID.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand(
|
||||
'playback/refreshPlayQueue', playQueueID=playQueueID, type=mtype)
|
||||
|
||||
def seekTo(self, offset, mtype=DEFAULT_MTYPE):
|
||||
""" Seek to the specified offset (ms) during playback.
|
||||
|
||||
Parameters:
|
||||
offset (int): Position to seek to (milliseconds).
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/seekTo', offset=offset, type=mtype)
|
||||
|
||||
def skipNext(self, mtype=DEFAULT_MTYPE):
|
||||
""" Skip to the next playback item.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/skipNext', type=mtype)
|
||||
|
||||
def skipPrevious(self, mtype=DEFAULT_MTYPE):
|
||||
""" Skip to previous playback item.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/skipPrevious', type=mtype)
|
||||
|
||||
def skipTo(self, key, mtype=DEFAULT_MTYPE):
|
||||
""" Skip to the playback item with the specified key.
|
||||
|
||||
Parameters:
|
||||
key (str): Key of the media item to skip to.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/skipTo', key=key, type=mtype)
|
||||
|
||||
def stepBack(self, mtype=DEFAULT_MTYPE):
|
||||
""" Step backward a chunk of time in the current playback item.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/stepBack', type=mtype)
|
||||
|
||||
def stepForward(self, mtype=DEFAULT_MTYPE):
|
||||
""" Step forward a chunk of time in the current playback item.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/stepForward', type=mtype)
|
||||
|
||||
def stop(self, mtype=DEFAULT_MTYPE):
|
||||
""" Stop the currently playing item.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/stop', type=mtype)
|
||||
|
||||
def setRepeat(self, repeat, mtype=DEFAULT_MTYPE):
|
||||
""" Enable repeat for the specified playback items.
|
||||
|
||||
Parameters:
|
||||
repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall).
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setParameters(repeat=repeat, mtype=mtype)
|
||||
|
||||
def setShuffle(self, shuffle, mtype=DEFAULT_MTYPE):
|
||||
""" Enable shuffle for the specified playback items.
|
||||
|
||||
Parameters:
|
||||
shuffle (int): Shuffle mode (0=off, 1=on)
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setParameters(shuffle=shuffle, mtype=mtype)
|
||||
|
||||
def setVolume(self, volume, mtype=DEFAULT_MTYPE):
|
||||
""" Enable volume for the current playback item.
|
||||
|
||||
Parameters:
|
||||
volume (int): Volume level (0-100).
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setParameters(volume=volume, mtype=mtype)
|
||||
|
||||
def setAudioStream(self, audioStreamID, mtype=DEFAULT_MTYPE):
|
||||
""" Select the audio stream for the current playback item (only video).
|
||||
|
||||
Parameters:
|
||||
audioStreamID (str): ID of the audio stream from the media object.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setStreams(audioStreamID=audioStreamID, mtype=mtype)
|
||||
|
||||
def setSubtitleStream(self, subtitleStreamID, mtype=DEFAULT_MTYPE):
|
||||
""" Select the subtitle stream for the current playback item (only video).
|
||||
|
||||
Parameters:
|
||||
subtitleStreamID (str): ID of the subtitle stream from the media object.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setStreams(subtitleStreamID=subtitleStreamID, mtype=mtype)
|
||||
|
||||
def setVideoStream(self, videoStreamID, mtype=DEFAULT_MTYPE):
|
||||
""" Select the video stream for the current playback item (only video).
|
||||
|
||||
Parameters:
|
||||
videoStreamID (str): ID of the video stream from the media object.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setStreams(videoStreamID=videoStreamID, mtype=mtype)
|
||||
|
||||
def playMedia(self, media, offset=0, **params):
|
||||
""" Start playback of the specified media item. See also:
|
||||
|
||||
Parameters:
|
||||
media (:class:`~plexapi.media.Media`): Media item to be played back
|
||||
(movie, music, photo, playlist, playqueue).
|
||||
offset (int): Number of milliseconds at which to start playing with zero
|
||||
representing the beginning (default 0).
|
||||
**params (dict): Optional additional parameters to include in the playback request. See
|
||||
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.
|
||||
"""
|
||||
if not self._server:
|
||||
raise Unsupported('A server must be specified before using this command.')
|
||||
server_url = media._server._baseurl.split(':')
|
||||
|
||||
if self.product != 'OpenPHT':
|
||||
try:
|
||||
self.sendCommand('timeline/subscribe', port=server_url[1].strip('/'), 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,
|
||||
# but it might still work so we swallow it.
|
||||
log.exception('%s failed to subscribe ' % self.title)
|
||||
|
||||
playqueue = media if isinstance(media, PlayQueue) else self._server.createPlayQueue(media)
|
||||
self.sendCommand('playback/playMedia', **dict({
|
||||
'machineIdentifier': self._server.machineIdentifier,
|
||||
'address': server_url[1].strip('/'),
|
||||
'port': server_url[-1],
|
||||
'offset': offset,
|
||||
'key': media.key,
|
||||
'token': media._server._token,
|
||||
'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID,
|
||||
}, **params))
|
||||
|
||||
def setParameters(self, volume=None, shuffle=None, repeat=None, mtype=DEFAULT_MTYPE):
|
||||
""" Set multiple playback parameters at once.
|
||||
|
||||
Parameters:
|
||||
volume (int): Volume level (0-100; optional).
|
||||
shuffle (int): Shuffle mode (0=off, 1=on; optional).
|
||||
repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall; optional).
|
||||
mtype (str): Media type to take action against (optional music, photo, video).
|
||||
"""
|
||||
params = {}
|
||||
if repeat is not None:
|
||||
params['repeat'] = repeat
|
||||
if shuffle is not None:
|
||||
params['shuffle'] = shuffle
|
||||
if volume is not None:
|
||||
params['volume'] = volume
|
||||
if mtype is not None:
|
||||
params['type'] = mtype
|
||||
self.sendCommand('playback/setParameters', **params)
|
||||
|
||||
def setStreams(self, audioStreamID=None, subtitleStreamID=None, videoStreamID=None, mtype=DEFAULT_MTYPE):
|
||||
""" Select multiple playback streams at once.
|
||||
|
||||
Parameters:
|
||||
audioStreamID (str): ID of the audio stream from the media object.
|
||||
subtitleStreamID (str): ID of the subtitle stream from the media object.
|
||||
videoStreamID (str): ID of the video stream from the media object.
|
||||
mtype (str): Media type to take action against (optional music, photo, video).
|
||||
"""
|
||||
params = {}
|
||||
if audioStreamID is not None:
|
||||
params['audioStreamID'] = audioStreamID
|
||||
if subtitleStreamID is not None:
|
||||
params['subtitleStreamID'] = subtitleStreamID
|
||||
if videoStreamID is not None:
|
||||
params['videoStreamID'] = videoStreamID
|
||||
if mtype is not None:
|
||||
params['type'] = mtype
|
||||
self.sendCommand('playback/setStreams', **params)
|
||||
|
||||
# -------------------
|
||||
# Timeline Commands
|
||||
def timeline(self):
|
||||
""" Poll the current timeline and return the XML response. """
|
||||
return self.sendCommand('timeline/poll', wait=1)
|
||||
|
||||
def isPlayingMedia(self, includePaused=False):
|
||||
""" Returns True if any media is currently playing.
|
||||
|
||||
Parameters:
|
||||
includePaused (bool): Set True to treat currently paused items
|
||||
as playing (optional; default True).
|
||||
"""
|
||||
for mediatype in self.timeline():
|
||||
if mediatype.get('state') == 'playing':
|
||||
return True
|
||||
if includePaused and mediatype.get('state') == 'paused':
|
||||
return True
|
||||
return False
|
53
lib/plexapi/compat.py
Normal file
53
lib/plexapi/compat.py
Normal file
|
@ -0,0 +1,53 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Python 2/3 compatability
|
||||
# Always try Py3 first
|
||||
import os
|
||||
from sys import version_info
|
||||
|
||||
ustr = str
|
||||
if version_info < (3,):
|
||||
ustr = unicode
|
||||
|
||||
try:
|
||||
string_type = basestring
|
||||
except NameError:
|
||||
string_type = str
|
||||
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
except ImportError:
|
||||
from urllib import urlencode
|
||||
|
||||
try:
|
||||
from urllib.parse import quote
|
||||
except ImportError:
|
||||
from urllib import quote
|
||||
|
||||
try:
|
||||
from urllib.parse import quote_plus
|
||||
except ImportError:
|
||||
from urllib import quote_plus
|
||||
|
||||
try:
|
||||
from urllib.parse import unquote
|
||||
except ImportError:
|
||||
from urllib import unquote
|
||||
|
||||
try:
|
||||
from configparser import ConfigParser
|
||||
except ImportError:
|
||||
from ConfigParser import ConfigParser
|
||||
|
||||
try:
|
||||
from xml.etree import cElementTree as ElementTree
|
||||
except ImportError:
|
||||
from xml.etree import ElementTree
|
||||
|
||||
|
||||
def makedirs(name, mode=0o777, exist_ok=False):
|
||||
""" Mimicks os.makedirs() from Python 3. """
|
||||
try:
|
||||
os.makedirs(name, mode)
|
||||
except OSError:
|
||||
if not os.path.isdir(name) or not exist_ok:
|
||||
raise
|
63
lib/plexapi/config.py
Normal file
63
lib/plexapi/config.py
Normal file
|
@ -0,0 +1,63 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from plexapi.compat import ConfigParser
|
||||
|
||||
|
||||
class PlexConfig(ConfigParser):
|
||||
""" PlexAPI configuration object. Settings are stored in an INI file within the
|
||||
user's home directory and can be overridden after importing plexapi by simply
|
||||
setting the value. See the documentation section 'Configuration' for more
|
||||
details on available options.
|
||||
|
||||
Parameters:
|
||||
path (str): Path of the configuration file to load.
|
||||
"""
|
||||
def __init__(self, path):
|
||||
ConfigParser.__init__(self)
|
||||
self.read(path)
|
||||
self.data = self._asDict()
|
||||
|
||||
def get(self, key, default=None, cast=None):
|
||||
""" Returns the specified configuration value or <default> if not found.
|
||||
|
||||
Parameters:
|
||||
key (str): Configuration variable to load in the format '<section>.<variable>'.
|
||||
default: Default value to use if key not found.
|
||||
cast (func): Cast the value to the specified type before returning.
|
||||
"""
|
||||
try:
|
||||
# First: check environment variable is set
|
||||
envkey = 'PLEXAPI_%s' % key.upper().replace('.', '_')
|
||||
value = os.environ.get(envkey)
|
||||
if value is None:
|
||||
# Second: check the config file has attr
|
||||
section, name = key.lower().split('.')
|
||||
value = self.data.get(section, {}).get(name, default)
|
||||
return cast(value) if cast else value
|
||||
except: # noqa: E722
|
||||
return default
|
||||
|
||||
def _asDict(self):
|
||||
""" Returns all configuration values as a dictionary. """
|
||||
config = defaultdict(dict)
|
||||
for section in self._sections:
|
||||
for name, value in self._sections[section].items():
|
||||
if name != '__name__':
|
||||
config[section.lower()][name.lower()] = value
|
||||
return dict(config)
|
||||
|
||||
|
||||
def reset_base_headers():
|
||||
""" Convenience function returns a dict of all base X-Plex-* headers for session requests. """
|
||||
import plexapi
|
||||
return {
|
||||
'X-Plex-Platform': plexapi.X_PLEX_PLATFORM,
|
||||
'X-Plex-Platform-Version': plexapi.X_PLEX_PLATFORM_VERSION,
|
||||
'X-Plex-Provides': plexapi.X_PLEX_PROVIDES,
|
||||
'X-Plex-Product': plexapi.X_PLEX_PRODUCT,
|
||||
'X-Plex-Version': plexapi.X_PLEX_VERSION,
|
||||
'X-Plex-Device': plexapi.X_PLEX_DEVICE,
|
||||
'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME,
|
||||
'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER,
|
||||
}
|
31
lib/plexapi/exceptions.py
Normal file
31
lib/plexapi/exceptions.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
class PlexApiException(Exception):
|
||||
""" Base class for all PlexAPI exceptions. """
|
||||
pass
|
||||
|
||||
|
||||
class BadRequest(PlexApiException):
|
||||
""" An invalid request, generally a user error. """
|
||||
pass
|
||||
|
||||
|
||||
class NotFound(PlexApiException):
|
||||
""" Request media item or device is not found. """
|
||||
pass
|
||||
|
||||
|
||||
class UnknownType(PlexApiException):
|
||||
""" Unknown library type. """
|
||||
pass
|
||||
|
||||
|
||||
class Unsupported(PlexApiException):
|
||||
""" Unsupported client request. """
|
||||
pass
|
||||
|
||||
|
||||
class Unauthorized(PlexApiException):
|
||||
""" Invalid username or password. """
|
||||
pass
|
716
lib/plexapi/library.py
Normal file
716
lib/plexapi/library.py
Normal file
|
@ -0,0 +1,716 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from plexapi import X_PLEX_CONTAINER_SIZE, log, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.compat import unquote, urlencode, quote_plus
|
||||
from plexapi.media import MediaTag
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
|
||||
|
||||
class Library(PlexObject):
|
||||
""" Represents a PlexServer library. This contains all sections of media defined
|
||||
in your Plex server including video, shows and audio.
|
||||
|
||||
Attributes:
|
||||
key (str): '/library'
|
||||
identifier (str): Unknown ('com.plexapp.plugins.library').
|
||||
mediaTagVersion (str): Unknown (/system/bundle/media/flags/)
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to.
|
||||
title1 (str): 'Plex Library' (not sure how useful this is).
|
||||
title2 (str): Second title (this is blank on my setup).
|
||||
"""
|
||||
key = '/library'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self._sectionsByID = {} # cached Section UUIDs
|
||||
self.identifier = data.attrib.get('identifier')
|
||||
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
|
||||
self.title1 = data.attrib.get('title1')
|
||||
self.title2 = data.attrib.get('title2')
|
||||
|
||||
def sections(self):
|
||||
""" Returns a list of all media sections in this library. Library sections may be any of
|
||||
:class:`~plexapi.library.MovieSection`, :class:`~plexapi.library.ShowSection`,
|
||||
:class:`~plexapi.library.MusicSection`, :class:`~plexapi.library.PhotoSection`.
|
||||
"""
|
||||
key = '/library/sections'
|
||||
sections = []
|
||||
for elem in self._server.query(key):
|
||||
for cls in (MovieSection, ShowSection, MusicSection, PhotoSection):
|
||||
if elem.attrib.get('type') == cls.TYPE:
|
||||
section = cls(self._server, elem, key)
|
||||
self._sectionsByID[section.key] = section
|
||||
sections.append(section)
|
||||
return sections
|
||||
|
||||
def section(self, title=None):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the section to return.
|
||||
"""
|
||||
for section in self.sections():
|
||||
if section.title.lower() == title.lower():
|
||||
return section
|
||||
raise NotFound('Invalid library section: %s' % title)
|
||||
|
||||
def sectionByID(self, sectionID):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` that matches the specified sectionID.
|
||||
|
||||
Parameters:
|
||||
sectionID (str): ID of the section to return.
|
||||
"""
|
||||
if not self._sectionsByID or sectionID not in self._sectionsByID:
|
||||
self.sections()
|
||||
return self._sectionsByID[sectionID]
|
||||
|
||||
def all(self, **kwargs):
|
||||
""" Returns a list of all media from all library sections.
|
||||
This may be a very large dataset to retrieve.
|
||||
"""
|
||||
items = []
|
||||
for section in self.sections():
|
||||
for item in section.all(**kwargs):
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
def onDeck(self):
|
||||
""" Returns a list of all media items on deck. """
|
||||
return self.fetchItems('/library/onDeck')
|
||||
|
||||
def recentlyAdded(self):
|
||||
""" Returns a list of all media items recently added. """
|
||||
return self.fetchItems('/library/recentlyAdded')
|
||||
|
||||
def search(self, title=None, libtype=None, **kwargs):
|
||||
""" Searching within a library section is much more powerful. It seems certain
|
||||
attributes on the media objects can be targeted to filter this search down
|
||||
a bit, but I havent found the documentation for it.
|
||||
|
||||
Example: "studio=Comedy%20Central" or "year=1999" "title=Kung Fu" all work. Other items
|
||||
such as actor=<id> seem to work, but require you already know the id of the actor.
|
||||
TLDR: This is untested but seems to work. Use library section search when you can.
|
||||
"""
|
||||
args = {}
|
||||
if title:
|
||||
args['title'] = title
|
||||
if libtype:
|
||||
args['type'] = utils.searchType(libtype)
|
||||
for attr, value in kwargs.items():
|
||||
args[attr] = value
|
||||
key = '/library/all%s' % utils.joinArgs(args)
|
||||
return self.fetchItems(key)
|
||||
|
||||
def cleanBundles(self):
|
||||
""" Poster images and other metadata for items in your library are kept in "bundle"
|
||||
packages. When you remove items from your library, these bundles aren't immediately
|
||||
removed. Removing these old bundles can reduce the size of your install. By default, your
|
||||
server will automatically clean up old bundles once a week as part of Scheduled Tasks.
|
||||
"""
|
||||
# TODO: Should this check the response for success or the correct mediaprefix?
|
||||
self._server.query('/library/clean/bundles')
|
||||
|
||||
def emptyTrash(self):
|
||||
""" If a library has items in the Library Trash, use this option to empty the Trash. """
|
||||
for section in self.sections():
|
||||
section.emptyTrash()
|
||||
|
||||
def optimize(self):
|
||||
""" The Optimize option cleans up the server database from unused or fragmented data.
|
||||
For example, if you have deleted or added an entire library or many items in a
|
||||
library, you may like to optimize the database.
|
||||
"""
|
||||
self._server.query('/library/optimize')
|
||||
|
||||
def update(self):
|
||||
""" Scan this library for new items."""
|
||||
self._server.query('/library/sections/all/refresh')
|
||||
|
||||
def cancelUpdate(self):
|
||||
""" Cancel a library update. """
|
||||
key = '/library/sections/all/refresh'
|
||||
self._server.query(key, method=self._server._session.delete)
|
||||
|
||||
def refresh(self):
|
||||
""" Forces a download of fresh media information from the internet.
|
||||
This can take a long time. Any locked fields are not modified.
|
||||
"""
|
||||
self._server.query('/library/sections/all/refresh?force=1')
|
||||
|
||||
def deleteMediaPreviews(self):
|
||||
""" Delete the preview thumbnails for the all sections. This cannot be
|
||||
undone. Recreating media preview files can take hours or even days.
|
||||
"""
|
||||
for section in self.sections():
|
||||
section.deleteMediaPreviews()
|
||||
|
||||
def add(self, name='', type='', agent='', scanner='', location='', language='en', *args, **kwargs):
|
||||
""" Simplified add for the most common options.
|
||||
|
||||
Parameters:
|
||||
name (str): Name of the library
|
||||
agent (str): Example com.plexapp.agents.imdb
|
||||
type (str): movie, show, # check me
|
||||
location (str): /path/to/files
|
||||
language (str): Two letter language fx en
|
||||
kwargs (dict): Advanced options should be passed as a dict. where the id is the key.
|
||||
|
||||
**Photo Preferences**
|
||||
|
||||
* **agent** (str): com.plexapp.agents.none
|
||||
* **enableAutoPhotoTags** (bool): Tag photos. Default value false.
|
||||
* **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true.
|
||||
* **includeInGlobal** (bool): Include in dashboard. Default value true.
|
||||
* **scanner** (str): Plex Photo Scanner
|
||||
|
||||
**Movie Preferences**
|
||||
|
||||
* **agent** (str): com.plexapp.agents.none, com.plexapp.agents.imdb, com.plexapp.agents.themoviedb
|
||||
* **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true.
|
||||
* **enableCinemaTrailers** (bool): Enable Cinema Trailers. Default value true.
|
||||
* **includeInGlobal** (bool): Include in dashboard. Default value true.
|
||||
* **scanner** (str): Plex Movie Scanner, Plex Video Files Scanner
|
||||
|
||||
**IMDB Movie Options** (com.plexapp.agents.imdb)
|
||||
|
||||
* **title** (bool): Localized titles. Default value false.
|
||||
* **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true.
|
||||
* **only_trailers** (bool): Skip extras which aren't trailers. Default value false.
|
||||
* **redband** (bool): Use red band (restricted audiences) trailers when available. Default value false.
|
||||
* **native_subs** (bool): Include extras with subtitles in Library language. Default value false.
|
||||
* **cast_list** (int): Cast List Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database.
|
||||
* **ratings** (int): Ratings Source, Default value 0 Possible options:
|
||||
0:Rotten Tomatoes, 1:IMDb, 2:The Movie Database.
|
||||
* **summary** (int): Plot Summary Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database.
|
||||
* **country** (int): Default value 46 Possible options 0:Argentina, 1:Australia, 2:Austria,
|
||||
3:Belgium, 4:Belize, 5:Bolivia, 6:Brazil, 7:Canada, 8:Chile, 9:Colombia, 10:Costa Rica,
|
||||
11:Czech Republic, 12:Denmark, 13:Dominican Republic, 14:Ecuador, 15:El Salvador,
|
||||
16:France, 17:Germany, 18:Guatemala, 19:Honduras, 20:Hong Kong SAR, 21:Ireland,
|
||||
22:Italy, 23:Jamaica, 24:Korea, 25:Liechtenstein, 26:Luxembourg, 27:Mexico, 28:Netherlands,
|
||||
29:New Zealand, 30:Nicaragua, 31:Panama, 32:Paraguay, 33:Peru, 34:Portugal,
|
||||
35:Peoples Republic of China, 36:Puerto Rico, 37:Russia, 38:Singapore, 39:South Africa,
|
||||
40:Spain, 41:Sweden, 42:Switzerland, 43:Taiwan, 44:Trinidad, 45:United Kingdom,
|
||||
46:United States, 47:Uruguay, 48:Venezuela.
|
||||
* **collections** (bool): Use collection info from The Movie Database. Default value false.
|
||||
* **localart** (bool): Prefer artwork based on library language. Default value true.
|
||||
* **adult** (bool): Include adult content. Default value false.
|
||||
* **usage** (bool): Send anonymous usage data to Plex. Default value true.
|
||||
|
||||
**TheMovieDB Movie Options** (com.plexapp.agents.themoviedb)
|
||||
|
||||
* **collections** (bool): Use collection info from The Movie Database. Default value false.
|
||||
* **localart** (bool): Prefer artwork based on library language. Default value true.
|
||||
* **adult** (bool): Include adult content. Default value false.
|
||||
* **country** (int): Country (used for release date and content rating). Default value 47 Possible
|
||||
options 0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize, 6:Bolivia, 7:Brazil, 8:Canada,
|
||||
9:Chile, 10:Colombia, 11:Costa Rica, 12:Czech Republic, 13:Denmark, 14:Dominican Republic, 15:Ecuador,
|
||||
16:El Salvador, 17:France, 18:Germany, 19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland,
|
||||
23:Italy, 24:Jamaica, 25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands,
|
||||
30:New Zealand, 31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal,
|
||||
36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore, 40:South Africa, 41:Spain,
|
||||
42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad, 46:United Kingdom, 47:United States, 48:Uruguay,
|
||||
49:Venezuela.
|
||||
|
||||
**Show Preferences**
|
||||
|
||||
* **agent** (str): com.plexapp.agents.none, com.plexapp.agents.thetvdb, com.plexapp.agents.themoviedb
|
||||
* **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true.
|
||||
* **episodeSort** (int): Episode order. Default -1 Possible options: 0:Oldest first, 1:Newest first.
|
||||
* **flattenSeasons** (int): Seasons. Default value 0 Possible options: 0:Show,1:Hide.
|
||||
* **includeInGlobal** (bool): Include in dashboard. Default value true.
|
||||
* **scanner** (str): Plex Series Scanner
|
||||
|
||||
**TheTVDB Show Options** (com.plexapp.agents.thetvdb)
|
||||
|
||||
* **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true.
|
||||
* **native_subs** (bool): Include extras with subtitles in Library language. Default value false.
|
||||
|
||||
**TheMovieDB Show Options** (com.plexapp.agents.themoviedb)
|
||||
|
||||
* **collections** (bool): Use collection info from The Movie Database. Default value false.
|
||||
* **localart** (bool): Prefer artwork based on library language. Default value true.
|
||||
* **adult** (bool): Include adult content. Default value false.
|
||||
* **country** (int): Country (used for release date and content rating). Default value 47 options
|
||||
0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize, 6:Bolivia, 7:Brazil, 8:Canada, 9:Chile,
|
||||
10:Colombia, 11:Costa Rica, 12:Czech Republic, 13:Denmark, 14:Dominican Republic, 15:Ecuador,
|
||||
16:El Salvador, 17:France, 18:Germany, 19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland,
|
||||
23:Italy, 24:Jamaica, 25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands,
|
||||
30:New Zealand, 31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal,
|
||||
36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore, 40:South Africa,
|
||||
41:Spain, 42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad, 46:United Kingdom, 47:United States,
|
||||
48:Uruguay, 49:Venezuela.
|
||||
|
||||
**Other Video Preferences**
|
||||
|
||||
* **agent** (str): com.plexapp.agents.none, com.plexapp.agents.imdb, com.plexapp.agents.themoviedb
|
||||
* **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true.
|
||||
* **enableCinemaTrailers** (bool): Enable Cinema Trailers. Default value true.
|
||||
* **includeInGlobal** (bool): Include in dashboard. Default value true.
|
||||
* **scanner** (str): Plex Movie Scanner, Plex Video Files Scanner
|
||||
|
||||
**IMDB Other Video Options** (com.plexapp.agents.imdb)
|
||||
|
||||
* **title** (bool): Localized titles. Default value false.
|
||||
* **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true.
|
||||
* **only_trailers** (bool): Skip extras which aren't trailers. Default value false.
|
||||
* **redband** (bool): Use red band (restricted audiences) trailers when available. Default value false.
|
||||
* **native_subs** (bool): Include extras with subtitles in Library language. Default value false.
|
||||
* **cast_list** (int): Cast List Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database.
|
||||
* **ratings** (int): Ratings Source Default value 0 Possible options:
|
||||
0:Rotten Tomatoes,1:IMDb,2:The Movie Database.
|
||||
* **summary** (int): Plot Summary Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database.
|
||||
* **country** (int): Country: Default value 46 Possible options: 0:Argentina, 1:Australia, 2:Austria,
|
||||
3:Belgium, 4:Belize, 5:Bolivia, 6:Brazil, 7:Canada, 8:Chile, 9:Colombia, 10:Costa Rica,
|
||||
11:Czech Republic, 12:Denmark, 13:Dominican Republic, 14:Ecuador, 15:El Salvador, 16:France,
|
||||
17:Germany, 18:Guatemala, 19:Honduras, 20:Hong Kong SAR, 21:Ireland, 22:Italy, 23:Jamaica,
|
||||
24:Korea, 25:Liechtenstein, 26:Luxembourg, 27:Mexico, 28:Netherlands, 29:New Zealand, 30:Nicaragua,
|
||||
31:Panama, 32:Paraguay, 33:Peru, 34:Portugal, 35:Peoples Republic of China, 36:Puerto Rico,
|
||||
37:Russia, 38:Singapore, 39:South Africa, 40:Spain, 41:Sweden, 42:Switzerland, 43:Taiwan, 44:Trinidad,
|
||||
45:United Kingdom, 46:United States, 47:Uruguay, 48:Venezuela.
|
||||
* **collections** (bool): Use collection info from The Movie Database. Default value false.
|
||||
* **localart** (bool): Prefer artwork based on library language. Default value true.
|
||||
* **adult** (bool): Include adult content. Default value false.
|
||||
* **usage** (bool): Send anonymous usage data to Plex. Default value true.
|
||||
|
||||
**TheMovieDB Other Video Options** (com.plexapp.agents.themoviedb)
|
||||
|
||||
* **collections** (bool): Use collection info from The Movie Database. Default value false.
|
||||
* **localart** (bool): Prefer artwork based on library language. Default value true.
|
||||
* **adult** (bool): Include adult content. Default value false.
|
||||
* **country** (int): Country (used for release date and content rating). Default
|
||||
value 47 Possible options 0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize,
|
||||
6:Bolivia, 7:Brazil, 8:Canada, 9:Chile, 10:Colombia, 11:Costa Rica, 12:Czech Republic,
|
||||
13:Denmark, 14:Dominican Republic, 15:Ecuador, 16:El Salvador, 17:France, 18:Germany,
|
||||
19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland, 23:Italy, 24:Jamaica,
|
||||
25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands, 30:New Zealand,
|
||||
31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal,
|
||||
36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore,
|
||||
40:South Africa, 41:Spain, 42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad,
|
||||
46:United Kingdom, 47:United States, 48:Uruguay, 49:Venezuela.
|
||||
"""
|
||||
part = '/library/sections?name=%s&type=%s&agent=%s&scanner=%s&language=%s&location=%s' % (
|
||||
quote_plus(name), type, agent, quote_plus(scanner), language, quote_plus(location)) # noqa E126
|
||||
if kwargs:
|
||||
part += urlencode(kwargs)
|
||||
return self._server.query(part, method=self._server._session.post)
|
||||
|
||||
|
||||
class LibrarySection(PlexObject):
|
||||
""" Base class for a single library section.
|
||||
|
||||
Attributes:
|
||||
ALLOWED_FILTERS (tuple): ()
|
||||
ALLOWED_SORT (tuple): ()
|
||||
BOOLEAN_FILTERS (tuple<str>): ('unwatched', 'duplicate')
|
||||
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
|
||||
initpath (str): Path requested when building this object.
|
||||
agent (str): Unknown (com.plexapp.agents.imdb, etc)
|
||||
allowSync (bool): True if you allow syncing content from this section.
|
||||
art (str): Wallpaper artwork used to respresent this section.
|
||||
composite (str): Composit image used to represent this section.
|
||||
createdAt (datetime): Datetime this library section was created.
|
||||
filters (str): Unknown
|
||||
key (str): Key (or ID) of this library section.
|
||||
language (str): Language represented in this section (en, xn, etc).
|
||||
locations (str): Paths on disk where section content is stored.
|
||||
refreshing (str): True if this section is currently being refreshed.
|
||||
scanner (str): Internal scanner used to find media (Plex Movie Scanner, Plex Premium Music Scanner, etc.)
|
||||
thumb (str): Thumbnail image used to represent this section.
|
||||
title (str): Title of this section.
|
||||
type (str): Type of content section represents (movie, artist, photo, show).
|
||||
updatedAt (datetime): Datetime this library section was last updated.
|
||||
uuid (str): Unique id for this section (32258d7c-3e6c-4ac5-98ad-bad7a3b78c63)
|
||||
"""
|
||||
ALLOWED_FILTERS = ()
|
||||
ALLOWED_SORT = ()
|
||||
BOOLEAN_FILTERS = ('unwatched', 'duplicate')
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.agent = data.attrib.get('agent')
|
||||
self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
|
||||
self.art = data.attrib.get('art')
|
||||
self.composite = data.attrib.get('composite')
|
||||
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
|
||||
self.filters = data.attrib.get('filters')
|
||||
self.key = data.attrib.get('key') # invalid key from plex
|
||||
self.language = data.attrib.get('language')
|
||||
self.locations = self.listAttrs(data, 'path', etag='Location')
|
||||
self.refreshing = utils.cast(bool, data.attrib.get('refreshing'))
|
||||
self.scanner = data.attrib.get('scanner')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.uuid = data.attrib.get('uuid')
|
||||
|
||||
def delete(self):
|
||||
""" Delete a library section. """
|
||||
try:
|
||||
return self._server.query('/library/sections/%s' % self.key, method=self._server._session.delete)
|
||||
except BadRequest: # pragma: no cover
|
||||
msg = 'Failed to delete library %s' % self.key
|
||||
msg += 'You may need to allow this permission in your Plex settings.'
|
||||
log.error(msg)
|
||||
raise
|
||||
|
||||
def edit(self, **kwargs):
|
||||
""" Edit a library (Note: agent is required). See :class:`~plexapi.library.Library` for example usage.
|
||||
|
||||
Parameters:
|
||||
kwargs (dict): Dict of settings to edit.
|
||||
"""
|
||||
part = '/library/sections/%s?%s' % (self.key, urlencode(kwargs))
|
||||
self._server.query(part, method=self._server._session.put)
|
||||
|
||||
# Reload this way since the self.key dont have a full path, but is simply a id.
|
||||
for s in self._server.library.sections():
|
||||
if s.key == self.key:
|
||||
return s
|
||||
|
||||
def get(self, title):
|
||||
""" Returns the media item with the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the item to return.
|
||||
"""
|
||||
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
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def onDeck(self):
|
||||
""" Returns a list of media items on deck from this library section. """
|
||||
key = '/library/sections/%s/onDeck' % self.key
|
||||
return self.fetchItems(key)
|
||||
|
||||
def recentlyAdded(self, maxresults=50):
|
||||
""" Returns a list of media items recently added from this library section.
|
||||
|
||||
Parameters:
|
||||
maxresults (int): Max number of items to return (default 50).
|
||||
"""
|
||||
return self.search(sort='addedAt:desc', maxresults=maxresults)
|
||||
|
||||
def analyze(self):
|
||||
""" Run an analysis on all of the items in this library section. See
|
||||
See :func:`~plexapi.base.PlexPartialObject.analyze` for more details.
|
||||
"""
|
||||
key = '/library/sections/%s/analyze' % self.key
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def emptyTrash(self):
|
||||
""" If a section has items in the Trash, use this option to empty the Trash. """
|
||||
key = '/library/sections/%s/emptyTrash' % self.key
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def update(self):
|
||||
""" Scan this section for new media. """
|
||||
key = '/library/sections/%s/refresh' % self.key
|
||||
self._server.query(key)
|
||||
|
||||
def cancelUpdate(self):
|
||||
""" Cancel update of this Library Section. """
|
||||
key = '/library/sections/%s/refresh' % self.key
|
||||
self._server.query(key, method=self._server._session.delete)
|
||||
|
||||
def refresh(self):
|
||||
""" Forces a download of fresh media information from the internet.
|
||||
This can take a long time. Any locked fields are not modified.
|
||||
"""
|
||||
key = '/library/sections/%s/refresh?force=1' % self.key
|
||||
self._server.query(key)
|
||||
|
||||
def deleteMediaPreviews(self):
|
||||
""" Delete the preview thumbnails for items in this library. This cannot
|
||||
be undone. Recreating media preview files can take hours or even days.
|
||||
"""
|
||||
key = '/library/sections/%s/indexes' % self.key
|
||||
self._server.query(key, method=self._server._session.delete)
|
||||
|
||||
def listChoices(self, category, libtype=None, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.library.FilterChoice` objects for the
|
||||
specified category and libtype. kwargs can be any of the same kwargs in
|
||||
:func:`plexapi.library.LibraySection.search()` to help narrow down the choices
|
||||
to only those that matter in your current context.
|
||||
|
||||
Parameters:
|
||||
category (str): Category to list choices for (genre, contentRating, etc).
|
||||
libtype (int): Library type of item filter.
|
||||
**kwargs (dict): Additional kwargs to narrow down the choices.
|
||||
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.BadRequest`: Cannot include kwarg equal to specified category.
|
||||
"""
|
||||
# TODO: Should this be moved to base?
|
||||
if category in kwargs:
|
||||
raise BadRequest('Cannot include kwarg equal to specified category: %s' % category)
|
||||
args = {}
|
||||
for subcategory, value in kwargs.items():
|
||||
args[category] = self._cleanSearchFilter(subcategory, value)
|
||||
if libtype is not None:
|
||||
args['type'] = utils.searchType(libtype)
|
||||
key = '/library/sections/%s/%s%s' % (self.key, category, utils.joinArgs(args))
|
||||
return self.fetchItems(key, cls=FilterChoice)
|
||||
|
||||
def search(self, title=None, sort=None, maxresults=999999, libtype=None, **kwargs):
|
||||
""" Search the library. 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:
|
||||
title (str): General string query to search for (optional).
|
||||
sort (str): column:dir; column can be any of {addedAt, originallyAvailableAt, lastViewedAt,
|
||||
titleSort, rating, mediaHeight, duration}. dir can be asc or desc (optional).
|
||||
maxresults (int): Only return the specified number of results (optional).
|
||||
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.
|
||||
|
||||
* unwatched: Display or hide unwatched content (True, False). [all]
|
||||
* duplicate: Display or hide duplicate items (True, False). [movie]
|
||||
* actor: List of actors to search ([actor_or_id, ...]). [movie]
|
||||
* collection: List of collections to search within ([collection_or_id, ...]). [all]
|
||||
* contentRating: List of content ratings to search within ([rating_or_key, ...]). [movie,tv]
|
||||
* country: List of countries to search within ([country_or_key, ...]). [movie,music]
|
||||
* decade: List of decades to search within ([yyy0, ...]). [movie]
|
||||
* director: List of directors to search ([director_or_id, ...]). [movie]
|
||||
* genre: List Genres to search within ([genere_or_id, ...]). [all]
|
||||
* network: List of TV networks to search within ([resolution_or_key, ...]). [tv]
|
||||
* 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]
|
||||
"""
|
||||
# cleanup the core arguments
|
||||
args = {}
|
||||
for category, value in kwargs.items():
|
||||
args[category] = self._cleanSearchFilter(category, value, libtype)
|
||||
if title is not None:
|
||||
args['title'] = title
|
||||
if sort is not None:
|
||||
args['sort'] = self._cleanSearchSort(sort)
|
||||
if libtype is not None:
|
||||
args['type'] = utils.searchType(libtype)
|
||||
# iterate over the results
|
||||
results, subresults = [], '_init'
|
||||
args['X-Plex-Container-Start'] = 0
|
||||
args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
|
||||
while subresults and maxresults > len(results):
|
||||
key = '/library/sections/%s/all%s' % (self.key, 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 _cleanSearchFilter(self, category, value, libtype=None):
|
||||
# check a few things before we begin
|
||||
if 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'
|
||||
if not isinstance(value, (list, tuple)):
|
||||
value = [value]
|
||||
# convert list of values to list of keys or ids
|
||||
result = set()
|
||||
choices = self.listChoices(category, libtype)
|
||||
lookup = {c.title.lower(): unquote(unquote(c.key)) for c in choices}
|
||||
allowed = set(c.key for c in choices)
|
||||
for item in value:
|
||||
item = str((item.id or item.tag) if isinstance(item, MediaTag) else item).lower()
|
||||
# find most logical choice(s) to use in url
|
||||
if item in allowed: result.add(item); continue
|
||||
if item in lookup: result.add(lookup[item]); continue
|
||||
matches = [k for t, k in lookup.items() if item in t]
|
||||
if matches: map(result.add, matches); continue
|
||||
# nothing matched; use raw item value
|
||||
log.warning('Filter value not listed, using raw item value: %s' % item)
|
||||
result.add(item)
|
||||
return ','.join(result)
|
||||
|
||||
def _cleanSearchSort(self, sort):
|
||||
sort = '%s:asc' % sort if ':' not in sort else sort
|
||||
scol, sdir = sort.lower().split(':')
|
||||
lookup = {s.lower(): s for s in self.ALLOWED_SORT}
|
||||
if scol not in lookup:
|
||||
raise BadRequest('Unknown sort column: %s' % scol)
|
||||
if sdir not in ('asc', 'desc'):
|
||||
raise BadRequest('Unknown sort dir: %s' % sdir)
|
||||
return '%s:%s' % (lookup[scol], sdir)
|
||||
|
||||
|
||||
class MovieSection(LibrarySection):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing movies.
|
||||
|
||||
Attributes:
|
||||
ALLOWED_FILTERS (list<str>): List of allowed search filters. ('unwatched',
|
||||
'duplicate', 'year', 'decade', 'genre', 'contentRating', 'collection',
|
||||
'director', 'actor', 'country', 'studio', 'resolution', 'guid', 'label')
|
||||
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt',
|
||||
'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating',
|
||||
'mediaHeight', 'duration')
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'movie'
|
||||
"""
|
||||
ALLOWED_FILTERS = ('unwatched', 'duplicate', 'year', 'decade', 'genre', 'contentRating',
|
||||
'collection', 'director', 'actor', 'country', 'studio', 'resolution',
|
||||
'guid', 'label')
|
||||
ALLOWED_SORT = ('addedAt', 'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating',
|
||||
'mediaHeight', 'duration')
|
||||
TAG = 'Directory'
|
||||
TYPE = 'movie'
|
||||
|
||||
|
||||
class ShowSection(LibrarySection):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows.
|
||||
|
||||
Attributes:
|
||||
ALLOWED_FILTERS (list<str>): List of allowed search filters. ('unwatched',
|
||||
'year', 'genre', 'contentRating', 'network', 'collection', 'guid', 'label')
|
||||
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt', 'lastViewedAt',
|
||||
'originallyAvailableAt', 'titleSort', 'rating', 'unwatched')
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'show'
|
||||
"""
|
||||
ALLOWED_FILTERS = ('unwatched', 'year', 'genre', 'contentRating', 'network', 'collection',
|
||||
'guid', 'duplicate', 'label')
|
||||
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'originallyAvailableAt', 'titleSort',
|
||||
'rating', 'unwatched')
|
||||
TAG = 'Directory'
|
||||
TYPE = 'show'
|
||||
|
||||
def searchShows(self, **kwargs):
|
||||
""" Search for a show. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='show', **kwargs)
|
||||
|
||||
def searchEpisodes(self, **kwargs):
|
||||
""" Search for an episode. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='episode', **kwargs)
|
||||
|
||||
def recentlyAdded(self, libtype='episode', maxresults=50):
|
||||
""" Returns a list of recently added episodes from this library section.
|
||||
|
||||
Parameters:
|
||||
maxresults (int): Max number of items to return (default 50).
|
||||
"""
|
||||
return self.search(sort='addedAt:desc', libtype=libtype, maxresults=maxresults)
|
||||
|
||||
|
||||
class MusicSection(LibrarySection):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing music artists.
|
||||
|
||||
Attributes:
|
||||
ALLOWED_FILTERS (list<str>): List of allowed search filters. ('genre',
|
||||
'country', 'collection')
|
||||
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt',
|
||||
'lastViewedAt', 'viewCount', 'titleSort')
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'artist'
|
||||
"""
|
||||
ALLOWED_FILTERS = ('genre', 'country', 'collection', 'mood')
|
||||
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort')
|
||||
TAG = 'Directory'
|
||||
TYPE = 'artist'
|
||||
|
||||
def albums(self):
|
||||
""" Returns a list of :class:`~plexapi.audio.Album` objects in this section. """
|
||||
key = '/library/sections/%s/albums' % self.key
|
||||
return self.fetchItems(key)
|
||||
|
||||
def searchArtists(self, **kwargs):
|
||||
""" Search for an artist. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='artist', **kwargs)
|
||||
|
||||
def searchAlbums(self, **kwargs):
|
||||
""" Search for an album. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='album', **kwargs)
|
||||
|
||||
def searchTracks(self, **kwargs):
|
||||
""" Search for a track. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='track', **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')
|
||||
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_SORT = ('addedAt',)
|
||||
TAG = 'Directory'
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class FilterChoice(PlexObject):
|
||||
""" Represents a single filter choice. These objects are gathered when using filters
|
||||
while searching for library items and is the object returned in the result set of
|
||||
:func:`~plexapi.library.LibrarySection.listChoices()`.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to.
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
fastKey (str): API path to quickly list all items in this filter
|
||||
(/library/sections/<section>/all?genre=<key>)
|
||||
key (str): Short key (id) of this filter option (used ad <key> in fastKey above).
|
||||
thumb (str): Thumbnail used to represent this filter option.
|
||||
title (str): Human readable name for this filter option.
|
||||
type (str): Filter type (genre, contentRating, etc).
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.fastKey = data.attrib.get('fastKey')
|
||||
self.key = data.attrib.get('key')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Hub(PlexObject):
|
||||
""" Represents a single Hub (or category) in the PlexServer search.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Hub'
|
||||
hubIdentifier (str): Unknown.
|
||||
size (int): Number of items found.
|
||||
title (str): Title of this Hub.
|
||||
type (str): Type of items in the Hub.
|
||||
items (str): List of items in the Hub.
|
||||
"""
|
||||
TAG = 'Hub'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.hubIdentifier = data.attrib.get('hubIdentifier')
|
||||
self.size = utils.cast(int, data.attrib.get('size'))
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.items = self.findItems(data)
|
||||
|
||||
def __len__(self):
|
||||
return self.size
|
504
lib/plexapi/media.py
Normal file
504
lib/plexapi/media.py
Normal file
|
@ -0,0 +1,504 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from plexapi import log, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.utils import cast
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Media(PlexObject):
|
||||
""" Container object for all MediaPart objects. Provides useful data about the
|
||||
video this media belong to such as video framerate, resolution, etc.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Media'
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
initpath (str): Relative path requested when retrieving specified data.
|
||||
video (str): Video this media belongs to.
|
||||
aspectRatio (float): Aspect ratio of the video (ex: 2.35).
|
||||
audioChannels (int): Number of audio channels for this video (ex: 6).
|
||||
audioCodec (str): Audio codec used within the video (ex: ac3).
|
||||
bitrate (int): Bitrate of the video (ex: 1624)
|
||||
container (str): Container this video is in (ex: avi).
|
||||
duration (int): Length of the video in milliseconds (ex: 6990483).
|
||||
height (int): Height of the video in pixels (ex: 256).
|
||||
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.
|
||||
videoCodec (str): Video codec used within the video (ex: ac3).
|
||||
videoFrameRate (str): Video frame rate (ex: 24p).
|
||||
videoResolution (str): Video resolution (ex: sd).
|
||||
width (int): Width of the video in pixels (ex: 608).
|
||||
parts (list<:class:`~plexapi.media.MediaPart`>): List of MediaParts in this video.
|
||||
"""
|
||||
TAG = 'Media'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.aspectRatio = cast(float, data.attrib.get('aspectRatio'))
|
||||
self.audioChannels = cast(int, data.attrib.get('audioChannels'))
|
||||
self.audioCodec = data.attrib.get('audioCodec')
|
||||
self.bitrate = cast(int, data.attrib.get('bitrate'))
|
||||
self.container = data.attrib.get('container')
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.height = cast(int, data.attrib.get('height'))
|
||||
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.videoCodec = data.attrib.get('videoCodec')
|
||||
self.videoFrameRate = data.attrib.get('videoFrameRate')
|
||||
self.videoResolution = data.attrib.get('videoResolution')
|
||||
self.width = cast(int, data.attrib.get('width'))
|
||||
self.parts = self.findItems(data, MediaPart)
|
||||
|
||||
def delete(self):
|
||||
part = self._initpath + '/media/%s' % self.id
|
||||
try:
|
||||
return self._server.query(part, method=self._server._session.delete)
|
||||
except BadRequest:
|
||||
log.error("Failed to delete %s. This could be because you havn't allowed "
|
||||
"items to be deleted" % part)
|
||||
raise
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class MediaPart(PlexObject):
|
||||
""" Represents a single media part (often a single file) for the media this belongs to.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Part'
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
initpath (str): Relative path requested when retrieving specified data.
|
||||
media (:class:`~plexapi.media.Media`): Media object this part belongs to.
|
||||
container (str): Container type of this media part (ex: avi).
|
||||
duration (int): Length of this media part in milliseconds.
|
||||
file (str): Path to this file on disk (ex: /media/Movies/Cars.(2006)/Cars.cd2.avi)
|
||||
id (int): Unique ID of this media part.
|
||||
indexes (str, None): None or SD.
|
||||
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.
|
||||
"""
|
||||
TAG = 'Part'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.container = data.attrib.get('container')
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.file = data.attrib.get('file')
|
||||
self.id = cast(int, data.attrib.get('id'))
|
||||
self.indexes = data.attrib.get('indexes')
|
||||
self.key = data.attrib.get('key')
|
||||
self.size = cast(int, data.attrib.get('size'))
|
||||
self.streams = self._buildStreams(data)
|
||||
|
||||
def _buildStreams(self, data):
|
||||
streams = []
|
||||
for elem in data:
|
||||
for cls in (VideoStream, AudioStream, SubtitleStream):
|
||||
if elem.attrib.get('streamType') == str(cls.STREAMTYPE):
|
||||
streams.append(cls(self._server, elem, self._initpath))
|
||||
return streams
|
||||
|
||||
def videoStreams(self):
|
||||
""" Returns a list of :class:`~plexapi.media.VideoStream` objects in this MediaPart. """
|
||||
return [stream for stream in self.streams if stream.streamType == VideoStream.STREAMTYPE]
|
||||
|
||||
def audioStreams(self):
|
||||
""" Returns a list of :class:`~plexapi.media.AudioStream` objects in this MediaPart. """
|
||||
return [stream for stream in self.streams if stream.streamType == AudioStream.STREAMTYPE]
|
||||
|
||||
def subtitleStreams(self):
|
||||
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """
|
||||
return [stream for stream in self.streams if stream.streamType == SubtitleStream.STREAMTYPE]
|
||||
|
||||
|
||||
class MediaPartStream(PlexObject):
|
||||
""" Base class for media streams. These consist of video, audio and subtitles.
|
||||
|
||||
Attributes:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
initpath (str): Relative path requested when retrieving specified data.
|
||||
part (:class:`~plexapi.media.MediaPart`): Media part this stream belongs to.
|
||||
codec (str): Codec of this stream (ex: srt, ac3, mpeg4).
|
||||
codecID (str): Codec ID (ex: XVID).
|
||||
id (int): Unique stream ID on this server.
|
||||
index (int): Unknown
|
||||
language (str): Stream language (ex: English, ไทย).
|
||||
languageCode (str): Ascii code for language (ex: eng, tha).
|
||||
selected (bool): True if this stream is selected.
|
||||
streamType (int): Stream type (1=:class:`~plexapi.media.VideoStream`,
|
||||
2=:class:`~plexapi.media.AudioStream`, 3=:class:`~plexapi.media.SubtitleStream`).
|
||||
type (int): Alias for streamType.
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.codec = data.attrib.get('codec')
|
||||
self.codecID = data.attrib.get('codecID')
|
||||
self.id = cast(int, data.attrib.get('id'))
|
||||
self.index = cast(int, data.attrib.get('index', '-1'))
|
||||
self.language = data.attrib.get('language')
|
||||
self.languageCode = data.attrib.get('languageCode')
|
||||
self.selected = cast(bool, data.attrib.get('selected', '0'))
|
||||
self.streamType = cast(int, data.attrib.get('streamType'))
|
||||
self.type = cast(int, data.attrib.get('streamType'))
|
||||
|
||||
@staticmethod
|
||||
def parse(server, data, initpath): # pragma: no cover seems to be dead code.
|
||||
""" Factory method returns a new MediaPartStream from xml data. """
|
||||
STREAMCLS = {1: VideoStream, 2: AudioStream, 3: SubtitleStream}
|
||||
stype = cast(int, data.attrib.get('streamType'))
|
||||
cls = STREAMCLS.get(stype, MediaPartStream)
|
||||
return cls(server, data, initpath)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class VideoStream(MediaPartStream):
|
||||
""" Respresents a video stream within a :class:`~plexapi.media.MediaPart`.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Stream'
|
||||
STREAMTYPE (int): 1
|
||||
bitDepth (int): Bit depth (ex: 8).
|
||||
bitrate (int): Bitrate (ex: 1169)
|
||||
cabac (int): Unknown
|
||||
chromaSubsampling (str): Chroma Subsampling (ex: 4:2:0).
|
||||
colorSpace (str): Unknown
|
||||
duration (int): Duration of video stream in milliseconds.
|
||||
frameRate (float): Frame rate (ex: 23.976)
|
||||
frameRateMode (str): Unknown
|
||||
hasScallingMatrix (bool): True if video stream has a scaling matrix.
|
||||
height (int): Height of video stream.
|
||||
level (int): Videl stream level (?).
|
||||
profile (str): Video stream profile (ex: asp).
|
||||
refFrames (int): Unknown
|
||||
scanType (str): Video stream scan type (ex: progressive).
|
||||
title (str): Title of this video stream.
|
||||
width (int): Width of video stream.
|
||||
"""
|
||||
TAG = 'Stream'
|
||||
STREAMTYPE = 1
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
super(VideoStream, self)._loadData(data)
|
||||
self.bitDepth = cast(int, data.attrib.get('bitDepth'))
|
||||
self.bitrate = cast(int, data.attrib.get('bitrate'))
|
||||
self.cabac = cast(int, data.attrib.get('cabac'))
|
||||
self.chromaSubsampling = data.attrib.get('chromaSubsampling')
|
||||
self.colorSpace = data.attrib.get('colorSpace')
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.frameRate = cast(float, data.attrib.get('frameRate'))
|
||||
self.frameRateMode = data.attrib.get('frameRateMode')
|
||||
self.hasScallingMatrix = cast(bool, data.attrib.get('hasScallingMatrix'))
|
||||
self.height = cast(int, data.attrib.get('height'))
|
||||
self.level = cast(int, data.attrib.get('level'))
|
||||
self.profile = data.attrib.get('profile')
|
||||
self.refFrames = cast(int, data.attrib.get('refFrames'))
|
||||
self.scanType = data.attrib.get('scanType')
|
||||
self.title = data.attrib.get('title')
|
||||
self.width = cast(int, data.attrib.get('width'))
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class AudioStream(MediaPartStream):
|
||||
""" Respresents a audio stream within a :class:`~plexapi.media.MediaPart`.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Stream'
|
||||
STREAMTYPE (int): 2
|
||||
audioChannelLayout (str): Audio channel layout (ex: 5.1(side)).
|
||||
bitDepth (int): Bit depth (ex: 16).
|
||||
bitrate (int): Audio bitrate (ex: 448).
|
||||
bitrateMode (str): Bitrate mode (ex: cbr).
|
||||
channels (int): number of channels in this stream (ex: 6).
|
||||
dialogNorm (int): Unknown (ex: -27).
|
||||
duration (int): Duration of audio stream in milliseconds.
|
||||
samplingRate (int): Sampling rate (ex: xxx)
|
||||
title (str): Title of this audio stream.
|
||||
"""
|
||||
TAG = 'Stream'
|
||||
STREAMTYPE = 2
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
super(AudioStream, self)._loadData(data)
|
||||
self.audioChannelLayout = data.attrib.get('audioChannelLayout')
|
||||
self.bitDepth = cast(int, data.attrib.get('bitDepth'))
|
||||
self.bitrate = cast(int, data.attrib.get('bitrate'))
|
||||
self.bitrateMode = data.attrib.get('bitrateMode')
|
||||
self.channels = cast(int, data.attrib.get('channels'))
|
||||
self.dialogNorm = cast(int, data.attrib.get('dialogNorm'))
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.samplingRate = cast(int, data.attrib.get('samplingRate'))
|
||||
self.title = data.attrib.get('title')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class SubtitleStream(MediaPartStream):
|
||||
""" Respresents a audio stream within a :class:`~plexapi.media.MediaPart`.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Stream'
|
||||
STREAMTYPE (int): 3
|
||||
format (str): Subtitle format (ex: srt).
|
||||
key (str): Key of this subtitle stream (ex: /library/streams/212284).
|
||||
title (str): Title of this subtitle stream.
|
||||
"""
|
||||
TAG = 'Stream'
|
||||
STREAMTYPE = 3
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
super(SubtitleStream, self)._loadData(data)
|
||||
self.format = data.attrib.get('format')
|
||||
self.key = data.attrib.get('key')
|
||||
self.title = data.attrib.get('title')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Session(PlexObject):
|
||||
""" Represents a current session. """
|
||||
TAG = 'Session'
|
||||
|
||||
def _loadData(self, data):
|
||||
self.id = data.attrib.get('id')
|
||||
self.bandwidth = utils.cast(int, data.attrib.get('bandwidth'))
|
||||
self.location = data.attrib.get('location')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class TranscodeSession(PlexObject):
|
||||
""" Represents a current transcode session.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'TranscodeSession'
|
||||
TODO: Document this.
|
||||
"""
|
||||
TAG = 'TranscodeSession'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.audioChannels = cast(int, data.attrib.get('audioChannels'))
|
||||
self.audioCodec = data.attrib.get('audioCodec')
|
||||
self.audioDecision = data.attrib.get('audioDecision')
|
||||
self.container = data.attrib.get('container')
|
||||
self.context = data.attrib.get('context')
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.height = cast(int, data.attrib.get('height'))
|
||||
self.key = data.attrib.get('key')
|
||||
self.progress = cast(float, data.attrib.get('progress'))
|
||||
self.protocol = data.attrib.get('protocol')
|
||||
self.remaining = cast(int, data.attrib.get('remaining'))
|
||||
self.speed = cast(int, data.attrib.get('speed'))
|
||||
self.throttled = cast(int, data.attrib.get('throttled'))
|
||||
self.sourceVideoCodec = data.attrib.get('sourceVideoCodec')
|
||||
self.videoCodec = data.attrib.get('videoCodec')
|
||||
self.videoDecision = data.attrib.get('videoDecision')
|
||||
self.width = cast(int, data.attrib.get('width'))
|
||||
|
||||
|
||||
class MediaTag(PlexObject):
|
||||
""" Base class for media tags used for filtering and searching your library
|
||||
items or navigating the metadata of media items in your library. Tags are
|
||||
the construct used for things such as Country, Director, Genre, etc.
|
||||
|
||||
Attributes:
|
||||
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
|
||||
id (id): Tag ID (This seems meaningless except to use it as a unique id).
|
||||
role (str): Unknown
|
||||
tag (str): Name of the tag. This will be Animation, SciFi etc for Genres. The name of
|
||||
person for Directors and Roles (ex: Animation, Stephen Graham, etc).
|
||||
<Hub_Search_Attributes>: Attributes only applicable in search results from
|
||||
PlexServer :func:`~plexapi.server.PlexServer.search()`. They provide details of which
|
||||
library section the tag was found as well as the url to dig deeper into the results.
|
||||
|
||||
* key (str): API URL to dig deeper into this tag (ex: /library/sections/1/all?actor=9081).
|
||||
* librarySectionID (int): Section ID this tag was generated from.
|
||||
* librarySectionTitle (str): Library section title this tag was found.
|
||||
* librarySectionType (str): Media type of the library section this tag was found.
|
||||
* tagType (int): Tag type ID.
|
||||
* thumb (str): URL to thumbnail image.
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.id = cast(int, data.attrib.get('id'))
|
||||
self.role = data.attrib.get('role')
|
||||
self.tag = data.attrib.get('tag')
|
||||
# additional attributes only from hub search
|
||||
self.key = data.attrib.get('key')
|
||||
self.librarySectionID = cast(int, data.attrib.get('librarySectionID'))
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.librarySectionType = data.attrib.get('librarySectionType')
|
||||
self.tagType = cast(int, data.attrib.get('tagType'))
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
|
||||
def items(self, *args, **kwargs):
|
||||
""" Return the list of items within this tag. This function is only applicable
|
||||
in search results from PlexServer :func:`~plexapi.server.PlexServer.search()`.
|
||||
"""
|
||||
if not self.key:
|
||||
raise BadRequest('Key is not defined for this tag: %s' % self.tag)
|
||||
return self.fetchItems(self.key)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Collection(MediaTag):
|
||||
""" Represents a single Collection media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Collection'
|
||||
FILTER (str): 'collection'
|
||||
"""
|
||||
TAG = 'Collection'
|
||||
FILTER = 'collection'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Label(MediaTag):
|
||||
""" Represents a single label media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'label'
|
||||
FILTER (str): 'label'
|
||||
"""
|
||||
TAG = 'Label'
|
||||
FILTER = 'label'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Country(MediaTag):
|
||||
""" Represents a single Country media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Country'
|
||||
FILTER (str): 'country'
|
||||
"""
|
||||
TAG = 'Country'
|
||||
FILTER = 'country'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Director(MediaTag):
|
||||
""" Represents a single Director media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Director'
|
||||
FILTER (str): 'director'
|
||||
"""
|
||||
TAG = 'Director'
|
||||
FILTER = 'director'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Genre(MediaTag):
|
||||
""" Represents a single Genre media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Genre'
|
||||
FILTER (str): 'genre'
|
||||
"""
|
||||
TAG = 'Genre'
|
||||
FILTER = 'genre'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Mood(MediaTag):
|
||||
""" Represents a single Mood media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Mood'
|
||||
FILTER (str): 'mood'
|
||||
"""
|
||||
TAG = 'Mood'
|
||||
FILTER = 'mood'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Producer(MediaTag):
|
||||
""" Represents a single Producer media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Producer'
|
||||
FILTER (str): 'producer'
|
||||
"""
|
||||
TAG = 'Producer'
|
||||
FILTER = 'producer'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Role(MediaTag):
|
||||
""" Represents a single Role (actor/actress) media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Role'
|
||||
FILTER (str): 'role'
|
||||
"""
|
||||
TAG = 'Role'
|
||||
FILTER = 'role'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Similar(MediaTag):
|
||||
""" Represents a single Similar media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Similar'
|
||||
FILTER (str): 'similar'
|
||||
"""
|
||||
TAG = 'Similar'
|
||||
FILTER = 'similar'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Writer(MediaTag):
|
||||
""" Represents a single Writer media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Writer'
|
||||
FILTER (str): 'writer'
|
||||
"""
|
||||
TAG = 'Writer'
|
||||
FILTER = 'writer'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Chapter(PlexObject):
|
||||
""" Represents a single Writer media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Chapter'
|
||||
"""
|
||||
TAG = 'Chapter'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.id = cast(int, data.attrib.get('id', 0))
|
||||
self.filter = data.attrib.get('filter') # I couldn't filter on it anyways
|
||||
self.tag = data.attrib.get('tag')
|
||||
self.title = self.tag
|
||||
self.index = cast(int, data.attrib.get('index'))
|
||||
self.start = cast(int, data.attrib.get('startTimeOffset'))
|
||||
self.end = cast(int, data.attrib.get('endTimeOffset'))
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Field(PlexObject):
|
||||
""" Represents a single Field.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Field'
|
||||
"""
|
||||
TAG = 'Field'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.name = data.attrib.get('name')
|
||||
self.locked = cast(bool, data.attrib.get('locked'))
|
727
lib/plexapi/myplex.py
Normal file
727
lib/plexapi/myplex.py
Normal file
|
@ -0,0 +1,727 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import copy
|
||||
import requests
|
||||
import time
|
||||
from requests.status_codes import _codes as codes
|
||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
|
||||
from plexapi import log, logfilter, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
from plexapi.client import PlexClient
|
||||
from plexapi.compat import ElementTree
|
||||
from plexapi.library import LibrarySection
|
||||
from plexapi.server import PlexServer
|
||||
from plexapi.utils import joinArgs
|
||||
|
||||
|
||||
class MyPlexAccount(PlexObject):
|
||||
""" MyPlex account and profile information. This object represents the data found Account on
|
||||
the myplex.tv servers at the url https://plex.tv/users/account. You may create this object
|
||||
directly by passing in your username & password (or token). There is also a convenience
|
||||
method provided at :class:`~plexapi.server.PlexServer.myPlexAccount()` which will create
|
||||
and return this object.
|
||||
|
||||
Parameters:
|
||||
username (str): Your MyPlex username.
|
||||
password (str): Your MyPlex password.
|
||||
session (requests.Session, optional): Use your own session object if you want to
|
||||
cache the http responses from PMS
|
||||
timeout (int): timeout in seconds on initial connect to myplex (default config.TIMEOUT).
|
||||
|
||||
Attributes:
|
||||
SIGNIN (str): 'https://plex.tv/users/sign_in.xml'
|
||||
key (str): 'https://plex.tv/users/account'
|
||||
authenticationToken (str): Unknown.
|
||||
certificateVersion (str): Unknown.
|
||||
cloudSyncDevice (str): Unknown.
|
||||
email (str): Your current Plex email address.
|
||||
entitlements (List<str>): List of devices your allowed to use with this account.
|
||||
guest (bool): Unknown.
|
||||
home (bool): Unknown.
|
||||
homeSize (int): Unknown.
|
||||
id (str): Your Plex account ID.
|
||||
locale (str): Your Plex locale
|
||||
mailing_list_status (str): Your current mailing list status.
|
||||
maxHomeSize (int): Unknown.
|
||||
queueEmail (str): Email address to add items to your `Watch Later` queue.
|
||||
queueUid (str): Unknown.
|
||||
restricted (bool): Unknown.
|
||||
roles: (List<str>) Lit of account roles. Plexpass membership listed here.
|
||||
scrobbleTypes (str): Description
|
||||
secure (bool): Description
|
||||
subscriptionActive (bool): True if your subsctiption is active.
|
||||
subscriptionFeatures: (List<str>) List of features allowed on your subscription.
|
||||
subscriptionPlan (str): Name of subscription plan.
|
||||
subscriptionStatus (str): String representation of `subscriptionActive`.
|
||||
thumb (str): URL of your account thumbnail.
|
||||
title (str): Unknown. - Looks like an alias for `username`.
|
||||
username (str): Your account username.
|
||||
uuid (str): Unknown.
|
||||
_token (str): Token used to access this client.
|
||||
_session (obj): Requests session object used to access this client.
|
||||
"""
|
||||
FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # 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
|
||||
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
|
||||
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
|
||||
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
|
||||
# Key may someday switch to the following url. For now the current value works.
|
||||
# https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId}
|
||||
key = 'https://plex.tv/users/account'
|
||||
|
||||
def __init__(self, username=None, password=None, token=None, session=None, timeout=None):
|
||||
self._token = token
|
||||
self._session = session or requests.Session()
|
||||
data, initpath = self._signin(username, password, timeout)
|
||||
super(MyPlexAccount, self).__init__(self, data, initpath)
|
||||
|
||||
def _signin(self, username, password, timeout):
|
||||
if self._token:
|
||||
return self.query(self.key), self.key
|
||||
username = username or CONFIG.get('auth.myplex_username')
|
||||
password = password or CONFIG.get('auth.myplex_password')
|
||||
data = self.query(self.SIGNIN, method=self._session.post, auth=(username, password), timeout=timeout)
|
||||
return data, self.SIGNIN
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self._token = logfilter.add_secret(data.attrib.get('authenticationToken'))
|
||||
self._webhooks = []
|
||||
self.authenticationToken = self._token
|
||||
self.certificateVersion = data.attrib.get('certificateVersion')
|
||||
self.cloudSyncDevice = data.attrib.get('cloudSyncDevice')
|
||||
self.email = data.attrib.get('email')
|
||||
self.guest = utils.cast(bool, data.attrib.get('guest'))
|
||||
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||
self.homeSize = utils.cast(int, data.attrib.get('homeSize'))
|
||||
self.id = data.attrib.get('id')
|
||||
self.locale = data.attrib.get('locale')
|
||||
self.mailing_list_status = data.attrib.get('mailing_list_status')
|
||||
self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize'))
|
||||
self.queueEmail = data.attrib.get('queueEmail')
|
||||
self.queueUid = data.attrib.get('queueUid')
|
||||
self.restricted = utils.cast(bool, data.attrib.get('restricted'))
|
||||
self.scrobbleTypes = data.attrib.get('scrobbleTypes')
|
||||
self.secure = utils.cast(bool, data.attrib.get('secure'))
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.username = data.attrib.get('username')
|
||||
self.uuid = data.attrib.get('uuid')
|
||||
# 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
|
||||
|
||||
def device(self, name):
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified.
|
||||
|
||||
Parameters:
|
||||
name (str): Name to match against.
|
||||
"""
|
||||
for device in self.devices():
|
||||
if device.name.lower() == name.lower():
|
||||
return device
|
||||
raise NotFound('Unable to find device %s' % name)
|
||||
|
||||
def devices(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """
|
||||
data = self.query(MyPlexDevice.key)
|
||||
return [MyPlexDevice(self, elem) for elem in data]
|
||||
|
||||
def _headers(self, **kwargs):
|
||||
""" Returns dict containing base headers for all requests to the server. """
|
||||
headers = BASE_HEADERS.copy()
|
||||
if self._token:
|
||||
headers['X-Plex-Token'] = self._token
|
||||
headers.update(kwargs)
|
||||
return headers
|
||||
|
||||
def query(self, url, method=None, headers=None, timeout=None, **kwargs):
|
||||
method = method or self._session.get
|
||||
timeout = timeout or TIMEOUT
|
||||
log.debug('%s %s %s', method.__name__.upper(), url, kwargs.get('json', ''))
|
||||
headers = self._headers(**headers or {})
|
||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
||||
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
|
||||
|
||||
def resource(self, name):
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexResource` that matches the name specified.
|
||||
|
||||
Parameters:
|
||||
name (str): Name to match against.
|
||||
"""
|
||||
for resource in self.resources():
|
||||
if resource.name.lower() == name.lower():
|
||||
return resource
|
||||
raise NotFound('Unable to find resource %s' % name)
|
||||
|
||||
def resources(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexResource` objects connected to the server. """
|
||||
data = self.query(MyPlexResource.key)
|
||||
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):
|
||||
""" 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']}
|
||||
"""
|
||||
username = user.username if isinstance(user, MyPlexUser) else user
|
||||
machineId = server.machineIdentifier if isinstance(server, PlexServer) else server
|
||||
sectionIds = self._getSectionIds(machineId, sections)
|
||||
params = {
|
||||
'server_id': machineId,
|
||||
'shared_server': {'library_section_ids': sectionIds, 'invited_email': username},
|
||||
'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 {}),
|
||||
},
|
||||
}
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
url = self.FRIENDINVITE.format(machineId=machineId)
|
||||
return self.query(url, self._session.post, json=params, headers=headers)
|
||||
|
||||
def removeFriend(self, user):
|
||||
""" Remove the specified user from all sharing.
|
||||
|
||||
Parameters:
|
||||
user (str): MyPlexUser, username, email of the user to be added.
|
||||
"""
|
||||
user = self.user(user)
|
||||
url = self.FRIENDUPDATE if user.friend else self.REMOVEINVITE
|
||||
url = url.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.
|
||||
|
||||
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).
|
||||
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.
|
||||
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']}
|
||||
"""
|
||||
# Update friend servers
|
||||
response_filters = ''
|
||||
response_servers = ''
|
||||
user = self.user(user.username if isinstance(user, MyPlexUser) else user)
|
||||
machineId = server.machineIdentifier if isinstance(server, PlexServer) else server
|
||||
sectionIds = self._getSectionIds(machineId, sections)
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
# Determine whether user has access to the shared server.
|
||||
user_servers = [s for s in user.servers if s.machineIdentifier == machineId]
|
||||
if user_servers and sectionIds:
|
||||
serverId = user_servers[0].id
|
||||
params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds}}
|
||||
url = self.FRIENDSERVERS.format(machineId=machineId, serverId=serverId)
|
||||
else:
|
||||
params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds,
|
||||
"invited_id": user.id}}
|
||||
url = self.FRIENDINVITE.format(machineId=machineId)
|
||||
# Remove share sections, add shares to user without shares, or update shares
|
||||
if 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', ''):
|
||||
response_servers = self.query(url, self._session.post, json=params, headers=headers)
|
||||
else:
|
||||
response_servers = self.query(url, self._session.put, json=params, headers=headers)
|
||||
else:
|
||||
log.warning('Section name, number of section object is required changing library sections')
|
||||
# Update friend filters
|
||||
url = self.FRIENDUPDATE.format(userId=user.id)
|
||||
params = {}
|
||||
if isinstance(allowSync, bool):
|
||||
params['allowSync'] = '1' if allowSync else '0'
|
||||
if isinstance(allowCameraUpload, bool):
|
||||
params['allowCameraUpload'] = '1' if allowCameraUpload else '0'
|
||||
if isinstance(allowChannels, bool):
|
||||
params['allowChannels'] = '1' if allowChannels else '0'
|
||||
if isinstance(filterMovies, dict):
|
||||
params['filterMovies'] = self._filterDictToStr(filterMovies or {}) # '1' if allowChannels else '0'
|
||||
if isinstance(filterTelevision, dict):
|
||||
params['filterTelevision'] = self._filterDictToStr(filterTelevision or {})
|
||||
if isinstance(allowChannels, dict):
|
||||
params['filterMusic'] = self._filterDictToStr(filterMusic or {})
|
||||
if params:
|
||||
url += joinArgs(params)
|
||||
response_filters = self.query(url, self._session.put)
|
||||
return response_servers, response_filters
|
||||
|
||||
def user(self, username):
|
||||
""" Returns the :class:`~myplex.MyPlexUser` that matches the email or username specified.
|
||||
|
||||
Parameters:
|
||||
username (str): Username, email or id of the user to return.
|
||||
"""
|
||||
for user in self.users():
|
||||
# Home users don't have email, username etc.
|
||||
if username.lower() == user.title.lower():
|
||||
return user
|
||||
|
||||
elif (user.username and user.email and user.id and username.lower() in
|
||||
(user.username.lower(), user.email.lower(), str(user.id))):
|
||||
return user
|
||||
|
||||
raise NotFound('Unable to find user %s' % username)
|
||||
|
||||
def users(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexUser` objects connected to your account.
|
||||
This includes both friends and pending invites. You can reference the user.friend to
|
||||
distinguish between the two.
|
||||
"""
|
||||
friends = [MyPlexUser(self, elem) for elem in self.query(MyPlexUser.key)]
|
||||
requested = [MyPlexUser(self, elem, self.REQUESTED) for elem in self.query(self.REQUESTED)]
|
||||
return friends + requested
|
||||
|
||||
def _getSectionIds(self, server, sections):
|
||||
""" Converts a list of section objects or names to sectionIds needed for library sharing. """
|
||||
if not sections: return []
|
||||
# Get a list of all section ids for looking up each section.
|
||||
allSectionIds = {}
|
||||
machineIdentifier = server.machineIdentifier if isinstance(server, PlexServer) else server
|
||||
url = self.PLEXSERVERS.replace('{machineId}', machineIdentifier)
|
||||
data = self.query(url, self._session.get)
|
||||
for elem in data[0]:
|
||||
allSectionIds[elem.attrib.get('id', '').lower()] = elem.attrib.get('id')
|
||||
allSectionIds[elem.attrib.get('title', '').lower()] = elem.attrib.get('id')
|
||||
allSectionIds[elem.attrib.get('key', '').lower()] = elem.attrib.get('id')
|
||||
log.debug(allSectionIds)
|
||||
# Convert passed in section items to section ids from above lookup
|
||||
sectionIds = []
|
||||
for section in sections:
|
||||
sectionKey = section.key if isinstance(section, LibrarySection) else section
|
||||
sectionIds.append(allSectionIds[sectionKey.lower()])
|
||||
return sectionIds
|
||||
|
||||
def _filterDictToStr(self, filterDict):
|
||||
""" Converts friend filters to a string representation for transport. """
|
||||
values = []
|
||||
for key, vals in filterDict.items():
|
||||
if key not in ('contentRating', 'label'):
|
||||
raise BadRequest('Unknown filter key: %s', key)
|
||||
values.append('%s=%s' % (key, '%2C'.join(vals)))
|
||||
return '|'.join(values)
|
||||
|
||||
def addWebhook(self, url):
|
||||
# copy _webhooks and append url
|
||||
urls = self._webhooks[:] + [url]
|
||||
return self.setWebhooks(urls)
|
||||
|
||||
def deleteWebhook(self, url):
|
||||
urls = copy.copy(self._webhooks)
|
||||
if url not in urls:
|
||||
raise BadRequest('Webhook does not exist: %s' % url)
|
||||
urls.remove(url)
|
||||
return self.setWebhooks(urls)
|
||||
|
||||
def setWebhooks(self, urls):
|
||||
log.info('Setting webhooks: %s' % urls)
|
||||
data = self.query(self.WEBHOOKS, self._session.post, data={'urls[]': urls})
|
||||
self._webhooks = self.listAttrs(data, 'url', etag='webhook')
|
||||
return self._webhooks
|
||||
|
||||
def webhooks(self):
|
||||
data = self.query(self.WEBHOOKS)
|
||||
self._webhooks = self.listAttrs(data, 'url', etag='webhook')
|
||||
return self._webhooks
|
||||
|
||||
def optOut(self, playback=None, library=None):
|
||||
""" Opt in or out of sharing stuff with plex.
|
||||
See: https://www.plex.tv/about/privacy-legal/
|
||||
"""
|
||||
params = {}
|
||||
if playback is not None:
|
||||
params['optOutPlayback'] = int(playback)
|
||||
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)
|
||||
|
||||
|
||||
class MyPlexUser(PlexObject):
|
||||
""" This object represents non-signed in users such as friends and linked
|
||||
accounts. NOTE: This should not be confused with the :class:`~myplex.MyPlexAccount`
|
||||
which is your specific account. The raw xml for the data presented here
|
||||
can be found at: https://plex.tv/api/users/
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'User'
|
||||
key (str): 'https://plex.tv/api/users/'
|
||||
allowCameraUpload (bool): True if this user can upload images.
|
||||
allowChannels (bool): True if this user has access to channels.
|
||||
allowSync (bool): True if this user can sync.
|
||||
email (str): User's email address (user@gmail.com).
|
||||
filterAll (str): Unknown.
|
||||
filterMovies (str): Unknown.
|
||||
filterMusic (str): Unknown.
|
||||
filterPhotos (str): Unknown.
|
||||
filterTelevision (str): Unknown.
|
||||
home (bool): Unknown.
|
||||
id (int): User's Plex account ID.
|
||||
protected (False): Unknown (possibly SSL enabled?).
|
||||
recommendationsPlaylistId (str): Unknown.
|
||||
restricted (str): Unknown.
|
||||
thumb (str): Link to the users avatar.
|
||||
title (str): Seems to be an aliad for username.
|
||||
username (str): User's username.
|
||||
"""
|
||||
TAG = 'User'
|
||||
key = 'https://plex.tv/api/users/'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.friend = self._initpath == self.key
|
||||
self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload'))
|
||||
self.allowChannels = utils.cast(bool, data.attrib.get('allowChannels'))
|
||||
self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
|
||||
self.email = data.attrib.get('email')
|
||||
self.filterAll = data.attrib.get('filterAll')
|
||||
self.filterMovies = data.attrib.get('filterMovies')
|
||||
self.filterMusic = data.attrib.get('filterMusic')
|
||||
self.filterPhotos = data.attrib.get('filterPhotos')
|
||||
self.filterTelevision = data.attrib.get('filterTelevision')
|
||||
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||
self.id = utils.cast(int, data.attrib.get('id'))
|
||||
self.protected = utils.cast(bool, data.attrib.get('protected'))
|
||||
self.recommendationsPlaylistId = data.attrib.get('recommendationsPlaylistId')
|
||||
self.restricted = data.attrib.get('restricted')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title', '')
|
||||
self.username = data.attrib.get('username', '')
|
||||
self.servers = self.findItems(data, MyPlexServerShare)
|
||||
|
||||
def get_token(self, machineIdentifier):
|
||||
try:
|
||||
for item in self._server.query(self._server.FRIENDINVITE.format(machineId=machineIdentifier)):
|
||||
if utils.cast(int, item.attrib.get('userID')) == self.id:
|
||||
return item.attrib.get('accessToken')
|
||||
except Exception:
|
||||
log.exception('Failed to get access token for %s' % self.title)
|
||||
|
||||
|
||||
class Section(PlexObject):
|
||||
""" This refers to a shared section. The raw xml for the data presented here
|
||||
can be found at: https://plex.tv/api/servers/{machineId}/shared_servers/{serverId}
|
||||
|
||||
Attributes:
|
||||
TAG (str): section
|
||||
id (int): shared section id
|
||||
sectionKey (str): what key we use for this section
|
||||
title (str): Title of the section
|
||||
sectionId (str): shared section id
|
||||
type (str): movie, tvshow, artist
|
||||
shared (bool): If this section is shared with the user
|
||||
|
||||
"""
|
||||
TAG = 'Section'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
# self.id = utils.cast(int, data.attrib.get('id')) # Havnt decided if this should be changed.
|
||||
self.sectionKey = data.attrib.get('key')
|
||||
self.title = data.attrib.get('title')
|
||||
self.sectionId = data.attrib.get('id')
|
||||
self.type = data.attrib.get('type')
|
||||
self.shared = utils.cast(bool, data.attrib.get('shared'))
|
||||
|
||||
|
||||
class MyPlexServerShare(PlexObject):
|
||||
""" Represents a single user's server reference. Used for library sharing.
|
||||
|
||||
Attributes:
|
||||
id (int): id for this share
|
||||
serverId (str): what id plex uses for this.
|
||||
machineIdentifier (str): The servers machineIdentifier
|
||||
name (str): The servers name
|
||||
lastSeenAt (datetime): Last connected to the server?
|
||||
numLibraries (int): Total number of libraries
|
||||
allLibraries (bool): True if all libraries is shared with this user.
|
||||
owned (bool): 1 if the server is owned by the user
|
||||
pending (bool): True if the invite is pending.
|
||||
|
||||
"""
|
||||
TAG = 'Server'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.id = utils.cast(int, data.attrib.get('id'))
|
||||
self.serverId = utils.cast(int, data.attrib.get('serverId'))
|
||||
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
||||
self.name = data.attrib.get('name')
|
||||
self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt'))
|
||||
self.numLibraries = utils.cast(int, data.attrib.get('numLibraries'))
|
||||
self.allLibraries = utils.cast(bool, data.attrib.get('allLibraries'))
|
||||
self.owned = utils.cast(bool, data.attrib.get('owned'))
|
||||
self.pending = utils.cast(bool, data.attrib.get('pending'))
|
||||
|
||||
def sections(self):
|
||||
url = MyPlexAccount.FRIENDSERVERS.format(machineId=self.machineIdentifier, serverId=self.id)
|
||||
data = self._server.query(url)
|
||||
sections = []
|
||||
|
||||
for section in data.iter('Section'):
|
||||
if ElementTree.iselement(section):
|
||||
sections.append(Section(self, section, url))
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
class MyPlexResource(PlexObject):
|
||||
""" This object represents resources connected to your Plex server that can provide
|
||||
content such as Plex Media Servers, iPhone or Android clients, etc. The raw xml
|
||||
for the data presented here can be found at:
|
||||
https://plex.tv/api/resources?includeHttps=1&includeRelay=1
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Device'
|
||||
key (str): 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1'
|
||||
accessToken (str): This resources accesstoken.
|
||||
clientIdentifier (str): Unique ID for this resource.
|
||||
connections (list): List of :class:`~myplex.ResourceConnection` objects
|
||||
for this resource.
|
||||
createdAt (datetime): Timestamp this resource first connected to your server.
|
||||
device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc).
|
||||
home (bool): Unknown
|
||||
lastSeenAt (datetime): Timestamp this resource last connected.
|
||||
name (str): Descriptive name of this resource.
|
||||
owned (bool): True if this resource is one of your own (you logged into it).
|
||||
platform (str): OS the resource is running (Linux, Windows, Chrome, etc.)
|
||||
platformVersion (str): Version of the platform.
|
||||
presence (bool): True if the resource is online
|
||||
product (str): Plex product (Plex Media Server, Plex for iOS, Plex Web, etc.)
|
||||
productVersion (str): Version of the product.
|
||||
provides (str): List of services this resource provides (client, server,
|
||||
player, pubsub-player, etc.)
|
||||
synced (bool): Unknown (possibly True if the resource has synced content?)
|
||||
"""
|
||||
TAG = 'Device'
|
||||
key = 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.name = data.attrib.get('name')
|
||||
self.accessToken = logfilter.add_secret(data.attrib.get('accessToken'))
|
||||
self.product = data.attrib.get('product')
|
||||
self.productVersion = data.attrib.get('productVersion')
|
||||
self.platform = data.attrib.get('platform')
|
||||
self.platformVersion = data.attrib.get('platformVersion')
|
||||
self.device = data.attrib.get('device')
|
||||
self.clientIdentifier = data.attrib.get('clientIdentifier')
|
||||
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
|
||||
self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt'))
|
||||
self.provides = data.attrib.get('provides')
|
||||
self.owned = utils.cast(bool, data.attrib.get('owned'))
|
||||
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||
self.synced = utils.cast(bool, data.attrib.get('synced'))
|
||||
self.presence = utils.cast(bool, data.attrib.get('presence'))
|
||||
self.connections = self.findItems(data, ResourceConnection)
|
||||
self.publicAddressMatches = utils.cast(bool, data.attrib.get('publicAddressMatches'))
|
||||
# This seems to only be available if its not your device (say are shared server)
|
||||
self.httpsRequired = utils.cast(bool, data.attrib.get('httpsRequired'))
|
||||
self.ownerid = utils.cast(int, data.attrib.get('ownerId', 0))
|
||||
self.sourceTitle = data.attrib.get('sourceTitle') # owners plex username.
|
||||
|
||||
def connect(self, ssl=None, timeout=None):
|
||||
""" Returns a new :class:`~server.PlexServer` or :class:`~client.PlexClient` object.
|
||||
Often times there is more than one address specified for a server or client.
|
||||
This function will prioritize local connections before remote and HTTPS before HTTP.
|
||||
After trying to connect to all available addresses for this resource and
|
||||
assuming at least one connection was successful, the PlexServer object is built and returned.
|
||||
|
||||
Parameters:
|
||||
ssl (optional): Set True to only connect to HTTPS connections. Set False to
|
||||
only connect to HTTP connections. Set None (default) to connect to any
|
||||
HTTP or HTTPS connection.
|
||||
|
||||
Raises:
|
||||
: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
|
||||
connections = sorted(self.connections, key=lambda c: c.local, reverse=True)
|
||||
owned_or_unowned_non_local = lambda x: self.owned or (not self.owned and not x.local)
|
||||
https = [c.uri for c in connections if owned_or_unowned_non_local(c)]
|
||||
http = [c.httpuri for c in connections if owned_or_unowned_non_local(c)]
|
||||
cls = PlexServer if 'server' in self.provides else PlexClient
|
||||
# Force ssl, no ssl, or any (default)
|
||||
if ssl is True: connections = https
|
||||
elif ssl is False: connections = http
|
||||
else: connections = https + http
|
||||
# Try connecting to all known resource connections in parellel, but
|
||||
# only return the first server (in order) that provides a response.
|
||||
listargs = [[cls, url, self.accessToken, timeout] for url in connections]
|
||||
log.info('Testing %s resource connections..', len(listargs))
|
||||
results = utils.threaded(_connect, listargs)
|
||||
return _chooseConnection('Resource', self.name, results)
|
||||
|
||||
|
||||
class ResourceConnection(PlexObject):
|
||||
""" Represents a Resource Connection object found within the
|
||||
:class:`~myplex.MyPlexResource` objects.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Connection'
|
||||
address (str): Local IP address
|
||||
httpuri (str): Full local address
|
||||
local (bool): True if local
|
||||
port (int): 32400
|
||||
protocol (str): HTTP or HTTPS
|
||||
uri (str): External address
|
||||
"""
|
||||
TAG = 'Connection'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.protocol = data.attrib.get('protocol')
|
||||
self.address = data.attrib.get('address')
|
||||
self.port = utils.cast(int, data.attrib.get('port'))
|
||||
self.uri = data.attrib.get('uri')
|
||||
self.local = utils.cast(bool, data.attrib.get('local'))
|
||||
self.httpuri = 'http://%s:%s' % (self.address, self.port)
|
||||
self.relay = utils.cast(bool, data.attrib.get('relay'))
|
||||
|
||||
|
||||
class MyPlexDevice(PlexObject):
|
||||
""" This object represents resources connected to your Plex server that provide
|
||||
playback ability from your Plex Server, iPhone or Android clients, Plex Web,
|
||||
this API, etc. The raw xml for the data presented here can be found at:
|
||||
https://plex.tv/devices.xml
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Device'
|
||||
key (str): 'https://plex.tv/devices.xml'
|
||||
clientIdentifier (str): Unique ID for this resource.
|
||||
connections (list): List of connection URIs for the device.
|
||||
device (str): Best guess on the type of device this is (Linux, iPad, AFTB, etc).
|
||||
id (str): MyPlex ID of the device.
|
||||
model (str): Model of the device (bueller, Linux, x86_64, etc.)
|
||||
name (str): Hostname of the device.
|
||||
platform (str): OS the resource is running (Linux, Windows, Chrome, etc.)
|
||||
platformVersion (str): Version of the platform.
|
||||
product (str): Plex product (Plex Media Server, Plex for iOS, Plex Web, etc.)
|
||||
productVersion (string): Version of the product.
|
||||
provides (str): List of services this resource provides (client, controller,
|
||||
sync-target, player, pubsub-player).
|
||||
publicAddress (str): Public IP address.
|
||||
screenDensity (str): Unknown
|
||||
screenResolution (str): Screen resolution (750x1334, 1242x2208, etc.)
|
||||
token (str): Plex authentication token for the device.
|
||||
vendor (str): Device vendor (ubuntu, etc).
|
||||
version (str): Unknown (1, 2, 1.3.3.3148-b38628e, 1.3.15, etc.)
|
||||
"""
|
||||
TAG = 'Device'
|
||||
key = 'https://plex.tv/devices.xml'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.name = data.attrib.get('name')
|
||||
self.publicAddress = data.attrib.get('publicAddress')
|
||||
self.product = data.attrib.get('product')
|
||||
self.productVersion = data.attrib.get('productVersion')
|
||||
self.platform = data.attrib.get('platform')
|
||||
self.platformVersion = data.attrib.get('platformVersion')
|
||||
self.device = data.attrib.get('device')
|
||||
self.model = data.attrib.get('model')
|
||||
self.vendor = data.attrib.get('vendor')
|
||||
self.provides = data.attrib.get('provides')
|
||||
self.clientIdentifier = data.attrib.get('clientIdentifier')
|
||||
self.version = data.attrib.get('version')
|
||||
self.id = data.attrib.get('id')
|
||||
self.token = logfilter.add_secret(data.attrib.get('token'))
|
||||
self.screenResolution = data.attrib.get('screenResolution')
|
||||
self.screenDensity = data.attrib.get('screenDensity')
|
||||
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
|
||||
self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt'))
|
||||
self.connections = [connection.attrib.get('uri') for connection in data.iter('Connection')]
|
||||
|
||||
def connect(self, timeout=None):
|
||||
""" Returns a new :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer`
|
||||
Sometimes there is more than one address specified for a server or client.
|
||||
After trying to connect to all available addresses for this client and assuming
|
||||
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.
|
||||
"""
|
||||
cls = PlexServer if 'server' in self.provides else PlexClient
|
||||
listargs = [[cls, url, self.token, timeout] for url in self.connections]
|
||||
log.info('Testing %s device connections..', len(listargs))
|
||||
results = utils.threaded(_connect, listargs)
|
||||
return _chooseConnection('Device', self.name, results)
|
||||
|
||||
def delete(self):
|
||||
""" Remove this device from your account. """
|
||||
key = 'https://plex.tv/devices/%s.xml' % self.id
|
||||
self._server.query(key, self._server._session.delete)
|
||||
|
||||
|
||||
def _connect(cls, url, token, timeout, results, i):
|
||||
""" Connects to the specified cls with url and token. Stores the connection
|
||||
information to results[i] in a threadsafe way.
|
||||
"""
|
||||
starttime = time.time()
|
||||
try:
|
||||
device = cls(baseurl=url, token=token, timeout=timeout)
|
||||
runtime = int(time.time() - starttime)
|
||||
results[i] = (url, token, device, runtime)
|
||||
except Exception as err:
|
||||
runtime = int(time.time() - starttime)
|
||||
log.error('%s: %s', url, err)
|
||||
results[i] = (url, token, None, runtime)
|
||||
|
||||
|
||||
def _chooseConnection(ctype, name, results):
|
||||
""" Chooses the first (best) connection from the given _connect results. """
|
||||
# At this point we have a list of result tuples containing (url, token, PlexServer, runtime)
|
||||
# or (url, token, None, runtime) in the case a connection could not be established.
|
||||
for url, token, result, runtime in results:
|
||||
okerr = 'OK' if result else 'ERR'
|
||||
log.info('%s connection %s (%ss): %s?X-Plex-Token=%s', ctype, okerr, runtime, url, token)
|
||||
results = [r[2] for r in results if r and r[2] is not None]
|
||||
if results:
|
||||
log.info('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token)
|
||||
return results[0]
|
||||
raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name))
|
125
lib/plexapi/photo.py
Normal file
125
lib/plexapi/photo.py
Normal file
|
@ -0,0 +1,125 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from plexapi import media, utils
|
||||
from plexapi.base import PlexPartialObject
|
||||
from plexapi.exceptions import NotFound
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Photoalbum(PlexPartialObject):
|
||||
""" Represents a photoalbum (collection of photos).
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'photo'
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
art (str): Photo art (/library/metadata/<ratingkey>/art/<artid>)
|
||||
composite (str): Unknown
|
||||
guid (str): Unknown (unique ID)
|
||||
index (sting): Index number of this album.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
listType (str): Hardcoded as 'photo' (useful for search filters).
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
summary (str): Summary of the photoalbum.
|
||||
thumb (str): URL to thumbnail image.
|
||||
title (str): Photoalbum title. (Trip to Disney World)
|
||||
type (str): Unknown
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'photo'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self.listType = 'photo'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.art = data.attrib.get('art')
|
||||
self.composite = data.attrib.get('composite')
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key')
|
||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||
self.ratingKey = data.attrib.get('ratingKey')
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
|
||||
def albums(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.photo.Photoalbum` objects in this album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, etag='Directory', **kwargs)
|
||||
|
||||
def album(self, title):
|
||||
""" Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title. """
|
||||
for album in self.albums():
|
||||
if album.title.lower() == title.lower():
|
||||
return album
|
||||
raise NotFound('Unable to find album: %s' % title)
|
||||
|
||||
def photos(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.photo.Photo` objects in this album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, etag='Photo', **kwargs)
|
||||
|
||||
def photo(self, title):
|
||||
""" Returns the :class:`~plexapi.photo.Photo` that matches the specified title. """
|
||||
for photo in self.photos():
|
||||
if photo.title.lower() == title.lower():
|
||||
return photo
|
||||
raise NotFound('Unable to find photo: %s' % title)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Photo(PlexPartialObject):
|
||||
""" Represents a single photo.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Photo'
|
||||
TYPE (str): 'photo'
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
index (sting): Index number of this photo.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
listType (str): Hardcoded as 'photo' (useful for search filters).
|
||||
media (TYPE): Unknown
|
||||
originallyAvailableAt (datetime): Datetime this photo was added to Plex.
|
||||
parentKey (str): Photoalbum API URL.
|
||||
parentRatingKey (int): Unique key identifying the photoalbum.
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
summary (str): Summary of the photo.
|
||||
thumb (str): URL to thumbnail image.
|
||||
title (str): Photo title.
|
||||
type (str): Unknown
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
year (int): Year this photo was taken.
|
||||
"""
|
||||
TAG = 'Photo'
|
||||
TYPE = 'photo'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self.listType = 'photo'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key')
|
||||
self.originallyAvailableAt = utils.toDatetime(
|
||||
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = data.attrib.get('parentRatingKey')
|
||||
self.ratingKey = data.attrib.get('ratingKey')
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.media = self.findItems(data, media.Media)
|
||||
|
||||
def photoalbum(self):
|
||||
""" Return this photo's :class:`~plexapi.photo.Photoalbum`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def section(self):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
|
||||
return self._server.library.sectionByID(self.photoalbum().librarySectionID)
|
134
lib/plexapi/playlist.py
Normal file
134
lib/plexapi/playlist.py
Normal file
|
@ -0,0 +1,134 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from plexapi import utils
|
||||
from plexapi.base import PlexPartialObject, Playable
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.playqueue import PlayQueue
|
||||
from plexapi.utils import cast, toDatetime
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Playlist(PlexPartialObject, Playable):
|
||||
""" Represents a single Playlist object.
|
||||
# TODO: Document attributes
|
||||
"""
|
||||
TAG = 'Playlist'
|
||||
TYPE = 'playlist'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Playable._loadData(self, data)
|
||||
self.addedAt = toDatetime(data.attrib.get('addedAt'))
|
||||
self.composite = data.attrib.get('composite') # url to thumbnail
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.durationInSeconds = cast(int, data.attrib.get('durationInSeconds'))
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.key = data.attrib.get('key')
|
||||
self.key = self.key.replace('/items', '') if self.key else self.key # FIX_BUG_50
|
||||
self.leafCount = cast(int, data.attrib.get('leafCount'))
|
||||
self.playlistType = data.attrib.get('playlistType')
|
||||
self.ratingKey = cast(int, data.attrib.get('ratingKey'))
|
||||
self.smart = cast(bool, data.attrib.get('smart'))
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = toDatetime(data.attrib.get('updatedAt'))
|
||||
self._items = None # cache for self.items
|
||||
|
||||
def __len__(self): # pragma: no cover
|
||||
return len(self.items())
|
||||
|
||||
def __contains__(self, other): # pragma: no cover
|
||||
return any(i.key == other.key for i in self.items())
|
||||
|
||||
def __getitem__(self, key): # pragma: no cover
|
||||
return self.items()[key]
|
||||
|
||||
def items(self):
|
||||
""" Returns a list of all items in the playlist. """
|
||||
if self._items is None:
|
||||
key = '%s/items' % self.key
|
||||
items = self.fetchItems(key)
|
||||
self._items = items
|
||||
return self._items
|
||||
|
||||
def addItems(self, items):
|
||||
""" Add items to a playlist. """
|
||||
if not isinstance(items, (list, tuple)):
|
||||
items = [items]
|
||||
ratingKeys = []
|
||||
for item in items:
|
||||
if item.listType != self.playlistType: # pragma: no cover
|
||||
raise BadRequest('Can not mix media types when building a playlist: %s and %s' %
|
||||
(self.playlistType, item.listType))
|
||||
ratingKeys.append(str(item.ratingKey))
|
||||
uuid = items[0].section().uuid
|
||||
ratingKeys = ','.join(ratingKeys)
|
||||
key = '%s/items%s' % (self.key, utils.joinArgs({
|
||||
'uri': 'library://%s/directory//library/metadata/%s' % (uuid, ratingKeys)
|
||||
}))
|
||||
result = self._server.query(key, method=self._server._session.put)
|
||||
self.reload()
|
||||
return result
|
||||
|
||||
def removeItem(self, item):
|
||||
""" Remove a file from a playlist. """
|
||||
key = '%s/items/%s' % (self.key, item.playlistItemID)
|
||||
result = self._server.query(key, method=self._server._session.delete)
|
||||
self.reload()
|
||||
return result
|
||||
|
||||
def moveItem(self, item, after=None):
|
||||
""" Move a to a new position in playlist. """
|
||||
key = '%s/items/%s/move' % (self.key, item.playlistItemID)
|
||||
if after:
|
||||
key += '?after=%s' % after.playlistItemID
|
||||
result = self._server.query(key, method=self._server._session.put)
|
||||
self.reload()
|
||||
return result
|
||||
|
||||
def edit(self, title=None, summary=None):
|
||||
""" Edit playlist. """
|
||||
key = '/library/metadata/%s%s' % (self.ratingKey, utils.joinArgs({'title': title, 'summary': summary}))
|
||||
result = self._server.query(key, method=self._server._session.put)
|
||||
self.reload()
|
||||
return result
|
||||
|
||||
def delete(self):
|
||||
""" Delete playlist. """
|
||||
return self._server.query(self.key, method=self._server._session.delete)
|
||||
|
||||
def playQueue(self, *args, **kwargs):
|
||||
""" Create a playqueue from this playlist. """
|
||||
return PlayQueue.create(self._server, self, *args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def create(cls, server, title, items):
|
||||
""" Create a playlist. """
|
||||
if not isinstance(items, (list, tuple)):
|
||||
items = [items]
|
||||
ratingKeys = []
|
||||
for item in items:
|
||||
if item.listType != items[0].listType: # pragma: no cover
|
||||
raise BadRequest('Can not mix media types when building a playlist')
|
||||
ratingKeys.append(str(item.ratingKey))
|
||||
ratingKeys = ','.join(ratingKeys)
|
||||
uuid = items[0].section().uuid
|
||||
key = '/playlists%s' % utils.joinArgs({
|
||||
'uri': 'library://%s/directory//library/metadata/%s' % (uuid, ratingKeys),
|
||||
'type': items[0].listType,
|
||||
'title': title,
|
||||
'smart': 0
|
||||
})
|
||||
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
|
||||
myplex = self._server.myPlexAccount()
|
||||
user = myplex.user(user)
|
||||
# Get the token for your machine.
|
||||
token = user.get_token(self._server.machineIdentifier)
|
||||
# Login to your server using your friends credentials.
|
||||
user_server = PlexServer(self._server._baseurl, token)
|
||||
return self.create(user_server, self.title, self.items())
|
75
lib/plexapi/playqueue.py
Normal file
75
lib/plexapi/playqueue.py
Normal file
|
@ -0,0 +1,75 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from plexapi import utils
|
||||
from plexapi.base import PlexObject
|
||||
|
||||
|
||||
class PlayQueue(PlexObject):
|
||||
""" Control a PlayQueue.
|
||||
|
||||
Attributes:
|
||||
key (str): This is only added to support playMedia
|
||||
identifier (str): com.plexapp.plugins.library
|
||||
initpath (str): Relative url where data was grabbed from.
|
||||
items (list): List of :class:`~plexapi.media.Media` or class:`~plexapi.playlist.Playlist`
|
||||
mediaTagPrefix (str): Fx /system/bundle/media/flags/
|
||||
mediaTagVersion (str): Fx 1485957738
|
||||
playQueueID (str): a id for the playqueue
|
||||
playQueueSelectedItemID (str): playQueueSelectedItemID
|
||||
playQueueSelectedItemOffset (str): playQueueSelectedItemOffset
|
||||
playQueueSelectedMetadataItemID (<type 'str'>): 7
|
||||
playQueueShuffled (bool): True if shuffled
|
||||
playQueueSourceURI (str): Fx library://150425c9-0d99-4242-821e-e5ab81cd2221/item//library/metadata/7
|
||||
playQueueTotalCount (str): How many items in the play queue.
|
||||
playQueueVersion (str): What version the playqueue is.
|
||||
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
|
||||
size (str): Seems to be a alias for playQueueTotalCount.
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.identifier = data.attrib.get('identifier')
|
||||
self.mediaTagPrefix = data.attrib.get('mediaTagPrefix')
|
||||
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
|
||||
self.playQueueID = data.attrib.get('playQueueID')
|
||||
self.playQueueSelectedItemID = data.attrib.get('playQueueSelectedItemID')
|
||||
self.playQueueSelectedItemOffset = data.attrib.get('playQueueSelectedItemOffset')
|
||||
self.playQueueSelectedMetadataItemID = data.attrib.get('playQueueSelectedMetadataItemID')
|
||||
self.playQueueShuffled = utils.cast(bool, data.attrib.get('playQueueShuffled', 0))
|
||||
self.playQueueSourceURI = data.attrib.get('playQueueSourceURI')
|
||||
self.playQueueTotalCount = data.attrib.get('playQueueTotalCount')
|
||||
self.playQueueVersion = data.attrib.get('playQueueVersion')
|
||||
self.size = utils.cast(int, data.attrib.get('size', 0))
|
||||
self.items = self.findItems(data)
|
||||
|
||||
@classmethod
|
||||
def create(cls, server, item, shuffle=0, repeat=0, includeChapters=1, includeRelated=1):
|
||||
""" Create and returns a new :class:`~plexapi.playqueue.PlayQueue`.
|
||||
|
||||
Paramaters:
|
||||
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
|
||||
item (:class:`~plexapi.media.Media` or class:`~plexapi.playlist.Playlist`): A media or Playlist.
|
||||
shuffle (int, optional): Start the playqueue shuffled.
|
||||
repeat (int, optional): Start the playqueue shuffled.
|
||||
includeChapters (int, optional): include Chapters.
|
||||
includeRelated (int, optional): include Related.
|
||||
"""
|
||||
args = {}
|
||||
args['includeChapters'] = includeChapters
|
||||
args['includeRelated'] = includeRelated
|
||||
args['repeat'] = repeat
|
||||
args['shuffle'] = shuffle
|
||||
if item.type == 'playlist':
|
||||
args['playlistID'] = item.ratingKey
|
||||
args['type'] = item.playlistType
|
||||
else:
|
||||
uuid = item.section().uuid
|
||||
args['key'] = item.key
|
||||
args['type'] = item.listType
|
||||
args['uri'] = 'library://%s/item/%s' % (uuid, item.key)
|
||||
path = '/playQueues%s' % utils.joinArgs(args)
|
||||
data = server.query(path, method=server._session.post)
|
||||
c = cls(server, data, initpath=path)
|
||||
# we manually add a key so we can pass this to playMedia
|
||||
# since the data, does not contain a key.
|
||||
c.key = item.key
|
||||
return c
|
471
lib/plexapi/server.py
Normal file
471
lib/plexapi/server.py
Normal file
|
@ -0,0 +1,471 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import requests
|
||||
from requests.status_codes import _codes as codes
|
||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
|
||||
from plexapi import log, logfilter, utils
|
||||
from plexapi.alert import AlertListener
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.client import PlexClient
|
||||
from plexapi.compat import ElementTree, urlencode
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
from plexapi.library import Library, Hub
|
||||
from plexapi.settings import Settings
|
||||
from plexapi.playlist import Playlist
|
||||
from plexapi.playqueue import PlayQueue
|
||||
from plexapi.utils import cast
|
||||
|
||||
# Need these imports to populate utils.PLEXOBJECTS
|
||||
from plexapi import (audio as _audio, video as _video, # noqa: F401
|
||||
photo as _photo, media as _media, playlist as _playlist) # noqa: F401
|
||||
|
||||
|
||||
class PlexServer(PlexObject):
|
||||
""" This is the main entry point to interacting with a Plex server. It allows you to
|
||||
list connected clients, browse your library sections and perform actions such as
|
||||
emptying trash. If you do not know the auth token required to access your Plex
|
||||
server, or simply want to access your server with your username and password, you
|
||||
can also create an PlexServer instance from :class:`~plexapi.myplex.MyPlexAccount`.
|
||||
|
||||
Parameters:
|
||||
baseurl (str): Base url for to access the Plex Media Server (default: 'http://localhost:32400').
|
||||
token (str): Required Plex authentication token to access the server.
|
||||
session (requests.Session, optional): Use your own session object if you want to
|
||||
cache the http responses from PMS
|
||||
timeout (int): timeout in seconds on initial connect to server (default config.TIMEOUT).
|
||||
|
||||
Attributes:
|
||||
allowCameraUpload (bool): True if server allows camera upload.
|
||||
allowChannelAccess (bool): True if server allows channel access (iTunes?).
|
||||
allowMediaDeletion (bool): True is server allows media to be deleted.
|
||||
allowSharing (bool): True is server allows sharing.
|
||||
allowSync (bool): True is server allows sync.
|
||||
backgroundProcessing (bool): Unknown
|
||||
certificate (bool): True if server has an HTTPS certificate.
|
||||
companionProxy (bool): Unknown
|
||||
diagnostics (bool): Unknown
|
||||
eventStream (bool): Unknown
|
||||
friendlyName (str): Human friendly name for this server.
|
||||
hubSearch (bool): True if `Hub Search <https://www.plex.tv/blog
|
||||
/seek-plex-shall-find-leveling-web-app/>`_ is enabled. I believe this
|
||||
is enabled for everyone
|
||||
machineIdentifier (str): Unique ID for this server (looks like an md5).
|
||||
multiuser (bool): True if `multiusers <https://support.plex.tv/hc/en-us/articles
|
||||
/200250367-Multi-User-Support>`_ are enabled.
|
||||
myPlex (bool): Unknown (True if logged into myPlex?).
|
||||
myPlexMappingState (str): Unknown (ex: mapped).
|
||||
myPlexSigninState (str): Unknown (ex: ok).
|
||||
myPlexSubscription (bool): True if you have a myPlex subscription.
|
||||
myPlexUsername (str): Email address if signed into myPlex (user@example.com)
|
||||
ownerFeatures (list): List of features allowed by the server owner. This may be based
|
||||
on your PlexPass subscription. Features include: camera_upload, cloudsync,
|
||||
content_filter, dvr, hardware_transcoding, home, lyrics, music_videos, pass,
|
||||
photo_autotags, premium_music_metadata, session_bandwidth_restrictions, sync,
|
||||
trailers, webhooks (and maybe more).
|
||||
photoAutoTag (bool): True if photo `auto-tagging <https://support.plex.tv/hc/en-us
|
||||
/articles/234976627-Auto-Tagging-of-Photos>`_ is enabled.
|
||||
platform (str): Platform the server is hosted on (ex: Linux)
|
||||
platformVersion (str): Platform version (ex: '6.1 (Build 7601)', '4.4.0-59-generic').
|
||||
pluginHost (bool): Unknown
|
||||
readOnlyLibraries (bool): Unknown
|
||||
requestParametersInCookie (bool): Unknown
|
||||
streamingBrainVersion (bool): Current `Streaming Brain <https://www.plex.tv/blog
|
||||
/mcstreamy-brain-take-world-two-easy-steps/>`_ version.
|
||||
sync (bool): True if `syncing to a device <https://support.plex.tv/hc/en-us/articles
|
||||
/201053678-Sync-Media-to-a-Device>`_ is enabled.
|
||||
transcoderActiveVideoSessions (int): Number of active video transcoding sessions.
|
||||
transcoderAudio (bool): True if audio transcoding audio is available.
|
||||
transcoderLyrics (bool): True if audio transcoding lyrics is available.
|
||||
transcoderPhoto (bool): True if audio transcoding photos is available.
|
||||
transcoderSubtitles (bool): True if audio transcoding subtitles is available.
|
||||
transcoderVideo (bool): True if audio transcoding video is available.
|
||||
transcoderVideoBitrates (bool): List of video bitrates.
|
||||
transcoderVideoQualities (bool): List of video qualities.
|
||||
transcoderVideoResolutions (bool): List of video resolutions.
|
||||
updatedAt (int): Datetime the server was updated.
|
||||
updater (bool): Unknown
|
||||
version (str): Current Plex version (ex: 1.3.2.3112-1751929)
|
||||
voiceSearch (bool): True if voice search is enabled. (is this Google Voice search?)
|
||||
_baseurl (str): HTTP address of the client.
|
||||
_token (str): Token used to access this client.
|
||||
_session (obj): Requests session object used to access this client.
|
||||
"""
|
||||
key = '/'
|
||||
|
||||
def __init__(self, baseurl=None, token=None, session=None, timeout=None):
|
||||
self._baseurl = baseurl or CONFIG.get('auth.server_baseurl', 'http://localhost:32400')
|
||||
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()
|
||||
self._library = None # cached library
|
||||
self._settings = None # cached settings
|
||||
self._myPlexAccount = None # cached myPlexAccount
|
||||
data = self.query(self.key, timeout=timeout)
|
||||
super(PlexServer, self).__init__(self, data, self.key)
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.allowCameraUpload = cast(bool, data.attrib.get('allowCameraUpload'))
|
||||
self.allowChannelAccess = cast(bool, data.attrib.get('allowChannelAccess'))
|
||||
self.allowMediaDeletion = cast(bool, data.attrib.get('allowMediaDeletion'))
|
||||
self.allowSharing = cast(bool, data.attrib.get('allowSharing'))
|
||||
self.allowSync = cast(bool, data.attrib.get('allowSync'))
|
||||
self.backgroundProcessing = cast(bool, data.attrib.get('backgroundProcessing'))
|
||||
self.certificate = cast(bool, data.attrib.get('certificate'))
|
||||
self.companionProxy = cast(bool, data.attrib.get('companionProxy'))
|
||||
self.diagnostics = utils.toList(data.attrib.get('diagnostics'))
|
||||
self.eventStream = cast(bool, data.attrib.get('eventStream'))
|
||||
self.friendlyName = data.attrib.get('friendlyName')
|
||||
self.hubSearch = cast(bool, data.attrib.get('hubSearch'))
|
||||
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
||||
self.multiuser = cast(bool, data.attrib.get('multiuser'))
|
||||
self.myPlex = cast(bool, data.attrib.get('myPlex'))
|
||||
self.myPlexMappingState = data.attrib.get('myPlexMappingState')
|
||||
self.myPlexSigninState = data.attrib.get('myPlexSigninState')
|
||||
self.myPlexSubscription = cast(bool, data.attrib.get('myPlexSubscription'))
|
||||
self.myPlexUsername = data.attrib.get('myPlexUsername')
|
||||
self.ownerFeatures = utils.toList(data.attrib.get('ownerFeatures'))
|
||||
self.photoAutoTag = cast(bool, data.attrib.get('photoAutoTag'))
|
||||
self.platform = data.attrib.get('platform')
|
||||
self.platformVersion = data.attrib.get('platformVersion')
|
||||
self.pluginHost = cast(bool, data.attrib.get('pluginHost'))
|
||||
self.readOnlyLibraries = cast(int, data.attrib.get('readOnlyLibraries'))
|
||||
self.requestParametersInCookie = cast(bool, data.attrib.get('requestParametersInCookie'))
|
||||
self.streamingBrainVersion = data.attrib.get('streamingBrainVersion')
|
||||
self.sync = cast(bool, data.attrib.get('sync'))
|
||||
self.transcoderActiveVideoSessions = int(data.attrib.get('transcoderActiveVideoSessions', 0))
|
||||
self.transcoderAudio = cast(bool, data.attrib.get('transcoderAudio'))
|
||||
self.transcoderLyrics = cast(bool, data.attrib.get('transcoderLyrics'))
|
||||
self.transcoderPhoto = cast(bool, data.attrib.get('transcoderPhoto'))
|
||||
self.transcoderSubtitles = cast(bool, data.attrib.get('transcoderSubtitles'))
|
||||
self.transcoderVideo = cast(bool, data.attrib.get('transcoderVideo'))
|
||||
self.transcoderVideoBitrates = utils.toList(data.attrib.get('transcoderVideoBitrates'))
|
||||
self.transcoderVideoQualities = utils.toList(data.attrib.get('transcoderVideoQualities'))
|
||||
self.transcoderVideoResolutions = utils.toList(data.attrib.get('transcoderVideoResolutions'))
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.updater = cast(bool, data.attrib.get('updater'))
|
||||
self.version = data.attrib.get('version')
|
||||
self.voiceSearch = cast(bool, data.attrib.get('voiceSearch'))
|
||||
|
||||
def _headers(self, **kwargs):
|
||||
""" Returns dict containing base headers for all requests to the server. """
|
||||
headers = BASE_HEADERS.copy()
|
||||
if self._token:
|
||||
headers['X-Plex-Token'] = self._token
|
||||
headers.update(kwargs)
|
||||
return headers
|
||||
|
||||
@property
|
||||
def library(self):
|
||||
""" Library to browse or search your media. """
|
||||
if not self._library:
|
||||
try:
|
||||
data = self.query(Library.key)
|
||||
self._library = Library(self, data)
|
||||
except BadRequest:
|
||||
data = self.query('/library/sections/')
|
||||
# Only the owner has access to /library
|
||||
# so just return the library without the data.
|
||||
return Library(self, data)
|
||||
return self._library
|
||||
|
||||
@property
|
||||
def settings(self):
|
||||
""" Returns a list of all server settings. """
|
||||
if not self._settings:
|
||||
data = self.query(Settings.key)
|
||||
self._settings = Settings(self, data)
|
||||
return self._settings
|
||||
|
||||
def account(self):
|
||||
""" Returns the :class:`~plexapi.server.Account` object this server belongs to. """
|
||||
data = self.query(Account.key)
|
||||
return Account(self, data)
|
||||
|
||||
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
|
||||
you're likley to recieve an authentication error calling this.
|
||||
"""
|
||||
if self._myPlexAccount is None:
|
||||
from plexapi.myplex import MyPlexAccount
|
||||
self._myPlexAccount = MyPlexAccount(token=self._token)
|
||||
return self._myPlexAccount
|
||||
|
||||
def _myPlexClientPorts(self):
|
||||
""" Sometimes the PlexServer does not properly advertise port numbers required
|
||||
to connect. This attemps to look up device port number from plex.tv.
|
||||
See issue #126: Make PlexServer.clients() more user friendly.
|
||||
https://github.com/pkkid/python-plexapi/issues/126
|
||||
"""
|
||||
try:
|
||||
ports = {}
|
||||
account = self.myPlexAccount()
|
||||
for device in account.devices():
|
||||
if device.connections and ':' in device.connections[0][6:]:
|
||||
ports[device.clientIdentifier] = device.connections[0].split(':')[-1]
|
||||
return ports
|
||||
except Exception as err:
|
||||
log.warning('Unable to fetch client ports from myPlex: %s', err)
|
||||
return ports
|
||||
|
||||
def clients(self):
|
||||
""" Returns list of all :class:`~plexapi.client.PlexClient` objects connected to server. """
|
||||
items = []
|
||||
ports = None
|
||||
for elem in self.query('/clients'):
|
||||
port = elem.attrib.get('port')
|
||||
if not port:
|
||||
log.warning('%s did not advertise a port, checking plex.tv.', elem.attrib.get('name'))
|
||||
ports = self._myPlexClientPorts() if ports is None else ports
|
||||
port = ports.get(elem.attrib.get('machineIdentifier'))
|
||||
baseurl = 'http://%s:%s' % (elem.attrib['host'], port)
|
||||
items.append(PlexClient(baseurl=baseurl, server=self,
|
||||
token=self._token, data=elem, connect=False))
|
||||
|
||||
return items
|
||||
|
||||
def client(self, name):
|
||||
""" Returns the :class:`~plexapi.client.PlexClient` that matches the specified name.
|
||||
|
||||
Parameters:
|
||||
name (str): Name of the client to return.
|
||||
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.NotFound`: Unknown client name
|
||||
"""
|
||||
for client in self.clients():
|
||||
if client and client.title == name:
|
||||
return client
|
||||
|
||||
raise NotFound('Unknown client name: %s' % name)
|
||||
|
||||
def createPlaylist(self, title, items):
|
||||
""" 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)
|
||||
|
||||
def createPlayQueue(self, item, **kwargs):
|
||||
""" Creates and returns a new :class:`~plexapi.playqueue.PlayQueue`.
|
||||
|
||||
Parameters:
|
||||
item (Media or Playlist): Media or playlist to add to PlayQueue.
|
||||
kwargs (dict): See `~plexapi.playerque.PlayQueue.create`.
|
||||
"""
|
||||
return PlayQueue.create(self, item, **kwargs)
|
||||
|
||||
def downloadDatabases(self, savepath=None, unpack=False):
|
||||
""" Download databases.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
unpack (bool): Unpack the zip file.
|
||||
"""
|
||||
url = self.url('/diagnostics/databases')
|
||||
filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack)
|
||||
return filepath
|
||||
|
||||
def downloadLogs(self, savepath=None, unpack=False):
|
||||
""" Download server logs.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
unpack (bool): Unpack the zip file.
|
||||
"""
|
||||
url = self.url('/diagnostics/logs')
|
||||
filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack)
|
||||
return filepath
|
||||
|
||||
def check_for_update(self, force=True, download=False):
|
||||
""" Returns a :class:`~plexapi.base.Release` object containing release info.
|
||||
|
||||
Parameters:
|
||||
force (bool): Force server to check for new releases
|
||||
download (bool): Download if a update is available.
|
||||
"""
|
||||
part = '/updater/check?download=%s' % (1 if download else 0)
|
||||
if force:
|
||||
self.query(part, method=self._session.put)
|
||||
return self.fetchItem('/updater/status')
|
||||
|
||||
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)
|
||||
|
||||
def installUpdate(self):
|
||||
""" Install the newest version of Plex Media Server. """
|
||||
# We can add this but dunno how useful this is since it sometimes
|
||||
# requires user action using a gui.
|
||||
part = '/updater/apply'
|
||||
release = self.check_for_update(force=True, download=True)
|
||||
if release and release.version != self.version:
|
||||
# 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 playlists(self):
|
||||
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects saved on the server. """
|
||||
# TODO: Add sort and type options?
|
||||
# /playlists/all?type=15&sort=titleSort%3Aasc&playlistType=video&smart=0
|
||||
return self.fetchItems('/playlists')
|
||||
|
||||
def playlist(self, title):
|
||||
""" Returns the :class:`~plexapi.client.Playlist` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the playlist to return.
|
||||
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.NotFound`: Invalid playlist title
|
||||
"""
|
||||
return self.fetchItem('/playlists', title=title)
|
||||
|
||||
def query(self, key, method=None, headers=None, timeout=None, **kwargs):
|
||||
""" Main method used to handle HTTPS requests to the Plex server. This method helps
|
||||
by encoding the response to utf-8 and parsing the returned XML into and
|
||||
ElementTree object. Returns None if no data exists in the response.
|
||||
"""
|
||||
url = self.url(key)
|
||||
method = method or self._session.get
|
||||
timeout = timeout or TIMEOUT
|
||||
log.debug('%s %s', method.__name__.upper(), url)
|
||||
headers = self._headers(**headers or {})
|
||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
||||
if response.status_code not in (200, 201):
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
|
||||
raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext))
|
||||
data = response.text.encode('utf8')
|
||||
return ElementTree.fromstring(data) if data.strip() else None
|
||||
|
||||
def search(self, query, mediatype=None, limit=None):
|
||||
""" Returns a list of media items or filter categories from the resulting
|
||||
`Hub Search <https://www.plex.tv/blog/seek-plex-shall-find-leveling-web-app/>`_
|
||||
against all items in your Plex library. This searches genres, actors, directors,
|
||||
playlists, as well as all the obvious media titles. It performs spell-checking
|
||||
against your search terms (because KUROSAWA is hard to spell). It also provides
|
||||
contextual search results. So for example, if you search for 'Pernice', it’ll
|
||||
return 'Pernice Brothers' as the artist result, but we’ll also go ahead and
|
||||
return your most-listened to albums and tracks from the artist. If you type
|
||||
'Arnold' you’ll get a result for the actor, but also the most recently added
|
||||
movies he’s in.
|
||||
|
||||
Parameters:
|
||||
query (str): Query to use when searching your library.
|
||||
mediatype (str): Optionally limit your search to the specified media type.
|
||||
limit (int): Optionally limit to the specified number of results per Hub.
|
||||
"""
|
||||
results = []
|
||||
params = {'query': query}
|
||||
if mediatype:
|
||||
params['section'] = utils.SEARCHTYPES[mediatype]
|
||||
if limit:
|
||||
params['limit'] = limit
|
||||
key = '/hubs/search?%s' % urlencode(params)
|
||||
for hub in self.fetchItems(key, Hub):
|
||||
results += hub.items
|
||||
return results
|
||||
|
||||
def sessions(self):
|
||||
""" Returns a list of all active session (currently playing) media objects. """
|
||||
return self.fetchItems('/status/sessions')
|
||||
|
||||
def startAlertListener(self, callback=None):
|
||||
""" Creates a websocket connection to the Plex Server to optionally recieve
|
||||
notifications. These often include messages from Plex about media scans
|
||||
as well as updates to currently running Transcode Sessions.
|
||||
|
||||
NOTE: You need websocket-client installed in order to use this feature.
|
||||
>> pip install websocket-client
|
||||
|
||||
Parameters:
|
||||
callback (func): Callback function to call on recieved messages.
|
||||
|
||||
raises:
|
||||
:class:`~plexapi.exception.Unsupported`: Websocket-client not installed.
|
||||
"""
|
||||
notifier = AlertListener(self, callback)
|
||||
notifier.start()
|
||||
return notifier
|
||||
|
||||
def transcodeImage(self, media, height, width, opacity=100, saturation=100):
|
||||
""" Returns the URL for a transcoded image from the specified media object.
|
||||
Returns None if no media specified (needed if user tries to pass thumb
|
||||
or art directly).
|
||||
|
||||
Parameters:
|
||||
height (int): Height to transcode the image to.
|
||||
width (int): Width to transcode the image to.
|
||||
opacity (int): Opacity of the resulting image (possibly deprecated).
|
||||
saturation (int): Saturating of the resulting image.
|
||||
"""
|
||||
if media:
|
||||
transcode_url = '/photo/:/transcode?height=%s&width=%s&opacity=%s&saturation=%s&url=%s' % (
|
||||
height, width, opacity, saturation, media)
|
||||
return self.url(transcode_url, includeToken=True)
|
||||
|
||||
def url(self, key, includeToken=None):
|
||||
""" Build a URL string with proper token argument. Token will be appended to the URL
|
||||
if either includeToken is True or CONFIG.log.show_secrets is 'true'.
|
||||
"""
|
||||
if self._token and (includeToken or self._showSecrets):
|
||||
delim = '&' if '?' in key else '?'
|
||||
return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token)
|
||||
return '%s%s' % (self._baseurl, key)
|
||||
|
||||
|
||||
class Account(PlexObject):
|
||||
""" Contains the locally cached MyPlex account information. The properties provided don't
|
||||
match the :class:`~plexapi.myplex.MyPlexAccount` object very well. I believe this exists
|
||||
because access to myplex is not required to get basic plex information. I can't imagine
|
||||
object is terribly useful except unless you were needed this information while offline.
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this account is connected to (optional)
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
|
||||
Attributes:
|
||||
authToken (str): Plex authentication token to access the server.
|
||||
mappingError (str): Unknown
|
||||
mappingErrorMessage (str): Unknown
|
||||
mappingState (str): Unknown
|
||||
privateAddress (str): Local IP address of the Plex server.
|
||||
privatePort (str): Local port of the Plex server.
|
||||
publicAddress (str): Public IP address of the Plex server.
|
||||
publicPort (str): Public port of the Plex server.
|
||||
signInState (str): Signin state for this account (ex: ok).
|
||||
subscriptionActive (str): True if the account subscription is active.
|
||||
subscriptionFeatures (str): List of features allowed by the server for this account.
|
||||
This may be based on your PlexPass subscription. Features include: camera_upload,
|
||||
cloudsync, content_filter, dvr, hardware_transcoding, home, lyrics, music_videos,
|
||||
pass, photo_autotags, premium_music_metadata, session_bandwidth_restrictions,
|
||||
sync, trailers, webhooks' (and maybe more).
|
||||
subscriptionState (str): 'Active' if this subscription is active.
|
||||
username (str): Plex account username (user@example.com).
|
||||
"""
|
||||
key = '/myplex/account'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.authToken = data.attrib.get('authToken')
|
||||
self.username = data.attrib.get('username')
|
||||
self.mappingState = data.attrib.get('mappingState')
|
||||
self.mappingError = data.attrib.get('mappingError')
|
||||
self.mappingErrorMessage = data.attrib.get('mappingErrorMessage')
|
||||
self.signInState = data.attrib.get('signInState')
|
||||
self.publicAddress = data.attrib.get('publicAddress')
|
||||
self.publicPort = data.attrib.get('publicPort')
|
||||
self.privateAddress = data.attrib.get('privateAddress')
|
||||
self.privatePort = data.attrib.get('privatePort')
|
||||
self.subscriptionFeatures = utils.toList(data.attrib.get('subscriptionFeatures'))
|
||||
self.subscriptionActive = cast(bool, data.attrib.get('subscriptionActive'))
|
||||
self.subscriptionState = data.attrib.get('subscriptionState')
|
156
lib/plexapi/settings.py
Normal file
156
lib/plexapi/settings.py
Normal file
|
@ -0,0 +1,156 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from collections import defaultdict
|
||||
|
||||
from plexapi import log, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.compat import quote, string_type
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
|
||||
|
||||
class Settings(PlexObject):
|
||||
""" Container class for all settings. Allows getting and setting PlexServer settings.
|
||||
|
||||
Attributes:
|
||||
key (str): '/:/prefs'
|
||||
"""
|
||||
key = '/:/prefs'
|
||||
|
||||
def __init__(self, server, data, initpath=None):
|
||||
self._settings = {}
|
||||
super(Settings, self).__init__(server, data, initpath)
|
||||
|
||||
def __getattr__(self, attr):
|
||||
if attr.startswith('_'):
|
||||
return self.__dict__[attr]
|
||||
return self.get(attr).value
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
if not attr.startswith('_'):
|
||||
return self.get(attr).set(value)
|
||||
self.__dict__[attr] = value
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
for elem in data:
|
||||
id = utils.lowerFirst(elem.attrib['id'])
|
||||
if id in self._settings:
|
||||
self._settings[id]._loadData(elem)
|
||||
continue
|
||||
self._settings[id] = Setting(self._server, elem, self._initpath)
|
||||
|
||||
def all(self):
|
||||
""" Returns a list of all :class:`~plexapi.settings.Setting` objects available. """
|
||||
return list(v for id, v in sorted(self._settings.items()))
|
||||
|
||||
def get(self, id):
|
||||
""" Return the :class:`~plexapi.settings.Setting` object with the specified id. """
|
||||
id = utils.lowerFirst(id)
|
||||
if id in self._settings:
|
||||
return self._settings[id]
|
||||
raise NotFound('Invalid setting id: %s' % id)
|
||||
|
||||
def groups(self):
|
||||
""" Returns a dict of lists for all :class:`~plexapi.settings.Setting`
|
||||
objects grouped by setting group.
|
||||
"""
|
||||
groups = defaultdict(list)
|
||||
for setting in self.all():
|
||||
groups[setting.group].append(setting)
|
||||
return dict(groups)
|
||||
|
||||
def group(self, group):
|
||||
""" Return a list of all :class:`~plexapi.settings.Setting` objects in the specified group.
|
||||
|
||||
Parameters:
|
||||
group (str): Group to return all settings.
|
||||
"""
|
||||
return self.groups().get(group, [])
|
||||
|
||||
def save(self):
|
||||
""" Save any outstanding settnig changes to the :class:`~plexapi.server.PlexServer`. This
|
||||
performs a full reload() of Settings after complete.
|
||||
"""
|
||||
params = {}
|
||||
for setting in self.all():
|
||||
if setting._setValue:
|
||||
log.info('Saving PlexServer setting %s = %s' % (setting.id, setting._setValue))
|
||||
params[setting.id] = quote(setting._setValue)
|
||||
if not params:
|
||||
raise BadRequest('No setting have been modified.')
|
||||
querystr = '&'.join(['%s=%s' % (k, v) for k, v in params.items()])
|
||||
url = '%s?%s' % (self.key, querystr)
|
||||
self._server.query(url, self._server._session.put)
|
||||
self.reload()
|
||||
|
||||
|
||||
class Setting(PlexObject):
|
||||
""" Represents a single Plex setting.
|
||||
|
||||
Attributes:
|
||||
id (str): Setting id (or name).
|
||||
label (str): Short description of what this setting is.
|
||||
summary (str): Long description of what this setting is.
|
||||
type (str): Setting type (text, int, double, bool).
|
||||
default (str): Default value for this setting.
|
||||
value (str,bool,int,float): Current value for this setting.
|
||||
hidden (bool): True if this is a hidden setting.
|
||||
advanced (bool): True if this is an advanced setting.
|
||||
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_str = lambda x: str(x).lower()
|
||||
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},
|
||||
}
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._setValue = None
|
||||
self.id = data.attrib.get('id')
|
||||
self.label = data.attrib.get('label')
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.type = data.attrib.get('type')
|
||||
self.default = self._cast(data.attrib.get('default'))
|
||||
self.value = self._cast(data.attrib.get('value'))
|
||||
self.hidden = utils.cast(bool, data.attrib.get('hidden'))
|
||||
self.advanced = utils.cast(bool, data.attrib.get('advanced'))
|
||||
self.group = data.attrib.get('group')
|
||||
self.enumValues = self._getEnumValues(data)
|
||||
|
||||
def _cast(self, value):
|
||||
""" Cast the specifief value to the type of this setting. """
|
||||
if self.type != 'text':
|
||||
value = utils.cast(self.TYPES.get(self.type)['cast'], value)
|
||||
return value
|
||||
|
||||
def _getEnumValues(self, data):
|
||||
""" Returns a list of dictionary of valis value for this setting. """
|
||||
enumstr = data.attrib.get('enumValues')
|
||||
if not enumstr:
|
||||
return None
|
||||
if ':' in enumstr:
|
||||
return {self._cast(k): v for k, v in [kv.split(':') for kv in enumstr.split('|')]}
|
||||
return enumstr.split('|')
|
||||
|
||||
def set(self, value):
|
||||
""" Set a new value for this setitng. NOTE: You must call plex.settings.save() for before
|
||||
any changes to setting values are persisted to the :class:`~plexapi.server.PlexServer`.
|
||||
"""
|
||||
# check a few things up front
|
||||
if not isinstance(value, self.TYPES[self.type]['type']):
|
||||
badtype = type(value).__name__
|
||||
raise BadRequest('Invalid value for %s: a %s is required, not %s' % (self.id, self.type, badtype))
|
||||
if self.enumValues and value not in self.enumValues:
|
||||
raise BadRequest('Invalid value for %s: %s not in %s' % (self.id, value, list(self.enumValues)))
|
||||
# store value off to the side until we call settings.save()
|
||||
tostr = self.TYPES[self.type]['tostr']
|
||||
self._setValue = tostr(value)
|
||||
|
||||
def toUrl(self):
|
||||
"""Helper for urls"""
|
||||
return '%s=%s' % (self.id, self._value or self.value)
|
42
lib/plexapi/sync.py
Normal file
42
lib/plexapi/sync.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import requests
|
||||
from plexapi import utils
|
||||
from plexapi.exceptions import NotFound
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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.rootTitle = data.attrib.get('rootTitle')
|
||||
self.title = data.attrib.get('title')
|
||||
self.metadataType = data.attrib.get('metadataType')
|
||||
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()
|
||||
|
||||
def server(self):
|
||||
server = list(filter(lambda x: x.machineIdentifier == self.machineIdentifier, self._servers))
|
||||
if 0 == len(server):
|
||||
raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier)
|
||||
return server[0]
|
||||
|
||||
def getMedia(self):
|
||||
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)
|
363
lib/plexapi/utils.py
Normal file
363
lib/plexapi/utils.py
Normal file
|
@ -0,0 +1,363 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from getpass import getpass
|
||||
from threading import Thread
|
||||
from tqdm import tqdm
|
||||
from plexapi import compat
|
||||
from plexapi.exceptions import NotFound
|
||||
|
||||
# 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}
|
||||
PLEXOBJECTS = {}
|
||||
|
||||
|
||||
class SecretsFilter(logging.Filter):
|
||||
""" Logging filter to hide secrets. """
|
||||
|
||||
def __init__(self, secrets=None):
|
||||
self.secrets = secrets or set()
|
||||
|
||||
def add_secret(self, secret):
|
||||
if secret is not None:
|
||||
self.secrets.add(secret)
|
||||
return secret
|
||||
|
||||
def filter(self, record):
|
||||
cleanargs = list(record.args)
|
||||
for i in range(len(cleanargs)):
|
||||
if isinstance(cleanargs[i], compat.string_type):
|
||||
for secret in self.secrets:
|
||||
cleanargs[i] = cleanargs[i].replace(secret, '<hidden>')
|
||||
record.args = tuple(cleanargs)
|
||||
return True
|
||||
|
||||
|
||||
def registerPlexObject(cls):
|
||||
""" Registry of library types we may come across when parsing XML. This allows us to
|
||||
define a few helper functions to dynamically convery the XML into objects. See
|
||||
buildItem() below for an example.
|
||||
"""
|
||||
etype = getattr(cls, 'STREAMTYPE', cls.TYPE)
|
||||
ehash = '%s.%s' % (cls.TAG, etype) if etype else cls.TAG
|
||||
if ehash in PLEXOBJECTS:
|
||||
raise Exception('Ambiguous PlexObject definition %s(tag=%s, type=%s) with %s' %
|
||||
(cls.__name__, cls.TAG, etype, PLEXOBJECTS[ehash].__name__))
|
||||
PLEXOBJECTS[ehash] = cls
|
||||
return cls
|
||||
|
||||
|
||||
def cast(func, value):
|
||||
""" Cast the specified value to the specified type (returned by func). Currently this
|
||||
only support int, float, bool. Should be extended if needed.
|
||||
|
||||
Parameters:
|
||||
func (func): Calback function to used cast to type (int, bool, float).
|
||||
value (any): value to be cast and returned.
|
||||
"""
|
||||
if value is not None:
|
||||
if func == bool:
|
||||
return bool(int(value))
|
||||
elif func in (int, float):
|
||||
try:
|
||||
return func(value)
|
||||
except ValueError:
|
||||
return float('nan')
|
||||
return func(value)
|
||||
return value
|
||||
|
||||
|
||||
def joinArgs(args):
|
||||
""" Returns a query string (uses for HTTP URLs) where only the value is URL encoded.
|
||||
Example return value: '?genre=action&type=1337'.
|
||||
|
||||
Parameters:
|
||||
args (dict): Arguments to include in query string.
|
||||
"""
|
||||
if not args:
|
||||
return ''
|
||||
arglist = []
|
||||
for key in sorted(args, key=lambda x: x.lower()):
|
||||
value = compat.ustr(args[key])
|
||||
arglist.append('%s=%s' % (key, compat.quote(value)))
|
||||
return '?%s' % '&'.join(arglist)
|
||||
|
||||
|
||||
def lowerFirst(s):
|
||||
return s[0].lower() + s[1:]
|
||||
|
||||
|
||||
def rget(obj, attrstr, default=None, delim='.'): # pragma: no cover
|
||||
""" Returns the value at the specified attrstr location within a nexted tree of
|
||||
dicts, lists, tuples, functions, classes, etc. The lookup is done recursivley
|
||||
for each key in attrstr (split by by the delimiter) This function is heavily
|
||||
influenced by the lookups used in Django templates.
|
||||
|
||||
Parameters:
|
||||
obj (any): Object to start the lookup in (dict, obj, list, tuple, etc).
|
||||
attrstr (str): String to lookup (ex: 'foo.bar.baz.value')
|
||||
default (any): Default value to return if not found.
|
||||
delim (str): Delimiter separating keys in attrstr.
|
||||
"""
|
||||
try:
|
||||
parts = attrstr.split(delim, 1)
|
||||
attr = parts[0]
|
||||
attrstr = parts[1] if len(parts) == 2 else None
|
||||
if isinstance(obj, dict):
|
||||
value = obj[attr]
|
||||
elif isinstance(obj, list):
|
||||
value = obj[int(attr)]
|
||||
elif isinstance(obj, tuple):
|
||||
value = obj[int(attr)]
|
||||
elif isinstance(obj, object):
|
||||
value = getattr(obj, attr)
|
||||
if attrstr:
|
||||
return rget(value, attrstr, default, delim)
|
||||
return value
|
||||
except: # noqa: E722
|
||||
return default
|
||||
|
||||
|
||||
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)
|
||||
|
||||
Raises:
|
||||
NotFound: Unknown libtype
|
||||
"""
|
||||
libtype = compat.ustr(libtype)
|
||||
if libtype in [compat.ustr(v) for v in SEARCHTYPES.values()]:
|
||||
return libtype
|
||||
if SEARCHTYPES.get(libtype) is not None:
|
||||
return SEARCHTYPES[libtype]
|
||||
raise NotFound('Unknown libtype: %s' % 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.
|
||||
|
||||
Parameters:
|
||||
callback (func): Callback function to apply to each set of \*args.
|
||||
listargs (list): List of lists; \*args to pass each thread.
|
||||
"""
|
||||
threads, results = [], []
|
||||
for args in listargs:
|
||||
args += [results, len(results)]
|
||||
results.append(None)
|
||||
threads.append(Thread(target=callback, args=args))
|
||||
threads[-1].setDaemon(True)
|
||||
threads[-1].start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
return results
|
||||
|
||||
|
||||
def toDatetime(value, format=None):
|
||||
""" Returns a datetime object from the specified value.
|
||||
|
||||
Parameters:
|
||||
value (str): value to return as a datetime
|
||||
format (str): Format to pass strftime (optional; if value is a str).
|
||||
"""
|
||||
if value and value is not None:
|
||||
if format:
|
||||
value = datetime.strptime(value, format)
|
||||
else:
|
||||
value = datetime.fromtimestamp(int(value))
|
||||
return value
|
||||
|
||||
|
||||
def toList(value, itemcast=None, delim=','):
|
||||
""" Returns a list of strings from the specified value.
|
||||
|
||||
Parameters:
|
||||
value (str): comma delimited string to convert to list.
|
||||
itemcast (func): Function to cast each list item to (default str).
|
||||
delim (str): string delimiter (optional; default ',').
|
||||
"""
|
||||
value = value or ''
|
||||
itemcast = itemcast or str
|
||||
return [itemcast(item) for item in value.split(delim) if item != '']
|
||||
|
||||
|
||||
def downloadSessionImages(server, filename=None, height=150, width=150,
|
||||
opacity=100, saturation=100): # pragma: no cover
|
||||
""" Helper to download a bif image or thumb.url from plex.server.sessions.
|
||||
|
||||
Parameters:
|
||||
filename (str): default to None,
|
||||
height (int): Height of the image.
|
||||
width (int): width of the image.
|
||||
opacity (int): Opacity of the resulting image (possibly deprecated).
|
||||
saturation (int): Saturating of the resulting image.
|
||||
|
||||
Returns:
|
||||
{'hellowlol': {'filepath': '<filepath>', 'url': 'http://<url>'},
|
||||
{'<username>': {filepath, url}}, ...
|
||||
"""
|
||||
info = {}
|
||||
for media in server.sessions():
|
||||
url = None
|
||||
for part in media.iterParts():
|
||||
if media.thumb:
|
||||
url = media.thumb
|
||||
if part.indexes: # always use bif images if available.
|
||||
url = '/library/parts/%s/indexes/%s/%s' % (part.id, part.indexes.lower(), media.viewOffset)
|
||||
if url:
|
||||
if filename is None:
|
||||
prettyname = media._prettyfilename()
|
||||
filename = 'session_transcode_%s_%s_%s' % (media.usernames[0], prettyname, int(time.time()))
|
||||
url = server.transcodeImage(url, height, width, opacity, saturation)
|
||||
filepath = download(url, filename=filename)
|
||||
info['username'] = {'filepath': filepath, 'url': url}
|
||||
return info
|
||||
|
||||
|
||||
def download(url, token, filename=None, savepath=None, session=None, chunksize=4024,
|
||||
unpack=False, mocked=False, showstatus=False):
|
||||
""" Helper to download a thumb, videofile or other media item. Returns the local
|
||||
path to the downloaded file.
|
||||
|
||||
Parameters:
|
||||
url (str): URL where the content be reached.
|
||||
token (str): Plex auth token to include in headers.
|
||||
filename (str): Filename of the downloaded file, default None.
|
||||
savepath (str): Defaults to current working dir.
|
||||
chunksize (int): What chunksize read/write at the time.
|
||||
mocked (bool): Helper to do evertything except write the file.
|
||||
unpack (bool): Unpack the zip file.
|
||||
showstatus(bool): Display a progressbar.
|
||||
|
||||
Example:
|
||||
>>> 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}
|
||||
response = session.get(url, headers=headers, stream=True)
|
||||
# make sure the savepath directory exists
|
||||
savepath = savepath or os.getcwd()
|
||||
compat.makedirs(savepath, exist_ok=True)
|
||||
|
||||
# try getting filename from header if not specified in arguments (used for logs, db)
|
||||
if not filename and response.headers.get('Content-Disposition'):
|
||||
filename = re.findall(r'filename=\"(.+)\"', response.headers.get('Content-Disposition'))
|
||||
filename = filename[0] if filename[0] else None
|
||||
|
||||
filename = os.path.basename(filename)
|
||||
fullpath = os.path.join(savepath, filename)
|
||||
# append file.ext from content-type if not already there
|
||||
extension = os.path.splitext(fullpath)[-1]
|
||||
if not extension:
|
||||
contenttype = response.headers.get('content-type')
|
||||
if contenttype and 'image' in contenttype:
|
||||
fullpath += contenttype.split('/')[1]
|
||||
|
||||
# check this is a mocked download (testing)
|
||||
if mocked:
|
||||
log.debug('Mocked download %s', fullpath)
|
||||
return fullpath
|
||||
|
||||
# save the file to disk
|
||||
log.info('Downloading: %s', fullpath)
|
||||
if showstatus: # pragma: no cover
|
||||
total = int(response.headers.get('content-length', 0))
|
||||
bar = tqdm(unit='B', unit_scale=True, total=total, desc=filename)
|
||||
|
||||
with open(fullpath, 'wb') as handle:
|
||||
for chunk in response.iter_content(chunk_size=chunksize):
|
||||
handle.write(chunk)
|
||||
if showstatus:
|
||||
bar.update(len(chunk))
|
||||
|
||||
if showstatus: # pragma: no cover
|
||||
bar.close()
|
||||
# check we want to unzip the contents
|
||||
if fullpath.endswith('zip') and unpack:
|
||||
with zipfile.ZipFile(fullpath, 'r') as handle:
|
||||
handle.extractall(savepath)
|
||||
|
||||
return fullpath
|
||||
|
||||
|
||||
def tag_helper(tag, items, locked=True, remove=False):
|
||||
""" Simple tag helper for editing a object. """
|
||||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
data = {}
|
||||
if not remove:
|
||||
for i, item in enumerate(items):
|
||||
tagname = '%s[%s].tag.tag' % (tag, i)
|
||||
data[tagname] = item
|
||||
if remove:
|
||||
tagname = '%s[].tag.tag-' % tag
|
||||
data[tagname] = ','.join(items)
|
||||
data['%s.locked' % tag] = 1 if locked else 0
|
||||
return data
|
||||
|
||||
|
||||
def getMyPlexAccount(opts=None): # pragma: no cover
|
||||
""" Helper function tries to get a MyPlex Account instance by checking
|
||||
the the following locations for a username and password. This is
|
||||
useful to create user-friendly command line tools.
|
||||
1. command-line options (opts).
|
||||
2. environment variables and config.ini
|
||||
3. Prompt on the command line.
|
||||
"""
|
||||
from plexapi import CONFIG
|
||||
from plexapi.myplex import MyPlexAccount
|
||||
# 1. Check command-line options
|
||||
if opts and opts.username and opts.password:
|
||||
print('Authenticating with Plex.tv as %s..' % opts.username)
|
||||
return MyPlexAccount(opts.username, opts.password)
|
||||
# 2. Check Plexconfig (environment variables and config.ini)
|
||||
config_username = CONFIG.get('auth.myplex_username')
|
||||
config_password = CONFIG.get('auth.myplex_password')
|
||||
if config_username and config_password:
|
||||
print('Authenticating with Plex.tv as %s..' % config_username)
|
||||
return MyPlexAccount(config_username, config_password)
|
||||
# 3. Prompt for username and password on the command line
|
||||
username = input('What is your plex.tv username: ')
|
||||
password = getpass('What is your plex.tv password: ')
|
||||
print('Authenticating with Plex.tv as %s..' % username)
|
||||
return MyPlexAccount(username, password)
|
||||
|
||||
|
||||
def choose(msg, items, attr): # pragma: no cover
|
||||
""" Command line helper to display a list of choices, asking the
|
||||
user to choose one of the options.
|
||||
"""
|
||||
# Return the first item if there is only one choice
|
||||
if len(items) == 1:
|
||||
return items[0]
|
||||
# Print all choices to the command line
|
||||
print()
|
||||
for index, i in enumerate(items):
|
||||
name = attr(i) if callable(attr) else getattr(i, attr)
|
||||
print(' %s: %s' % (index, name))
|
||||
print()
|
||||
# Request choice from the user
|
||||
while True:
|
||||
try:
|
||||
inp = input('%s: ' % msg)
|
||||
if any(s in inp for s in (':', '::', '-')):
|
||||
idx = slice(*map(lambda x: int(x.strip()) if x.strip() else None, inp.split(':')))
|
||||
return items[idx]
|
||||
else:
|
||||
return items[int(inp)]
|
||||
|
||||
except (ValueError, IndexError):
|
||||
pass
|
560
lib/plexapi/video.py
Normal file
560
lib/plexapi/video.py
Normal file
|
@ -0,0 +1,560 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from plexapi import media, utils
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
|
||||
|
||||
class Video(PlexPartialObject):
|
||||
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
|
||||
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
|
||||
:class:`~plexapi.video.Episode`.
|
||||
|
||||
Attributes:
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
lastViewedAt (datetime): Datetime item was last accessed.
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
listType (str): Hardcoded as 'audio' (useful for search filters).
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
summary (str): Summary of the artist, track, or album.
|
||||
thumb (str): URL to thumbnail image.
|
||||
title (str): Artist, Album or Track title. (Jason Mraz, We Sing, Lucky, etc.)
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'artist', 'album', or 'track'.
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
viewCount (int): Count of times this item was accessed.
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.listType = 'video'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.key = data.attrib.get('key', '')
|
||||
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
|
||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
""" Returns True if this video is watched. """
|
||||
return bool(self.viewCount > 0) if self.viewCount else False
|
||||
|
||||
@property
|
||||
def thumbUrl(self):
|
||||
""" Return the first first thumbnail url starting on
|
||||
the most specific thumbnail for that item.
|
||||
"""
|
||||
thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb')
|
||||
return self._server.url(thumb, includeToken=True) if thumb else None
|
||||
|
||||
@property
|
||||
def artUrl(self):
|
||||
""" Return the first first art url starting on the most specific for that item."""
|
||||
art = self.firstAttr('art', 'grandparentArt')
|
||||
return self._server.url(art, includeToken=True) if art else None
|
||||
|
||||
def url(self, part):
|
||||
""" Returns the full url for something. Typically used for getting a specific image. """
|
||||
return self._server.url(part, includeToken=True) if part else None
|
||||
|
||||
def markWatched(self):
|
||||
""" Mark video as watched. """
|
||||
key = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
|
||||
self._server.query(key)
|
||||
self.reload()
|
||||
|
||||
def markUnwatched(self):
|
||||
""" Mark video unwatched. """
|
||||
key = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
|
||||
self._server.query(key)
|
||||
self.reload()
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Movie(Playable, Video):
|
||||
""" Represents a single Movie.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Video'
|
||||
TYPE (str): 'movie'
|
||||
art (str): Key to movie artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
audienceRating (float): Audience rating (usually from Rotten Tomatoes).
|
||||
audienceRatingImage (str): Key to audience rating image (rottentomatoes://image.rating.spilled)
|
||||
chapterSource (str): Chapter source (agent; media; mixed).
|
||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||
duration (int): Duration of movie in milliseconds.
|
||||
guid: Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
|
||||
originalTitle (str): Original title, often the foreign title (転々; 엽기적인 그녀).
|
||||
originallyAvailableAt (datetime): Datetime movie was released.
|
||||
primaryExtraKey (str) Primary extra key (/library/metadata/66351).
|
||||
rating (float): Movie rating (7.9; 9.8; 8.1).
|
||||
ratingImage (str): Key to rating image (rottentomatoes://image.rating.rotten).
|
||||
studio (str): Studio that created movie (Di Bonaventura Pictures; 21 Laps Entertainment).
|
||||
tagline (str): Movie tag line (Back 2 Work; Who says men can't change?).
|
||||
userRating (float): User rating (2.0; 8.0).
|
||||
viewOffset (int): View offset in milliseconds.
|
||||
year (int): Year movie was released.
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collections this media belongs.
|
||||
countries (List<:class:`~plexapi.media.Country`>): List of countries objects.
|
||||
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||
producers (List<:class:`~plexapi.media.Producer`>): List of producers objects.
|
||||
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
|
||||
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
|
||||
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
|
||||
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
|
||||
"""
|
||||
TAG = 'Video'
|
||||
TYPE = 'movie'
|
||||
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
|
||||
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
|
||||
'&includeConcerts=1&includePreferences=1')
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Video._loadData(self, data)
|
||||
Playable._loadData(self, data)
|
||||
|
||||
self._details_key = self.key + self._include
|
||||
self.art = data.attrib.get('art')
|
||||
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
||||
self.audienceRatingImage = data.attrib.get('audienceRatingImage')
|
||||
self.chapterSource = data.attrib.get('chapterSource')
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.originalTitle = data.attrib.get('originalTitle')
|
||||
self.originallyAvailableAt = utils.toDatetime(
|
||||
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.ratingImage = data.attrib.get('ratingImage')
|
||||
self.studio = data.attrib.get('studio')
|
||||
self.tagline = data.attrib.get('tagline')
|
||||
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.countries = self.findItems(data, media.Country)
|
||||
self.directors = self.findItems(data, media.Director)
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.producers = self.findItems(data, media.Producer)
|
||||
self.roles = self.findItems(data, media.Role)
|
||||
self.writers = self.findItems(data, media.Writer)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.chapters = self.findItems(data, media.Chapter)
|
||||
self.similar = self.findItems(data, media.Similar)
|
||||
|
||||
@property
|
||||
def actors(self):
|
||||
""" Alias to self.roles. """
|
||||
return self.roles
|
||||
|
||||
@property
|
||||
def locations(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
interface to get the location of the Movie/Show/Episode
|
||||
"""
|
||||
return [part.file for part in self.iterParts() if part]
|
||||
|
||||
def subtitleStreams(self):
|
||||
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """
|
||||
streams = []
|
||||
for elem in self.media:
|
||||
for part in elem.parts:
|
||||
streams += part.subtitleStreams()
|
||||
return streams
|
||||
|
||||
def _prettyfilename(self):
|
||||
# This is just for compat.
|
||||
return self.title
|
||||
|
||||
def download(self, savepath=None, keep_orginal_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
|
||||
a friendlier is generated.
|
||||
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`.
|
||||
"""
|
||||
filepaths = []
|
||||
locations = [i for i in self.iterParts() if i]
|
||||
for location in locations:
|
||||
name = location.file
|
||||
if not keep_orginal_name:
|
||||
title = self.title.replace(' ', '.')
|
||||
name = '%s.%s' % (title, location.container)
|
||||
if kwargs is not None:
|
||||
url = self.getStreamURL(**kwargs)
|
||||
else:
|
||||
self._server.url('%s?download=1' % location.key)
|
||||
filepath = utils.download(url, self._server._token, filename=name,
|
||||
savepath=savepath, session=self._server._session)
|
||||
if filepath:
|
||||
filepaths.append(filepath)
|
||||
return filepaths
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Show(Video):
|
||||
""" Represents a single Show (including all seasons and episodes).
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'show'
|
||||
art (str): Key to show artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
banner (str): Key to banner artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
childCount (int): Unknown.
|
||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||
duration (int): Duration of show in milliseconds.
|
||||
guid (str): Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
|
||||
index (int): Plex index (?)
|
||||
leafCount (int): Unknown.
|
||||
locations (list<str>): List of locations paths.
|
||||
originallyAvailableAt (datetime): Datetime show was released.
|
||||
rating (float): Show rating (7.9; 9.8; 8.1).
|
||||
studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment).
|
||||
theme (str): Key to theme resource (/library/metadata/<ratingkey>/theme/<themeid>)
|
||||
viewedLeafCount (int): Unknown.
|
||||
year (int): Year the show was released.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
|
||||
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'show'
|
||||
|
||||
def __iter__(self):
|
||||
for season in self.seasons():
|
||||
yield season
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Video._loadData(self, data)
|
||||
# fix key if loaded from search
|
||||
self.key = self.key.replace('/children', '')
|
||||
self.art = data.attrib.get('art')
|
||||
self.banner = data.attrib.get('banner')
|
||||
self.childCount = utils.cast(int, data.attrib.get('childCount'))
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.index = data.attrib.get('index')
|
||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
|
||||
self.locations = self.listAttrs(data, 'path', etag='Location')
|
||||
self.originallyAvailableAt = utils.toDatetime(
|
||||
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.studio = data.attrib.get('studio')
|
||||
self.theme = data.attrib.get('theme')
|
||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.roles = self.findItems(data, media.Role)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.similar = self.findItems(data, media.Similar)
|
||||
|
||||
@property
|
||||
def actors(self):
|
||||
""" Alias to self.roles. """
|
||||
return self.roles
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
""" Returns True if this show is fully watched. """
|
||||
return bool(self.viewedLeafCount == self.leafCount)
|
||||
|
||||
def seasons(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Season` objects. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def season(self, title=None):
|
||||
""" Returns the season with the specified title or number.
|
||||
|
||||
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
|
||||
return self.fetchItem(key, etag='Directory', title__iexact=title)
|
||||
|
||||
def episodes(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Episode` objects. """
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def episode(self, title=None, season=None, episode=None):
|
||||
""" Find a episode using a title or season and episode.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the episode to return
|
||||
season (int): Season number (default:None; required if title not specified).
|
||||
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.
|
||||
"""
|
||||
if title:
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
elif season and episode:
|
||||
results = [i for i in self.episodes() if i.seasonNumber == season and i.index == episode]
|
||||
if results:
|
||||
return results[0]
|
||||
raise NotFound('Couldnt find %s S%s E%s' % (self.title, season, episode))
|
||||
raise BadRequest('Missing argument: title or season and episode are required')
|
||||
|
||||
def watched(self):
|
||||
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
|
||||
return self.episodes(viewCount__gt=0)
|
||||
|
||||
def unwatched(self):
|
||||
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
|
||||
return self.episodes(viewCount=0)
|
||||
|
||||
def get(self, title=None, season=None, episode=None):
|
||||
""" Alias to :func:`~plexapi.video.Show.episode()`. """
|
||||
return self.episode(title, season, episode)
|
||||
|
||||
def download(self, savepath=None, keep_orginal_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
|
||||
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)
|
||||
return filepaths
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Season(Video):
|
||||
""" Represents a single Show Season (including all episodes).
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'season'
|
||||
leafCount (int): Number of episodes in season.
|
||||
index (int): Season number.
|
||||
parentKey (str): Key to this seasons :class:`~plexapi.video.Show`.
|
||||
parentRatingKey (int): Unique key for this seasons :class:`~plexapi.video.Show`.
|
||||
parentTitle (str): Title of this seasons :class:`~plexapi.video.Show`.
|
||||
viewedLeafCount (int): Number of watched episodes in season.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'season'
|
||||
|
||||
def __iter__(self):
|
||||
for episode in self.episodes():
|
||||
yield episode
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Video._loadData(self, data)
|
||||
# fix key if loaded from search
|
||||
self.key = self.key.replace('/children', '')
|
||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % ':'.join([p for p in [
|
||||
self.__class__.__name__,
|
||||
self.key.replace('/library/metadata/', '').replace('/children', ''),
|
||||
'%s-s%s' % (self.parentTitle.replace(' ', '-')[:20], self.seasonNumber),
|
||||
] if p])
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
""" Returns True if this season is fully watched. """
|
||||
return bool(self.viewedLeafCount == self.leafCount)
|
||||
|
||||
@property
|
||||
def seasonNumber(self):
|
||||
""" Returns season number. """
|
||||
return self.index
|
||||
|
||||
def episodes(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Episode` objects. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def episode(self, title=None, episode=None):
|
||||
""" Returns the episode with the given title or number.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the episode to return.
|
||||
episode (int): Episode number (default:None; required if title not specified).
|
||||
"""
|
||||
if not title and not episode:
|
||||
raise BadRequest('Missing argument, you need to use title or episode.')
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
if title:
|
||||
return self.fetchItem(key, title=title)
|
||||
return self.fetchItem(key, seasonNumber=self.index, index=episode)
|
||||
|
||||
def get(self, title=None, episode=None):
|
||||
""" Alias to :func:`~plexapi.video.Season.episode()`. """
|
||||
return self.episode(title, episode)
|
||||
|
||||
def show(self):
|
||||
""" Return this seasons :func:`~plexapi.video.Show`.. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def watched(self):
|
||||
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
|
||||
return self.episodes(watched=True)
|
||||
|
||||
def unwatched(self):
|
||||
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
|
||||
return self.episodes(watched=False)
|
||||
|
||||
def download(self, savepath=None, keep_orginal_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
|
||||
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)
|
||||
return filepaths
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Episode(Playable, Video):
|
||||
""" Represents a single Shows Episode.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Video'
|
||||
TYPE (str): 'episode'
|
||||
art (str): Key to episode artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
chapterSource (str): Unknown (media).
|
||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||
duration (int): Duration of episode in milliseconds.
|
||||
grandparentArt (str): Key to this episodes :class:`~plexapi.video.Show` artwork.
|
||||
grandparentKey (str): Key to this episodes :class:`~plexapi.video.Show`.
|
||||
grandparentRatingKey (str): Unique key for this episodes :class:`~plexapi.video.Show`.
|
||||
grandparentTheme (str): Key to this episodes :class:`~plexapi.video.Show` theme.
|
||||
grandparentThumb (str): Key to this episodes :class:`~plexapi.video.Show` thumb.
|
||||
grandparentTitle (str): Title of this episodes :class:`~plexapi.video.Show`.
|
||||
guid (str): Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
|
||||
index (int): Episode number.
|
||||
originallyAvailableAt (datetime): Datetime episode was released.
|
||||
parentIndex (str): Season number of episode.
|
||||
parentKey (str): Key to this episodes :class:`~plexapi.video.Season`.
|
||||
parentRatingKey (int): Unique key for this episodes :class:`~plexapi.video.Season`.
|
||||
parentThumb (str): Key to this episodes thumbnail.
|
||||
parentTitle (str): Name of this episode's season
|
||||
title (str): Name of this Episode
|
||||
rating (float): Movie rating (7.9; 9.8; 8.1).
|
||||
viewOffset (int): View offset in milliseconds.
|
||||
year (int): Year episode was released.
|
||||
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
|
||||
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
|
||||
"""
|
||||
TAG = 'Video'
|
||||
TYPE = 'episode'
|
||||
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
|
||||
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
|
||||
'&includeConcerts=1&includePreferences=1')
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Video._loadData(self, data)
|
||||
Playable._loadData(self, data)
|
||||
self._details_key = self.key + self._include
|
||||
self._seasonNumber = None # cached season number
|
||||
self.art = data.attrib.get('art')
|
||||
self.chapterSource = data.attrib.get('chapterSource')
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.grandparentArt = data.attrib.get('grandparentArt')
|
||||
self.grandparentKey = data.attrib.get('grandparentKey')
|
||||
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
|
||||
self.grandparentTheme = data.attrib.get('grandparentTheme')
|
||||
self.grandparentThumb = data.attrib.get('grandparentThumb')
|
||||
self.grandparentTitle = data.attrib.get('grandparentTitle')
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.parentIndex = data.attrib.get('parentIndex')
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.title = data.attrib.get('title')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.directors = self.findItems(data, media.Director)
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.writers = self.findItems(data, media.Writer)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.chapters = self.findItems(data, media.Chapter)
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % ':'.join([p for p in [
|
||||
self.__class__.__name__,
|
||||
self.key.replace('/library/metadata/', '').replace('/children', ''),
|
||||
'%s-%s' % (self.grandparentTitle.replace(' ', '-')[:20], self.seasonEpisode),
|
||||
] if p])
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a human friendly filename. """
|
||||
return '%s.%s' % (self.grandparentTitle.replace(' ', '.'), self.seasonEpisode)
|
||||
|
||||
@property
|
||||
def locations(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
interface to get the location of the Movie/Show
|
||||
"""
|
||||
return [part.file for part in self.iterParts() if part]
|
||||
|
||||
@property
|
||||
def seasonNumber(self):
|
||||
""" Returns this episodes season number. """
|
||||
if self._seasonNumber is None:
|
||||
self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber
|
||||
return utils.cast(int, self._seasonNumber)
|
||||
|
||||
@property
|
||||
def seasonEpisode(self):
|
||||
""" Returns the s00e00 string containing the season and episode. """
|
||||
return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.index).zfill(2))
|
||||
|
||||
def season(self):
|
||||
"""" Return this episodes :func:`~plexapi.video.Season`.. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def show(self):
|
||||
"""" Return this episodes :func:`~plexapi.video.Show`.. """
|
||||
return self.fetchItem(self.grandparentKey)
|
Loading…
Add table
Add a link
Reference in a new issue