diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index 2cb78c53..fe6f0be0 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -6,6 +6,7 @@ from xml.etree import ElementTree from plexapi import log, utils from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported +from plexapi.utils import cached_property USER_DONT_RELOAD_FOR_KEYS = set() _DONT_RELOAD_FOR_KEYS = {'key'} @@ -666,6 +667,13 @@ class PlexPartialObject(PlexObject): """ return self._getWebURL(base=base) + def playQueue(self, *args, **kwargs): + """ Returns a new :class:`~plexapi.playqueue.PlayQueue` from this media item. + See :func:`~plexapi.playqueue.PlayQueue.create` for available parameters. + """ + from plexapi.playqueue import PlayQueue + return PlayQueue.create(self._server, self, *args, **kwargs) + class Playable: """ This is a general place to store functions specific to media that is Playable. @@ -841,7 +849,6 @@ class PlexSession(object): user = data.find('User') self._username = user.attrib.get('title') self._userId = utils.cast(int, user.attrib.get('id')) - self._user = None # Cache for user object # For backwards compatibility self.players = [self.player] if self.player else [] @@ -849,18 +856,16 @@ class PlexSession(object): self.transcodeSessions = [self.transcodeSession] if self.transcodeSession else [] self.usernames = [self._username] if self._username else [] - @property + @cached_property def user(self): """ Returns the :class:`~plexapi.myplex.MyPlexAccount` object (for admin) or :class:`~plexapi.myplex.MyPlexUser` object (for users) for this session. """ - if self._user is None: - myPlexAccount = self._server.myPlexAccount() - if self._userId == 1: - self._user = myPlexAccount - else: - self._user = myPlexAccount.user(self._username) - return self._user + myPlexAccount = self._server.myPlexAccount() + if self._userId == 1: + return myPlexAccount + + return myPlexAccount.user(self._username) def reload(self): """ Reload the data for the session. diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py index ff26cf32..4561c158 100644 --- a/lib/plexapi/collection.py +++ b/lib/plexapi/collection.py @@ -11,7 +11,6 @@ from plexapi.mixins import ( ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin, LabelMixin ) -from plexapi.playqueue import PlayQueue from plexapi.utils import deprecated @@ -427,10 +426,6 @@ class Collection( """ Delete the collection. """ super(Collection, self).delete() - def playQueue(self, *args, **kwargs): - """ Returns a new :class:`~plexapi.playqueue.PlayQueue` from the collection. """ - return PlayQueue.create(self._server, self.items(), *args, **kwargs) - @classmethod def _create(cls, server, title, section, items): """ Create a regular collection. """ diff --git a/lib/plexapi/const.py b/lib/plexapi/const.py index 8ac71b57..605ed78c 100644 --- a/lib/plexapi/const.py +++ b/lib/plexapi/const.py @@ -4,6 +4,6 @@ # Library version MAJOR_VERSION = 4 MINOR_VERSION = 13 -PATCH_VERSION = 1 +PATCH_VERSION = 2 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index 647a89f0..ce2df733 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -7,7 +7,7 @@ from plexapi import X_PLEX_CONTAINER_SIZE, log, media, utils from plexapi.base import OPERATORS, PlexObject from plexapi.exceptions import BadRequest, NotFound from plexapi.settings import Setting -from plexapi.utils import deprecated +from plexapi.utils import cached_property, deprecated class Library(PlexObject): @@ -418,7 +418,6 @@ class LibrarySection(PlexObject): self._filterTypes = None self._fieldTypes = None self._totalViewSize = None - self._totalSize = None self._totalDuration = None self._totalStorage = None @@ -456,12 +455,10 @@ class LibrarySection(PlexObject): item.librarySectionID = librarySectionID return items - @property + @cached_property def totalSize(self): """ Returns the total number of items in the library for the default library type. """ - if self._totalSize is None: - self._totalSize = self.totalViewSize(includeCollections=False) - return self._totalSize + return self.totalViewSize(includeCollections=False) @property def totalDuration(self): @@ -644,12 +641,12 @@ class LibrarySection(PlexObject): guidLookup = {} for item in library.all(): guidLookup[item.guid] = item - guidLookup.update({guid.id for guid in item.guids}} + guidLookup.update({guid.id: item for guid in item.guids}} result1 = guidLookup['plex://show/5d9c086c46115600200aa2fe'] result2 = guidLookup['imdb://tt0944947'] - result4 = guidLookup['tmdb://1399'] - result5 = guidLookup['tvdb://121361'] + result3 = guidLookup['tmdb://1399'] + result4 = guidLookup['tvdb://121361'] """ @@ -1671,13 +1668,13 @@ class LibrarySection(PlexObject): return self.search(libtype='collection', **kwargs) def createPlaylist(self, title, items=None, smart=False, limit=None, - sort=None, filters=None, **kwargs): + sort=None, filters=None, m3ufilepath=None, **kwargs): """ Alias for :func:`~plexapi.server.PlexServer.createPlaylist` using this :class:`~plexapi.library.LibrarySection`. """ return self._server.createPlaylist( title, section=self, items=items, smart=smart, limit=limit, - sort=sort, filters=filters, **kwargs) + sort=sort, filters=filters, m3ufilepath=m3ufilepath, **kwargs) def playlist(self, title): """ Returns the playlist with the specified title. diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index ccdf9fab..c8ea8c4e 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -672,6 +672,7 @@ class MediaTag(PlexObject): role (str): The name of the character role for :class:`~plexapi.media.Role` only. tag (str): Name of the tag. This will be Animation, SciFi etc for Genres. The name of person for Directors and Roles (ex: Animation, Stephen Graham, etc). + tagKey (str): Plex GUID for the actor/actress for :class:`~plexapi.media.Role` only. thumb (str): URL to thumbnail image for :class:`~plexapi.media.Role` only. """ @@ -687,6 +688,7 @@ class MediaTag(PlexObject): self.key = data.attrib.get('key') self.role = data.attrib.get('role') self.tag = data.attrib.get('tag') + self.tagKey = data.attrib.get('tagKey') self.thumb = data.attrib.get('thumb') parent = self._parent() @@ -879,12 +881,15 @@ class Writer(MediaTag): FILTER = 'writer' -class GuidTag(PlexObject): - """ Base class for guid tags used only for Guids, as they contain only a string identifier +@utils.registerPlexObject +class Guid(PlexObject): + """ Represents a single Guid media tag. Attributes: + TAG (str): 'Guid' id (id): The guid for external metadata sources (e.g. IMDB, TMDB, TVDB, MBID). """ + TAG = 'Guid' def _loadData(self, data): """ Load attribute values from Plex XML response. """ @@ -893,13 +898,25 @@ class GuidTag(PlexObject): @utils.registerPlexObject -class Guid(GuidTag): - """ Represents a single Guid media tag. +class Rating(PlexObject): + """ Represents a single Rating media tag. Attributes: - TAG (str): 'Guid' + TAG (str): 'Rating' + image (str): The uri for the rating image + (e.g. ``imdb://image.rating``, ``rottentomatoes://image.rating.ripe``, + ``rottentomatoes://image.rating.upright``, ``themoviedb://image.rating``). + type (str): The type of rating (e.g. audience or critic). + value (float): The rating value. """ - TAG = 'Guid' + TAG = 'Rating' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.image = data.attrib.get('image') + self.type = data.attrib.get('type') + self.value = utils.cast(float, data.attrib.get('value')) @utils.registerPlexObject @@ -908,7 +925,7 @@ class Review(PlexObject): Attributes: TAG (str): 'Review' - filter (str): filter for reviews? + filter (str): The library filter for the review. id (int): The ID of the review. image (str): The image uri for the review. link (str): The url to the online review. @@ -983,18 +1000,34 @@ class Chapter(PlexObject): Attributes: TAG (str): 'Chapter' + end (int): The end time of the chapter in milliseconds. + filter (str): The library filter for the chapter. + id (int): The ID of the chapter. + index (int): The index of the chapter. + tag (str): The name of the chapter. + title (str): The title of the chapter. + thumb (str): The URL to retrieve the chapter thumbnail. + start (int): The start time of the chapter in milliseconds. """ TAG = 'Chapter' + def __repr__(self): + name = self._clean(self.firstAttr('tag')) + start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start'))) + end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end'))) + offsets = f'{start}-{end}' + return f"<{':'.join([self.__class__.__name__, name, offsets])}>" + def _loadData(self, data): self._data = data + self.end = utils.cast(int, data.attrib.get('endTimeOffset')) + self.filter = data.attrib.get('filter') self.id = utils.cast(int, data.attrib.get('id', 0)) - self.filter = data.attrib.get('filter') # I couldn't filter on it anyways + self.index = utils.cast(int, data.attrib.get('index')) self.tag = data.attrib.get('tag') self.title = self.tag - self.index = utils.cast(int, data.attrib.get('index')) + self.thumb = data.attrib.get('thumb') self.start = utils.cast(int, data.attrib.get('startTimeOffset')) - self.end = utils.cast(int, data.attrib.get('endTimeOffset')) @utils.registerPlexObject @@ -1003,6 +1036,10 @@ class Marker(PlexObject): Attributes: TAG (str): 'Marker' + end (int): The end time of the marker in milliseconds. + id (int): The ID of the marker. + type (str): The type of marker. + start (int): The start time of the marker in milliseconds. """ TAG = 'Marker' @@ -1015,10 +1052,10 @@ class Marker(PlexObject): def _loadData(self, data): self._data = data + self.end = utils.cast(int, data.attrib.get('endTimeOffset')) self.id = utils.cast(int, data.attrib.get('id')) self.type = data.attrib.get('type') self.start = utils.cast(int, data.attrib.get('startTimeOffset')) - self.end = utils.cast(int, data.attrib.get('endTimeOffset')) @utils.registerPlexObject @@ -1027,13 +1064,15 @@ class Field(PlexObject): Attributes: TAG (str): 'Field' + locked (bool): True if the field is locked. + name (str): The name of the field. """ TAG = 'Field' def _loadData(self, data): self._data = data - self.name = data.attrib.get('name') self.locked = utils.cast(bool, data.attrib.get('locked')) + self.name = data.attrib.get('name') @utils.registerPlexObject diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py index 16414cf5..91c9caaa 100644 --- a/lib/plexapi/mixins.py +++ b/lib/plexapi/mixins.py @@ -5,7 +5,7 @@ from urllib.parse import parse_qsl, quote_plus, unquote, urlencode, urlsplit from plexapi import media, settings, utils from plexapi.exceptions import BadRequest, NotFound -from plexapi.utils import deprecated +from plexapi.utils import deprecated, openOrRead class AdvancedSettingsMixin: @@ -341,14 +341,14 @@ class ArtMixin(ArtUrlMixin): Parameters: url (str): The full URL to the image to upload. - filepath (str): The full file path the the image to upload. + filepath (str): The full file path the the image to upload or file-like object. """ if url: key = f'/library/metadata/{self.ratingKey}/arts?url={quote_plus(url)}' self._server.query(key, method=self._server._session.post) elif filepath: key = f'/library/metadata/{self.ratingKey}/arts' - data = open(filepath, 'rb').read() + data = openOrRead(filepath) self._server.query(key, method=self._server._session.post, data=data) return self @@ -392,14 +392,14 @@ class BannerMixin(BannerUrlMixin): Parameters: url (str): The full URL to the image to upload. - filepath (str): The full file path the the image to upload. + filepath (str): The full file path the the image to upload or file-like object. """ if url: key = f'/library/metadata/{self.ratingKey}/banners?url={quote_plus(url)}' self._server.query(key, method=self._server._session.post) elif filepath: key = f'/library/metadata/{self.ratingKey}/banners' - data = open(filepath, 'rb').read() + data = openOrRead(filepath) self._server.query(key, method=self._server._session.post, data=data) return self @@ -448,14 +448,14 @@ class PosterMixin(PosterUrlMixin): Parameters: url (str): The full URL to the image to upload. - filepath (str): The full file path the the image to upload. + filepath (str): The full file path the the image to upload or file-like object. """ if url: key = f'/library/metadata/{self.ratingKey}/posters?url={quote_plus(url)}' self._server.query(key, method=self._server._session.post) elif filepath: key = f'/library/metadata/{self.ratingKey}/posters' - data = open(filepath, 'rb').read() + data = openOrRead(filepath) self._server.query(key, method=self._server._session.post, data=data) return self @@ -494,22 +494,24 @@ class ThemeMixin(ThemeUrlMixin): """ Returns list of available :class:`~plexapi.media.Theme` objects. """ return self.fetchItems(f'/library/metadata/{self.ratingKey}/themes', cls=media.Theme) - def uploadTheme(self, url=None, filepath=None): + def uploadTheme(self, url=None, filepath=None, timeout=None): """ Upload a theme from url or filepath. Warning: Themes cannot be deleted using PlexAPI! Parameters: url (str): The full URL to the theme to upload. - filepath (str): The full file path to the theme to upload. + filepath (str): The full file path to the theme to upload or file-like object. + timeout (int, optional): Timeout, in seconds, to use when uploading themes to the server. + (default config.TIMEOUT). """ if url: key = f'/library/metadata/{self.ratingKey}/themes?url={quote_plus(url)}' - self._server.query(key, method=self._server._session.post) + self._server.query(key, method=self._server._session.post, timeout=timeout) elif filepath: key = f'/library/metadata/{self.ratingKey}/themes' - data = open(filepath, 'rb').read() - self._server.query(key, method=self._server._session.post, data=data) + data = openOrRead(filepath) + self._server.query(key, method=self._server._session.post, data=data, timeout=timeout) return self def setTheme(self, theme): diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py index 7bfbe7ab..ca3f026a 100644 --- a/lib/plexapi/myplex.py +++ b/lib/plexapi/myplex.py @@ -32,6 +32,7 @@ class MyPlexAccount(PlexObject): session (requests.Session, optional): Use your own session object if you want to cache the http responses from PMS timeout (int): timeout in seconds on initial connect to myplex (default config.TIMEOUT). + code (str): Two-factor authentication code to use when logging in. Attributes: SIGNIN (str): 'https://plex.tv/users/sign_in.xml' @@ -88,19 +89,21 @@ class MyPlexAccount(PlexObject): # https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId} key = 'https://plex.tv/users/account' - def __init__(self, username=None, password=None, token=None, session=None, timeout=None): + def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None): self._token = token or CONFIG.get('auth.server_token') self._session = session or requests.Session() self._sonos_cache = [] self._sonos_cache_timestamp = 0 - data, initpath = self._signin(username, password, timeout) + data, initpath = self._signin(username, password, code, timeout) super(MyPlexAccount, self).__init__(self, data, initpath) - def _signin(self, username, password, timeout): + def _signin(self, username, password, code, timeout): if self._token: return self.query(self.key), self.key username = username or CONFIG.get('auth.myplex_username') password = password or CONFIG.get('auth.myplex_password') + if code: + password += code data = self.query(self.SIGNIN, method=self._session.post, auth=(username, password), timeout=timeout) return data, self.SIGNIN @@ -390,12 +393,13 @@ class MyPlexAccount(PlexObject): url = self.HOMEUSER.format(userId=user.id) return self.query(url, self._session.delete) - def switchHomeUser(self, user): + def switchHomeUser(self, user, pin=None): """ Returns a new :class:`~plexapi.myplex.MyPlexAccount` object switched to the given home user. Parameters: user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser`, username, or email of the home user to switch to. + pin (str): PIN for the home user (required if the home user has a PIN set). Example: @@ -410,9 +414,12 @@ class MyPlexAccount(PlexObject): """ user = user if isinstance(user, MyPlexUser) else self.user(user) url = f'{self.HOMEUSERS}/{user.id}/switch' - data = self.query(url, self._session.post) + params = {} + if pin: + params['pin'] = pin + data = self.query(url, self._session.post, params=params) userToken = data.attrib.get('authenticationToken') - return MyPlexAccount(token=userToken) + return MyPlexAccount(token=userToken, session=self._session) def setPin(self, newPin, currentPin=None): """ Set a new Plex Home PIN for the account. @@ -861,7 +868,12 @@ class MyPlexAccount(PlexObject): results += subresults[:maxresults - len(results)] params['X-Plex-Container-Start'] += params['X-Plex-Container-Size'] - return self._toOnlineMetadata(results) + # totalSize is available in first response, update maxresults from it + totalSize = utils.cast(int, data.attrib.get('totalSize')) + if maxresults > totalSize: + maxresults = totalSize + + return self._toOnlineMetadata(results, **kwargs) def onWatchlist(self, item): """ Returns True if the item is on the user's watchlist. @@ -941,7 +953,7 @@ class MyPlexAccount(PlexObject): } params = { 'query': query, - 'limit ': limit, + 'limit': limit, 'searchTypes': libtype, 'includeMetadata': 1 } @@ -1005,21 +1017,24 @@ class MyPlexAccount(PlexObject): data = {'code': pin} self.query(self.LINK, self._session.put, headers=headers, data=data) - def _toOnlineMetadata(self, objs): + def _toOnlineMetadata(self, objs, **kwargs): """ Convert a list of media objects to online metadata objects. """ # TODO: Add proper support for metadata.provider.plex.tv # Temporary workaround to allow reloading and browsing of online media objects - server = PlexServer(self.METADATA, self._token) + server = PlexServer(self.METADATA, self._token, session=self._session) + + includeUserState = int(bool(kwargs.pop('includeUserState', True))) if not isinstance(objs, list): objs = [objs] + for obj in objs: obj._server = server # Parse details key to modify query string url = urlsplit(obj._details_key) query = dict(parse_qsl(url.query)) - query['includeUserState'] = 1 + query['includeUserState'] = includeUserState query.pop('includeFields', None) obj._details_key = urlunsplit((url.scheme, url.netloc, url.path, urlencode(query), url.fragment)) diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index b4174c84..f5ece634 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -5,9 +5,8 @@ from urllib.parse import quote_plus, unquote from plexapi import media, utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound, Unsupported -from plexapi.library import LibrarySection +from plexapi.library import LibrarySection, MusicSection from plexapi.mixins import SmartFilterMixin, ArtMixin, PosterMixin -from plexapi.playqueue import PlayQueue from plexapi.utils import deprecated @@ -330,10 +329,6 @@ class Playlist( """ Delete the playlist. """ self._server.query(self.key, method=self._server._session.delete) - def playQueue(self, *args, **kwargs): - """ Returns a new :class:`~plexapi.playqueue.PlayQueue` from the playlist. """ - return PlayQueue.create(self._server, self, *args, **kwargs) - @classmethod def _create(cls, server, title, items): """ Create a regular playlist. """ @@ -375,15 +370,32 @@ class Playlist( data = server.query(key, method=server._session.post)[0] return cls(server, data, initpath=key) + @classmethod + def _createFromM3U(cls, server, title, section, m3ufilepath): + """ Create a playlist from uploading an m3u file. """ + if not isinstance(section, LibrarySection): + section = server.library.section(section) + + if not isinstance(section, MusicSection): + raise BadRequest('Can only create playlists from m3u files in a music library.') + + args = {'sectionID': section.key, 'path': m3ufilepath} + key = f"/playlists/upload{utils.joinArgs(args)}" + server.query(key, method=server._session.post) + try: + return server.playlists(sectionId=section.key, guid__endswith=m3ufilepath)[0].edit(title=title).reload() + except IndexError: + raise BadRequest('Failed to create playlist from m3u file.') from None + @classmethod def create(cls, server, title, section=None, items=None, smart=False, limit=None, - libtype=None, sort=None, filters=None, **kwargs): + libtype=None, sort=None, filters=None, m3ufilepath=None, **kwargs): """ Create a playlist. Parameters: server (:class:`~plexapi.server.PlexServer`): Server to create the playlist on. title (str): Title of the playlist. - section (:class:`~plexapi.library.LibrarySection`, str): Smart playlists only, + section (:class:`~plexapi.library.LibrarySection`, str): Smart playlists and m3u import only, the library section to create the playlist in. items (List): Regular playlists only, list of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the playlist. @@ -396,17 +408,23 @@ class Playlist( See :func:`~plexapi.library.LibrarySection.search` for more info. filters (dict): Smart playlists only, a dictionary of advanced filters. See :func:`~plexapi.library.LibrarySection.search` for more info. + m3ufilepath (str): Music playlists only, the full file path to an m3u file to import. + Note: This will overwrite any playlist previously created from the same m3u file. **kwargs (dict): Smart playlists only, additional custom filters to apply to the search results. See :func:`~plexapi.library.LibrarySection.search` for more info. Raises: :class:`plexapi.exceptions.BadRequest`: When no items are included to create the playlist. :class:`plexapi.exceptions.BadRequest`: When mixing media types in the playlist. + :class:`plexapi.exceptions.BadRequest`: When attempting to import m3u file into non-music library. + :class:`plexapi.exceptions.BadRequest`: When failed to import m3u file. Returns: :class:`~plexapi.playlist.Playlist`: A new instance of the created Playlist. """ - if smart: + if m3ufilepath: + return cls._createFromM3U(server, title, section, m3ufilepath) + elif smart: return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs) else: return cls._create(server, title, items) diff --git a/lib/plexapi/playqueue.py b/lib/plexapi/playqueue.py index 6f4646dc..4c49f8d2 100644 --- a/lib/plexapi/playqueue.py +++ b/lib/plexapi/playqueue.py @@ -150,8 +150,8 @@ class PlayQueue(PlexObject): Parameters: server (:class:`~plexapi.server.PlexServer`): Server you are connected to. - items (:class:`~plexapi.base.Playable` or :class:`~plexapi.playlist.Playlist`): - A media item, list of media items, or Playlist. + items (:class:`~plexapi.base.PlexPartialObject`): + A media item or a list of media items. startItem (:class:`~plexapi.base.Playable`, optional): Media item in the PlayQueue where playback should begin. shuffle (int, optional): Start the playqueue shuffled. @@ -174,16 +174,13 @@ class PlayQueue(PlexObject): uri_args = quote_plus(f"/library/metadata/{item_keys}") args["uri"] = f"library:///directory/{uri_args}" args["type"] = items[0].listType - elif items.type == "playlist": - args["type"] = items.playlistType - if items.radio: - args["uri"] = f"server://{server.machineIdentifier}/{server.library.identifier}{items.key}" - else: - args["playlistID"] = items.ratingKey else: - uuid = items.section().uuid - args["type"] = items.listType - args["uri"] = f"library://{uuid}/item/{items.key}" + if items.type == "playlist": + args["type"] = items.playlistType + args["playlistID"] = items.ratingKey + else: + args["type"] = items.listType + args["uri"] = f"server://{server.machineIdentifier}/{server.library.identifier}{items.key}" if startItem: args["key"] = startItem.key diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index 168efd40..bec9fc08 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -17,7 +17,7 @@ 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 deprecated +from plexapi.utils import cached_property, deprecated from requests.status_codes import _codes as codes # Need these imports to populate utils.PLEXOBJECTS @@ -109,8 +109,6 @@ class PlexServer(PlexObject): self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true' self._session = session or requests.Session() self._timeout = timeout - self._library = None # cached library - self._settings = None # cached settings self._myPlexAccount = None # cached myPlexAccount self._systemAccounts = None # cached list of SystemAccount self._systemDevices = None # cached list of SystemDevice @@ -173,27 +171,22 @@ class PlexServer(PlexObject): def _uriRoot(self): return f'server://{self.machineIdentifier}/com.plexapp.plugins.library' - @property + @cached_property def library(self): """ Library to browse or search your media. """ - if not self._library: - try: - data = self.query(Library.key) - self._library = Library(self, data) - except BadRequest: - data = self.query('/library/sections/') - # Only the owner has access to /library - # so just return the library without the data. - return Library(self, data) - return self._library + try: + data = self.query(Library.key) + except BadRequest: + # Only the owner has access to /library + # so just return the library without the data. + data = self.query('/library/sections/') + return Library(self, data) - @property + @cached_property def settings(self): """ Returns a list of all server settings. """ - if not self._settings: - data = self.query(Settings.key) - self._settings = Settings(self, data) - return self._settings + data = self.query(Settings.key) + return Settings(self, data) def account(self): """ Returns the :class:`~plexapi.server.Account` object this server belongs to. """ @@ -318,7 +311,7 @@ class PlexServer(PlexObject): """ if self._myPlexAccount is None: from plexapi.myplex import MyPlexAccount - self._myPlexAccount = MyPlexAccount(token=self._token) + self._myPlexAccount = MyPlexAccount(token=self._token, session=self._session) return self._myPlexAccount def _myPlexClientPorts(self): @@ -454,19 +447,42 @@ class PlexServer(PlexObject): Returns: :class:`~plexapi.collection.Collection`: A new instance of the created Collection. + + Example: + + .. code-block:: python + + # Create a regular collection + movies = plex.library.section("Movies") + movie1 = movies.get("Big Buck Bunny") + movie2 = movies.get("Sita Sings the Blues") + collection = plex.createCollection( + title="Favorite Movies", + section=movies, + items=[movie1, movie2] + ) + + # Create a smart collection + collection = plex.createCollection( + title="Recently Aired Comedy TV Shows", + section="TV Shows", + smart=True, + sort="episode.originallyAvailableAt:desc", + filters={"episode.originallyAvailableAt>>": "4w", "genre": "comedy"} + ) """ return Collection.create( self, title, section, items=items, smart=smart, limit=limit, libtype=libtype, sort=sort, filters=filters, **kwargs) def createPlaylist(self, title, section=None, items=None, smart=False, limit=None, - libtype=None, sort=None, filters=None, **kwargs): + libtype=None, sort=None, filters=None, m3ufilepath=None, **kwargs): """ Creates and returns a new :class:`~plexapi.playlist.Playlist`. Parameters: title (str): Title of the playlist. - section (:class:`~plexapi.library.LibrarySection`, str): Smart playlists only, - library section to create the playlist in. + section (:class:`~plexapi.library.LibrarySection`, str): Smart playlists and m3u import only, + the library section to create the playlist in. items (List): Regular playlists only, list of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the playlist. smart (bool): True to create a smart playlist. Default False. @@ -478,19 +494,51 @@ class PlexServer(PlexObject): See :func:`~plexapi.library.LibrarySection.search` for more info. filters (dict): Smart playlists only, a dictionary of advanced filters. See :func:`~plexapi.library.LibrarySection.search` for more info. + m3ufilepath (str): Music playlists only, the full file path to an m3u file to import. + Note: This will overwrite any playlist previously created from the same m3u file. **kwargs (dict): Smart playlists only, additional custom filters to apply to the search results. See :func:`~plexapi.library.LibrarySection.search` for more info. Raises: :class:`plexapi.exceptions.BadRequest`: When no items are included to create the playlist. :class:`plexapi.exceptions.BadRequest`: When mixing media types in the playlist. + :class:`plexapi.exceptions.BadRequest`: When attempting to import m3u file into non-music library. + :class:`plexapi.exceptions.BadRequest`: When failed to import m3u file. Returns: :class:`~plexapi.playlist.Playlist`: A new instance of the created Playlist. + + Example: + + .. code-block:: python + + # Create a regular playlist + episodes = plex.library.section("TV Shows").get("Game of Thrones").episodes() + playlist = plex.createPlaylist( + title="GoT Episodes", + items=episodes + ) + + # Create a smart playlist + playlist = plex.createPlaylist( + title="Top 10 Unwatched Movies", + section="Movies", + smart=True, + limit=10, + sort="audienceRating:desc", + filters={"audienceRating>>": 8.0, "unwatched": True} + ) + + # Create a music playlist from an m3u file + playlist = plex.createPlaylist( + title="Favorite Tracks", + section="Music", + m3ufilepath="/path/to/playlist.m3u" + ) """ return Playlist.create( self, title, section=section, items=items, smart=smart, limit=limit, - libtype=libtype, sort=sort, filters=filters, **kwargs) + libtype=libtype, sort=sort, filters=filters, m3ufilepath=m3ufilepath, **kwargs) def createPlayQueue(self, item, **kwargs): """ Creates and returns a new :class:`~plexapi.playqueue.PlayQueue`. diff --git a/lib/plexapi/settings.py b/lib/plexapi/settings.py index b6b4d2e6..ef91391b 100644 --- a/lib/plexapi/settings.py +++ b/lib/plexapi/settings.py @@ -139,7 +139,14 @@ class Setting(PlexObject): if not enumstr: return None if ':' in enumstr: - return {self._cast(k): v for k, v in [kv.split(':') for kv in enumstr.split('|')]} + d = {} + for kv in enumstr.split('|'): + try: + k, v = kv.split(':') + d[self._cast(k)] = v + except ValueError: + d[self._cast(kv)] = kv + return d return enumstr.split('|') def set(self, value): diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index 36827311..f429147e 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -24,6 +24,11 @@ try: except ImportError: tqdm = None +try: + from functools import cached_property +except ImportError: + from backports.cached_property import cached_property # noqa: F401 + log = logging.getLogger('plexapi') # Search Types - Plex uses these to filter specific media types when searching. @@ -618,3 +623,10 @@ def toJson(obj, **kwargs): return obj.isoformat() return {k: v for k, v in obj.__dict__.items() if not k.startswith('_')} return json.dumps(obj, default=serialize, **kwargs) + + +def openOrRead(file): + if hasattr(file, 'read'): + return file.read() + with open(file, 'rb') as f: + return f.read() diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index 0d664c14..fe12ce67 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -323,6 +323,7 @@ class Movie( producers (List<:class:`~plexapi.media.Producer`>): List of producers objects. rating (float): Movie critic rating (7.9; 9.8; 8.1). ratingImage (str): Key to critic rating image (rottentomatoes://image.rating.rotten). + ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. roles (List<:class:`~plexapi.media.Role`>): List of role objects. similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. studio (str): Studio that created movie (Di Bonaventura Pictures; 21 Laps Entertainment). @@ -363,6 +364,7 @@ class Movie( self.producers = self.findItems(data, media.Producer) self.rating = utils.cast(float, data.attrib.get('rating')) self.ratingImage = data.attrib.get('ratingImage') + self.ratings = self.findItems(data, media.Rating) self.roles = self.findItems(data, media.Role) self.similar = self.findItems(data, media.Similar) self.studio = data.attrib.get('studio') @@ -459,6 +461,7 @@ class Show( 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). + ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. roles (List<:class:`~plexapi.media.Role`>): List of role objects. showOrdering (str): Setting that indicates the episode ordering for the show (None = Library default). @@ -503,6 +506,7 @@ class Show( 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.ratings = self.findItems(data, media.Rating) self.roles = self.findItems(data, media.Role) self.showOrdering = data.attrib.get('showOrdering') self.similar = self.findItems(data, media.Similar) @@ -639,6 +643,7 @@ class Season( parentTheme (str): URL to show theme resource (/library/metadata//theme/). parentThumb (str): URL to show thumbnail image (/library/metadata//thumb/). parentTitle (str): Name of the show for the season. + ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. viewedLeafCount (int): Number of items marked as played in the season view. year (int): Year the season was released. """ @@ -663,6 +668,7 @@ class Season( self.parentTheme = data.attrib.get('parentTheme') self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') + self.ratings = self.findItems(data, media.Rating) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) @@ -800,6 +806,7 @@ class Episode( parentYear (int): Year the season was released. producers (List<:class:`~plexapi.media.Producer`>): List of producers objects. rating (float): Episode rating (7.9; 9.8; 8.1). + ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. roles (List<:class:`~plexapi.media.Role`>): List of role objects. skipParent (bool): True if the show's seasons are set to hidden. viewOffset (int): View offset in milliseconds. @@ -845,6 +852,7 @@ class Episode( self.parentYear = utils.cast(int, data.attrib.get('parentYear')) self.producers = self.findItems(data, media.Producer) self.rating = utils.cast(float, data.attrib.get('rating')) + self.ratings = self.findItems(data, media.Rating) self.roles = self.findItems(data, media.Role) self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0')) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) diff --git a/requirements.txt b/requirements.txt index 70ca360f..b69f5fc2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ MarkupSafe==2.1.1 musicbrainzngs==0.7.1 packaging==22.0 paho-mqtt==1.6.1 -plexapi==4.13.1 +plexapi==4.13.2 portend==3.1.0 profilehooks==1.12.0 PyJWT==2.6.0