mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-14 01:02:59 -07:00
Update plexapi to v3.6.0
This commit is contained in:
parent
873194b402
commit
6e53743716
18 changed files with 1500 additions and 104 deletions
|
@ -3,9 +3,10 @@ import logging
|
||||||
import os
|
import os
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from platform import uname
|
from platform import uname
|
||||||
|
from uuid import getnode
|
||||||
|
|
||||||
from plexapi.config import PlexConfig, reset_base_headers
|
from plexapi.config import PlexConfig, reset_base_headers
|
||||||
from plexapi.utils import SecretsFilter
|
from plexapi.utils import SecretsFilter
|
||||||
from uuid import getnode
|
|
||||||
|
|
||||||
# Load User Defined Config
|
# Load User Defined Config
|
||||||
DEFAULT_CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini')
|
DEFAULT_CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini')
|
||||||
|
@ -14,7 +15,7 @@ CONFIG = PlexConfig(CONFIG_PATH)
|
||||||
|
|
||||||
# PlexAPI Settings
|
# PlexAPI Settings
|
||||||
PROJECT = 'PlexAPI'
|
PROJECT = 'PlexAPI'
|
||||||
VERSION = '3.3.0'
|
VERSION = '3.6.0'
|
||||||
TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
|
TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
|
||||||
X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int)
|
X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int)
|
||||||
X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool)
|
X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool)
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
import websocket
|
|
||||||
from plexapi import log
|
from plexapi import log
|
||||||
|
|
||||||
|
|
||||||
class AlertListener(threading.Thread):
|
class AlertListener(threading.Thread):
|
||||||
""" Creates a websocket connection to the PlexServer to optionally recieve alert notifications.
|
""" Creates a websocket connection to the PlexServer to optionally receive alert notifications.
|
||||||
These often include messages from Plex about media scans as well as updates to currently running
|
These often include messages from Plex about media scans as well as updates to currently running
|
||||||
Transcode Sessions. This class implements threading.Thread, therfore to start monitoring
|
Transcode Sessions. This class implements threading.Thread, therefore to start monitoring
|
||||||
alerts you must call .start() on the object once it's created. When calling
|
alerts you must call .start() on the object once it's created. When calling
|
||||||
`PlexServer.startAlertListener()`, the thread will be started for you.
|
`PlexServer.startAlertListener()`, the thread will be started for you.
|
||||||
|
|
||||||
|
@ -26,9 +26,9 @@ class AlertListener(threading.Thread):
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to.
|
server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to.
|
||||||
callback (func): Callback function to call on recieved messages. The callback function
|
callback (func): Callback function to call on received messages. The callback function
|
||||||
will be sent a single argument 'data' which will contain a dictionary of data
|
will be sent a single argument 'data' which will contain a dictionary of data
|
||||||
recieved from the server. :samp:`def my_callback(data): ...`
|
received from the server. :samp:`def my_callback(data): ...`
|
||||||
"""
|
"""
|
||||||
key = '/:/websockets/notifications'
|
key = '/:/websockets/notifications'
|
||||||
|
|
||||||
|
@ -40,6 +40,11 @@ class AlertListener(threading.Thread):
|
||||||
self._ws = None
|
self._ws = None
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
|
try:
|
||||||
|
import websocket
|
||||||
|
except ImportError:
|
||||||
|
log.warning("Can't use the AlertListener without websocket")
|
||||||
|
return
|
||||||
# create the websocket connection
|
# create the websocket connection
|
||||||
url = self._server.url(self.key, includeToken=True).replace('http', 'ws')
|
url = self._server.url(self.key, includeToken=True).replace('http', 'ws')
|
||||||
log.info('Starting AlertListener: %s', url)
|
log.info('Starting AlertListener: %s', url)
|
||||||
|
@ -48,15 +53,21 @@ class AlertListener(threading.Thread):
|
||||||
self._ws.run_forever()
|
self._ws.run_forever()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
""" Stop the AlertListener thread. Once the notifier is stopped, it cannot be diractly
|
""" Stop the AlertListener thread. Once the notifier is stopped, it cannot be directly
|
||||||
started again. You must call :func:`plexapi.server.PlexServer.startAlertListener()`
|
started again. You must call :func:`plexapi.server.PlexServer.startAlertListener()`
|
||||||
from a PlexServer instance.
|
from a PlexServer instance.
|
||||||
"""
|
"""
|
||||||
log.info('Stopping AlertListener.')
|
log.info('Stopping AlertListener.')
|
||||||
self._ws.close()
|
self._ws.close()
|
||||||
|
|
||||||
def _onMessage(self, ws, message):
|
def _onMessage(self, *args):
|
||||||
""" Called when websocket message is recieved. """
|
""" Called when websocket message is received.
|
||||||
|
In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp
|
||||||
|
object and the message as a STR. Current releases appear to only return the message.
|
||||||
|
We are assuming the last argument in the tuple is the message.
|
||||||
|
This is to support compatibility with current and previous releases of websocket-client.
|
||||||
|
"""
|
||||||
|
message = args[-1]
|
||||||
try:
|
try:
|
||||||
data = json.loads(message)['NotificationContainer']
|
data = json.loads(message)['NotificationContainer']
|
||||||
log.debug('Alert: %s %s %s', *data)
|
log.debug('Alert: %s %s %s', *data)
|
||||||
|
@ -65,6 +76,12 @@ class AlertListener(threading.Thread):
|
||||||
except Exception as err: # pragma: no cover
|
except Exception as err: # pragma: no cover
|
||||||
log.error('AlertListener Msg Error: %s', err)
|
log.error('AlertListener Msg Error: %s', err)
|
||||||
|
|
||||||
def _onError(self, ws, err): # pragma: no cover
|
def _onError(self, *args): # pragma: no cover
|
||||||
""" Called when websocket error is recieved. """
|
""" Called when websocket error is received.
|
||||||
|
In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp
|
||||||
|
object and the error. Current releases appear to only return the error.
|
||||||
|
We are assuming the last argument in the tuple is the message.
|
||||||
|
This is to support compatibility with current and previous releases of websocket-client.
|
||||||
|
"""
|
||||||
|
err = args[-1]
|
||||||
log.error('AlertListener Error: %s' % err)
|
log.error('AlertListener Error: %s' % err)
|
||||||
|
|
|
@ -284,15 +284,15 @@ class Track(Audio, Playable):
|
||||||
art (str): Track artwork (/library/metadata/<ratingkey>/art/<artid>)
|
art (str): Track artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||||
chapterSource (TYPE): Unknown
|
chapterSource (TYPE): Unknown
|
||||||
duration (int): Length of this album in seconds.
|
duration (int): Length of this album in seconds.
|
||||||
grandparentArt (str): Artist artowrk.
|
grandparentArt (str): Album artist artwork.
|
||||||
grandparentKey (str): Artist API URL.
|
grandparentKey (str): Album artist API URL.
|
||||||
grandparentRatingKey (str): Unique key identifying artist.
|
grandparentRatingKey (str): Unique key identifying album artist.
|
||||||
grandparentThumb (str): URL to artist thumbnail image.
|
grandparentThumb (str): URL to album artist thumbnail image.
|
||||||
grandparentTitle (str): Name of the artist for this track.
|
grandparentTitle (str): Name of the album artist for this track.
|
||||||
guid (str): Unknown (unique ID).
|
guid (str): Unknown (unique ID).
|
||||||
media (list): List of :class:`~plexapi.media.Media` objects for this track.
|
media (list): List of :class:`~plexapi.media.Media` objects for this track.
|
||||||
moods (list): List of :class:`~plexapi.media.Mood` objects for this track.
|
moods (list): List of :class:`~plexapi.media.Mood` objects for this track.
|
||||||
originalTitle (str): Original track title (if translated).
|
originalTitle (str): Track artist.
|
||||||
parentIndex (int): Album index.
|
parentIndex (int): Album index.
|
||||||
parentKey (str): Album API URL.
|
parentKey (str): Album API URL.
|
||||||
parentRatingKey (int): Unique key identifying album.
|
parentRatingKey (int): Unique key identifying album.
|
||||||
|
|
|
@ -132,6 +132,8 @@ class PlexObject(object):
|
||||||
* __regex: Value matches the specified regular expression.
|
* __regex: Value matches the specified regular expression.
|
||||||
* __startswith: Value starts with specified arg.
|
* __startswith: Value starts with specified arg.
|
||||||
"""
|
"""
|
||||||
|
if ekey is None:
|
||||||
|
raise BadRequest('ekey was not provided')
|
||||||
if isinstance(ekey, int):
|
if isinstance(ekey, int):
|
||||||
ekey = '/library/metadata/%s' % ekey
|
ekey = '/library/metadata/%s' % ekey
|
||||||
for elem in self._server.query(ekey):
|
for elem in self._server.query(ekey):
|
||||||
|
@ -140,13 +142,27 @@ class PlexObject(object):
|
||||||
clsname = cls.__name__ if cls else 'None'
|
clsname = cls.__name__ if cls else 'None'
|
||||||
raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs))
|
raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs))
|
||||||
|
|
||||||
def fetchItems(self, ekey, cls=None, **kwargs):
|
def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs):
|
||||||
""" Load the specified key to find and build all items with the specified tag
|
""" Load the specified key to find and build all items with the specified tag
|
||||||
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
||||||
on how this is used.
|
on how this is used.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
container_start (None, int): offset to get a subset of the data
|
||||||
|
container_size (None, int): How many items in data
|
||||||
|
|
||||||
"""
|
"""
|
||||||
data = self._server.query(ekey)
|
url_kw = {}
|
||||||
|
if container_start is not None:
|
||||||
|
url_kw["X-Plex-Container-Start"] = container_start
|
||||||
|
if container_size is not None:
|
||||||
|
url_kw["X-Plex-Container-Size"] = container_size
|
||||||
|
|
||||||
|
if ekey is None:
|
||||||
|
raise BadRequest('ekey was not provided')
|
||||||
|
data = self._server.query(ekey, params=url_kw)
|
||||||
items = self.findItems(data, cls, ekey, **kwargs)
|
items = self.findItems(data, cls, ekey, **kwargs)
|
||||||
|
|
||||||
librarySectionID = data.attrib.get('librarySectionID')
|
librarySectionID = data.attrib.get('librarySectionID')
|
||||||
if librarySectionID:
|
if librarySectionID:
|
||||||
for item in items:
|
for item in items:
|
||||||
|
@ -421,6 +437,141 @@ class PlexPartialObject(PlexObject):
|
||||||
'havnt allowed items to be deleted' % self.key)
|
'havnt allowed items to be deleted' % self.key)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def history(self, maxresults=9999999, mindate=None):
|
||||||
|
""" Get Play History for a media item.
|
||||||
|
Parameters:
|
||||||
|
maxresults (int): Only return the specified number of results (optional).
|
||||||
|
mindate (datetime): Min datetime to return results from.
|
||||||
|
"""
|
||||||
|
return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey)
|
||||||
|
|
||||||
|
def posters(self):
|
||||||
|
""" Returns list of available poster objects. :class:`~plexapi.media.Poster`. """
|
||||||
|
|
||||||
|
return self.fetchItems('%s/posters' % self.key)
|
||||||
|
|
||||||
|
def uploadPoster(self, url=None, filepath=None):
|
||||||
|
""" Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
|
||||||
|
if url:
|
||||||
|
key = '%s/posters?url=%s' % (self.key, quote_plus(url))
|
||||||
|
self._server.query(key, method=self._server._session.post)
|
||||||
|
elif filepath:
|
||||||
|
key = '%s/posters?' % self.key
|
||||||
|
data = open(filepath, 'rb').read()
|
||||||
|
self._server.query(key, method=self._server._session.post, data=data)
|
||||||
|
|
||||||
|
def setPoster(self, poster):
|
||||||
|
""" Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
|
||||||
|
poster.select()
|
||||||
|
|
||||||
|
def arts(self):
|
||||||
|
""" Returns list of available art objects. :class:`~plexapi.media.Poster`. """
|
||||||
|
|
||||||
|
return self.fetchItems('%s/arts' % self.key)
|
||||||
|
|
||||||
|
def uploadArt(self, url=None, filepath=None):
|
||||||
|
""" Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
|
||||||
|
if url:
|
||||||
|
key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url))
|
||||||
|
self._server.query(key, method=self._server._session.post)
|
||||||
|
elif filepath:
|
||||||
|
key = '/library/metadata/%s/arts?' % self.ratingKey
|
||||||
|
data = open(filepath, 'rb').read()
|
||||||
|
self._server.query(key, method=self._server._session.post, data=data)
|
||||||
|
|
||||||
|
def setArt(self, art):
|
||||||
|
""" Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
|
||||||
|
art.select()
|
||||||
|
|
||||||
|
def unmatch(self):
|
||||||
|
""" Unmatches metadata match from object. """
|
||||||
|
key = '/library/metadata/%s/unmatch' % self.ratingKey
|
||||||
|
self._server.query(key, method=self._server._session.put)
|
||||||
|
|
||||||
|
def matches(self, agent=None, title=None, year=None, language=None):
|
||||||
|
""" Return list of (:class:`~plexapi.media.SearchResult`) metadata matches.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.)
|
||||||
|
title (str): Title of item to search for
|
||||||
|
year (str): Year of item to search in
|
||||||
|
language (str) : Language of item to search in
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
1. video.matches()
|
||||||
|
2. video.matches(title="something", year=2020)
|
||||||
|
3. video.matches(title="something")
|
||||||
|
4. video.matches(year=2020)
|
||||||
|
5. video.matches(title="something", year="")
|
||||||
|
6. video.matches(title="", year=2020)
|
||||||
|
7. video.matches(title="", year="")
|
||||||
|
|
||||||
|
1. The default behaviour in Plex Web = no params in plexapi
|
||||||
|
2. Both title and year specified by user
|
||||||
|
3. Year automatically filled in
|
||||||
|
4. Title automatically filled in
|
||||||
|
5. Explicitly searches for title with blank year
|
||||||
|
6. Explicitly searches for blank title with year
|
||||||
|
7. I don't know what the user is thinking... return the same result as 1
|
||||||
|
|
||||||
|
For 2 to 7, the agent and language is automatically filled in
|
||||||
|
"""
|
||||||
|
key = '/library/metadata/%s/matches' % self.ratingKey
|
||||||
|
params = {'manual': 1}
|
||||||
|
|
||||||
|
if agent and not any([title, year, language]):
|
||||||
|
params['language'] = self.section().language
|
||||||
|
params['agent'] = utils.getAgentIdentifier(self.section(), agent)
|
||||||
|
else:
|
||||||
|
if any(x is not None for x in [agent, title, year, language]):
|
||||||
|
if title is None:
|
||||||
|
params['title'] = self.title
|
||||||
|
else:
|
||||||
|
params['title'] = title
|
||||||
|
|
||||||
|
if year is None:
|
||||||
|
params['year'] = self.year
|
||||||
|
else:
|
||||||
|
params['year'] = year
|
||||||
|
|
||||||
|
params['language'] = language or self.section().language
|
||||||
|
|
||||||
|
if agent is None:
|
||||||
|
params['agent'] = self.section().agent
|
||||||
|
else:
|
||||||
|
params['agent'] = utils.getAgentIdentifier(self.section(), agent)
|
||||||
|
|
||||||
|
key = key + '?' + urlencode(params)
|
||||||
|
data = self._server.query(key, method=self._server._session.get)
|
||||||
|
return self.findItems(data, initpath=key)
|
||||||
|
|
||||||
|
def fixMatch(self, searchResult=None, auto=False, agent=None):
|
||||||
|
""" Use match result to update show metadata.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
auto (bool): True uses first match from matches
|
||||||
|
False allows user to provide the match
|
||||||
|
searchResult (:class:`~plexapi.media.SearchResult`): Search result from
|
||||||
|
~plexapi.base.matches()
|
||||||
|
agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.)
|
||||||
|
"""
|
||||||
|
key = '/library/metadata/%s/match' % self.ratingKey
|
||||||
|
if auto:
|
||||||
|
autoMatch = self.matches(agent=agent)
|
||||||
|
if autoMatch:
|
||||||
|
searchResult = autoMatch[0]
|
||||||
|
else:
|
||||||
|
raise NotFound('No matches found using this agent: (%s:%s)' % (agent, autoMatch))
|
||||||
|
elif not searchResult:
|
||||||
|
raise NotFound('fixMatch() requires either auto=True or '
|
||||||
|
'searchResult=:class:`~plexapi.media.SearchResult`.')
|
||||||
|
|
||||||
|
params = {'guid': searchResult.guid,
|
||||||
|
'name': searchResult.name}
|
||||||
|
|
||||||
|
data = key + '?' + urlencode(params)
|
||||||
|
self._server.query(data, method=self._server._session.put)
|
||||||
|
|
||||||
# The photo tag cant be built atm. TODO
|
# The photo tag cant be built atm. TODO
|
||||||
# def arts(self):
|
# def arts(self):
|
||||||
# part = '%s/arts' % self.key
|
# part = '%s/arts' % self.key
|
||||||
|
@ -509,6 +660,14 @@ class Playable(object):
|
||||||
key = '%s/split' % self.key
|
key = '%s/split' % self.key
|
||||||
return self._server.query(key, method=self._server._session.put)
|
return self._server.query(key, method=self._server._session.put)
|
||||||
|
|
||||||
|
def merge(self, ratingKeys):
|
||||||
|
"""Merge duplicate items."""
|
||||||
|
if not isinstance(ratingKeys, list):
|
||||||
|
ratingKeys = str(ratingKeys).split(",")
|
||||||
|
|
||||||
|
key = '%s/merge?ids=%s' % (self.key, ','.join(ratingKeys))
|
||||||
|
return self._server.query(key, method=self._server._session.put)
|
||||||
|
|
||||||
def unmatch(self):
|
def unmatch(self):
|
||||||
"""Unmatch a media file."""
|
"""Unmatch a media file."""
|
||||||
key = '%s/unmatch' % self.key
|
key = '%s/unmatch' % self.key
|
||||||
|
@ -573,7 +732,7 @@ class Playable(object):
|
||||||
time, state)
|
time, state)
|
||||||
self._server.query(key)
|
self._server.query(key)
|
||||||
self.reload()
|
self.reload()
|
||||||
|
|
||||||
def updateTimeline(self, time, state='stopped', duration=None):
|
def updateTimeline(self, time, state='stopped', duration=None):
|
||||||
""" Set the timeline progress for this video.
|
""" Set the timeline progress for this video.
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import time
|
import time
|
||||||
import requests
|
|
||||||
|
|
||||||
from requests.status_codes import _codes as codes
|
import requests
|
||||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
|
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils
|
||||||
from plexapi import log, logfilter, utils
|
|
||||||
from plexapi.base import PlexObject
|
from plexapi.base import PlexObject
|
||||||
from plexapi.compat import ElementTree
|
from plexapi.compat import ElementTree
|
||||||
from plexapi.exceptions import BadRequest, Unsupported
|
from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported
|
||||||
from plexapi.playqueue import PlayQueue
|
from plexapi.playqueue import PlayQueue
|
||||||
|
from requests.status_codes import _codes as codes
|
||||||
|
|
||||||
DEFAULT_MTYPE = 'video'
|
DEFAULT_MTYPE = 'video'
|
||||||
|
|
||||||
|
@ -159,11 +157,16 @@ class PlexClient(PlexObject):
|
||||||
log.debug('%s %s', method.__name__.upper(), url)
|
log.debug('%s %s', method.__name__.upper(), url)
|
||||||
headers = self._headers(**headers or {})
|
headers = self._headers(**headers or {})
|
||||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
||||||
if response.status_code not in (200, 201):
|
if response.status_code not in (200, 201, 204):
|
||||||
codename = codes.get(response.status_code)[0]
|
codename = codes.get(response.status_code)[0]
|
||||||
errtext = response.text.replace('\n', ' ')
|
errtext = response.text.replace('\n', ' ')
|
||||||
log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
|
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
|
||||||
raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext))
|
if response.status_code == 401:
|
||||||
|
raise Unauthorized(message)
|
||||||
|
elif response.status_code == 404:
|
||||||
|
raise NotFound(message)
|
||||||
|
else:
|
||||||
|
raise BadRequest(message)
|
||||||
data = response.text.encode('utf8')
|
data = response.text.encode('utf8')
|
||||||
return ElementTree.fromstring(data) if data.strip() else None
|
return ElementTree.fromstring(data) if data.strip() else None
|
||||||
|
|
||||||
|
@ -204,10 +207,13 @@ class PlexClient(PlexObject):
|
||||||
return query(key, headers=headers)
|
return query(key, headers=headers)
|
||||||
except ElementTree.ParseError:
|
except ElementTree.ParseError:
|
||||||
# Workaround for players which don't return valid XML on successful commands
|
# Workaround for players which don't return valid XML on successful commands
|
||||||
# - Plexamp: `b'OK'`
|
# - Plexamp, Plex for Android: `b'OK'`
|
||||||
|
# - Plex for Samsung: `b'<?xml version="1.0"?><Response code="200" status="OK">'`
|
||||||
if self.product in (
|
if self.product in (
|
||||||
'Plexamp',
|
'Plexamp',
|
||||||
'Plex for Android (TV)',
|
'Plex for Android (TV)',
|
||||||
|
'Plex for Android (Mobile)',
|
||||||
|
'Plex for Samsung',
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
raise
|
raise
|
||||||
|
@ -300,6 +306,8 @@ class PlexClient(PlexObject):
|
||||||
'address': server_url[1].strip('/'),
|
'address': server_url[1].strip('/'),
|
||||||
'port': server_url[-1],
|
'port': server_url[-1],
|
||||||
'key': media.key,
|
'key': media.key,
|
||||||
|
'protocol': server_url[0],
|
||||||
|
'token': media._server.createToken()
|
||||||
}, **params))
|
}, **params))
|
||||||
|
|
||||||
# -------------------
|
# -------------------
|
||||||
|
@ -465,6 +473,18 @@ class PlexClient(PlexObject):
|
||||||
server_url = media._server._baseurl.split(':')
|
server_url = media._server._baseurl.split(':')
|
||||||
server_port = server_url[-1].strip('/')
|
server_port = server_url[-1].strip('/')
|
||||||
|
|
||||||
|
if hasattr(media, "playlistType"):
|
||||||
|
mediatype = media.playlistType
|
||||||
|
else:
|
||||||
|
if isinstance(media, PlayQueue):
|
||||||
|
mediatype = media.items[0].listType
|
||||||
|
else:
|
||||||
|
mediatype = media.listType
|
||||||
|
|
||||||
|
# mediatype must be in ["video", "music", "photo"]
|
||||||
|
if mediatype == "audio":
|
||||||
|
mediatype = "music"
|
||||||
|
|
||||||
if self.product != 'OpenPHT':
|
if self.product != 'OpenPHT':
|
||||||
try:
|
try:
|
||||||
self.sendCommand('timeline/subscribe', port=server_port, protocol='http')
|
self.sendCommand('timeline/subscribe', port=server_port, protocol='http')
|
||||||
|
@ -481,7 +501,8 @@ class PlexClient(PlexObject):
|
||||||
'port': server_port,
|
'port': server_port,
|
||||||
'offset': offset,
|
'offset': offset,
|
||||||
'key': media.key,
|
'key': media.key,
|
||||||
'token': media._server._token,
|
'token': media._server.createToken(),
|
||||||
|
'type': mediatype,
|
||||||
'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID,
|
'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID,
|
||||||
}, **params))
|
}, **params))
|
||||||
|
|
||||||
|
@ -527,9 +548,9 @@ class PlexClient(PlexObject):
|
||||||
|
|
||||||
# -------------------
|
# -------------------
|
||||||
# Timeline Commands
|
# Timeline Commands
|
||||||
def timeline(self):
|
def timeline(self, wait=1):
|
||||||
""" Poll the current timeline and return the XML response. """
|
""" Poll the current timeline and return the XML response. """
|
||||||
return self.sendCommand('timeline/poll', wait=1)
|
return self.sendCommand('timeline/poll', wait=wait)
|
||||||
|
|
||||||
def isPlayingMedia(self, includePaused=False):
|
def isPlayingMedia(self, includePaused=False):
|
||||||
""" Returns True if any media is currently playing.
|
""" Returns True if any media is currently playing.
|
||||||
|
@ -538,7 +559,7 @@ class PlexClient(PlexObject):
|
||||||
includePaused (bool): Set True to treat currently paused items
|
includePaused (bool): Set True to treat currently paused items
|
||||||
as playing (optional; default True).
|
as playing (optional; default True).
|
||||||
"""
|
"""
|
||||||
for mediatype in self.timeline():
|
for mediatype in self.timeline(wait=0):
|
||||||
if mediatype.get('state') == 'playing':
|
if mediatype.get('state') == 'playing':
|
||||||
return True
|
return True
|
||||||
if includePaused and mediatype.get('state') == 'paused':
|
if includePaused and mediatype.get('state') == 'paused':
|
||||||
|
|
|
@ -25,9 +25,9 @@ except ImportError:
|
||||||
from urllib import quote
|
from urllib import quote
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus, quote
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from urllib import quote_plus
|
from urllib import quote_plus, quote
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
@ -44,11 +44,6 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from xml.etree import ElementTree
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
try:
|
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
except ImportError:
|
|
||||||
from mock import patch, MagicMock
|
|
||||||
|
|
||||||
|
|
||||||
def makedirs(name, mode=0o777, exist_ok=False):
|
def makedirs(name, mode=0o777, exist_ok=False):
|
||||||
""" Mimicks os.makedirs() from Python 3. """
|
""" Mimicks os.makedirs() from Python 3. """
|
||||||
|
|
|
@ -26,6 +26,6 @@ class Unsupported(PlexApiException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Unauthorized(PlexApiException):
|
class Unauthorized(BadRequest):
|
||||||
""" Invalid username or password. """
|
""" Invalid username/password or token. """
|
||||||
pass
|
pass
|
||||||
|
|
148
lib/plexapi/gdm.py
Normal file
148
lib/plexapi/gdm.py
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
"""
|
||||||
|
Support for discovery using GDM (Good Day Mate), multicast protocol by Plex.
|
||||||
|
|
||||||
|
# Licensed Apache 2.0
|
||||||
|
# From https://github.com/home-assistant/netdisco/netdisco/gdm.py
|
||||||
|
|
||||||
|
Inspired by:
|
||||||
|
hippojay's plexGDM: https://github.com/hippojay/script.plexbmc.helper/resources/lib/plexgdm.py
|
||||||
|
iBaa's PlexConnect: https://github.com/iBaa/PlexConnect/PlexAPI.py
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
|
||||||
|
|
||||||
|
class GDM:
|
||||||
|
"""Base class to discover GDM services."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.entries = []
|
||||||
|
self.last_scan = None
|
||||||
|
|
||||||
|
def scan(self, scan_for_clients=False):
|
||||||
|
"""Scan the network."""
|
||||||
|
self.update(scan_for_clients)
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
"""Return all found entries.
|
||||||
|
|
||||||
|
Will scan for entries if not scanned recently.
|
||||||
|
"""
|
||||||
|
self.scan()
|
||||||
|
return list(self.entries)
|
||||||
|
|
||||||
|
def find_by_content_type(self, value):
|
||||||
|
"""Return a list of entries that match the content_type."""
|
||||||
|
self.scan()
|
||||||
|
return [entry for entry in self.entries
|
||||||
|
if value in entry['data']['Content_Type']]
|
||||||
|
|
||||||
|
def find_by_data(self, values):
|
||||||
|
"""Return a list of entries that match the search parameters."""
|
||||||
|
self.scan()
|
||||||
|
return [entry for entry in self.entries
|
||||||
|
if all(item in entry['data'].items()
|
||||||
|
for item in values.items())]
|
||||||
|
|
||||||
|
def update(self, scan_for_clients):
|
||||||
|
"""Scan for new GDM services.
|
||||||
|
|
||||||
|
Examples of the dict list assigned to self.entries by this function:
|
||||||
|
|
||||||
|
Server:
|
||||||
|
|
||||||
|
[{'data': {
|
||||||
|
'Content-Type': 'plex/media-server',
|
||||||
|
'Host': '53f4b5b6023d41182fe88a99b0e714ba.plex.direct',
|
||||||
|
'Name': 'myfirstplexserver',
|
||||||
|
'Port': '32400',
|
||||||
|
'Resource-Identifier': '646ab0aa8a01c543e94ba975f6fd6efadc36b7',
|
||||||
|
'Updated-At': '1585769946',
|
||||||
|
'Version': '1.18.8.2527-740d4c206',
|
||||||
|
},
|
||||||
|
'from': ('10.10.10.100', 32414)}]
|
||||||
|
|
||||||
|
Clients:
|
||||||
|
|
||||||
|
[{'data': {'Content-Type': 'plex/media-player',
|
||||||
|
'Device-Class': 'stb',
|
||||||
|
'Name': 'plexamp',
|
||||||
|
'Port': '36000',
|
||||||
|
'Product': 'Plexamp',
|
||||||
|
'Protocol': 'plex',
|
||||||
|
'Protocol-Capabilities': 'timeline,playback,playqueues,playqueues-creation',
|
||||||
|
'Protocol-Version': '1',
|
||||||
|
'Resource-Identifier': 'b6e57a3f-e0f8-494f-8884-f4b58501467e',
|
||||||
|
'Version': '1.1.0',
|
||||||
|
},
|
||||||
|
'from': ('10.10.10.101', 32412)}]
|
||||||
|
"""
|
||||||
|
|
||||||
|
gdm_msg = 'M-SEARCH * HTTP/1.0'.encode('ascii')
|
||||||
|
gdm_timeout = 1
|
||||||
|
|
||||||
|
self.entries = []
|
||||||
|
known_responses = []
|
||||||
|
|
||||||
|
# setup socket for discovery -> multicast message
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.settimeout(gdm_timeout)
|
||||||
|
|
||||||
|
# Set the time-to-live for messages for local network
|
||||||
|
sock.setsockopt(socket.IPPROTO_IP,
|
||||||
|
socket.IP_MULTICAST_TTL,
|
||||||
|
struct.pack("B", gdm_timeout))
|
||||||
|
|
||||||
|
if scan_for_clients:
|
||||||
|
# setup socket for broadcast to Plex clients
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||||
|
gdm_ip = '255.255.255.255'
|
||||||
|
gdm_port = 32412
|
||||||
|
else:
|
||||||
|
# setup socket for multicast to Plex server(s)
|
||||||
|
gdm_ip = '239.0.0.250'
|
||||||
|
gdm_port = 32414
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send data to the multicast group
|
||||||
|
sock.sendto(gdm_msg, (gdm_ip, gdm_port))
|
||||||
|
|
||||||
|
# Look for responses from all recipients
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
bdata, host = sock.recvfrom(1024)
|
||||||
|
data = bdata.decode('utf-8')
|
||||||
|
if '200 OK' in data.splitlines()[0]:
|
||||||
|
ddata = {k: v.strip() for (k, v) in (
|
||||||
|
line.split(':') for line in
|
||||||
|
data.splitlines() if ':' in line)}
|
||||||
|
identifier = ddata.get('Resource-Identifier')
|
||||||
|
if identifier and identifier in known_responses:
|
||||||
|
continue
|
||||||
|
known_responses.append(identifier)
|
||||||
|
self.entries.append({'data': ddata,
|
||||||
|
'from': host})
|
||||||
|
except socket.timeout:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Test GDM discovery."""
|
||||||
|
from pprint import pprint
|
||||||
|
|
||||||
|
gdm = GDM()
|
||||||
|
|
||||||
|
pprint("Scanning GDM for servers...")
|
||||||
|
gdm.scan()
|
||||||
|
pprint(gdm.entries)
|
||||||
|
|
||||||
|
pprint("Scanning GDM for clients...")
|
||||||
|
gdm.scan(scan_for_clients=True)
|
||||||
|
pprint(gdm.entries)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
|
@ -1,9 +1,10 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from plexapi import X_PLEX_CONTAINER_SIZE, log, utils
|
from plexapi import X_PLEX_CONTAINER_SIZE, log, utils
|
||||||
from plexapi.base import PlexObject
|
from plexapi.base import PlexObject
|
||||||
from plexapi.compat import unquote, urlencode, quote_plus
|
from plexapi.compat import quote, quote_plus, unquote, urlencode
|
||||||
from plexapi.media import MediaTag
|
|
||||||
from plexapi.exceptions import BadRequest, NotFound
|
from plexapi.exceptions import BadRequest, NotFound
|
||||||
|
from plexapi.media import MediaTag
|
||||||
|
from plexapi.settings import Setting
|
||||||
|
|
||||||
|
|
||||||
class Library(PlexObject):
|
class Library(PlexObject):
|
||||||
|
@ -294,6 +295,17 @@ class Library(PlexObject):
|
||||||
part += urlencode(kwargs)
|
part += urlencode(kwargs)
|
||||||
return self._server.query(part, method=self._server._session.post)
|
return self._server.query(part, method=self._server._session.post)
|
||||||
|
|
||||||
|
def history(self, maxresults=9999999, mindate=None):
|
||||||
|
""" Get Play History for all library Sections for the owner.
|
||||||
|
Parameters:
|
||||||
|
maxresults (int): Only return the specified number of results (optional).
|
||||||
|
mindate (datetime): Min datetime to return results from.
|
||||||
|
"""
|
||||||
|
hist = []
|
||||||
|
for section in self.sections():
|
||||||
|
hist.extend(section.history(maxresults=maxresults, mindate=mindate))
|
||||||
|
return hist
|
||||||
|
|
||||||
|
|
||||||
class LibrarySection(PlexObject):
|
class LibrarySection(PlexObject):
|
||||||
""" Base class for a single library section.
|
""" Base class for a single library section.
|
||||||
|
@ -320,6 +332,8 @@ class LibrarySection(PlexObject):
|
||||||
type (str): Type of content section represents (movie, artist, photo, show).
|
type (str): Type of content section represents (movie, artist, photo, show).
|
||||||
updatedAt (datetime): Datetime this library section was last updated.
|
updatedAt (datetime): Datetime this library section was last updated.
|
||||||
uuid (str): Unique id for this section (32258d7c-3e6c-4ac5-98ad-bad7a3b78c63)
|
uuid (str): Unique id for this section (32258d7c-3e6c-4ac5-98ad-bad7a3b78c63)
|
||||||
|
totalSize (int): Total number of item in the library
|
||||||
|
|
||||||
"""
|
"""
|
||||||
ALLOWED_FILTERS = ()
|
ALLOWED_FILTERS = ()
|
||||||
ALLOWED_SORT = ()
|
ALLOWED_SORT = ()
|
||||||
|
@ -343,6 +357,51 @@ class LibrarySection(PlexObject):
|
||||||
self.type = data.attrib.get('type')
|
self.type = data.attrib.get('type')
|
||||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||||
self.uuid = data.attrib.get('uuid')
|
self.uuid = data.attrib.get('uuid')
|
||||||
|
# Private attrs as we dont want a reload.
|
||||||
|
self._total_size = None
|
||||||
|
|
||||||
|
def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs):
|
||||||
|
""" Load the specified key to find and build all items with the specified tag
|
||||||
|
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
||||||
|
on how this is used.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
container_start (None, int): offset to get a subset of the data
|
||||||
|
container_size (None, int): How many items in data
|
||||||
|
|
||||||
|
"""
|
||||||
|
url_kw = {}
|
||||||
|
if container_start is not None:
|
||||||
|
url_kw["X-Plex-Container-Start"] = container_start
|
||||||
|
if container_size is not None:
|
||||||
|
url_kw["X-Plex-Container-Size"] = container_size
|
||||||
|
|
||||||
|
if ekey is None:
|
||||||
|
raise BadRequest('ekey was not provided')
|
||||||
|
data = self._server.query(ekey, params=url_kw)
|
||||||
|
|
||||||
|
if '/all' in ekey:
|
||||||
|
# totalSize is only included in the xml response
|
||||||
|
# if container size is used.
|
||||||
|
total_size = data.attrib.get("totalSize") or data.attrib.get("size")
|
||||||
|
self._total_size = utils.cast(int, total_size)
|
||||||
|
|
||||||
|
items = self.findItems(data, cls, ekey, **kwargs)
|
||||||
|
|
||||||
|
librarySectionID = data.attrib.get('librarySectionID')
|
||||||
|
if librarySectionID:
|
||||||
|
for item in items:
|
||||||
|
item.librarySectionID = librarySectionID
|
||||||
|
return items
|
||||||
|
|
||||||
|
@property
|
||||||
|
def totalSize(self):
|
||||||
|
if self._total_size is None:
|
||||||
|
part = '/library/sections/%s/all?X-Plex-Container-Start=0&X-Plex-Container-Size=1' % self.key
|
||||||
|
data = self._server.query(part)
|
||||||
|
self._total_size = int(data.attrib.get("totalSize"))
|
||||||
|
|
||||||
|
return self._total_size
|
||||||
|
|
||||||
def delete(self):
|
def delete(self):
|
||||||
""" Delete a library section. """
|
""" Delete a library section. """
|
||||||
|
@ -354,13 +413,18 @@ class LibrarySection(PlexObject):
|
||||||
log.error(msg)
|
log.error(msg)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def edit(self, **kwargs):
|
def reload(self, key=None):
|
||||||
|
return self._server.library.section(self.title)
|
||||||
|
|
||||||
|
def edit(self, agent=None, **kwargs):
|
||||||
""" Edit a library (Note: agent is required). See :class:`~plexapi.library.Library` for example usage.
|
""" Edit a library (Note: agent is required). See :class:`~plexapi.library.Library` for example usage.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
kwargs (dict): Dict of settings to edit.
|
kwargs (dict): Dict of settings to edit.
|
||||||
"""
|
"""
|
||||||
part = '/library/sections/%s?%s' % (self.key, urlencode(kwargs))
|
if not agent:
|
||||||
|
agent = self.agent
|
||||||
|
part = '/library/sections/%s?agent=%s&%s' % (self.key, agent, urlencode(kwargs))
|
||||||
self._server.query(part, method=self._server._session.put)
|
self._server.query(part, method=self._server._session.put)
|
||||||
|
|
||||||
# Reload this way since the self.key dont have a full path, but is simply a id.
|
# Reload this way since the self.key dont have a full path, but is simply a id.
|
||||||
|
@ -374,7 +438,7 @@ class LibrarySection(PlexObject):
|
||||||
Parameters:
|
Parameters:
|
||||||
title (str): Title of the item to return.
|
title (str): Title of the item to return.
|
||||||
"""
|
"""
|
||||||
key = '/library/sections/%s/all' % self.key
|
key = '/library/sections/%s/all?title=%s' % (self.key, quote(title, safe=''))
|
||||||
return self.fetchItem(key, title__iexact=title)
|
return self.fetchItem(key, title__iexact=title)
|
||||||
|
|
||||||
def all(self, sort=None, **kwargs):
|
def all(self, sort=None, **kwargs):
|
||||||
|
@ -390,6 +454,17 @@ class LibrarySection(PlexObject):
|
||||||
key = '/library/sections/%s/all%s' % (self.key, sortStr)
|
key = '/library/sections/%s/all%s' % (self.key, sortStr)
|
||||||
return self.fetchItems(key, **kwargs)
|
return self.fetchItems(key, **kwargs)
|
||||||
|
|
||||||
|
def agents(self):
|
||||||
|
""" Returns a list of available `:class:`~plexapi.media.Agent` for this library section.
|
||||||
|
"""
|
||||||
|
return self._server.agents(utils.searchType(self.type))
|
||||||
|
|
||||||
|
def settings(self):
|
||||||
|
""" Returns a list of all library settings. """
|
||||||
|
key = '/library/sections/%s/prefs' % self.key
|
||||||
|
data = self._server.query(key)
|
||||||
|
return self.findItems(data, cls=Setting)
|
||||||
|
|
||||||
def onDeck(self):
|
def onDeck(self):
|
||||||
""" Returns a list of media items on deck from this library section. """
|
""" Returns a list of media items on deck from this library section. """
|
||||||
key = '/library/sections/%s/onDeck' % self.key
|
key = '/library/sections/%s/onDeck' % self.key
|
||||||
|
@ -464,9 +539,9 @@ class LibrarySection(PlexObject):
|
||||||
key = '/library/sections/%s/%s%s' % (self.key, category, utils.joinArgs(args))
|
key = '/library/sections/%s/%s%s' % (self.key, category, utils.joinArgs(args))
|
||||||
return self.fetchItems(key, cls=FilterChoice)
|
return self.fetchItems(key, cls=FilterChoice)
|
||||||
|
|
||||||
def search(self, title=None, sort=None, maxresults=999999, libtype=None, **kwargs):
|
def search(self, title=None, sort=None, maxresults=None,
|
||||||
""" Search the library. If there are many results, they will be fetched from the server
|
libtype=None, container_start=0, container_size=X_PLEX_CONTAINER_SIZE, **kwargs):
|
||||||
in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only looking for the first <num>
|
""" Search the library. The http requests will be batched in container_size. If you're only looking for the first <num>
|
||||||
results, it would be wise to set the maxresults option to that amount so this functions
|
results, it would be wise to set the maxresults option to that amount so this functions
|
||||||
doesn't iterate over all results on the server.
|
doesn't iterate over all results on the server.
|
||||||
|
|
||||||
|
@ -477,6 +552,8 @@ class LibrarySection(PlexObject):
|
||||||
maxresults (int): Only return the specified number of results (optional).
|
maxresults (int): Only return the specified number of results (optional).
|
||||||
libtype (str): Filter results to a spcifiec libtype (movie, show, episode, artist,
|
libtype (str): Filter results to a spcifiec libtype (movie, show, episode, artist,
|
||||||
album, track; optional).
|
album, track; optional).
|
||||||
|
container_start (int): default 0
|
||||||
|
container_size (int): default X_PLEX_CONTAINER_SIZE in your config file.
|
||||||
**kwargs (dict): Any of the available filters for the current library section. Partial string
|
**kwargs (dict): Any of the available filters for the current library section. Partial string
|
||||||
matches allowed. Multiple matches OR together. Negative filtering also possible, just add an
|
matches allowed. Multiple matches OR together. Negative filtering also possible, just add an
|
||||||
exclamation mark to the end of filter name, e.g. `resolution!=1x1`.
|
exclamation mark to the end of filter name, e.g. `resolution!=1x1`.
|
||||||
|
@ -508,15 +585,37 @@ class LibrarySection(PlexObject):
|
||||||
args['sort'] = self._cleanSearchSort(sort)
|
args['sort'] = self._cleanSearchSort(sort)
|
||||||
if libtype is not None:
|
if libtype is not None:
|
||||||
args['type'] = utils.searchType(libtype)
|
args['type'] = utils.searchType(libtype)
|
||||||
# iterate over the results
|
|
||||||
results, subresults = [], '_init'
|
results = []
|
||||||
args['X-Plex-Container-Start'] = 0
|
subresults = []
|
||||||
args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
|
offset = container_start
|
||||||
while subresults and maxresults > len(results):
|
|
||||||
|
if maxresults is not None:
|
||||||
|
container_size = min(container_size, maxresults)
|
||||||
|
while True:
|
||||||
key = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args))
|
key = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args))
|
||||||
subresults = self.fetchItems(key)
|
subresults = self.fetchItems(key, container_start=container_start,
|
||||||
results += subresults[:maxresults - len(results)]
|
container_size=container_size)
|
||||||
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
|
if not len(subresults):
|
||||||
|
if offset > self.totalSize:
|
||||||
|
log.info("container_start is higher then the number of items in the library")
|
||||||
|
break
|
||||||
|
|
||||||
|
results.extend(subresults)
|
||||||
|
|
||||||
|
# self.totalSize is not used as a condition in the while loop as
|
||||||
|
# this require a additional http request.
|
||||||
|
# self.totalSize is updated from .fetchItems
|
||||||
|
wanted_number_of_items = self.totalSize - offset
|
||||||
|
if maxresults is not None:
|
||||||
|
wanted_number_of_items = min(maxresults, wanted_number_of_items)
|
||||||
|
container_size = min(container_size, maxresults - len(results))
|
||||||
|
|
||||||
|
if wanted_number_of_items <= len(results):
|
||||||
|
break
|
||||||
|
|
||||||
|
container_start += container_size
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _cleanSearchFilter(self, category, value, libtype=None):
|
def _cleanSearchFilter(self, category, value, libtype=None):
|
||||||
|
@ -543,7 +642,7 @@ class LibrarySection(PlexObject):
|
||||||
matches = [k for t, k in lookup.items() if item in t]
|
matches = [k for t, k in lookup.items() if item in t]
|
||||||
if matches: map(result.add, matches); continue
|
if matches: map(result.add, matches); continue
|
||||||
# nothing matched; use raw item value
|
# nothing matched; use raw item value
|
||||||
log.warning('Filter value not listed, using raw item value: %s' % item)
|
log.debug('Filter value not listed, using raw item value: %s' % item)
|
||||||
result.add(item)
|
result.add(item)
|
||||||
return ','.join(result)
|
return ','.join(result)
|
||||||
|
|
||||||
|
@ -633,6 +732,14 @@ class LibrarySection(PlexObject):
|
||||||
|
|
||||||
return myplex.sync(client=client, clientId=clientId, sync_item=sync_item)
|
return myplex.sync(client=client, clientId=clientId, sync_item=sync_item)
|
||||||
|
|
||||||
|
def history(self, maxresults=9999999, mindate=None):
|
||||||
|
""" Get Play History for this library Section for the owner.
|
||||||
|
Parameters:
|
||||||
|
maxresults (int): Only return the specified number of results (optional).
|
||||||
|
mindate (datetime): Min datetime to return results from.
|
||||||
|
"""
|
||||||
|
return self._server.history(maxresults=maxresults, mindate=mindate, librarySectionID=self.key, accountID=1)
|
||||||
|
|
||||||
|
|
||||||
class MovieSection(LibrarySection):
|
class MovieSection(LibrarySection):
|
||||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing movies.
|
""" Represents a :class:`~plexapi.library.LibrarySection` section containing movies.
|
||||||
|
@ -869,7 +976,7 @@ class PhotoSection(LibrarySection):
|
||||||
TYPE (str): 'photo'
|
TYPE (str): 'photo'
|
||||||
"""
|
"""
|
||||||
ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure', 'device', 'resolution', 'place',
|
ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure', 'device', 'resolution', 'place',
|
||||||
'originallyAvailableAt', 'addedAt', 'title', 'userRating')
|
'originallyAvailableAt', 'addedAt', 'title', 'userRating', 'tag', 'year')
|
||||||
ALLOWED_SORT = ('addedAt',)
|
ALLOWED_SORT = ('addedAt',)
|
||||||
TAG = 'Directory'
|
TAG = 'Directory'
|
||||||
TYPE = 'photo'
|
TYPE = 'photo'
|
||||||
|
@ -968,6 +1075,7 @@ class Hub(PlexObject):
|
||||||
self.size = utils.cast(int, data.attrib.get('size'))
|
self.size = utils.cast(int, data.attrib.get('size'))
|
||||||
self.title = data.attrib.get('title')
|
self.title = data.attrib.get('title')
|
||||||
self.type = data.attrib.get('type')
|
self.type = data.attrib.get('type')
|
||||||
|
self.key = data.attrib.get('key')
|
||||||
self.items = self.findItems(data)
|
self.items = self.findItems(data)
|
||||||
|
|
||||||
def __len__(self):
|
def __len__(self):
|
||||||
|
@ -979,9 +1087,11 @@ class Collections(PlexObject):
|
||||||
|
|
||||||
TAG = 'Directory'
|
TAG = 'Directory'
|
||||||
TYPE = 'collection'
|
TYPE = 'collection'
|
||||||
|
_include = "?includeExternalMedia=1&includePreferences=1"
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||||
|
self._details_key = "/library/metadata/%s%s" % (self.ratingKey, self._include)
|
||||||
self.key = data.attrib.get('key')
|
self.key = data.attrib.get('key')
|
||||||
self.type = data.attrib.get('type')
|
self.type = data.attrib.get('type')
|
||||||
self.title = data.attrib.get('title')
|
self.title = data.attrib.get('title')
|
||||||
|
@ -1051,5 +1161,43 @@ class Collections(PlexObject):
|
||||||
part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key)
|
part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key)
|
||||||
return self._server.query(part, method=self._server._session.put)
|
return self._server.query(part, method=self._server._session.put)
|
||||||
|
|
||||||
|
def posters(self):
|
||||||
|
""" Returns list of available poster objects. :class:`~plexapi.media.Poster`. """
|
||||||
|
|
||||||
|
return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey)
|
||||||
|
|
||||||
|
def uploadPoster(self, url=None, filepath=None):
|
||||||
|
""" Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
|
||||||
|
if url:
|
||||||
|
key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url))
|
||||||
|
self._server.query(key, method=self._server._session.post)
|
||||||
|
elif filepath:
|
||||||
|
key = '/library/metadata/%s/posters?' % self.ratingKey
|
||||||
|
data = open(filepath, 'rb').read()
|
||||||
|
self._server.query(key, method=self._server._session.post, data=data)
|
||||||
|
|
||||||
|
def setPoster(self, poster):
|
||||||
|
""" Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
|
||||||
|
poster.select()
|
||||||
|
|
||||||
|
def arts(self):
|
||||||
|
""" Returns list of available art objects. :class:`~plexapi.media.Poster`. """
|
||||||
|
|
||||||
|
return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey)
|
||||||
|
|
||||||
|
def uploadArt(self, url=None, filepath=None):
|
||||||
|
""" Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
|
||||||
|
if url:
|
||||||
|
key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url))
|
||||||
|
self._server.query(key, method=self._server._session.post)
|
||||||
|
elif filepath:
|
||||||
|
key = '/library/metadata/%s/arts?' % self.ratingKey
|
||||||
|
data = open(filepath, 'rb').read()
|
||||||
|
self._server.query(key, method=self._server._session.post, data=data)
|
||||||
|
|
||||||
|
def setArt(self, art):
|
||||||
|
""" Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
|
||||||
|
art.select()
|
||||||
|
|
||||||
# def edit(self, **kwargs):
|
# def edit(self, **kwargs):
|
||||||
# TODO
|
# TODO
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from plexapi import log, utils
|
|
||||||
|
import xml
|
||||||
|
|
||||||
|
from plexapi import compat, log, settings, utils
|
||||||
from plexapi.base import PlexObject
|
from plexapi.base import PlexObject
|
||||||
from plexapi.exceptions import BadRequest
|
from plexapi.exceptions import BadRequest
|
||||||
from plexapi.utils import cast
|
from plexapi.utils import cast
|
||||||
|
@ -143,7 +146,7 @@ class MediaPart(PlexObject):
|
||||||
|
|
||||||
def setDefaultSubtitleStream(self, stream):
|
def setDefaultSubtitleStream(self, stream):
|
||||||
""" Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart.
|
""" Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default.
|
stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default.
|
||||||
"""
|
"""
|
||||||
|
@ -349,6 +352,118 @@ class TranscodeSession(PlexObject):
|
||||||
self.width = cast(int, data.attrib.get('width'))
|
self.width = cast(int, data.attrib.get('width'))
|
||||||
|
|
||||||
|
|
||||||
|
@utils.registerPlexObject
|
||||||
|
class TranscodeJob(PlexObject):
|
||||||
|
""" Represents an Optimizing job.
|
||||||
|
TrancodeJobs are the process for optimizing conversions.
|
||||||
|
Active or paused optimization items. Usually one item as a time"""
|
||||||
|
TAG = 'TranscodeJob'
|
||||||
|
|
||||||
|
def _loadData(self, data):
|
||||||
|
self._data = data
|
||||||
|
self.generatorID = data.attrib.get('generatorID')
|
||||||
|
self.key = data.attrib.get('key')
|
||||||
|
self.progress = data.attrib.get('progress')
|
||||||
|
self.ratingKey = data.attrib.get('ratingKey')
|
||||||
|
self.size = data.attrib.get('size')
|
||||||
|
self.targetTagID = data.attrib.get('targetTagID')
|
||||||
|
self.thumb = data.attrib.get('thumb')
|
||||||
|
self.title = data.attrib.get('title')
|
||||||
|
self.type = data.attrib.get('type')
|
||||||
|
|
||||||
|
|
||||||
|
@utils.registerPlexObject
|
||||||
|
class Optimized(PlexObject):
|
||||||
|
""" Represents a Optimized item.
|
||||||
|
Optimized items are optimized and queued conversions items."""
|
||||||
|
TAG = 'Item'
|
||||||
|
|
||||||
|
def _loadData(self, data):
|
||||||
|
self._data = data
|
||||||
|
self.id = data.attrib.get('id')
|
||||||
|
self.composite = data.attrib.get('composite')
|
||||||
|
self.title = data.attrib.get('title')
|
||||||
|
self.type = data.attrib.get('type')
|
||||||
|
self.target = data.attrib.get('target')
|
||||||
|
self.targetTagID = data.attrib.get('targetTagID')
|
||||||
|
|
||||||
|
def remove(self):
|
||||||
|
""" Remove an Optimized item"""
|
||||||
|
key = '%s/%s' % (self._initpath, self.id)
|
||||||
|
self._server.query(key, method=self._server._session.delete)
|
||||||
|
|
||||||
|
def rename(self, title):
|
||||||
|
""" Rename an Optimized item"""
|
||||||
|
key = '%s/%s?Item[title]=%s' % (self._initpath, self.id, title)
|
||||||
|
self._server.query(key, method=self._server._session.put)
|
||||||
|
|
||||||
|
def reprocess(self, ratingKey):
|
||||||
|
""" Reprocess a removed Conversion item that is still a listed Optimize item"""
|
||||||
|
key = '%s/%s/%s/enable' % (self._initpath, self.id, ratingKey)
|
||||||
|
self._server.query(key, method=self._server._session.put)
|
||||||
|
|
||||||
|
|
||||||
|
@utils.registerPlexObject
|
||||||
|
class Conversion(PlexObject):
|
||||||
|
""" Represents a Conversion item.
|
||||||
|
Conversions are items queued for optimization or being actively optimized."""
|
||||||
|
TAG = 'Video'
|
||||||
|
|
||||||
|
def _loadData(self, data):
|
||||||
|
self._data = data
|
||||||
|
self.addedAt = data.attrib.get('addedAt')
|
||||||
|
self.art = data.attrib.get('art')
|
||||||
|
self.chapterSource = data.attrib.get('chapterSource')
|
||||||
|
self.contentRating = data.attrib.get('contentRating')
|
||||||
|
self.duration = data.attrib.get('duration')
|
||||||
|
self.generatorID = data.attrib.get('generatorID')
|
||||||
|
self.generatorType = data.attrib.get('generatorType')
|
||||||
|
self.guid = data.attrib.get('guid')
|
||||||
|
self.key = data.attrib.get('key')
|
||||||
|
self.lastViewedAt = data.attrib.get('lastViewedAt')
|
||||||
|
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||||
|
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||||
|
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||||
|
self.originallyAvailableAt = data.attrib.get('originallyAvailableAt')
|
||||||
|
self.playQueueItemID = data.attrib.get('playQueueItemID')
|
||||||
|
self.playlistID = data.attrib.get('playlistID')
|
||||||
|
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
|
||||||
|
self.rating = data.attrib.get('rating')
|
||||||
|
self.ratingKey = data.attrib.get('ratingKey')
|
||||||
|
self.studio = data.attrib.get('studio')
|
||||||
|
self.summary = data.attrib.get('summary')
|
||||||
|
self.tagline = data.attrib.get('tagline')
|
||||||
|
self.target = data.attrib.get('target')
|
||||||
|
self.thumb = data.attrib.get('thumb')
|
||||||
|
self.title = data.attrib.get('title')
|
||||||
|
self.type = data.attrib.get('type')
|
||||||
|
self.updatedAt = data.attrib.get('updatedAt')
|
||||||
|
self.userID = data.attrib.get('userID')
|
||||||
|
self.username = data.attrib.get('username')
|
||||||
|
self.viewOffset = data.attrib.get('viewOffset')
|
||||||
|
self.year = data.attrib.get('year')
|
||||||
|
|
||||||
|
def remove(self):
|
||||||
|
""" Remove Conversion from queue """
|
||||||
|
key = '/playlists/%s/items/%s/%s/disable' % (self.playlistID, self.generatorID, self.ratingKey)
|
||||||
|
self._server.query(key, method=self._server._session.put)
|
||||||
|
|
||||||
|
def move(self, after):
|
||||||
|
""" Move Conversion items position in queue
|
||||||
|
after (int): Place item after specified playQueueItemID. '-1' is the active conversion.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Move 5th conversion Item to active conversion
|
||||||
|
conversions[4].move('-1')
|
||||||
|
|
||||||
|
Move 4th conversion Item to 3rd in conversion queue
|
||||||
|
conversions[3].move(conversions[1].playQueueItemID)
|
||||||
|
"""
|
||||||
|
|
||||||
|
key = '%s/items/%s/move?after=%s' % (self._initpath, self.playQueueItemID, after)
|
||||||
|
self._server.query(key, method=self._server._session.put)
|
||||||
|
|
||||||
|
|
||||||
class MediaTag(PlexObject):
|
class MediaTag(PlexObject):
|
||||||
""" Base class for media tags used for filtering and searching your library
|
""" Base class for media tags used for filtering and searching your library
|
||||||
items or navigating the metadata of media items in your library. Tags are
|
items or navigating the metadata of media items in your library. Tags are
|
||||||
|
@ -419,6 +534,25 @@ class Label(MediaTag):
|
||||||
FILTER = 'label'
|
FILTER = 'label'
|
||||||
|
|
||||||
|
|
||||||
|
@utils.registerPlexObject
|
||||||
|
class Tag(MediaTag):
|
||||||
|
""" Represents a single tag media tag.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
TAG (str): 'tag'
|
||||||
|
FILTER (str): 'tag'
|
||||||
|
"""
|
||||||
|
TAG = 'Tag'
|
||||||
|
FILTER = 'tag'
|
||||||
|
|
||||||
|
def _loadData(self, data):
|
||||||
|
self._data = data
|
||||||
|
self.id = cast(int, data.attrib.get('id', 0))
|
||||||
|
self.filter = data.attrib.get('filter')
|
||||||
|
self.tag = data.attrib.get('tag')
|
||||||
|
self.title = self.tag
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Country(MediaTag):
|
class Country(MediaTag):
|
||||||
""" Represents a single Country media tag.
|
""" Represents a single Country media tag.
|
||||||
|
@ -483,6 +617,14 @@ class Poster(PlexObject):
|
||||||
self.selected = data.attrib.get('selected')
|
self.selected = data.attrib.get('selected')
|
||||||
self.thumb = data.attrib.get('thumb')
|
self.thumb = data.attrib.get('thumb')
|
||||||
|
|
||||||
|
def select(self):
|
||||||
|
key = self._initpath[:-1]
|
||||||
|
data = '%s?url=%s' % (key, compat.quote_plus(self.ratingKey))
|
||||||
|
try:
|
||||||
|
self._server.query(data, method=self._server._session.put)
|
||||||
|
except xml.etree.ElementTree.ParseError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Producer(MediaTag):
|
class Producer(MediaTag):
|
||||||
|
@ -565,3 +707,74 @@ class Field(PlexObject):
|
||||||
self._data = data
|
self._data = data
|
||||||
self.name = data.attrib.get('name')
|
self.name = data.attrib.get('name')
|
||||||
self.locked = cast(bool, data.attrib.get('locked'))
|
self.locked = cast(bool, data.attrib.get('locked'))
|
||||||
|
|
||||||
|
|
||||||
|
@utils.registerPlexObject
|
||||||
|
class SearchResult(PlexObject):
|
||||||
|
""" Represents a single SearchResult.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
TAG (str): 'SearchResult'
|
||||||
|
"""
|
||||||
|
TAG = 'SearchResult'
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
name = self._clean(self.firstAttr('name'))
|
||||||
|
score = self._clean(self.firstAttr('score'))
|
||||||
|
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, name, score] if p])
|
||||||
|
|
||||||
|
def _loadData(self, data):
|
||||||
|
self._data = data
|
||||||
|
self.guid = data.attrib.get('guid')
|
||||||
|
self.lifespanEnded = data.attrib.get('lifespanEnded')
|
||||||
|
self.name = data.attrib.get('name')
|
||||||
|
self.score = cast(int, data.attrib.get('score'))
|
||||||
|
self.year = data.attrib.get('year')
|
||||||
|
|
||||||
|
|
||||||
|
@utils.registerPlexObject
|
||||||
|
class Agent(PlexObject):
|
||||||
|
""" Represents a single Agent.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
TAG (str): 'Agent'
|
||||||
|
"""
|
||||||
|
TAG = 'Agent'
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
uid = self._clean(self.firstAttr('shortIdentifier'))
|
||||||
|
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p])
|
||||||
|
|
||||||
|
def _loadData(self, data):
|
||||||
|
self._data = data
|
||||||
|
self.hasAttribution = data.attrib.get('hasAttribution')
|
||||||
|
self.hasPrefs = data.attrib.get('hasPrefs')
|
||||||
|
self.identifier = data.attrib.get('identifier')
|
||||||
|
self.primary = data.attrib.get('primary')
|
||||||
|
self.shortIdentifier = self.identifier.rsplit('.', 1)[1]
|
||||||
|
if 'mediaType' in self._initpath:
|
||||||
|
self.name = data.attrib.get('name')
|
||||||
|
self.languageCode = []
|
||||||
|
for code in data:
|
||||||
|
self.languageCode += [code.attrib.get('code')]
|
||||||
|
else:
|
||||||
|
self.mediaTypes = [AgentMediaType(server=self._server, data=d) for d in data]
|
||||||
|
|
||||||
|
def _settings(self):
|
||||||
|
key = '/:/plugins/%s/prefs' % self.identifier
|
||||||
|
data = self._server.query(key)
|
||||||
|
return self.findItems(data, cls=settings.Setting)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentMediaType(Agent):
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
uid = self._clean(self.firstAttr('name'))
|
||||||
|
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p])
|
||||||
|
|
||||||
|
def _loadData(self, data):
|
||||||
|
self.mediaType = cast(int, data.attrib.get('mediaType'))
|
||||||
|
self.name = data.attrib.get('name')
|
||||||
|
self.languageCode = []
|
||||||
|
for code in data:
|
||||||
|
self.languageCode += [code.attrib.get('code')]
|
||||||
|
|
|
@ -1,18 +1,21 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import copy
|
import copy
|
||||||
import requests
|
import threading
|
||||||
import time
|
import time
|
||||||
from requests.status_codes import _codes as codes
|
|
||||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_IDENTIFIER, X_PLEX_ENABLE_FAST_CONNECT
|
import requests
|
||||||
from plexapi import log, logfilter, utils
|
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT,
|
||||||
|
X_PLEX_IDENTIFIER, log, logfilter, utils)
|
||||||
from plexapi.base import PlexObject
|
from plexapi.base import PlexObject
|
||||||
from plexapi.exceptions import BadRequest, NotFound
|
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||||
from plexapi.client import PlexClient
|
from plexapi.client import PlexClient
|
||||||
from plexapi.compat import ElementTree
|
from plexapi.compat import ElementTree
|
||||||
from plexapi.library import LibrarySection
|
from plexapi.library import LibrarySection
|
||||||
from plexapi.server import PlexServer
|
from plexapi.server import PlexServer
|
||||||
from plexapi.sync import SyncList, SyncItem
|
from plexapi.sonos import PlexSonosClient
|
||||||
|
from plexapi.sync import SyncItem, SyncList
|
||||||
from plexapi.utils import joinArgs
|
from plexapi.utils import joinArgs
|
||||||
|
from requests.status_codes import _codes as codes
|
||||||
|
|
||||||
|
|
||||||
class MyPlexAccount(PlexObject):
|
class MyPlexAccount(PlexObject):
|
||||||
|
@ -73,6 +76,12 @@ class MyPlexAccount(PlexObject):
|
||||||
REQUESTS = 'https://plex.tv/api/invites/requests' # get
|
REQUESTS = 'https://plex.tv/api/invites/requests' # get
|
||||||
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
|
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
|
||||||
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
|
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
|
||||||
|
# Hub sections
|
||||||
|
VOD = 'https://vod.provider.plex.tv/' # get
|
||||||
|
WEBSHOWS = 'https://webshows.provider.plex.tv/' # get
|
||||||
|
NEWS = 'https://news.provider.plex.tv/' # get
|
||||||
|
PODCASTS = 'https://podcasts.provider.plex.tv/' # get
|
||||||
|
MUSIC = 'https://music.provider.plex.tv/' # get
|
||||||
# Key may someday switch to the following url. For now the current value works.
|
# Key may someday switch to the following url. For now the current value works.
|
||||||
# https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId}
|
# https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId}
|
||||||
key = 'https://plex.tv/users/account'
|
key = 'https://plex.tv/users/account'
|
||||||
|
@ -80,6 +89,8 @@ class MyPlexAccount(PlexObject):
|
||||||
def __init__(self, username=None, password=None, token=None, session=None, timeout=None):
|
def __init__(self, username=None, password=None, token=None, session=None, timeout=None):
|
||||||
self._token = token
|
self._token = token
|
||||||
self._session = session or requests.Session()
|
self._session = session or requests.Session()
|
||||||
|
self._sonos_cache = []
|
||||||
|
self._sonos_cache_timestamp = 0
|
||||||
data, initpath = self._signin(username, password, timeout)
|
data, initpath = self._signin(username, password, timeout)
|
||||||
super(MyPlexAccount, self).__init__(self, data, initpath)
|
super(MyPlexAccount, self).__init__(self, data, initpath)
|
||||||
|
|
||||||
|
@ -175,7 +186,13 @@ class MyPlexAccount(PlexObject):
|
||||||
if response.status_code not in (200, 201, 204): # pragma: no cover
|
if response.status_code not in (200, 201, 204): # pragma: no cover
|
||||||
codename = codes.get(response.status_code)[0]
|
codename = codes.get(response.status_code)[0]
|
||||||
errtext = response.text.replace('\n', ' ')
|
errtext = response.text.replace('\n', ' ')
|
||||||
raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
|
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise Unauthorized(message)
|
||||||
|
elif response.status_code == 404:
|
||||||
|
raise NotFound(message)
|
||||||
|
else:
|
||||||
|
raise BadRequest(message)
|
||||||
data = response.text.encode('utf8')
|
data = response.text.encode('utf8')
|
||||||
return ElementTree.fromstring(data) if data.strip() else None
|
return ElementTree.fromstring(data) if data.strip() else None
|
||||||
|
|
||||||
|
@ -195,6 +212,24 @@ class MyPlexAccount(PlexObject):
|
||||||
data = self.query(MyPlexResource.key)
|
data = self.query(MyPlexResource.key)
|
||||||
return [MyPlexResource(self, elem) for elem in data]
|
return [MyPlexResource(self, elem) for elem in data]
|
||||||
|
|
||||||
|
def sonos_speakers(self):
|
||||||
|
if 'companions_sonos' not in self.subscriptionFeatures:
|
||||||
|
return []
|
||||||
|
|
||||||
|
t = time.time()
|
||||||
|
if t - self._sonos_cache_timestamp > 60:
|
||||||
|
self._sonos_cache_timestamp = t
|
||||||
|
data = self.query('https://sonos.plex.tv/resources')
|
||||||
|
self._sonos_cache = [PlexSonosClient(self, elem) for elem in data]
|
||||||
|
|
||||||
|
return self._sonos_cache
|
||||||
|
|
||||||
|
def sonos_speaker(self, name):
|
||||||
|
return [x for x in self.sonos_speakers() if x.title == name][0]
|
||||||
|
|
||||||
|
def sonos_speaker_by_id(self, identifier):
|
||||||
|
return [x for x in self.sonos_speakers() if x.machineIdentifier == identifier][0]
|
||||||
|
|
||||||
def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False,
|
def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False,
|
||||||
allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None):
|
allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None):
|
||||||
""" Share library content with the specified user.
|
""" Share library content with the specified user.
|
||||||
|
@ -384,8 +419,8 @@ class MyPlexAccount(PlexObject):
|
||||||
params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds}}
|
params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds}}
|
||||||
url = self.FRIENDSERVERS.format(machineId=machineId, serverId=serverId)
|
url = self.FRIENDSERVERS.format(machineId=machineId, serverId=serverId)
|
||||||
else:
|
else:
|
||||||
params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds,
|
params = {'server_id': machineId,
|
||||||
'invited_id': user.id}}
|
'shared_server': {'library_section_ids': sectionIds, 'invited_id': user.id}}
|
||||||
url = self.FRIENDINVITE.format(machineId=machineId)
|
url = self.FRIENDINVITE.format(machineId=machineId)
|
||||||
# Remove share sections, add shares to user without shares, or update shares
|
# Remove share sections, add shares to user without shares, or update shares
|
||||||
if not user_servers or sectionIds:
|
if not user_servers or sectionIds:
|
||||||
|
@ -429,7 +464,7 @@ class MyPlexAccount(PlexObject):
|
||||||
return user
|
return user
|
||||||
|
|
||||||
elif (user.username and user.email and user.id and username.lower() in
|
elif (user.username and user.email and user.id and username.lower() in
|
||||||
(user.username.lower(), user.email.lower(), str(user.id))):
|
(user.username.lower(), user.email.lower(), str(user.id))):
|
||||||
return user
|
return user
|
||||||
|
|
||||||
raise NotFound('Unable to find user %s' % username)
|
raise NotFound('Unable to find user %s' % username)
|
||||||
|
@ -600,6 +635,54 @@ class MyPlexAccount(PlexObject):
|
||||||
raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
|
raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
|
||||||
return response.json()['token']
|
return response.json()['token']
|
||||||
|
|
||||||
|
def history(self, maxresults=9999999, mindate=None):
|
||||||
|
""" Get Play History for all library sections on all servers for the owner.
|
||||||
|
Parameters:
|
||||||
|
maxresults (int): Only return the specified number of results (optional).
|
||||||
|
mindate (datetime): Min datetime to return results from.
|
||||||
|
"""
|
||||||
|
servers = [x for x in self.resources() if x.provides == 'server' and x.owned]
|
||||||
|
hist = []
|
||||||
|
for server in servers:
|
||||||
|
conn = server.connect()
|
||||||
|
hist.extend(conn.history(maxresults=maxresults, mindate=mindate, accountID=1))
|
||||||
|
return hist
|
||||||
|
|
||||||
|
def videoOnDemand(self):
|
||||||
|
""" Returns a list of VOD Hub items :class:`~plexapi.library.Hub`
|
||||||
|
"""
|
||||||
|
req = requests.get(self.VOD + 'hubs/', headers={'X-Plex-Token': self._token})
|
||||||
|
elem = ElementTree.fromstring(req.text)
|
||||||
|
return self.findItems(elem)
|
||||||
|
|
||||||
|
def webShows(self):
|
||||||
|
""" Returns a list of Webshow Hub items :class:`~plexapi.library.Hub`
|
||||||
|
"""
|
||||||
|
req = requests.get(self.WEBSHOWS + 'hubs/', headers={'X-Plex-Token': self._token})
|
||||||
|
elem = ElementTree.fromstring(req.text)
|
||||||
|
return self.findItems(elem)
|
||||||
|
|
||||||
|
def news(self):
|
||||||
|
""" Returns a list of News Hub items :class:`~plexapi.library.Hub`
|
||||||
|
"""
|
||||||
|
req = requests.get(self.NEWS + 'hubs/sections/all', headers={'X-Plex-Token': self._token})
|
||||||
|
elem = ElementTree.fromstring(req.text)
|
||||||
|
return self.findItems(elem)
|
||||||
|
|
||||||
|
def podcasts(self):
|
||||||
|
""" Returns a list of Podcasts Hub items :class:`~plexapi.library.Hub`
|
||||||
|
"""
|
||||||
|
req = requests.get(self.PODCASTS + 'hubs/', headers={'X-Plex-Token': self._token})
|
||||||
|
elem = ElementTree.fromstring(req.text)
|
||||||
|
return self.findItems(elem)
|
||||||
|
|
||||||
|
def tidal(self):
|
||||||
|
""" Returns a list of tidal Hub items :class:`~plexapi.library.Hub`
|
||||||
|
"""
|
||||||
|
req = requests.get(self.MUSIC + 'hubs/', headers={'X-Plex-Token': self._token})
|
||||||
|
elem = ElementTree.fromstring(req.text)
|
||||||
|
return self.findItems(elem)
|
||||||
|
|
||||||
|
|
||||||
class MyPlexUser(PlexObject):
|
class MyPlexUser(PlexObject):
|
||||||
""" This object represents non-signed in users such as friends and linked
|
""" This object represents non-signed in users such as friends and linked
|
||||||
|
@ -654,6 +737,8 @@ class MyPlexUser(PlexObject):
|
||||||
self.title = data.attrib.get('title', '')
|
self.title = data.attrib.get('title', '')
|
||||||
self.username = data.attrib.get('username', '')
|
self.username = data.attrib.get('username', '')
|
||||||
self.servers = self.findItems(data, MyPlexServerShare)
|
self.servers = self.findItems(data, MyPlexServerShare)
|
||||||
|
for server in self.servers:
|
||||||
|
server.accountID = self.id
|
||||||
|
|
||||||
def get_token(self, machineIdentifier):
|
def get_token(self, machineIdentifier):
|
||||||
try:
|
try:
|
||||||
|
@ -663,6 +748,29 @@ class MyPlexUser(PlexObject):
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception('Failed to get access token for %s' % self.title)
|
log.exception('Failed to get access token for %s' % self.title)
|
||||||
|
|
||||||
|
def server(self, name):
|
||||||
|
""" Returns the :class:`~plexapi.myplex.MyPlexServerShare` that matches the name specified.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
name (str): Name of the server to return.
|
||||||
|
"""
|
||||||
|
for server in self.servers:
|
||||||
|
if name.lower() == server.name.lower():
|
||||||
|
return server
|
||||||
|
|
||||||
|
raise NotFound('Unable to find server %s' % name)
|
||||||
|
|
||||||
|
def history(self, maxresults=9999999, mindate=None):
|
||||||
|
""" Get all Play History for a user in all shared servers.
|
||||||
|
Parameters:
|
||||||
|
maxresults (int): Only return the specified number of results (optional).
|
||||||
|
mindate (datetime): Min datetime to return results from.
|
||||||
|
"""
|
||||||
|
hist = []
|
||||||
|
for server in self.servers:
|
||||||
|
hist.extend(server.history(maxresults=maxresults, mindate=mindate))
|
||||||
|
return hist
|
||||||
|
|
||||||
|
|
||||||
class Section(PlexObject):
|
class Section(PlexObject):
|
||||||
""" This refers to a shared section. The raw xml for the data presented here
|
""" This refers to a shared section. The raw xml for the data presented here
|
||||||
|
@ -689,6 +797,16 @@ class Section(PlexObject):
|
||||||
self.type = data.attrib.get('type')
|
self.type = data.attrib.get('type')
|
||||||
self.shared = utils.cast(bool, data.attrib.get('shared'))
|
self.shared = utils.cast(bool, data.attrib.get('shared'))
|
||||||
|
|
||||||
|
def history(self, maxresults=9999999, mindate=None):
|
||||||
|
""" Get all Play History for a user for this section in this shared server.
|
||||||
|
Parameters:
|
||||||
|
maxresults (int): Only return the specified number of results (optional).
|
||||||
|
mindate (datetime): Min datetime to return results from.
|
||||||
|
"""
|
||||||
|
server = self._server._server.resource(self._server.name).connect()
|
||||||
|
return server.history(maxresults=maxresults, mindate=mindate,
|
||||||
|
accountID=self._server.accountID, librarySectionID=self.sectionKey)
|
||||||
|
|
||||||
|
|
||||||
class MyPlexServerShare(PlexObject):
|
class MyPlexServerShare(PlexObject):
|
||||||
""" Represents a single user's server reference. Used for library sharing.
|
""" Represents a single user's server reference. Used for library sharing.
|
||||||
|
@ -711,6 +829,7 @@ class MyPlexServerShare(PlexObject):
|
||||||
""" Load attribute values from Plex XML response. """
|
""" Load attribute values from Plex XML response. """
|
||||||
self._data = data
|
self._data = data
|
||||||
self.id = utils.cast(int, data.attrib.get('id'))
|
self.id = utils.cast(int, data.attrib.get('id'))
|
||||||
|
self.accountID = utils.cast(int, data.attrib.get('accountID'))
|
||||||
self.serverId = utils.cast(int, data.attrib.get('serverId'))
|
self.serverId = utils.cast(int, data.attrib.get('serverId'))
|
||||||
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
||||||
self.name = data.attrib.get('name')
|
self.name = data.attrib.get('name')
|
||||||
|
@ -720,7 +839,21 @@ class MyPlexServerShare(PlexObject):
|
||||||
self.owned = utils.cast(bool, data.attrib.get('owned'))
|
self.owned = utils.cast(bool, data.attrib.get('owned'))
|
||||||
self.pending = utils.cast(bool, data.attrib.get('pending'))
|
self.pending = utils.cast(bool, data.attrib.get('pending'))
|
||||||
|
|
||||||
|
def section(self, name):
|
||||||
|
""" Returns the :class:`~plexapi.myplex.Section` that matches the name specified.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
name (str): Name of the section to return.
|
||||||
|
"""
|
||||||
|
for section in self.sections():
|
||||||
|
if name.lower() == section.title.lower():
|
||||||
|
return section
|
||||||
|
|
||||||
|
raise NotFound('Unable to find section %s' % name)
|
||||||
|
|
||||||
def sections(self):
|
def sections(self):
|
||||||
|
""" Returns a list of all :class:`~plexapi.myplex.Section` objects shared with this user.
|
||||||
|
"""
|
||||||
url = MyPlexAccount.FRIENDSERVERS.format(machineId=self.machineIdentifier, serverId=self.id)
|
url = MyPlexAccount.FRIENDSERVERS.format(machineId=self.machineIdentifier, serverId=self.id)
|
||||||
data = self._server.query(url)
|
data = self._server.query(url)
|
||||||
sections = []
|
sections = []
|
||||||
|
@ -731,6 +864,15 @@ class MyPlexServerShare(PlexObject):
|
||||||
|
|
||||||
return sections
|
return sections
|
||||||
|
|
||||||
|
def history(self, maxresults=9999999, mindate=None):
|
||||||
|
""" Get all Play History for a user in this shared server.
|
||||||
|
Parameters:
|
||||||
|
maxresults (int): Only return the specified number of results (optional).
|
||||||
|
mindate (datetime): Min datetime to return results from.
|
||||||
|
"""
|
||||||
|
server = self._server.resource(self.name).connect()
|
||||||
|
return server.history(maxresults=maxresults, mindate=mindate, accountID=self.accountID)
|
||||||
|
|
||||||
|
|
||||||
class MyPlexResource(PlexObject):
|
class MyPlexResource(PlexObject):
|
||||||
""" This object represents resources connected to your Plex server that can provide
|
""" This object represents resources connected to your Plex server that can provide
|
||||||
|
@ -932,6 +1074,186 @@ class MyPlexDevice(PlexObject):
|
||||||
return self._server.syncItems(client=self)
|
return self._server.syncItems(client=self)
|
||||||
|
|
||||||
|
|
||||||
|
class MyPlexPinLogin(object):
|
||||||
|
"""
|
||||||
|
MyPlex PIN login class which supports getting the four character PIN which the user must
|
||||||
|
enter on https://plex.tv/link to authenticate the client and provide an access token to
|
||||||
|
create a :class:`~plexapi.myplex.MyPlexAccount` instance.
|
||||||
|
This helper class supports a polling, threaded and callback approach.
|
||||||
|
|
||||||
|
- The polling approach expects the developer to periodically check if the PIN login was
|
||||||
|
successful using :func:`plexapi.myplex.MyPlexPinLogin.checkLogin`.
|
||||||
|
- The threaded approach expects the developer to call
|
||||||
|
:func:`plexapi.myplex.MyPlexPinLogin.run` and then at a later time call
|
||||||
|
:func:`plexapi.myplex.MyPlexPinLogin.waitForLogin` to wait for and check the result.
|
||||||
|
- The callback approach is an extension of the threaded approach and expects the developer
|
||||||
|
to pass the `callback` parameter to the call to :func:`plexapi.myplex.MyPlexPinLogin.run`.
|
||||||
|
The callback will be called when the thread waiting for the PIN login to succeed either
|
||||||
|
finishes or expires. The parameter passed to the callback is the received authentication
|
||||||
|
token or `None` if the login expired.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
session (requests.Session, optional): Use your own session object if you want to
|
||||||
|
cache the http responses from PMS
|
||||||
|
requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
PINS (str): 'https://plex.tv/pins.xml'
|
||||||
|
CHECKPINS (str): 'https://plex.tv/pins/{pinid}.xml'
|
||||||
|
POLLINTERVAL (int): 1
|
||||||
|
finished (bool): Whether the pin login has finished or not.
|
||||||
|
expired (bool): Whether the pin login has expired or not.
|
||||||
|
token (str): Token retrieved through the pin login.
|
||||||
|
pin (str): Pin to use for the login on https://plex.tv/link.
|
||||||
|
"""
|
||||||
|
PINS = 'https://plex.tv/pins.xml' # get
|
||||||
|
CHECKPINS = 'https://plex.tv/pins/{pinid}.xml' # get
|
||||||
|
POLLINTERVAL = 1
|
||||||
|
|
||||||
|
def __init__(self, session=None, requestTimeout=None):
|
||||||
|
super(MyPlexPinLogin, self).__init__()
|
||||||
|
self._session = session or requests.Session()
|
||||||
|
self._requestTimeout = requestTimeout or TIMEOUT
|
||||||
|
|
||||||
|
self._loginTimeout = None
|
||||||
|
self._callback = None
|
||||||
|
self._thread = None
|
||||||
|
self._abort = False
|
||||||
|
self._id = None
|
||||||
|
|
||||||
|
self.finished = False
|
||||||
|
self.expired = False
|
||||||
|
self.token = None
|
||||||
|
self.pin = self._getPin()
|
||||||
|
|
||||||
|
def run(self, callback=None, timeout=None):
|
||||||
|
""" Starts the thread which monitors the PIN login state.
|
||||||
|
Parameters:
|
||||||
|
callback (Callable[str]): Callback called with the received authentication token (optional).
|
||||||
|
timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
:class:`RuntimeError`: if the thread is already running.
|
||||||
|
:class:`RuntimeError`: if the PIN login for the current PIN has expired.
|
||||||
|
"""
|
||||||
|
if self._thread and not self._abort:
|
||||||
|
raise RuntimeError('MyPlexPinLogin thread is already running')
|
||||||
|
if self.expired:
|
||||||
|
raise RuntimeError('MyPlexPinLogin has expired')
|
||||||
|
|
||||||
|
self._loginTimeout = timeout
|
||||||
|
self._callback = callback
|
||||||
|
self._abort = False
|
||||||
|
self.finished = False
|
||||||
|
self._thread = threading.Thread(target=self._pollLogin, name='plexapi.myplex.MyPlexPinLogin')
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def waitForLogin(self):
|
||||||
|
""" Waits for the PIN login to succeed or expire.
|
||||||
|
Parameters:
|
||||||
|
callback (Callable[str]): Callback called with the received authentication token (optional).
|
||||||
|
timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`True` if the PIN login succeeded or `False` otherwise.
|
||||||
|
"""
|
||||||
|
if not self._thread or self._abort:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._thread.join()
|
||||||
|
if self.expired or not self.token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
""" Stops the thread monitoring the PIN login state. """
|
||||||
|
if not self._thread or self._abort:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._abort = True
|
||||||
|
self._thread.join()
|
||||||
|
|
||||||
|
def checkLogin(self):
|
||||||
|
""" Returns `True` if the PIN login has succeeded. """
|
||||||
|
if self._thread:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._checkLogin()
|
||||||
|
except Exception:
|
||||||
|
self.expired = True
|
||||||
|
self.finished = True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _getPin(self):
|
||||||
|
if self.pin:
|
||||||
|
return self.pin
|
||||||
|
|
||||||
|
url = self.PINS
|
||||||
|
response = self._query(url, self._session.post)
|
||||||
|
if not response:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._id = response.find('id').text
|
||||||
|
self.pin = response.find('code').text
|
||||||
|
|
||||||
|
return self.pin
|
||||||
|
|
||||||
|
def _checkLogin(self):
|
||||||
|
if not self._id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.token:
|
||||||
|
return True
|
||||||
|
|
||||||
|
url = self.CHECKPINS.format(pinid=self._id)
|
||||||
|
response = self._query(url)
|
||||||
|
if not response:
|
||||||
|
return False
|
||||||
|
|
||||||
|
token = response.find('auth_token').text
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.token = token
|
||||||
|
self.finished = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _pollLogin(self):
|
||||||
|
try:
|
||||||
|
start = time.time()
|
||||||
|
while not self._abort and (not self._loginTimeout or (time.time() - start) < self._loginTimeout):
|
||||||
|
try:
|
||||||
|
result = self._checkLogin()
|
||||||
|
except Exception:
|
||||||
|
self.expired = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if result:
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(self.POLLINTERVAL)
|
||||||
|
|
||||||
|
if self.token and self._callback:
|
||||||
|
self._callback(self.token)
|
||||||
|
finally:
|
||||||
|
self.finished = True
|
||||||
|
|
||||||
|
def _query(self, url, method=None):
|
||||||
|
method = method or self._session.get
|
||||||
|
log.debug('%s %s', method.__name__.upper(), url)
|
||||||
|
headers = BASE_HEADERS.copy()
|
||||||
|
response = method(url, headers=headers, timeout=self._requestTimeout)
|
||||||
|
if not response.ok: # pragma: no cover
|
||||||
|
codename = codes.get(response.status_code)[0]
|
||||||
|
errtext = response.text.replace('\n', ' ')
|
||||||
|
raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
|
||||||
|
data = response.text.encode('utf8')
|
||||||
|
return ElementTree.fromstring(data) if data.strip() else None
|
||||||
|
|
||||||
|
|
||||||
def _connect(cls, url, token, timeout, results, i, job_is_done_event=None):
|
def _connect(cls, url, token, timeout, results, i, job_is_done_event=None):
|
||||||
""" Connects to the specified cls with url and token. Stores the connection
|
""" Connects to the specified cls with url and token. Stores the connection
|
||||||
information to results[i] in a threadsafe way.
|
information to results[i] in a threadsafe way.
|
||||||
|
|
|
@ -117,6 +117,7 @@ class Photo(PlexPartialObject):
|
||||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||||
self.year = utils.cast(int, data.attrib.get('year'))
|
self.year = utils.cast(int, data.attrib.get('year'))
|
||||||
self.media = self.findItems(data, media.Media)
|
self.media = self.findItems(data, media.Media)
|
||||||
|
self.tag = self.findItems(data, media.Tag)
|
||||||
|
|
||||||
def photoalbum(self):
|
def photoalbum(self):
|
||||||
""" Return this photo's :class:`~plexapi.photo.Photoalbum`. """
|
""" Return this photo's :class:`~plexapi.photo.Photoalbum`. """
|
||||||
|
|
|
@ -268,3 +268,41 @@ class Playlist(PlexPartialObject, Playable):
|
||||||
raise Unsupported('Unsupported playlist content')
|
raise Unsupported('Unsupported playlist content')
|
||||||
|
|
||||||
return myplex.sync(sync_item, client=client, clientId=clientId)
|
return myplex.sync(sync_item, client=client, clientId=clientId)
|
||||||
|
|
||||||
|
def posters(self):
|
||||||
|
""" Returns list of available poster objects. :class:`~plexapi.media.Poster`. """
|
||||||
|
|
||||||
|
return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey)
|
||||||
|
|
||||||
|
def uploadPoster(self, url=None, filepath=None):
|
||||||
|
""" Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
|
||||||
|
if url:
|
||||||
|
key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url))
|
||||||
|
self._server.query(key, method=self._server._session.post)
|
||||||
|
elif filepath:
|
||||||
|
key = '/library/metadata/%s/posters?' % self.ratingKey
|
||||||
|
data = open(filepath, 'rb').read()
|
||||||
|
self._server.query(key, method=self._server._session.post, data=data)
|
||||||
|
|
||||||
|
def setPoster(self, poster):
|
||||||
|
""" Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
|
||||||
|
poster.select()
|
||||||
|
|
||||||
|
def arts(self):
|
||||||
|
""" Returns list of available art objects. :class:`~plexapi.media.Poster`. """
|
||||||
|
|
||||||
|
return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey)
|
||||||
|
|
||||||
|
def uploadArt(self, url=None, filepath=None):
|
||||||
|
""" Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """
|
||||||
|
if url:
|
||||||
|
key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url))
|
||||||
|
self._server.query(key, method=self._server._session.post)
|
||||||
|
elif filepath:
|
||||||
|
key = '/library/metadata/%s/arts?' % self.ratingKey
|
||||||
|
data = open(filepath, 'rb').read()
|
||||||
|
self._server.query(key, method=self._server._session.post, data=data)
|
||||||
|
|
||||||
|
def setArt(self, art):
|
||||||
|
""" Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """
|
||||||
|
art.select()
|
||||||
|
|
|
@ -7,12 +7,13 @@ from plexapi.alert import AlertListener
|
||||||
from plexapi.base import PlexObject
|
from plexapi.base import PlexObject
|
||||||
from plexapi.client import PlexClient
|
from plexapi.client import PlexClient
|
||||||
from plexapi.compat import ElementTree, urlencode
|
from plexapi.compat import ElementTree, urlencode
|
||||||
from plexapi.exceptions import BadRequest, NotFound
|
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||||
from plexapi.library import Library, Hub
|
from plexapi.library import Library, Hub
|
||||||
from plexapi.settings import Settings
|
from plexapi.settings import Settings
|
||||||
from plexapi.playlist import Playlist
|
from plexapi.playlist import Playlist
|
||||||
from plexapi.playqueue import PlayQueue
|
from plexapi.playqueue import PlayQueue
|
||||||
from plexapi.utils import cast
|
from plexapi.utils import cast
|
||||||
|
from plexapi.media import Optimized, Conversion
|
||||||
|
|
||||||
# Need these imports to populate utils.PLEXOBJECTS
|
# Need these imports to populate utils.PLEXOBJECTS
|
||||||
from plexapi import (audio as _audio, video as _video, # noqa: F401
|
from plexapi import (audio as _audio, video as _video, # noqa: F401
|
||||||
|
@ -183,8 +184,18 @@ class PlexServer(PlexObject):
|
||||||
data = self.query(Account.key)
|
data = self.query(Account.key)
|
||||||
return Account(self, data)
|
return Account(self, data)
|
||||||
|
|
||||||
|
def agents(self, mediaType=None):
|
||||||
|
""" Returns the `:class:`~plexapi.media.Agent` objects this server has available. """
|
||||||
|
key = '/system/agents'
|
||||||
|
if mediaType:
|
||||||
|
key += '?mediaType=%s' % mediaType
|
||||||
|
return self.fetchItems(key)
|
||||||
|
|
||||||
def createToken(self, type='delegation', scope='all'):
|
def createToken(self, type='delegation', scope='all'):
|
||||||
"""Create a temp access token for the server."""
|
"""Create a temp access token for the server."""
|
||||||
|
if not self._token:
|
||||||
|
# Handle unclaimed servers
|
||||||
|
return None
|
||||||
q = self.query('/security/token?type=%s&scope=%s' % (type, scope))
|
q = self.query('/security/token?type=%s&scope=%s' % (type, scope))
|
||||||
return q.attrib.get('token')
|
return q.attrib.get('token')
|
||||||
|
|
||||||
|
@ -322,7 +333,7 @@ class PlexServer(PlexObject):
|
||||||
# figure out what method this is..
|
# figure out what method this is..
|
||||||
return self.query(part, method=self._session.put)
|
return self.query(part, method=self._session.put)
|
||||||
|
|
||||||
def history(self, maxresults=9999999, mindate=None):
|
def history(self, maxresults=9999999, mindate=None, ratingKey=None, accountID=None, librarySectionID=None):
|
||||||
""" Returns a list of media items from watched history. If there are many results, they will
|
""" Returns a list of media items from watched history. If there are many results, they will
|
||||||
be fetched from the server in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only
|
be fetched from the server in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only
|
||||||
looking for the first <num> results, it would be wise to set the maxresults option to that
|
looking for the first <num> results, it would be wise to set the maxresults option to that
|
||||||
|
@ -332,9 +343,18 @@ class PlexServer(PlexObject):
|
||||||
maxresults (int): Only return the specified number of results (optional).
|
maxresults (int): Only return the specified number of results (optional).
|
||||||
mindate (datetime): Min datetime to return results from. This really helps speed
|
mindate (datetime): Min datetime to return results from. This really helps speed
|
||||||
up the result listing. For example: datetime.now() - timedelta(days=7)
|
up the result listing. For example: datetime.now() - timedelta(days=7)
|
||||||
|
ratingKey (int/str) Request history for a specific ratingKey item.
|
||||||
|
accountID (int/str) Request history for a specific account ID.
|
||||||
|
librarySectionID (int/str) Request history for a specific library section ID.
|
||||||
"""
|
"""
|
||||||
results, subresults = [], '_init'
|
results, subresults = [], '_init'
|
||||||
args = {'sort': 'viewedAt:desc'}
|
args = {'sort': 'viewedAt:desc'}
|
||||||
|
if ratingKey:
|
||||||
|
args['metadataItemID'] = ratingKey
|
||||||
|
if accountID:
|
||||||
|
args['accountID'] = accountID
|
||||||
|
if librarySectionID:
|
||||||
|
args['librarySectionID'] = librarySectionID
|
||||||
if mindate:
|
if mindate:
|
||||||
args['viewedAt>'] = int(mindate.timestamp())
|
args['viewedAt>'] = int(mindate.timestamp())
|
||||||
args['X-Plex-Container-Start'] = 0
|
args['X-Plex-Container-Start'] = 0
|
||||||
|
@ -363,6 +383,36 @@ class PlexServer(PlexObject):
|
||||||
"""
|
"""
|
||||||
return self.fetchItem('/playlists', title=title)
|
return self.fetchItem('/playlists', title=title)
|
||||||
|
|
||||||
|
def optimizedItems(self, removeAll=None):
|
||||||
|
""" Returns list of all :class:`~plexapi.media.Optimized` objects connected to server. """
|
||||||
|
if removeAll is True:
|
||||||
|
key = '/playlists/generators?type=42'
|
||||||
|
self.query(key, method=self._server._session.delete)
|
||||||
|
else:
|
||||||
|
backgroundProcessing = self.fetchItem('/playlists?type=42')
|
||||||
|
return self.fetchItems('%s/items' % backgroundProcessing.key, cls=Optimized)
|
||||||
|
|
||||||
|
def optimizedItem(self, optimizedID):
|
||||||
|
""" Returns single queued optimized item :class:`~plexapi.media.Video` object.
|
||||||
|
Allows for using optimized item ID to connect back to source item.
|
||||||
|
"""
|
||||||
|
|
||||||
|
backgroundProcessing = self.fetchItem('/playlists?type=42')
|
||||||
|
return self.fetchItem('%s/items/%s/items' % (backgroundProcessing.key, optimizedID))
|
||||||
|
|
||||||
|
def conversions(self, pause=None):
|
||||||
|
""" Returns list of all :class:`~plexapi.media.Conversion` objects connected to server. """
|
||||||
|
if pause is True:
|
||||||
|
self.query('/:/prefs?BackgroundQueueIdlePaused=1', method=self._server._session.put)
|
||||||
|
elif pause is False:
|
||||||
|
self.query('/:/prefs?BackgroundQueueIdlePaused=0', method=self._server._session.put)
|
||||||
|
else:
|
||||||
|
return self.fetchItems('/playQueues/1', cls=Conversion)
|
||||||
|
|
||||||
|
def currentBackgroundProcess(self):
|
||||||
|
""" Returns list of all :class:`~plexapi.media.TranscodeJob` objects running or paused on server. """
|
||||||
|
return self.fetchItems('/status/sessions/background')
|
||||||
|
|
||||||
def query(self, key, method=None, headers=None, timeout=None, **kwargs):
|
def query(self, key, method=None, headers=None, timeout=None, **kwargs):
|
||||||
""" Main method used to handle HTTPS requests to the Plex server. This method helps
|
""" Main method used to handle HTTPS requests to the Plex server. This method helps
|
||||||
by encoding the response to utf-8 and parsing the returned XML into and
|
by encoding the response to utf-8 and parsing the returned XML into and
|
||||||
|
@ -377,8 +427,13 @@ class PlexServer(PlexObject):
|
||||||
if response.status_code not in (200, 201):
|
if response.status_code not in (200, 201):
|
||||||
codename = codes.get(response.status_code)[0]
|
codename = codes.get(response.status_code)[0]
|
||||||
errtext = response.text.replace('\n', ' ')
|
errtext = response.text.replace('\n', ' ')
|
||||||
log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
|
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
|
||||||
raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext))
|
if response.status_code == 401:
|
||||||
|
raise Unauthorized(message)
|
||||||
|
elif response.status_code == 404:
|
||||||
|
raise NotFound(message)
|
||||||
|
else:
|
||||||
|
raise BadRequest(message)
|
||||||
data = response.text.encode('utf8')
|
data = response.text.encode('utf8')
|
||||||
return ElementTree.fromstring(data) if data.strip() else None
|
return ElementTree.fromstring(data) if data.strip() else None
|
||||||
|
|
||||||
|
@ -472,6 +527,25 @@ class PlexServer(PlexObject):
|
||||||
self.refreshSynclist()
|
self.refreshSynclist()
|
||||||
self.refreshContent()
|
self.refreshContent()
|
||||||
|
|
||||||
|
def _allowMediaDeletion(self, toggle=False):
|
||||||
|
""" Toggle allowMediaDeletion.
|
||||||
|
Parameters:
|
||||||
|
toggle (bool): True enables Media Deletion
|
||||||
|
False or None disable Media Deletion (Default)
|
||||||
|
"""
|
||||||
|
if self.allowMediaDeletion and toggle is False:
|
||||||
|
log.debug('Plex is currently allowed to delete media. Toggling off.')
|
||||||
|
elif self.allowMediaDeletion and toggle is True:
|
||||||
|
log.debug('Plex is currently allowed to delete media. Toggle set to allow, exiting.')
|
||||||
|
raise BadRequest('Plex is currently allowed to delete media. Toggle set to allow, exiting.')
|
||||||
|
elif self.allowMediaDeletion is None and toggle is True:
|
||||||
|
log.debug('Plex is currently not allowed to delete media. Toggle set to allow.')
|
||||||
|
else:
|
||||||
|
log.debug('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.')
|
||||||
|
raise BadRequest('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.')
|
||||||
|
value = 1 if toggle is True else 0
|
||||||
|
return self.query('/:/prefs?allowMediaDeletion=%s' % value, self._session.put)
|
||||||
|
|
||||||
|
|
||||||
class Account(PlexObject):
|
class Account(PlexObject):
|
||||||
""" Contains the locally cached MyPlex account information. The properties provided don't
|
""" Contains the locally cached MyPlex account information. The properties provided don't
|
||||||
|
|
|
@ -124,8 +124,8 @@ class Setting(PlexObject):
|
||||||
self.enumValues = self._getEnumValues(data)
|
self.enumValues = self._getEnumValues(data)
|
||||||
|
|
||||||
def _cast(self, value):
|
def _cast(self, value):
|
||||||
""" Cast the specifief value to the type of this setting. """
|
""" Cast the specific value to the type of this setting. """
|
||||||
if self.type != 'text':
|
if self.type != 'enum':
|
||||||
value = utils.cast(self.TYPES.get(self.type)['cast'], value)
|
value = utils.cast(self.TYPES.get(self.type)['cast'], value)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
116
lib/plexapi/sonos.py
Normal file
116
lib/plexapi/sonos.py
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import requests
|
||||||
|
from plexapi import CONFIG, X_PLEX_IDENTIFIER
|
||||||
|
from plexapi.client import PlexClient
|
||||||
|
from plexapi.exceptions import BadRequest
|
||||||
|
from plexapi.playqueue import PlayQueue
|
||||||
|
|
||||||
|
|
||||||
|
class PlexSonosClient(PlexClient):
|
||||||
|
""" Class for interacting with a Sonos speaker via the Plex API. This class
|
||||||
|
makes requests to an external Plex API which then forwards the
|
||||||
|
Sonos-specific commands back to your Plex server & Sonos speakers. Use
|
||||||
|
of this feature requires an active Plex Pass subscription and Sonos
|
||||||
|
speakers linked to your Plex account. It also requires remote access to
|
||||||
|
be working properly.
|
||||||
|
|
||||||
|
More details on the Sonos integration are avaialble here:
|
||||||
|
https://support.plex.tv/articles/218237558-requirements-for-using-plex-for-sonos/
|
||||||
|
|
||||||
|
The Sonos API emulates the Plex player control API closely:
|
||||||
|
https://github.com/plexinc/plex-media-player/wiki/Remote-control-API
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
account (:class:`~plexapi.myplex.PlexAccount`): PlexAccount instance this
|
||||||
|
Sonos speaker is associated with.
|
||||||
|
data (ElementTree): Response from Plex Sonos API used to build this client.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
deviceClass (str): "speaker"
|
||||||
|
lanIP (str): Local IP address of speaker.
|
||||||
|
machineIdentifier (str): Unique ID for this device.
|
||||||
|
platform (str): "Sonos"
|
||||||
|
platformVersion (str): Build version of Sonos speaker firmware.
|
||||||
|
product (str): "Sonos"
|
||||||
|
protocol (str): "plex"
|
||||||
|
protocolCapabilities (list<str>): List of client capabilities (timeline, playback,
|
||||||
|
playqueues, provider-playback)
|
||||||
|
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
|
||||||
|
session (:class:`~requests.Session`): Session object used for connection.
|
||||||
|
title (str): Name of this Sonos speaker.
|
||||||
|
token (str): X-Plex-Token used for authenication
|
||||||
|
_baseurl (str): Address of public Plex Sonos API endpoint.
|
||||||
|
_commandId (int): Counter for commands sent to Plex API.
|
||||||
|
_token (str): Token associated with linked Plex account.
|
||||||
|
_session (obj): Requests session object used to access this client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, account, data):
|
||||||
|
self._data = data
|
||||||
|
self.deviceClass = data.attrib.get("deviceClass")
|
||||||
|
self.machineIdentifier = data.attrib.get("machineIdentifier")
|
||||||
|
self.product = data.attrib.get("product")
|
||||||
|
self.platform = data.attrib.get("platform")
|
||||||
|
self.platformVersion = data.attrib.get("platformVersion")
|
||||||
|
self.protocol = data.attrib.get("protocol")
|
||||||
|
self.protocolCapabilities = data.attrib.get("protocolCapabilities")
|
||||||
|
self.lanIP = data.attrib.get("lanIP")
|
||||||
|
self.title = data.attrib.get("title")
|
||||||
|
self._baseurl = "https://sonos.plex.tv"
|
||||||
|
self._commandId = 0
|
||||||
|
self._token = account._token
|
||||||
|
self._session = account._session or requests.Session()
|
||||||
|
|
||||||
|
# Dummy values for PlexClient inheritance
|
||||||
|
self._last_call = 0
|
||||||
|
self._proxyThroughServer = False
|
||||||
|
self._showSecrets = CONFIG.get("log.show_secrets", "").lower() == "true"
|
||||||
|
|
||||||
|
def playMedia(self, media, offset=0, **params):
|
||||||
|
|
||||||
|
if hasattr(media, "playlistType"):
|
||||||
|
mediatype = media.playlistType
|
||||||
|
else:
|
||||||
|
if isinstance(media, PlayQueue):
|
||||||
|
mediatype = media.items[0].listType
|
||||||
|
else:
|
||||||
|
mediatype = media.listType
|
||||||
|
|
||||||
|
if mediatype == "audio":
|
||||||
|
mediatype = "music"
|
||||||
|
else:
|
||||||
|
raise BadRequest("Sonos currently only supports music for playback")
|
||||||
|
|
||||||
|
server_protocol, server_address, server_port = media._server._baseurl.split(":")
|
||||||
|
server_address = server_address.strip("/")
|
||||||
|
server_port = server_port.strip("/")
|
||||||
|
|
||||||
|
playqueue = (
|
||||||
|
media
|
||||||
|
if isinstance(media, PlayQueue)
|
||||||
|
else media._server.createPlayQueue(media)
|
||||||
|
)
|
||||||
|
self.sendCommand(
|
||||||
|
"playback/playMedia",
|
||||||
|
**dict(
|
||||||
|
{
|
||||||
|
"type": "music",
|
||||||
|
"providerIdentifier": "com.plexapp.plugins.library",
|
||||||
|
"containerKey": "/playQueues/{}?own=1".format(
|
||||||
|
playqueue.playQueueID
|
||||||
|
),
|
||||||
|
"key": media.key,
|
||||||
|
"offset": offset,
|
||||||
|
"machineIdentifier": media._server.machineIdentifier,
|
||||||
|
"protocol": server_protocol,
|
||||||
|
"address": server_address,
|
||||||
|
"port": server_port,
|
||||||
|
"token": media._server.createToken(),
|
||||||
|
"commandID": self._nextCommandId(),
|
||||||
|
"X-Plex-Client-Identifier": X_PLEX_IDENTIFIER,
|
||||||
|
"X-Plex-Token": media._server._token,
|
||||||
|
"X-Plex-Target-Client-Identifier": self.machineIdentifier,
|
||||||
|
},
|
||||||
|
**params
|
||||||
|
)
|
||||||
|
)
|
|
@ -2,16 +2,21 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import requests
|
|
||||||
import time
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
from threading import Thread, Event
|
from threading import Event, Thread
|
||||||
from tqdm import tqdm
|
|
||||||
|
import requests
|
||||||
from plexapi import compat
|
from plexapi import compat
|
||||||
from plexapi.exceptions import NotFound
|
from plexapi.exceptions import NotFound
|
||||||
|
|
||||||
|
try:
|
||||||
|
from tqdm import tqdm
|
||||||
|
except ImportError:
|
||||||
|
tqdm = None
|
||||||
|
|
||||||
log = logging.getLogger('plexapi')
|
log = logging.getLogger('plexapi')
|
||||||
|
|
||||||
# Search Types - Plex uses these to filter specific media types when searching.
|
# Search Types - Plex uses these to filter specific media types when searching.
|
||||||
|
@ -59,7 +64,7 @@ def registerPlexObject(cls):
|
||||||
|
|
||||||
def cast(func, value):
|
def cast(func, value):
|
||||||
""" Cast the specified value to the specified type (returned by func). Currently this
|
""" Cast the specified value to the specified type (returned by func). Currently this
|
||||||
only support int, float, bool. Should be extended if needed.
|
only support str, int, float, bool. Should be extended if needed.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
func (func): Calback function to used cast to type (int, bool, float).
|
func (func): Calback function to used cast to type (int, bool, float).
|
||||||
|
@ -67,7 +72,13 @@ def cast(func, value):
|
||||||
"""
|
"""
|
||||||
if value is not None:
|
if value is not None:
|
||||||
if func == bool:
|
if func == bool:
|
||||||
return bool(int(value))
|
if value in (1, True, "1", "true"):
|
||||||
|
return True
|
||||||
|
elif value in (0, False, "0", "false"):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
raise ValueError(value)
|
||||||
|
|
||||||
elif func in (int, float):
|
elif func in (int, float):
|
||||||
try:
|
try:
|
||||||
return func(value)
|
return func(value)
|
||||||
|
@ -89,7 +100,7 @@ def joinArgs(args):
|
||||||
arglist = []
|
arglist = []
|
||||||
for key in sorted(args, key=lambda x: x.lower()):
|
for key in sorted(args, key=lambda x: x.lower()):
|
||||||
value = compat.ustr(args[key])
|
value = compat.ustr(args[key])
|
||||||
arglist.append('%s=%s' % (key, compat.quote(value)))
|
arglist.append('%s=%s' % (key, compat.quote(value, safe='')))
|
||||||
return '?%s' % '&'.join(arglist)
|
return '?%s' % '&'.join(arglist)
|
||||||
|
|
||||||
|
|
||||||
|
@ -287,17 +298,17 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4
|
||||||
|
|
||||||
# save the file to disk
|
# save the file to disk
|
||||||
log.info('Downloading: %s', fullpath)
|
log.info('Downloading: %s', fullpath)
|
||||||
if showstatus: # pragma: no cover
|
if showstatus and tqdm: # pragma: no cover
|
||||||
total = int(response.headers.get('content-length', 0))
|
total = int(response.headers.get('content-length', 0))
|
||||||
bar = tqdm(unit='B', unit_scale=True, total=total, desc=filename)
|
bar = tqdm(unit='B', unit_scale=True, total=total, desc=filename)
|
||||||
|
|
||||||
with open(fullpath, 'wb') as handle:
|
with open(fullpath, 'wb') as handle:
|
||||||
for chunk in response.iter_content(chunk_size=chunksize):
|
for chunk in response.iter_content(chunk_size=chunksize):
|
||||||
handle.write(chunk)
|
handle.write(chunk)
|
||||||
if showstatus:
|
if showstatus and tqdm:
|
||||||
bar.update(len(chunk))
|
bar.update(len(chunk))
|
||||||
|
|
||||||
if showstatus: # pragma: no cover
|
if showstatus and tqdm: # pragma: no cover
|
||||||
bar.close()
|
bar.close()
|
||||||
# check we want to unzip the contents
|
# check we want to unzip the contents
|
||||||
if fullpath.endswith('zip') and unpack:
|
if fullpath.endswith('zip') and unpack:
|
||||||
|
@ -375,3 +386,15 @@ def choose(msg, items, attr): # pragma: no cover
|
||||||
|
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def getAgentIdentifier(section, agent):
|
||||||
|
""" Return the full agent identifier from a short identifier, name, or confirm full identifier. """
|
||||||
|
agents = []
|
||||||
|
for ag in section.agents():
|
||||||
|
identifiers = [ag.identifier, ag.shortIdentifier, ag.name]
|
||||||
|
if agent in identifiers:
|
||||||
|
return ag.identifier
|
||||||
|
agents += identifiers
|
||||||
|
raise NotFound('Couldnt find "%s" in agents list (%s)' %
|
||||||
|
(agent, ', '.join(agents)))
|
||||||
|
|
|
@ -2,7 +2,8 @@
|
||||||
from plexapi import media, utils
|
from plexapi import media, utils
|
||||||
from plexapi.exceptions import BadRequest, NotFound
|
from plexapi.exceptions import BadRequest, NotFound
|
||||||
from plexapi.base import Playable, PlexPartialObject
|
from plexapi.base import Playable, PlexPartialObject
|
||||||
from plexapi.compat import quote_plus
|
from plexapi.compat import quote_plus, urlencode
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
class Video(PlexPartialObject):
|
class Video(PlexPartialObject):
|
||||||
|
@ -89,10 +90,112 @@ class Video(PlexPartialObject):
|
||||||
""" Returns str, default title for a new syncItem. """
|
""" Returns str, default title for a new syncItem. """
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
def posters(self):
|
def subtitleStreams(self):
|
||||||
""" Returns list of available poster objects. :class:`~plexapi.media.Poster`:"""
|
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """
|
||||||
|
streams = []
|
||||||
|
|
||||||
return self.fetchItems('%s/posters' % self.key, cls=media.Poster)
|
parts = self.iterParts()
|
||||||
|
for part in parts:
|
||||||
|
streams += part.subtitleStreams()
|
||||||
|
return streams
|
||||||
|
|
||||||
|
def uploadSubtitles(self, filepath):
|
||||||
|
""" Upload Subtitle file for video. """
|
||||||
|
url = '%s/subtitles' % self.key
|
||||||
|
filename = os.path.basename(filepath)
|
||||||
|
subFormat = os.path.splitext(filepath)[1][1:]
|
||||||
|
with open(filepath, 'rb') as subfile:
|
||||||
|
params = {'title': filename,
|
||||||
|
'format': subFormat
|
||||||
|
}
|
||||||
|
headers = {'Accept': 'text/plain, */*'}
|
||||||
|
self._server.query(url, self._server._session.post, data=subfile, params=params, headers=headers)
|
||||||
|
|
||||||
|
def removeSubtitles(self, streamID=None, streamTitle=None):
|
||||||
|
""" Remove Subtitle from movie's subtitles listing.
|
||||||
|
|
||||||
|
Note: If subtitle file is located inside video directory it will bbe deleted.
|
||||||
|
Files outside of video directory are not effected.
|
||||||
|
"""
|
||||||
|
for stream in self.subtitleStreams():
|
||||||
|
if streamID == stream.id or streamTitle == stream.title:
|
||||||
|
self._server.query(stream.key, self._server._session.delete)
|
||||||
|
|
||||||
|
def optimize(self, title=None, target="", targetTagID=None, locationID=-1, policyScope='all',
|
||||||
|
policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None):
|
||||||
|
""" Optimize item
|
||||||
|
|
||||||
|
locationID (int): -1 in folder with orginal items
|
||||||
|
2 library path
|
||||||
|
|
||||||
|
target (str): custom quality name.
|
||||||
|
if none provided use "Custom: {deviceProfile}"
|
||||||
|
|
||||||
|
targetTagID (int): Default quality settings
|
||||||
|
1 Mobile
|
||||||
|
2 TV
|
||||||
|
3 Original Quality
|
||||||
|
|
||||||
|
deviceProfile (str): Android, IOS, Universal TV, Universal Mobile, Windows Phone,
|
||||||
|
Windows, Xbox One
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Optimize for Mobile
|
||||||
|
item.optimize(targetTagID="Mobile") or item.optimize(targetTagID=1")
|
||||||
|
Optimize for Android 10 MBPS 1080p
|
||||||
|
item.optimize(deviceProfile="Android", videoQuality=10)
|
||||||
|
Optimize for IOS Original Quality
|
||||||
|
item.optimize(deviceProfile="IOS", videoQuality=-1)
|
||||||
|
|
||||||
|
* see sync.py VIDEO_QUALITIES for additional information for using videoQuality
|
||||||
|
"""
|
||||||
|
tagValues = [1, 2, 3]
|
||||||
|
tagKeys = ["Mobile", "TV", "Original Quality"]
|
||||||
|
tagIDs = tagKeys + tagValues
|
||||||
|
|
||||||
|
if targetTagID not in tagIDs and (deviceProfile is None or videoQuality is None):
|
||||||
|
raise BadRequest('Unexpected or missing quality profile.')
|
||||||
|
|
||||||
|
if isinstance(targetTagID, str):
|
||||||
|
tagIndex = tagKeys.index(targetTagID)
|
||||||
|
targetTagID = tagValues[tagIndex]
|
||||||
|
|
||||||
|
if title is None:
|
||||||
|
title = self.title
|
||||||
|
|
||||||
|
backgroundProcessing = self.fetchItem('/playlists?type=42')
|
||||||
|
key = '%s/items?' % backgroundProcessing.key
|
||||||
|
params = {
|
||||||
|
'Item[type]': 42,
|
||||||
|
'Item[target]': target,
|
||||||
|
'Item[targetTagID]': targetTagID if targetTagID else '',
|
||||||
|
'Item[locationID]': locationID,
|
||||||
|
'Item[Policy][scope]': policyScope,
|
||||||
|
'Item[Policy][value]': policyValue,
|
||||||
|
'Item[Policy][unwatched]': policyUnwatched
|
||||||
|
}
|
||||||
|
|
||||||
|
if deviceProfile:
|
||||||
|
params['Item[Device][profile]'] = deviceProfile
|
||||||
|
|
||||||
|
if videoQuality:
|
||||||
|
from plexapi.sync import MediaSettings
|
||||||
|
mediaSettings = MediaSettings.createVideo(videoQuality)
|
||||||
|
params['Item[MediaSettings][videoQuality]'] = mediaSettings.videoQuality
|
||||||
|
params['Item[MediaSettings][videoResolution]'] = mediaSettings.videoResolution
|
||||||
|
params['Item[MediaSettings][maxVideoBitrate]'] = mediaSettings.maxVideoBitrate
|
||||||
|
params['Item[MediaSettings][audioBoost]'] = ''
|
||||||
|
params['Item[MediaSettings][subtitleSize]'] = ''
|
||||||
|
params['Item[MediaSettings][musicBitrate]'] = ''
|
||||||
|
params['Item[MediaSettings][photoQuality]'] = ''
|
||||||
|
|
||||||
|
titleParam = {'Item[title]': title}
|
||||||
|
section = self._server.library.sectionByID(self.librarySectionID)
|
||||||
|
params['Item[Location][uri]'] = 'library://' + section.uuid + '/item/' + \
|
||||||
|
quote_plus(self.key + '?includeExternalMedia=1')
|
||||||
|
|
||||||
|
data = key + urlencode(params) + '&' + urlencode(titleParam)
|
||||||
|
return self._server.query(data, method=self._server._session.put)
|
||||||
|
|
||||||
def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None):
|
def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None):
|
||||||
""" Add current video (movie, tv-show, season or episode) as sync item for specified device.
|
""" Add current video (movie, tv-show, season or episode) as sync item for specified device.
|
||||||
|
@ -224,14 +327,6 @@ class Movie(Playable, Video):
|
||||||
"""
|
"""
|
||||||
return [part.file for part in self.iterParts() if part]
|
return [part.file for part in self.iterParts() if part]
|
||||||
|
|
||||||
def subtitleStreams(self):
|
|
||||||
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """
|
|
||||||
streams = []
|
|
||||||
for elem in self.media:
|
|
||||||
for part in elem.parts:
|
|
||||||
streams += part.subtitleStreams()
|
|
||||||
return streams
|
|
||||||
|
|
||||||
def _prettyfilename(self):
|
def _prettyfilename(self):
|
||||||
# This is just for compat.
|
# This is just for compat.
|
||||||
return self.title
|
return self.title
|
||||||
|
@ -257,7 +352,7 @@ class Movie(Playable, Video):
|
||||||
else:
|
else:
|
||||||
self._server.url('%s?download=1' % location.key)
|
self._server.url('%s?download=1' % location.key)
|
||||||
filepath = utils.download(url, self._server._token, filename=name,
|
filepath = utils.download(url, self._server._token, filename=name,
|
||||||
savepath=savepath, session=self._server._session)
|
savepath=savepath, session=self._server._session)
|
||||||
if filepath:
|
if filepath:
|
||||||
filepaths.append(filepath)
|
filepaths.append(filepath)
|
||||||
return filepaths
|
return filepaths
|
||||||
|
@ -481,7 +576,7 @@ class Season(Video):
|
||||||
|
|
||||||
def show(self):
|
def show(self):
|
||||||
""" Return this seasons :func:`~plexapi.video.Show`.. """
|
""" Return this seasons :func:`~plexapi.video.Show`.. """
|
||||||
return self.fetchItem(self.parentKey)
|
return self.fetchItem(int(self.parentRatingKey))
|
||||||
|
|
||||||
def watched(self):
|
def watched(self):
|
||||||
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
|
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
|
||||||
|
@ -622,8 +717,33 @@ class Episode(Playable, Video):
|
||||||
|
|
||||||
def show(self):
|
def show(self):
|
||||||
"""" Return this episodes :func:`~plexapi.video.Show`.. """
|
"""" Return this episodes :func:`~plexapi.video.Show`.. """
|
||||||
return self.fetchItem(self.grandparentKey)
|
return self.fetchItem(int(self.grandparentRatingKey))
|
||||||
|
|
||||||
def _defaultSyncTitle(self):
|
def _defaultSyncTitle(self):
|
||||||
""" Returns str, default title for a new syncItem. """
|
""" Returns str, default title for a new syncItem. """
|
||||||
return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title)
|
return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title)
|
||||||
|
|
||||||
|
|
||||||
|
@utils.registerPlexObject
|
||||||
|
class Clip(Playable, Video):
|
||||||
|
""" Represents a single Clip."""
|
||||||
|
|
||||||
|
TAG = 'Video'
|
||||||
|
TYPE = 'clip'
|
||||||
|
METADATA_TYPE = 'clip'
|
||||||
|
|
||||||
|
def _loadData(self, data):
|
||||||
|
self._data = data
|
||||||
|
self.addedAt = data.attrib.get('addedAt')
|
||||||
|
self.duration = data.attrib.get('duration')
|
||||||
|
self.guid = data.attrib.get('guid')
|
||||||
|
self.key = data.attrib.get('key')
|
||||||
|
self.originallyAvailableAt = data.attrib.get('originallyAvailableAt')
|
||||||
|
self.ratingKey = data.attrib.get('ratingKey')
|
||||||
|
self.skipDetails = utils.cast(int, data.attrib.get('skipDetails'))
|
||||||
|
self.subtype = data.attrib.get('subtype')
|
||||||
|
self.thumb = data.attrib.get('thumb')
|
||||||
|
self.thumbAspectRatio = data.attrib.get('thumbAspectRatio')
|
||||||
|
self.title = data.attrib.get('title')
|
||||||
|
self.type = data.attrib.get('type')
|
||||||
|
self.year = data.attrib.get('year')
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue