diff --git a/lib/plexapi/__init__.py b/lib/plexapi/__init__.py index 119dc7ae..95a8dfc0 100644 --- a/lib/plexapi/__init__.py +++ b/lib/plexapi/__init__.py @@ -3,9 +3,10 @@ import logging import os from logging.handlers import RotatingFileHandler from platform import uname +from uuid import getnode + from plexapi.config import PlexConfig, reset_base_headers from plexapi.utils import SecretsFilter -from uuid import getnode # Load User Defined Config DEFAULT_CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini') @@ -14,7 +15,7 @@ CONFIG = PlexConfig(CONFIG_PATH) # PlexAPI Settings PROJECT = 'PlexAPI' -VERSION = '3.3.0' +VERSION = '3.6.0' TIMEOUT = CONFIG.get('plexapi.timeout', 30, int) X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int) X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool) diff --git a/lib/plexapi/alert.py b/lib/plexapi/alert.py index 2a19c6d8..1a5469ab 100644 --- a/lib/plexapi/alert.py +++ b/lib/plexapi/alert.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- import json import threading -import websocket + from plexapi import log class AlertListener(threading.Thread): - """ Creates a websocket connection to the PlexServer to optionally recieve alert notifications. + """ 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 - 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 `PlexServer.startAlertListener()`, the thread will be started for you. @@ -26,9 +26,9 @@ class AlertListener(threading.Thread): Parameters: 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 - recieved from the server. :samp:`def my_callback(data): ...` + received from the server. :samp:`def my_callback(data): ...` """ key = '/:/websockets/notifications' @@ -40,6 +40,11 @@ class AlertListener(threading.Thread): self._ws = None def run(self): + try: + import websocket + except ImportError: + log.warning("Can't use the AlertListener without websocket") + return # create the websocket connection url = self._server.url(self.key, includeToken=True).replace('http', 'ws') log.info('Starting AlertListener: %s', url) @@ -48,15 +53,21 @@ class AlertListener(threading.Thread): self._ws.run_forever() 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()` from a PlexServer instance. """ log.info('Stopping AlertListener.') self._ws.close() - def _onMessage(self, ws, message): - """ Called when websocket message is recieved. """ + def _onMessage(self, *args): + """ 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: data = json.loads(message)['NotificationContainer'] log.debug('Alert: %s %s %s', *data) @@ -65,6 +76,12 @@ class AlertListener(threading.Thread): except Exception as err: # pragma: no cover log.error('AlertListener Msg Error: %s', err) - def _onError(self, ws, err): # pragma: no cover - """ Called when websocket error is recieved. """ + def _onError(self, *args): # pragma: no cover + """ 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) diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index d6826831..b70aa93c 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -284,15 +284,15 @@ class Track(Audio, Playable): art (str): Track artwork (/library/metadata//art/) chapterSource (TYPE): Unknown duration (int): Length of this album in seconds. - grandparentArt (str): Artist artowrk. - grandparentKey (str): Artist API URL. - grandparentRatingKey (str): Unique key identifying artist. - grandparentThumb (str): URL to artist thumbnail image. - grandparentTitle (str): Name of the artist for this track. + grandparentArt (str): Album artist artwork. + grandparentKey (str): Album artist API URL. + grandparentRatingKey (str): Unique key identifying album artist. + grandparentThumb (str): URL to album artist thumbnail image. + grandparentTitle (str): Name of the album artist for this track. guid (str): Unknown (unique ID). media (list): List of :class:`~plexapi.media.Media` objects for this track. moods (list): List of :class:`~plexapi.media.Mood` objects for this track. - originalTitle (str): Original track title (if translated). + originalTitle (str): Track artist. parentIndex (int): Album index. parentKey (str): Album API URL. parentRatingKey (int): Unique key identifying album. diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index 46a54581..6e5c6c2f 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -132,6 +132,8 @@ class PlexObject(object): * __regex: Value matches the specified regular expression. * __startswith: Value starts with specified arg. """ + if ekey is None: + raise BadRequest('ekey was not provided') if isinstance(ekey, int): ekey = '/library/metadata/%s' % ekey for elem in self._server.query(ekey): @@ -140,13 +142,27 @@ class PlexObject(object): clsname = cls.__name__ if cls else 'None' raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs)) - def fetchItems(self, ekey, cls=None, **kwargs): + 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 + """ - 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) + librarySectionID = data.attrib.get('librarySectionID') if librarySectionID: for item in items: @@ -421,6 +437,141 @@ class PlexPartialObject(PlexObject): 'havnt allowed items to be deleted' % self.key) 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 # def arts(self): # part = '%s/arts' % self.key @@ -509,6 +660,14 @@ class Playable(object): key = '%s/split' % self.key 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): """Unmatch a media file.""" key = '%s/unmatch' % self.key @@ -573,7 +732,7 @@ class Playable(object): time, state) self._server.query(key) self.reload() - + def updateTimeline(self, time, state='stopped', duration=None): """ Set the timeline progress for this video. diff --git a/lib/plexapi/client.py b/lib/plexapi/client.py index cd17c7b6..e4381496 100644 --- a/lib/plexapi/client.py +++ b/lib/plexapi/client.py @@ -1,15 +1,13 @@ # -*- coding: utf-8 -*- import time -import requests -from requests.status_codes import _codes as codes -from plexapi import BASE_HEADERS, CONFIG, TIMEOUT -from plexapi import log, logfilter, utils +import requests +from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils from plexapi.base import PlexObject 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 requests.status_codes import _codes as codes DEFAULT_MTYPE = 'video' @@ -159,11 +157,16 @@ class PlexClient(PlexObject): log.debug('%s %s', method.__name__.upper(), url) headers = self._headers(**headers or {}) response = method(url, headers=headers, timeout=timeout, **kwargs) - if response.status_code not in (200, 201): + if response.status_code not in (200, 201, 204): codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') - log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) - raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)) + 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') return ElementTree.fromstring(data) if data.strip() else None @@ -204,10 +207,13 @@ class PlexClient(PlexObject): return query(key, headers=headers) except ElementTree.ParseError: # 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''` if self.product in ( 'Plexamp', 'Plex for Android (TV)', + 'Plex for Android (Mobile)', + 'Plex for Samsung', ): return raise @@ -300,6 +306,8 @@ class PlexClient(PlexObject): 'address': server_url[1].strip('/'), 'port': server_url[-1], 'key': media.key, + 'protocol': server_url[0], + 'token': media._server.createToken() }, **params)) # ------------------- @@ -465,6 +473,18 @@ class PlexClient(PlexObject): server_url = media._server._baseurl.split(':') 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': try: self.sendCommand('timeline/subscribe', port=server_port, protocol='http') @@ -481,7 +501,8 @@ class PlexClient(PlexObject): 'port': server_port, 'offset': offset, 'key': media.key, - 'token': media._server._token, + 'token': media._server.createToken(), + 'type': mediatype, 'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID, }, **params)) @@ -527,9 +548,9 @@ class PlexClient(PlexObject): # ------------------- # Timeline Commands - def timeline(self): + def timeline(self, wait=1): """ 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): """ Returns True if any media is currently playing. @@ -538,7 +559,7 @@ class PlexClient(PlexObject): includePaused (bool): Set True to treat currently paused items as playing (optional; default True). """ - for mediatype in self.timeline(): + for mediatype in self.timeline(wait=0): if mediatype.get('state') == 'playing': return True if includePaused and mediatype.get('state') == 'paused': diff --git a/lib/plexapi/compat.py b/lib/plexapi/compat.py index 4a163ed1..0d52c70e 100644 --- a/lib/plexapi/compat.py +++ b/lib/plexapi/compat.py @@ -25,9 +25,9 @@ except ImportError: from urllib import quote try: - from urllib.parse import quote_plus + from urllib.parse import quote_plus, quote except ImportError: - from urllib import quote_plus + from urllib import quote_plus, quote try: from urllib.parse import unquote @@ -44,11 +44,6 @@ try: except ImportError: from xml.etree import ElementTree -try: - from unittest.mock import patch, MagicMock -except ImportError: - from mock import patch, MagicMock - def makedirs(name, mode=0o777, exist_ok=False): """ Mimicks os.makedirs() from Python 3. """ diff --git a/lib/plexapi/exceptions.py b/lib/plexapi/exceptions.py index 45da9f23..c269c38e 100644 --- a/lib/plexapi/exceptions.py +++ b/lib/plexapi/exceptions.py @@ -26,6 +26,6 @@ class Unsupported(PlexApiException): pass -class Unauthorized(PlexApiException): - """ Invalid username or password. """ +class Unauthorized(BadRequest): + """ Invalid username/password or token. """ pass diff --git a/lib/plexapi/gdm.py b/lib/plexapi/gdm.py new file mode 100644 index 00000000..84c7acaf --- /dev/null +++ b/lib/plexapi/gdm.py @@ -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() diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index 1c3cbc30..e7798459 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- from plexapi import X_PLEX_CONTAINER_SIZE, log, utils from plexapi.base import PlexObject -from plexapi.compat import unquote, urlencode, quote_plus -from plexapi.media import MediaTag +from plexapi.compat import quote, quote_plus, unquote, urlencode from plexapi.exceptions import BadRequest, NotFound +from plexapi.media import MediaTag +from plexapi.settings import Setting class Library(PlexObject): @@ -294,6 +295,17 @@ class Library(PlexObject): part += urlencode(kwargs) 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): """ 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). updatedAt (datetime): Datetime this library section was last updated. uuid (str): Unique id for this section (32258d7c-3e6c-4ac5-98ad-bad7a3b78c63) + totalSize (int): Total number of item in the library + """ ALLOWED_FILTERS = () ALLOWED_SORT = () @@ -343,6 +357,51 @@ class LibrarySection(PlexObject): self.type = data.attrib.get('type') self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) 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): """ Delete a library section. """ @@ -354,13 +413,18 @@ class LibrarySection(PlexObject): log.error(msg) 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. Parameters: 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) # 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: 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) def all(self, sort=None, **kwargs): @@ -390,6 +454,17 @@ class LibrarySection(PlexObject): key = '/library/sections/%s/all%s' % (self.key, sortStr) 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): """ Returns a list of media items on deck from this library section. """ 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)) return self.fetchItems(key, cls=FilterChoice) - def search(self, title=None, sort=None, maxresults=999999, libtype=None, **kwargs): - """ Search the library. If there are many results, they will be fetched from the server - in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only looking for the first + def search(self, title=None, sort=None, maxresults=None, + libtype=None, container_start=0, container_size=X_PLEX_CONTAINER_SIZE, **kwargs): + """ Search the library. The http requests will be batched in container_size. If you're only looking for the first 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. @@ -477,6 +552,8 @@ class LibrarySection(PlexObject): maxresults (int): Only return the specified number of results (optional). libtype (str): Filter results to a spcifiec libtype (movie, show, episode, artist, album, track; optional). + 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 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`. @@ -508,15 +585,37 @@ class LibrarySection(PlexObject): args['sort'] = self._cleanSearchSort(sort) if libtype is not None: args['type'] = utils.searchType(libtype) - # iterate over the results - results, subresults = [], '_init' - args['X-Plex-Container-Start'] = 0 - args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults) - while subresults and maxresults > len(results): + + results = [] + subresults = [] + offset = container_start + + if maxresults is not None: + container_size = min(container_size, maxresults) + while True: key = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args)) - subresults = self.fetchItems(key) - results += subresults[:maxresults - len(results)] - args['X-Plex-Container-Start'] += args['X-Plex-Container-Size'] + subresults = self.fetchItems(key, container_start=container_start, + container_size=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 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] if matches: map(result.add, matches); continue # nothing matched; use raw item value - log.warning('Filter value not listed, using raw item value: %s' % item) + log.debug('Filter value not listed, using raw item value: %s' % item) result.add(item) return ','.join(result) @@ -633,6 +732,14 @@ class LibrarySection(PlexObject): 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): """ Represents a :class:`~plexapi.library.LibrarySection` section containing movies. @@ -869,7 +976,7 @@ class PhotoSection(LibrarySection): TYPE (str): 'photo' """ ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure', 'device', 'resolution', 'place', - 'originallyAvailableAt', 'addedAt', 'title', 'userRating') + 'originallyAvailableAt', 'addedAt', 'title', 'userRating', 'tag', 'year') ALLOWED_SORT = ('addedAt',) TAG = 'Directory' TYPE = 'photo' @@ -968,6 +1075,7 @@ class Hub(PlexObject): self.size = utils.cast(int, data.attrib.get('size')) self.title = data.attrib.get('title') self.type = data.attrib.get('type') + self.key = data.attrib.get('key') self.items = self.findItems(data) def __len__(self): @@ -979,9 +1087,11 @@ class Collections(PlexObject): TAG = 'Directory' TYPE = 'collection' + _include = "?includeExternalMedia=1&includePreferences=1" def _loadData(self, data): 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.type = data.attrib.get('type') self.title = data.attrib.get('title') @@ -1051,5 +1161,43 @@ class Collections(PlexObject): part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key) 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): # TODO diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index cf1685ae..50252e4f 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -1,5 +1,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.exceptions import BadRequest from plexapi.utils import cast @@ -143,7 +146,7 @@ class MediaPart(PlexObject): def setDefaultSubtitleStream(self, stream): """ Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart. - + Parameters: stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default. """ @@ -349,6 +352,118 @@ class TranscodeSession(PlexObject): 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): """ 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 @@ -419,6 +534,25 @@ class Label(MediaTag): 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 class Country(MediaTag): """ Represents a single Country media tag. @@ -483,6 +617,14 @@ class Poster(PlexObject): self.selected = data.attrib.get('selected') 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 class Producer(MediaTag): @@ -565,3 +707,74 @@ class Field(PlexObject): self._data = data self.name = data.attrib.get('name') 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')] diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py index d3945fc0..e9bac9c0 100644 --- a/lib/plexapi/myplex.py +++ b/lib/plexapi/myplex.py @@ -1,18 +1,21 @@ # -*- coding: utf-8 -*- import copy -import requests +import threading 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 -from plexapi import log, logfilter, utils + +import requests +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.exceptions import BadRequest, NotFound +from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.client import PlexClient from plexapi.compat import ElementTree from plexapi.library import LibrarySection from plexapi.server import PlexServer -from plexapi.sync import SyncList, SyncItem +from plexapi.sonos import PlexSonosClient +from plexapi.sync import SyncItem, SyncList from plexapi.utils import joinArgs +from requests.status_codes import _codes as codes class MyPlexAccount(PlexObject): @@ -73,6 +76,12 @@ class MyPlexAccount(PlexObject): REQUESTS = 'https://plex.tv/api/invites/requests' # get SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data + # 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. # https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId} 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): self._token = token self._session = session or requests.Session() + self._sonos_cache = [] + self._sonos_cache_timestamp = 0 data, initpath = self._signin(username, password, timeout) 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 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)) + 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') return ElementTree.fromstring(data) if data.strip() else None @@ -195,6 +212,24 @@ class MyPlexAccount(PlexObject): data = self.query(MyPlexResource.key) 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, allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): """ Share library content with the specified user. @@ -384,8 +419,8 @@ class MyPlexAccount(PlexObject): params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds}} url = self.FRIENDSERVERS.format(machineId=machineId, serverId=serverId) else: - params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds, - 'invited_id': user.id}} + params = {'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_id': user.id}} url = self.FRIENDINVITE.format(machineId=machineId) # Remove share sections, add shares to user without shares, or update shares if not user_servers or sectionIds: @@ -429,7 +464,7 @@ class MyPlexAccount(PlexObject): return user 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 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)) 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): """ 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.username = data.attrib.get('username', '') self.servers = self.findItems(data, MyPlexServerShare) + for server in self.servers: + server.accountID = self.id def get_token(self, machineIdentifier): try: @@ -663,6 +748,29 @@ class MyPlexUser(PlexObject): except Exception: 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): """ 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.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): """ 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. """ self._data = data 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.machineIdentifier = data.attrib.get('machineIdentifier') self.name = data.attrib.get('name') @@ -720,7 +839,21 @@ class MyPlexServerShare(PlexObject): self.owned = utils.cast(bool, data.attrib.get('owned')) 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): + """ Returns a list of all :class:`~plexapi.myplex.Section` objects shared with this user. + """ url = MyPlexAccount.FRIENDSERVERS.format(machineId=self.machineIdentifier, serverId=self.id) data = self._server.query(url) sections = [] @@ -731,6 +864,15 @@ class MyPlexServerShare(PlexObject): 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): """ 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) +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): """ Connects to the specified cls with url and token. Stores the connection information to results[i] in a threadsafe way. diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py index bf1383c3..66c6d561 100644 --- a/lib/plexapi/photo.py +++ b/lib/plexapi/photo.py @@ -117,6 +117,7 @@ class Photo(PlexPartialObject): self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.year = utils.cast(int, data.attrib.get('year')) self.media = self.findItems(data, media.Media) + self.tag = self.findItems(data, media.Tag) def photoalbum(self): """ Return this photo's :class:`~plexapi.photo.Photoalbum`. """ diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index a40665d8..43c63c6f 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -268,3 +268,41 @@ class Playlist(PlexPartialObject, Playable): raise Unsupported('Unsupported playlist content') 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() diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index 741249ce..0fac1097 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -7,12 +7,13 @@ from plexapi.alert import AlertListener from plexapi.base import PlexObject from plexapi.client import PlexClient from plexapi.compat import ElementTree, urlencode -from plexapi.exceptions import BadRequest, NotFound +from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.library import Library, Hub from plexapi.settings import Settings from plexapi.playlist import Playlist from plexapi.playqueue import PlayQueue from plexapi.utils import cast +from plexapi.media import Optimized, Conversion # Need these imports to populate utils.PLEXOBJECTS from plexapi import (audio as _audio, video as _video, # noqa: F401 @@ -183,8 +184,18 @@ class PlexServer(PlexObject): data = self.query(Account.key) 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'): """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)) return q.attrib.get('token') @@ -322,7 +333,7 @@ class PlexServer(PlexObject): # figure out what method this is.. 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 be fetched from the server in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only looking for the first 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). mindate (datetime): Min datetime to return results from. This really helps speed 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' args = {'sort': 'viewedAt:desc'} + if ratingKey: + args['metadataItemID'] = ratingKey + if accountID: + args['accountID'] = accountID + if librarySectionID: + args['librarySectionID'] = librarySectionID if mindate: args['viewedAt>'] = int(mindate.timestamp()) args['X-Plex-Container-Start'] = 0 @@ -363,6 +383,36 @@ class PlexServer(PlexObject): """ 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): """ 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 @@ -377,8 +427,13 @@ class PlexServer(PlexObject): if response.status_code not in (200, 201): codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') - log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) - raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)) + 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') return ElementTree.fromstring(data) if data.strip() else None @@ -472,6 +527,25 @@ class PlexServer(PlexObject): self.refreshSynclist() 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): """ Contains the locally cached MyPlex account information. The properties provided don't diff --git a/lib/plexapi/settings.py b/lib/plexapi/settings.py index 0bbc70c8..1511c0bb 100644 --- a/lib/plexapi/settings.py +++ b/lib/plexapi/settings.py @@ -124,8 +124,8 @@ class Setting(PlexObject): self.enumValues = self._getEnumValues(data) def _cast(self, value): - """ Cast the specifief value to the type of this setting. """ - if self.type != 'text': + """ Cast the specific value to the type of this setting. """ + if self.type != 'enum': value = utils.cast(self.TYPES.get(self.type)['cast'], value) return value diff --git a/lib/plexapi/sonos.py b/lib/plexapi/sonos.py new file mode 100644 index 00000000..3bdfc1f2 --- /dev/null +++ b/lib/plexapi/sonos.py @@ -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): 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 + ) + ) diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index e8ff989d..a23719d5 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -2,16 +2,21 @@ import logging import os import re -import requests import time import zipfile from datetime import datetime from getpass import getpass -from threading import Thread, Event -from tqdm import tqdm +from threading import Event, Thread + +import requests from plexapi import compat from plexapi.exceptions import NotFound +try: + from tqdm import tqdm +except ImportError: + tqdm = None + log = logging.getLogger('plexapi') # Search Types - Plex uses these to filter specific media types when searching. @@ -59,7 +64,7 @@ def registerPlexObject(cls): def cast(func, value): """ Cast the specified value to the specified type (returned by func). Currently this - only support int, float, bool. Should be extended if needed. + only support str, int, float, bool. Should be extended if needed. Parameters: 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 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): try: return func(value) @@ -89,7 +100,7 @@ def joinArgs(args): arglist = [] for key in sorted(args, key=lambda x: x.lower()): 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) @@ -287,17 +298,17 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4 # save the file to disk 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)) bar = tqdm(unit='B', unit_scale=True, total=total, desc=filename) with open(fullpath, 'wb') as handle: for chunk in response.iter_content(chunk_size=chunksize): handle.write(chunk) - if showstatus: + if showstatus and tqdm: bar.update(len(chunk)) - if showstatus: # pragma: no cover + if showstatus and tqdm: # pragma: no cover bar.close() # check we want to unzip the contents if fullpath.endswith('zip') and unpack: @@ -375,3 +386,15 @@ def choose(msg, items, attr): # pragma: no cover except (ValueError, IndexError): 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))) diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index fe044218..2dcc73a7 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -2,7 +2,8 @@ from plexapi import media, utils from plexapi.exceptions import BadRequest, NotFound from plexapi.base import Playable, PlexPartialObject -from plexapi.compat import quote_plus +from plexapi.compat import quote_plus, urlencode +import os class Video(PlexPartialObject): @@ -89,10 +90,112 @@ class Video(PlexPartialObject): """ Returns str, default title for a new syncItem. """ return self.title - def posters(self): - """ Returns list of available poster objects. :class:`~plexapi.media.Poster`:""" + def subtitleStreams(self): + """ 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): """ 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] - def subtitleStreams(self): - """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """ - streams = [] - for elem in self.media: - for part in elem.parts: - streams += part.subtitleStreams() - return streams - def _prettyfilename(self): # This is just for compat. return self.title @@ -257,7 +352,7 @@ class Movie(Playable, Video): else: self._server.url('%s?download=1' % location.key) filepath = utils.download(url, self._server._token, filename=name, - savepath=savepath, session=self._server._session) + savepath=savepath, session=self._server._session) if filepath: filepaths.append(filepath) return filepaths @@ -481,7 +576,7 @@ class Season(Video): def show(self): """ Return this seasons :func:`~plexapi.video.Show`.. """ - return self.fetchItem(self.parentKey) + return self.fetchItem(int(self.parentRatingKey)) def watched(self): """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ @@ -622,8 +717,33 @@ class Episode(Playable, Video): def show(self): """" Return this episodes :func:`~plexapi.video.Show`.. """ - return self.fetchItem(self.grandparentKey) + return self.fetchItem(int(self.grandparentRatingKey)) def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ 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')