diff --git a/lib/plexapi/__init__.py b/lib/plexapi/__init__.py index 133641b4..86e21077 100644 --- a/lib/plexapi/__init__.py +++ b/lib/plexapi/__init__.py @@ -15,7 +15,7 @@ CONFIG = PlexConfig(CONFIG_PATH) # PlexAPI Settings PROJECT = 'PlexAPI' -VERSION = '4.3.1' +VERSION = '4.4.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 bf6e5394..9e0310fd 100644 --- a/lib/plexapi/alert.py +++ b/lib/plexapi/alert.py @@ -84,4 +84,4 @@ class AlertListener(threading.Thread): 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) diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index 53f7d9bd..caecfbe7 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -4,6 +4,9 @@ from urllib.parse import quote_plus from plexapi import library, media, utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest +from plexapi.mixins import ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin +from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin +from plexapi.mixins import CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin class Audio(PlexPartialObject): @@ -65,18 +68,6 @@ class Audio(PlexPartialObject): self.userRating = utils.cast(float, data.attrib.get('userRating', 0)) self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) - @property - def thumbUrl(self): - """ Return url to for the thumbnail image. """ - key = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') - return self._server.url(key, includeToken=True) if key else None - - @property - def artUrl(self): - """ Return the first art url starting on the most specific for that item.""" - art = self.firstAttr('art', 'grandparentArt') - return self._server.url(art, includeToken=True) if art else None - def url(self, part): """ Returns the full URL for the audio item. Typically used for getting a specific track. """ return self._server.url(part, includeToken=True) if part else None @@ -123,7 +114,8 @@ class Audio(PlexPartialObject): @utils.registerPlexObject -class Artist(Audio): +class Artist(Audio, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin, + CollectionMixin, CountryMixin, GenreMixin, MoodMixin, SimilarArtistMixin, StyleMixin): """ Represents a single Artist. Attributes: @@ -226,7 +218,8 @@ class Artist(Audio): @utils.registerPlexObject -class Album(Audio): +class Album(Audio, ArtMixin, PosterMixin, UnmatchMatchMixin, + CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin): """ Represents a single Album. Attributes: @@ -332,7 +325,7 @@ class Album(Audio): @utils.registerPlexObject -class Track(Audio, Playable): +class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, MoodMixin): """ Represents a single Track. Attributes: diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index 4d9f397f..96dbad60 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -5,9 +5,10 @@ from urllib.parse import quote_plus, urlencode from plexapi import log, utils from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported -from plexapi.utils import tag_helper +from plexapi.utils import tag_plural, tag_helper -DONT_RELOAD_FOR_KEYS = ['key', 'session'] +DONT_RELOAD_FOR_KEYS = {'key', 'session'} +DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'} OPERATORS = { 'exact': lambda v, q: v == q, 'iexact': lambda v, q: v.lower() == q.lower(), @@ -47,6 +48,7 @@ class PlexObject(object): self._data = data self._initpath = initpath or self.key self._parent = weakref.ref(parent) if parent else None + self._details_key = None if data is not None: self._loadData(data) self._details_key = self._buildDetailsKey() @@ -57,8 +59,11 @@ class PlexObject(object): return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p]) def __setattr__(self, attr, value): - # Don't overwrite an attr with None or [] unless it's a private variable - if value not in [None, []] or attr.startswith('_') or attr not in self.__dict__: + # Don't overwrite session specific attr with [] + if attr in DONT_OVERWRITE_SESSION_KEYS and value == []: + value = getattr(self, attr, []) + # Don't overwrite an attr with None unless it's a private variable + if value is not None or attr.startswith('_') or attr not in self.__dict__: self.__dict__[attr] = value def _clean(self, value): @@ -113,15 +118,15 @@ class PlexObject(object): def _isChildOf(self, **kwargs): """ Returns True if this object is a child of the given attributes. This will search the parent objects all the way to the top. - + Parameters: **kwargs (dict): The attributes and values to search for in the parent objects. See all possible `**kwargs*` in :func:`~plexapi.base.PlexObject.fetchItem`. """ obj = self - while obj._parent is not None: + while obj and obj._parent is not None: obj = obj._parent() - if obj._checkAttrs(obj._data, **kwargs): + if obj and obj._checkAttrs(obj._data, **kwargs): return True return False @@ -227,7 +232,7 @@ class PlexObject(object): def firstAttr(self, *attrs): """ Return the first attribute in attrs that is not None. """ for attr in attrs: - value = self.__dict__.get(attr) + value = getattr(self, attr, None) if value is not None: return value @@ -384,6 +389,7 @@ class PlexPartialObject(PlexObject): value = super(PlexPartialObject, self).__getattribute__(attr) # Check a few cases where we dont want to reload if attr in DONT_RELOAD_FOR_KEYS: return value + if attr in DONT_OVERWRITE_SESSION_KEYS: return value if attr.startswith('_'): return value if value not in (None, []): return value if self.isFullObject(): return value @@ -391,7 +397,7 @@ class PlexPartialObject(PlexObject): clsname = self.__class__.__name__ title = self.__dict__.get('title', self.__dict__.get('name')) objname = "%s '%s'" % (clsname, title) if title else clsname - log.debug("Reloading %s for attr '%s'" % (objname, attr)) + log.debug("Reloading %s for attr '%s'", objname, attr) # Reload and return the value self.reload() return super(PlexPartialObject, self).__getattribute__(attr) @@ -452,49 +458,20 @@ class PlexPartialObject(PlexObject): self._server.query(part, method=self._server._session.put) def _edit_tags(self, tag, items, locked=True, remove=False): - """ Helper to edit and refresh a tags. + """ Helper to edit tags. Parameters: - tag (str): tag name - items (list): list of tags to add - locked (bool): lock this field. - remove (bool): If this is active remove the tags in items. + tag (str): Tag name. + items (list): List of tags to add. + locked (bool): True to lock the field. + remove (bool): True to remove the tags in items. """ if not isinstance(items, list): items = [items] - value = getattr(self, tag + 's') - existing_cols = [t.tag for t in value if t and remove is False] - d = tag_helper(tag, existing_cols + items, locked, remove) - self.edit(**d) - self.refresh() - - def addCollection(self, collections): - """ Add a collection(s). - - Parameters: - collections (list): list of strings - """ - self._edit_tags('collection', collections) - - def removeCollection(self, collections): - """ Remove a collection(s). """ - self._edit_tags('collection', collections, remove=True) - - def addLabel(self, labels): - """ Add a label(s). """ - self._edit_tags('label', labels) - - def removeLabel(self, labels): - """ Remove a label(s). """ - self._edit_tags('label', labels, remove=True) - - def addGenre(self, genres): - """ Add a genre(s). """ - self._edit_tags('genre', genres) - - def removeGenre(self, genres): - """ Remove a genre(s). """ - self._edit_tags('genre', genres, remove=True) + value = getattr(self, tag_plural(tag)) + existing_tags = [t.tag for t in value if t and remove is False] + tag_edits = tag_helper(tag, existing_tags + items, locked, remove) + self.edit(**tag_edits) def refresh(self): """ Refreshing a Library or individual item causes the metadata for the item to be @@ -524,7 +501,7 @@ class PlexPartialObject(PlexObject): return self._server.query(self.key, method=self._server._session.delete) except BadRequest: # pragma: no cover log.error('Failed to delete %s. This could be because you ' - 'havnt allowed items to be deleted' % self.key) + 'have not allowed items to be deleted', self.key) raise def history(self, maxresults=9999999, mindate=None): @@ -535,142 +512,6 @@ class PlexPartialObject(PlexObject): """ 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 - # return self.fetchItem(part) - - # def poster(self): - # part = '%s/posters' % self.key - # return self.fetchItem(part, etag='Photo') - class Playable(object): """ This is a general place to store functions specific to media that is Playable. @@ -739,24 +580,6 @@ class Playable(object): for part in item.parts: yield part - def split(self): - """Split a duplicate.""" - key = '%s/split' % self.key - return self._server.query(key, method=self._server._session.put) - - def 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 - return self._server.query(key, method=self._server._session.put) - def play(self, client): """ Start playback on the specified client. @@ -834,17 +657,3 @@ class Playable(object): key %= (self.ratingKey, self.key, time, state, durationStr) self._server.query(key) self.reload() - - -@utils.registerPlexObject -class Release(PlexObject): - TAG = 'Release' - key = '/updater/status' - - def _loadData(self, data): - self.download_key = data.attrib.get('key') - self.version = data.attrib.get('version') - self.added = data.attrib.get('added') - self.fixed = data.attrib.get('fixed') - self.downloadURL = data.attrib.get('downloadURL') - self.state = data.attrib.get('state') diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py new file mode 100644 index 00000000..52762aed --- /dev/null +++ b/lib/plexapi/collection.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +from plexapi import media, utils +from plexapi.base import PlexPartialObject +from plexapi.exceptions import BadRequest +from plexapi.mixins import ArtMixin, PosterMixin +from plexapi.mixins import LabelMixin +from plexapi.settings import Setting +from plexapi.utils import deprecated + + +@utils.registerPlexObject +class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin): + """ Represents a single Collection. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'collection' + addedAt (datetime): Datetime the collection was added to the library. + art (str): URL to artwork image (/library/metadata//art/). + artBlurHash (str): BlurHash string for artwork image. + childCount (int): Number of items in the collection. + collectionMode (str): How the items in the collection are displayed. + collectionSort (str): How to sort the items in the collection. + contentRating (str) Content rating (PG-13; NR; TV-G). + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + guid (str): Plex GUID for the collection (collection://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX). + index (int): Plex index number for the collection. + key (str): API URL (/library/metadata/). + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. + maxYear (int): Maximum year for the items in the collection. + minYear (int): Minimum year for the items in the collection. + ratingKey (int): Unique key identifying the collection. + subtype (str): Media type of the items in the collection (movie, show, artist, or album). + summary (str): Summary of the collection. + thumb (str): URL to thumbnail image (/library/metadata//thumb/). + thumbBlurHash (str): BlurHash string for thumbnail image. + title (str): Name of the collection. + titleSort (str): Title to use when sorting (defaults to title). + type (str): 'collection' + updatedAt (datatime): Datetime the collection was updated. + """ + + TAG = 'Directory' + TYPE = 'collection' + + def _loadData(self, data): + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.art = data.attrib.get('art') + self.artBlurHash = data.attrib.get('artBlurHash') + self.childCount = utils.cast(int, data.attrib.get('childCount')) + self.collectionMode = utils.cast(int, data.attrib.get('collectionMode', '-1')) + self.collectionSort = utils.cast(int, data.attrib.get('collectionSort', '0')) + self.contentRating = data.attrib.get('contentRating') + self.fields = self.findItems(data, media.Field) + self.guid = data.attrib.get('guid') + self.index = utils.cast(int, data.attrib.get('index')) + self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 + self.labels = self.findItems(data, media.Label) + self.librarySectionID = data.attrib.get('librarySectionID') + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.maxYear = utils.cast(int, data.attrib.get('maxYear')) + self.minYear = utils.cast(int, data.attrib.get('minYear')) + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.subtype = data.attrib.get('subtype') + self.summary = data.attrib.get('summary') + self.thumb = data.attrib.get('thumb') + self.thumbBlurHash = data.attrib.get('thumbBlurHash') + self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort', self.title) + self.type = data.attrib.get('type') + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + + @property + @deprecated('use "items" instead', stacklevel=3) + def children(self): + return self.items() + + def item(self, title): + """ Returns the item in the collection that matches the specified title. + + Parameters: + title (str): Title of the item to return. + """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItem(key, title__iexact=title) + + def items(self): + """ Returns a list of all items in the collection. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key) + + def get(self, title): + """ Alias to :func:`~plexapi.library.Collection.item`. """ + return self.item(title) + + def __len__(self): + return self.childCount + + def _preferences(self): + """ Returns a list of :class:`~plexapi.settings.Preferences` objects. """ + items = [] + data = self._server.query(self._details_key) + for item in data.iter('Setting'): + items.append(Setting(data=item, server=self._server)) + + return items + + def modeUpdate(self, mode=None): + """ Update Collection Mode + + Parameters: + mode: default (Library default) + hide (Hide Collection) + hideItems (Hide Items in this Collection) + showItems (Show this Collection and its Items) + Example: + + collection = 'plexapi.library.Collections' + collection.updateMode(mode="hide") + """ + mode_dict = {'default': -1, + 'hide': 0, + 'hideItems': 1, + 'showItems': 2} + key = mode_dict.get(mode) + if key is None: + raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict))) + part = '/library/metadata/%s/prefs?collectionMode=%s' % (self.ratingKey, key) + return self._server.query(part, method=self._server._session.put) + + def sortUpdate(self, sort=None): + """ Update Collection Sorting + + Parameters: + sort: realease (Order Collection by realease dates) + alpha (Order Collection alphabetically) + custom (Custom collection order) + + Example: + + colleciton = 'plexapi.library.Collections' + collection.updateSort(mode="alpha") + """ + sort_dict = {'release': 0, + 'alpha': 1, + 'custom': 2} + key = sort_dict.get(sort) + if key is None: + raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict))) + part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key) + return self._server.query(part, method=self._server._session.put) diff --git a/lib/plexapi/gdm.py b/lib/plexapi/gdm.py index 9610bb0d..b2214e9e 100644 --- a/lib/plexapi/gdm.py +++ b/lib/plexapi/gdm.py @@ -13,11 +13,14 @@ import struct class GDM: - """Base class to discover GDM services.""" + """Base class to discover GDM services. + + Atrributes: + entries (List): List of server and/or client data discovered. + """ def __init__(self): self.entries = [] - self.last_scan = None def scan(self, scan_for_clients=False): """Scan the network.""" @@ -35,7 +38,7 @@ class GDM: """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']] + if value in entry['data']['Content-Type']] def find_by_data(self, values): """Return a list of entries that match the search parameters.""" diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index ce0e0ec6..206eb118 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -2,7 +2,7 @@ from urllib.parse import quote, quote_plus, unquote, urlencode from plexapi import X_PLEX_CONTAINER_SIZE, log, media, utils -from plexapi.base import OPERATORS, PlexObject, PlexPartialObject +from plexapi.base import OPERATORS, PlexObject from plexapi.exceptions import BadRequest, NotFound from plexapi.settings import Setting from plexapi.utils import deprecated @@ -723,7 +723,7 @@ class LibrarySection(PlexObject): result = set() choices = self.listChoices(category, libtype) lookup = {c.title.lower(): unquote(unquote(c.key)) for c in choices} - allowed = set(c.key for c in choices) + allowed = {c.key for c in choices} for item in value: item = str((item.id or item.tag) if isinstance(item, media.MediaTag) else item).lower() # find most logical choice(s) to use in url @@ -1525,206 +1525,6 @@ class FirstCharacter(PlexObject): self.title = data.attrib.get('title') -@utils.registerPlexObject -class Collections(PlexPartialObject): - """ Represents a single Collection. - - Attributes: - TAG (str): 'Directory' - TYPE (str): 'collection' - addedAt (datetime): Datetime the collection was added to the library. - art (str): URL to artwork image (/library/metadata//art/). - artBlurHash (str): BlurHash string for artwork image. - childCount (int): Number of items in the collection. - collectionMode (str): How the items in the collection are displayed. - collectionSort (str): How to sort the items in the collection. - contentRating (str) Content rating (PG-13; NR; TV-G). - fields (List<:class:`~plexapi.media.Field`>): List of field objects. - guid (str): Plex GUID for the collection (collection://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX). - index (int): Plex index number for the collection. - key (str): API URL (/library/metadata/). - labels (List<:class:`~plexapi.media.Label`>): List of label objects. - librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. - librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. - librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. - maxYear (int): Maximum year for the items in the collection. - minYear (int): Minimum year for the items in the collection. - ratingKey (int): Unique key identifying the collection. - subtype (str): Media type of the items in the collection (movie, show, artist, or album). - summary (str): Summary of the collection. - thumb (str): URL to thumbnail image (/library/metadata//thumb/). - thumbBlurHash (str): BlurHash string for thumbnail image. - title (str): Name of the collection. - titleSort (str): Title to use when sorting (defaults to title). - type (str): 'collection' - updatedAt (datatime): Datetime the collection was updated. - """ - - TAG = 'Directory' - TYPE = 'collection' - - def _loadData(self, data): - self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) - self.art = data.attrib.get('art') - self.artBlurHash = data.attrib.get('artBlurHash') - self.childCount = utils.cast(int, data.attrib.get('childCount')) - self.collectionMode = data.attrib.get('collectionMode') - self.collectionSort = data.attrib.get('collectionSort') - self.contentRating = data.attrib.get('contentRating') - self.fields = self.findItems(data, media.Field) - self.guid = data.attrib.get('guid') - self.index = utils.cast(int, data.attrib.get('index')) - self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 - self.labels = self.findItems(data, media.Label) - self.librarySectionID = data.attrib.get('librarySectionID') - self.librarySectionKey = data.attrib.get('librarySectionKey') - self.librarySectionTitle = data.attrib.get('librarySectionTitle') - self.maxYear = utils.cast(int, data.attrib.get('maxYear')) - self.minYear = utils.cast(int, data.attrib.get('minYear')) - self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) - self.subtype = data.attrib.get('subtype') - self.summary = data.attrib.get('summary') - self.thumb = data.attrib.get('thumb') - self.thumbBlurHash = data.attrib.get('thumbBlurHash') - self.title = data.attrib.get('title') - self.titleSort = data.attrib.get('titleSort', self.title) - self.type = data.attrib.get('type') - self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) - - @property - @deprecated('use "items" instead') - def children(self): - return self.fetchItems(self.key) - - @property - def thumbUrl(self): - """ Return the thumbnail url for the collection.""" - return self._server.url(self.thumb, includeToken=True) if self.thumb else None - - @property - def artUrl(self): - """ Return the art url for the collection.""" - return self._server.url(self.art, includeToken=True) if self.art else None - - def item(self, title): - """ Returns the item in the collection that matches the specified title. - - Parameters: - title (str): Title of the item to return. - """ - key = '/library/metadata/%s/children' % self.ratingKey - return self.fetchItem(key, title__iexact=title) - - def items(self): - """ Returns a list of all items in the collection. """ - key = '/library/metadata/%s/children' % self.ratingKey - return self.fetchItems(key) - - def get(self, title): - """ Alias to :func:`~plexapi.library.Collection.item`. """ - return self.item(title) - - def __len__(self): - return self.childCount - - def _preferences(self): - """ Returns a list of :class:`~plexapi.settings.Preferences` objects. """ - items = [] - data = self._server.query(self._details_key) - for item in data.iter('Setting'): - items.append(Setting(data=item, server=self._server)) - - return items - - def delete(self): - part = '/library/metadata/%s' % self.ratingKey - return self._server.query(part, method=self._server._session.delete) - - def modeUpdate(self, mode=None): - """ Update Collection Mode - - Parameters: - mode: default (Library default) - hide (Hide Collection) - hideItems (Hide Items in this Collection) - showItems (Show this Collection and its Items) - Example: - - collection = 'plexapi.library.Collections' - collection.updateMode(mode="hide") - """ - mode_dict = {'default': '-1', - 'hide': '0', - 'hideItems': '1', - 'showItems': '2'} - key = mode_dict.get(mode) - if key is None: - raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict))) - part = '/library/metadata/%s/prefs?collectionMode=%s' % (self.ratingKey, key) - return self._server.query(part, method=self._server._session.put) - - def sortUpdate(self, sort=None): - """ Update Collection Sorting - - Parameters: - sort: realease (Order Collection by realease dates) - alpha (Order Collection Alphabetically) - - Example: - - colleciton = 'plexapi.library.Collections' - collection.updateSort(mode="alpha") - """ - sort_dict = {'release': '0', - 'alpha': '1'} - key = sort_dict.get(sort) - if key is None: - raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict))) - part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key) - return self._server.query(part, method=self._server._session.put) - - def 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 - - @utils.registerPlexObject class Path(PlexObject): """ Represents a single directory Path. diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index 00007896..735bbe1b 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -708,10 +708,10 @@ class Collection(MediaTag): @utils.registerPlexObject class Label(MediaTag): - """ Represents a single label media tag. + """ Represents a single Label media tag. Attributes: - TAG (str): 'label' + TAG (str): 'Label' FILTER (str): 'label' """ TAG = 'Label' @@ -720,10 +720,10 @@ class Label(MediaTag): @utils.registerPlexObject class Tag(MediaTag): - """ Represents a single tag media tag. + """ Represents a single Tag media tag. Attributes: - TAG (str): 'tag' + TAG (str): 'Tag' FILTER (str): 'tag' """ TAG = 'Tag' @@ -807,20 +807,25 @@ class Style(MediaTag): FILTER = 'style' -@utils.registerPlexObject -class Poster(PlexObject): - """ Represents a Poster. +class BaseImage(PlexObject): + """ Base class for all Art, Banner, and Poster objects. Attributes: TAG (str): 'Photo' + key (str): API URL (/library/metadata/). + provider (str): The source of the poster or art. + ratingKey (str): Unique key identifying the poster or art. + selected (bool): True if the poster or art is currently selected. + thumb (str): The URL to retrieve the poster or art thumbnail. """ TAG = 'Photo' def _loadData(self, data): self._data = data self.key = data.attrib.get('key') + self.provider = data.attrib.get('provider') self.ratingKey = data.attrib.get('ratingKey') - self.selected = data.attrib.get('selected') + self.selected = cast(bool, data.attrib.get('selected')) self.thumb = data.attrib.get('thumb') def select(self): @@ -832,6 +837,18 @@ class Poster(PlexObject): pass +class Art(BaseImage): + """ Represents a single Art object. """ + + +class Banner(BaseImage): + """ Represents a single Banner object. """ + + +class Poster(BaseImage): + """ Represents a single Poster object. """ + + @utils.registerPlexObject class Producer(MediaTag): """ Represents a single Producer media tag. diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py new file mode 100644 index 00000000..4aa6fbcb --- /dev/null +++ b/lib/plexapi/mixins.py @@ -0,0 +1,489 @@ +# -*- coding: utf-8 -*- +from urllib.parse import quote_plus, urlencode + +from plexapi import media, utils +from plexapi.exceptions import NotFound + + +class ArtUrlMixin(object): + """ Mixin for Plex objects that can have a background artwork url. """ + + @property + def artUrl(self): + """ Return the art url for the Plex object. """ + art = self.firstAttr('art', 'grandparentArt') + return self._server.url(art, includeToken=True) if art else None + + +class ArtMixin(ArtUrlMixin): + """ Mixin for Plex objects that can have background artwork. """ + + def arts(self): + """ Returns list of available :class:`~plexapi.media.Art` objects. """ + return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey, cls=media.Art) + + def uploadArt(self, url=None, filepath=None): + """ Upload a background artwork from a url or filepath. + + Parameters: + url (str): The full URL to the image to upload. + filepath (str): The full file path the the image to upload. + """ + 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 the background artwork for a Plex object. + + Parameters: + art (:class:`~plexapi.media.Art`): The art object to select. + """ + art.select() + + +class BannerUrlMixin(object): + """ Mixin for Plex objects that can have a banner url. """ + + @property + def bannerUrl(self): + """ Return the banner url for the Plex object. """ + banner = self.firstAttr('banner') + return self._server.url(banner, includeToken=True) if banner else None + + +class BannerMixin(BannerUrlMixin): + """ Mixin for Plex objects that can have banners. """ + + def banners(self): + """ Returns list of available :class:`~plexapi.media.Banner` objects. """ + return self.fetchItems('/library/metadata/%s/banners' % self.ratingKey, cls=media.Banner) + + def uploadBanner(self, url=None, filepath=None): + """ Upload a banner from a url or filepath. + + Parameters: + url (str): The full URL to the image to upload. + filepath (str): The full file path the the image to upload. + """ + if url: + key = '/library/metadata/%s/banners?url=%s' % (self.ratingKey, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '/library/metadata/%s/banners?' % self.ratingKey + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setBanner(self, banner): + """ Set the banner for a Plex object. + + Parameters: + banner (:class:`~plexapi.media.Banner`): The banner object to select. + """ + banner.select() + + +class PosterUrlMixin(object): + """ Mixin for Plex objects that can have a poster url. """ + + @property + def thumbUrl(self): + """ Return the thumb url for the Plex object. """ + thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') + return self._server.url(thumb, includeToken=True) if thumb else None + + @property + def posterUrl(self): + """ Alias to self.thumbUrl. """ + return self.thumbUrl + + +class PosterMixin(PosterUrlMixin): + """ Mixin for Plex objects that can have posters. """ + + def posters(self): + """ Returns list of available :class:`~plexapi.media.Poster` objects. """ + return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey, cls=media.Poster) + + def uploadPoster(self, url=None, filepath=None): + """ Upload a poster from a url or filepath. + + Parameters: + url (str): The full URL to the image to upload. + filepath (str): The full file path the the image to upload. + """ + 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 the poster for a Plex object. + + Parameters: + poster (:class:`~plexapi.media.Poster`): The poster object to select. + """ + poster.select() + + +class SplitMergeMixin(object): + """ Mixin for Plex objects that can be split and merged. """ + + def split(self): + """ Split duplicated Plex object into separate objects. """ + key = '/library/metadata/%s/split' % self.ratingKey + return self._server.query(key, method=self._server._session.put) + + def merge(self, ratingKeys): + """ Merge other Plex objects into the current object. + + Parameters: + ratingKeys (list): A list of rating keys to merge. + """ + if not isinstance(ratingKeys, list): + ratingKeys = str(ratingKeys).split(',') + + key = '%s/merge?ids=%s' % (self.key, ','.join([str(r) for r in ratingKeys])) + return self._server.query(key, method=self._server._session.put) + + +class UnmatchMatchMixin(object): + """ Mixin for Plex objects that can be unmatched and matched. """ + + 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) + + +class CollectionMixin(object): + """ Mixin for Plex objects that can have collections. """ + + def addCollection(self, collections, locked=True): + """ Add a collection tag(s). + + Parameters: + collections (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('collection', collections, locked=locked) + + def removeCollection(self, collections, locked=True): + """ Remove a collection tag(s). + + Parameters: + collections (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('collection', collections, locked=locked, remove=True) + + +class CountryMixin(object): + """ Mixin for Plex objects that can have countries. """ + + def addCountry(self, countries, locked=True): + """ Add a country tag(s). + + Parameters: + countries (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('country', countries, locked=locked) + + def removeCountry(self, countries, locked=True): + """ Remove a country tag(s). + + Parameters: + countries (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('country', countries, locked=locked, remove=True) + + +class DirectorMixin(object): + """ Mixin for Plex objects that can have directors. """ + + def addDirector(self, directors, locked=True): + """ Add a director tag(s). + + Parameters: + directors (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('director', directors, locked=locked) + + def removeDirector(self, directors, locked=True): + """ Remove a director tag(s). + + Parameters: + directors (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('director', directors, locked=locked, remove=True) + + +class GenreMixin(object): + """ Mixin for Plex objects that can have genres. """ + + def addGenre(self, genres, locked=True): + """ Add a genre tag(s). + + Parameters: + genres (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('genre', genres, locked=locked) + + def removeGenre(self, genres, locked=True): + """ Remove a genre tag(s). + + Parameters: + genres (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('genre', genres, locked=locked, remove=True) + + +class LabelMixin(object): + """ Mixin for Plex objects that can have labels. """ + + def addLabel(self, labels, locked=True): + """ Add a label tag(s). + + Parameters: + labels (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('label', labels, locked=locked) + + def removeLabel(self, labels, locked=True): + """ Remove a label tag(s). + + Parameters: + labels (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('label', labels, locked=locked, remove=True) + + +class MoodMixin(object): + """ Mixin for Plex objects that can have moods. """ + + def addMood(self, moods, locked=True): + """ Add a mood tag(s). + + Parameters: + moods (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('mood', moods, locked=locked) + + def removeMood(self, moods, locked=True): + """ Remove a mood tag(s). + + Parameters: + moods (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('mood', moods, locked=locked, remove=True) + + +class ProducerMixin(object): + """ Mixin for Plex objects that can have producers. """ + + def addProducer(self, producers, locked=True): + """ Add a producer tag(s). + + Parameters: + producers (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('producer', producers, locked=locked) + + def removeProducer(self, producers, locked=True): + """ Remove a producer tag(s). + + Parameters: + producers (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('producer', producers, locked=locked, remove=True) + + +class SimilarArtistMixin(object): + """ Mixin for Plex objects that can have similar artists. """ + + def addSimilarArtist(self, artists, locked=True): + """ Add a similar artist tag(s). + + Parameters: + artists (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('similar', artists, locked=locked) + + def removeSimilarArtist(self, artists, locked=True): + """ Remove a similar artist tag(s). + + Parameters: + artists (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('similar', artists, locked=locked, remove=True) + + +class StyleMixin(object): + """ Mixin for Plex objects that can have styles. """ + + def addStyle(self, styles, locked=True): + """ Add a style tag(s). + + Parameters: + styles (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('style', styles, locked=locked) + + def removeStyle(self, styles, locked=True): + """ Remove a style tag(s). + + Parameters: + styles (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('style', styles, locked=locked, remove=True) + + +class TagMixin(object): + """ Mixin for Plex objects that can have tags. """ + + def addTag(self, tags, locked=True): + """ Add a tag(s). + + Parameters: + tags (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('tag', tags, locked=locked) + + def removeTag(self, tags, locked=True): + """ Remove a tag(s). + + Parameters: + tags (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('tag', tags, locked=locked, remove=True) + + +class WriterMixin(object): + """ Mixin for Plex objects that can have writers. """ + + def addWriter(self, writers, locked=True): + """ Add a writer tag(s). + + Parameters: + writers (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('writer', writers, locked=locked) + + def removeWriter(self, writers, locked=True): + """ Remove a writer tag(s). + + Parameters: + writers (list): List of strings. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + self._edit_tags('writer', writers, locked=locked, remove=True) diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py index 1e72f1cc..398cd7da 100644 --- a/lib/plexapi/photo.py +++ b/lib/plexapi/photo.py @@ -4,10 +4,11 @@ from urllib.parse import quote_plus from plexapi import media, utils, video from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest +from plexapi.mixins import ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, TagMixin @utils.registerPlexObject -class Photoalbum(PlexPartialObject): +class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin): """ Represents a single Photoalbum (collection of photos). Attributes: @@ -136,7 +137,7 @@ class Photoalbum(PlexPartialObject): @utils.registerPlexObject -class Photo(PlexPartialObject, Playable): +class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, TagMixin): """ Represents a single Photo. Attributes: @@ -163,7 +164,7 @@ class Photo(PlexPartialObject, Playable): parentTitle (str): Name of the photo album for the photo. ratingKey (int): Unique key identifying the photo. summary (str): Summary of the photo. - tag (List<:class:`~plexapi.media.Tag`>): List of tag objects. + tags (List<:class:`~plexapi.media.Tag`>): List of tag objects. thumb (str): URL to thumbnail image (/library/metadata//thumb/). title (str): Name of the photo. titleSort (str): Title to use when sorting (defaults to title). @@ -199,7 +200,7 @@ class Photo(PlexPartialObject, Playable): self.parentTitle = data.attrib.get('parentTitle') self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.summary = data.attrib.get('summary') - self.tag = self.findItems(data, media.Tag) + self.tags = self.findItems(data, media.Tag) self.thumb = data.attrib.get('thumb') self.title = data.attrib.get('title') self.titleSort = data.attrib.get('titleSort', self.title) @@ -207,12 +208,6 @@ class Photo(PlexPartialObject, Playable): self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.year = utils.cast(int, data.attrib.get('year')) - @property - def thumbUrl(self): - """Return URL for the thumbnail image.""" - key = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') - return self._server.url(key, includeToken=True) if key else None - def photoalbum(self): """ Return the photo's :class:`~plexapi.photo.Photoalbum`. """ return self.fetchItem(self.parentKey) diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index 9e691b52..36179dc5 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -5,12 +5,13 @@ from plexapi import utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection +from plexapi.mixins import ArtMixin, PosterMixin from plexapi.playqueue import PlayQueue from plexapi.utils import cast, toDatetime @utils.registerPlexObject -class Playlist(PlexPartialObject, Playable): +class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): """ Represents a single Playlist. Attributes: @@ -62,6 +63,11 @@ class Playlist(PlexPartialObject, Playable): for item in self.items(): yield item + @property + def thumb(self): + """ Alias to self.composite. """ + return self.composite + @property def metadataType(self): if self.isVideo: @@ -311,41 +317,3 @@ 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 42c92fdc..d90c76da 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -15,11 +15,12 @@ from plexapi.media import Conversion, Optimized from plexapi.playlist import Playlist from plexapi.playqueue import PlayQueue from plexapi.settings import Settings -from plexapi.utils import cast +from plexapi.utils import cast, deprecated from requests.status_codes import _codes as codes # Need these imports to populate utils.PLEXOBJECTS -from plexapi import audio as _audio # noqa: F401; noqa: F401 +from plexapi import audio as _audio # noqa: F401 +from plexapi import collection as _collection # noqa: F401 from plexapi import media as _media # noqa: F401 from plexapi import photo as _photo # noqa: F401 from plexapi import playlist as _playlist # noqa: F401 @@ -374,7 +375,11 @@ class PlexServer(PlexObject): filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack) return filepath + @deprecated('use "checkForUpdate" instead') def check_for_update(self, force=True, download=False): + return self.checkForUpdate() + + def checkForUpdate(self, force=True, download=False): """ Returns a :class:`~plexapi.base.Release` object containing release info. Parameters: @@ -390,7 +395,7 @@ class PlexServer(PlexObject): def isLatest(self): """ Check if the installed version of PMS is the latest. """ - release = self.check_for_update(force=True) + release = self.checkForUpdate(force=True) return release is None def installUpdate(self): @@ -398,7 +403,7 @@ class PlexServer(PlexObject): # We can add this but dunno how useful this is since it sometimes # requires user action using a gui. part = '/updater/apply' - release = self.check_for_update(force=True, download=True) + release = self.checkForUpdate(force=True, download=True) if release and release.version != self.version: # figure out what method this is.. return self.query(part, method=self._session.put) @@ -787,6 +792,20 @@ class Activity(PlexObject): self.uuid = data.attrib.get('uuid') +@utils.registerPlexObject +class Release(PlexObject): + TAG = 'Release' + key = '/updater/status' + + def _loadData(self, data): + self.download_key = data.attrib.get('key') + self.version = data.attrib.get('version') + self.added = data.attrib.get('added') + self.fixed = data.attrib.get('fixed') + self.downloadURL = data.attrib.get('downloadURL') + self.state = data.attrib.get('state') + + class SystemAccount(PlexObject): """ Represents a single system account. diff --git a/lib/plexapi/settings.py b/lib/plexapi/settings.py index 8416f871..734cc119 100644 --- a/lib/plexapi/settings.py +++ b/lib/plexapi/settings.py @@ -44,7 +44,7 @@ class Settings(PlexObject): def all(self): """ Returns a list of all :class:`~plexapi.settings.Setting` objects available. """ - return list(v for id, v in sorted(self._settings.items())) + return [v for id, v in sorted(self._settings.items())] def get(self, id): """ Return the :class:`~plexapi.settings.Setting` object with the specified id. """ @@ -102,7 +102,7 @@ class Setting(PlexObject): group (str): Group name this setting is categorized as. enumValues (list,dict): List or dictionary of valis values for this setting. """ - _bool_cast = lambda x: True if x == 'true' or x == '1' else False + _bool_cast = lambda x: bool(x == 'true' or x == '1') _bool_str = lambda x: str(x).lower() TYPES = { 'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str}, diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index 4895b974..be07672e 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -176,7 +176,7 @@ def threaded(callback, listargs): threads[-1].setDaemon(True) threads[-1].start() while not job_is_done_event.is_set(): - if all([not t.is_alive() for t in threads]): + if all(not t.is_alive() for t in threads): break time.sleep(0.05) @@ -334,6 +334,24 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4 return fullpath +def tag_singular(tag): + if tag == 'countries': + return 'country' + elif tag == 'similar': + return 'similar' + else: + return tag[:-1] + + +def tag_plural(tag): + if tag == 'country': + return 'countries' + elif tag == 'similar': + return 'similar' + else: + return tag + 's' + + def tag_helper(tag, items, locked=True, remove=False): """ Simple tag helper for editing a object. """ if not isinstance(items, list): @@ -448,7 +466,7 @@ def base64str(text): return base64.b64encode(text.encode('utf-8')).decode('utf-8') -def deprecated(message): +def deprecated(message, stacklevel=2): def decorator(func): """This is a decorator which can be used to mark functions as deprecated. It will result in a warning being emitted @@ -456,7 +474,7 @@ def deprecated(message): @functools.wraps(func) def wrapper(*args, **kwargs): msg = 'Call to deprecated function or method "%s", %s.' % (func.__name__, message) - warnings.warn(msg, category=DeprecationWarning, stacklevel=3) + warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel) log.warning(msg) return func(*args, **kwargs) return wrapper diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index 508dc5a4..e32deca1 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -5,6 +5,9 @@ from urllib.parse import quote_plus, urlencode from plexapi import library, media, settings, utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound +from plexapi.mixins import ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin +from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin +from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin class Video(PlexPartialObject): @@ -64,20 +67,6 @@ class Video(PlexPartialObject): """ Returns True if this video is watched. """ return bool(self.viewCount > 0) if self.viewCount else False - @property - def thumbUrl(self): - """ Return the first first thumbnail url starting on - the most specific thumbnail for that item. - """ - thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') - return self._server.url(thumb, includeToken=True) if thumb else None - - @property - def artUrl(self): - """ Return the first first art url starting on the most specific for that item.""" - art = self.firstAttr('art', 'grandparentArt') - return self._server.url(art, includeToken=True) if art else None - def url(self, part): """ Returns the full url for something. Typically used for getting a specific image. """ return self._server.url(part, includeToken=True) if part else None @@ -259,7 +248,8 @@ class Video(PlexPartialObject): @utils.registerPlexObject -class Movie(Playable, Video): +class Movie(Video, Playable, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin, + CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin): """ Represents a single Movie. Attributes: @@ -385,7 +375,8 @@ class Movie(Playable, Video): @utils.registerPlexObject -class Show(Video): +class Show(Video, ArtMixin, BannerMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin, + CollectionMixin, GenreMixin, LabelMixin): """ Represents a single Show (including all seasons and episodes). Attributes: @@ -403,6 +394,7 @@ class Show(Video): leafCount (int): Number of items in the show view. locations (List): List of folder paths where the show is found on disk. originallyAvailableAt (datetime): Datetime the show was released. + originalTitle (str): The original title of the show. rating (float): Show rating (7.9; 9.8; 8.1). roles (List<:class:`~plexapi.media.Role`>): List of role objects. similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. @@ -430,6 +422,7 @@ class Show(Video): self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.locations = self.listAttrs(data, 'path', etag='Location') self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.originalTitle = data.attrib.get('originalTitle') self.rating = utils.cast(float, data.attrib.get('rating')) self.roles = self.findItems(data, media.Role) self.similar = self.findItems(data, media.Similar) @@ -583,7 +576,7 @@ class Show(Video): @utils.registerPlexObject -class Season(Video): +class Season(Video, ArtMixin, PosterMixin): """ Represents a single Show Season (including all episodes). Attributes: @@ -709,7 +702,8 @@ class Season(Video): @utils.registerPlexObject -class Episode(Playable, Video): +class Episode(Video, Playable, ArtMixin, PosterMixin, + DirectorMixin, WriterMixin): """ Represents a single Shows Episode. Attributes: @@ -738,6 +732,7 @@ class Episode(Playable, Video): parentThumb (str): URL to season thumbnail image (/library/metadata//thumb/). parentTitle (str): Name of the season for the episode. rating (float): Episode rating (7.9; 9.8; 8.1). + skipParent (bool): True if the show's seasons are set to hidden. viewOffset (int): View offset in milliseconds. writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. year (int): Year episode was released. @@ -774,10 +769,23 @@ class Episode(Playable, Video): self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.rating = utils.cast(float, data.attrib.get('rating')) + self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0')) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.writers = self.findItems(data, media.Writer) self.year = utils.cast(int, data.attrib.get('year')) + # If seasons are hidden, parentKey and parentRatingKey are missing from the XML response. + # https://forums.plex.tv/t/parentratingkey-not-in-episode-xml-when-seasons-are-hidden/300553 + if self.skipParent and not self.parentRatingKey: + # Parse the parentRatingKey from the parentThumb + if self.parentThumb.startswith('/library/metadata/'): + self.parentRatingKey = utils.cast(int, self.parentThumb.split('/')[3]) + # Get the parentRatingKey from the season's ratingKey + if not self.parentRatingKey and self.grandparentRatingKey: + self.parentRatingKey = self.show().season(season=self.parentIndex).ratingKey + if self.parentRatingKey: + self.parentKey = '/library/metadata/%s' % self.parentRatingKey + def __repr__(self): return '<%s>' % ':'.join([p for p in [ self.__class__.__name__, @@ -832,8 +840,8 @@ class Episode(Playable, Video): @utils.registerPlexObject -class Clip(Playable, Video): - """Represents a single Clip. +class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin): + """ Represents a single Clip. Attributes: TAG (str): 'Video' @@ -855,7 +863,7 @@ class Clip(Playable, Video): METADATA_TYPE = 'clip' def _loadData(self, data): - """Load attribute values from Plex XML response.""" + """ Load attribute values from Plex XML response. """ Video._loadData(self, data) Playable._loadData(self, data) self._data = data