diff --git a/lib/plexapi/__init__.py b/lib/plexapi/__init__.py index 49720f6b..06a1ee63 100644 --- a/lib/plexapi/__init__.py +++ b/lib/plexapi/__init__.py @@ -6,6 +6,7 @@ from platform import uname from uuid import getnode from plexapi.config import PlexConfig, reset_base_headers +import plexapi.const as const from plexapi.utils import SecretsFilter # Load User Defined Config @@ -15,7 +16,7 @@ CONFIG = PlexConfig(CONFIG_PATH) # PlexAPI Settings PROJECT = 'PlexAPI' -VERSION = '4.6.1' +VERSION = __version__ = const.__version__ 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/audio.py b/lib/plexapi/audio.py index bb1dee22..20a70ffa 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -28,6 +28,7 @@ class Audio(PlexPartialObject): librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. listType (str): Hardcoded as 'audio' (useful for search filters). moods (List<:class:`~plexapi.media.Mood`>): List of mood objects. + musicAnalysisVersion (int): The Plex music analysis version for the item. ratingKey (int): Unique key identifying the item. summary (str): Summary of the artist, album, or track. thumb (str): URL to thumbnail image (/library/metadata//thumb/). @@ -59,6 +60,7 @@ class Audio(PlexPartialObject): self.librarySectionTitle = data.attrib.get('librarySectionTitle') self.listType = 'audio' self.moods = self.findItems(data, media.Mood) + self.musicAnalysisVersion = utils.cast(int, data.attrib.get('musicAnalysisVersion')) self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.summary = data.attrib.get('summary') self.thumb = data.attrib.get('thumb') @@ -78,6 +80,11 @@ class Audio(PlexPartialObject): """ Returns str, default title for a new syncItem. """ return self.title + @property + def hasSonicAnalysis(self): + """ Returns True if the audio has been sonically analyzed. """ + return self.musicAnalysisVersion == 1 + def sync(self, bitrate, client=None, clientId=None, limit=None, title=None): """ Add current audio (artist, album or track) as sync item for specified device. See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions. @@ -227,6 +234,7 @@ class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin, TAG (str): 'Directory' TYPE (str): 'album' collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + formats (List<:class:`~plexapi.media.Format`>): List of format objects. genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. key (str): API URL (/library/metadata/). labels (List<:class:`~plexapi.media.Label`>): List of label objects. @@ -241,6 +249,7 @@ class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin, rating (float): Album rating (7.9; 9.8; 8.1). studio (str): Studio that released the album. styles (List<:class:`~plexapi.media.Style`>): List of style objects. + subformats (List<:class:`~plexapi.media.Subformat`>): List of subformat objects. viewedLeafCount (int): Number of items marked as played in the album view. year (int): Year the album was released. """ @@ -251,6 +260,7 @@ class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin, """ Load attribute values from Plex XML response. """ Audio._loadData(self, data) self.collections = self.findItems(data, media.Collection) + self.formats = self.findItems(data, media.Format) self.genres = self.findItems(data, media.Genre) self.key = self.key.replace('/children', '') # FIX_BUG_50 self.labels = self.findItems(data, media.Label) @@ -265,6 +275,7 @@ class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin, self.rating = utils.cast(float, data.attrib.get('rating')) self.studio = data.attrib.get('studio') self.styles = self.findItems(data, media.Style) + self.subformats = self.findItems(data, media.Subformat) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) @@ -415,3 +426,7 @@ class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin, def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title) + + def _getWebURL(self, base=None): + """ Get the Plex Web URL with the correct parameters. """ + return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey) diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index bb41f11f..ab1bef44 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -505,6 +505,17 @@ class PlexPartialObject(PlexObject): """ Returns True if this is not a full object. """ return not self.isFullObject() + def _edit(self, **kwargs): + """ Actually edit an object. """ + if 'id' not in kwargs: + kwargs['id'] = self.ratingKey + if 'type' not in kwargs: + kwargs['type'] = utils.searchType(self.type) + + part = '/library/sections/%s/all?%s' % (self.librarySectionID, + urlencode(kwargs)) + self._server.query(part, method=self._server._session.put) + def edit(self, **kwargs): """ Edit an object. @@ -517,14 +528,7 @@ class PlexPartialObject(PlexObject): 'collection[0].tag.tag': 'Super', 'collection.locked': 0} """ - if 'id' not in kwargs: - kwargs['id'] = self.ratingKey - if 'type' not in kwargs: - kwargs['type'] = utils.searchType(self.type) - - part = '/library/sections/%s/all?%s' % (self.librarySectionID, - urlencode(kwargs)) - self._server.query(part, method=self._server._session.put) + self._edit(**kwargs) def _edit_tags(self, tag, items, locked=True, remove=False): """ Helper to edit tags. @@ -575,12 +579,28 @@ class PlexPartialObject(PlexObject): def history(self, maxresults=9999999, mindate=None): """ Get Play History for a media item. + Parameters: maxresults (int): Only return the specified number of results (optional). mindate (datetime): Min datetime to return results from. """ return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey) + def _getWebURL(self, base=None): + """ Get the Plex Web URL with the correct parameters. + Private method to allow overriding parameters from subclasses. + """ + return self._server._buildWebURL(base=base, endpoint='details', key=self.key) + + def getWebURL(self, base=None): + """ Returns the Plex Web URL for a media item. + + Parameters: + base (str): The base URL before the fragment (``#!``). + Default is https://app.plex.tv/desktop. + """ + return self._getWebURL(base=base) + class Playable(object): """ This is a general place to store functions specific to media that is Playable. diff --git a/lib/plexapi/client.py b/lib/plexapi/client.py index 1d45c3f1..56f522d2 100644 --- a/lib/plexapi/client.py +++ b/lib/plexapi/client.py @@ -511,7 +511,9 @@ class PlexClient(PlexObject): playqueue = media if isinstance(media, PlayQueue) else self._server.createPlayQueue(media) self.sendCommand('playback/playMedia', **dict({ + 'providerIdentifier': 'com.plexapp.plugins.library', 'machineIdentifier': self._server.machineIdentifier, + 'protocol': server_url[0], 'address': server_url[1].strip('/'), 'port': server_port, 'offset': offset, diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py index 1d0e1260..cd9e52c1 100644 --- a/lib/plexapi/collection.py +++ b/lib/plexapi/collection.py @@ -442,7 +442,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin smart (bool): True to create a smart collection. Default False. limit (int): Smart collections only, limit the number of items in the collection. libtype (str): Smart collections only, the specific type of content to filter - (movie, show, season, episode, artist, album, track, photoalbum, photo, collection). + (movie, show, season, episode, artist, album, track, photoalbum, photo). sort (str or list, optional): Smart collections only, a string of comma separated sort fields or a list of sort fields in the format ``column:dir``. See :func:`~plexapi.library.LibrarySection.search` for more info. diff --git a/lib/plexapi/const.py b/lib/plexapi/const.py new file mode 100644 index 00000000..cb518feb --- /dev/null +++ b/lib/plexapi/const.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +"""Constants used by plexapi.""" + +# Library version +MAJOR_VERSION = 4 +MINOR_VERSION = 7 +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 2b60144e..f62d5913 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -456,10 +456,45 @@ class LibrarySection(PlexObject): Parameters: title (str): Title of the item to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: The title is not found in the library. """ - key = '/library/sections/%s/all?title=%s' % (self.key, quote(title, safe='')) + key = '/library/sections/%s/all?includeGuids=1&title=%s' % (self.key, quote(title, safe='')) return self.fetchItem(key, title__iexact=title) + def getGuid(self, guid): + """ Returns the media item with the specified external IMDB, TMDB, or TVDB ID. + Note: This search uses a PlexAPI operator so performance may be slow. All items from the + entire Plex library need to be retrieved for each guid search. It is recommended to create + your own lookup dictionary if you are searching for a lot of external guids. + + Parameters: + guid (str): The external guid of the item to return. + Examples: IMDB ``imdb://tt0944947``, TMDB ``tmdb://1399``, TVDB ``tvdb://121361``. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: The guid is not found in the library. + + Example: + + .. code-block:: python + + # This will retrieve all items in the entire library 3 times + result1 = library.getGuid('imdb://tt0944947') + result2 = library.getGuid('tmdb://1399') + result3 = library.getGuid('tvdb://121361') + + # This will only retrieve all items in the library once to create a lookup dictionary + guidLookup = {guid.id: item for item in library.all() for guid in item.guids} + result1 = guidLookup['imdb://tt0944947'] + result2 = guidLookup['tmdb://1399'] + result3 = guidLookup['tvdb://121361'] + + """ + key = '/library/sections/%s/all?includeGuids=1' % self.key + return self.fetchItem(key, Guid__id__iexact=guid) + def all(self, libtype=None, **kwargs): """ Returns a list of all items from this library section. See description of :func:`~plexapi.library.LibrarySection.search()` for details about filtering / sorting. @@ -979,6 +1014,8 @@ class LibrarySection(PlexObject): """ args = {} filter_args = [] + + args['includeGuids'] = int(bool(kwargs.pop('includeGuids', True))) for field, values in list(kwargs.items()): if field.split('__')[-1] not in OPERATORS: filter_args.append(self._validateFilterField(field, values, libtype)) @@ -1405,10 +1442,14 @@ class LibrarySection(PlexObject): Parameters: title (str): Title of the item to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unable to find collection. """ - results = self.collections(title__iexact=title) - if results: - return results[0] + try: + return self.collections(title=title, title__iexact=title)[0] + except IndexError: + raise NotFound('Unable to find collection with title "%s".' % title) from None def collections(self, **kwargs): """ Returns a list of collections from this library section. @@ -1430,15 +1471,19 @@ class LibrarySection(PlexObject): Parameters: title (str): Title of the item to return. - """ - results = self.playlists(title__iexact=title) - if results: - return results[0] - def playlists(self, **kwargs): + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unable to find playlist. + """ + try: + return self.playlists(title=title, title__iexact=title)[0] + except IndexError: + raise NotFound('Unable to find playlist with title "%s".' % title) from None + + def playlists(self, sort=None, **kwargs): """ Returns a list of playlists from this library section. """ - key = '/playlists?type=15&playlistType=%s§ionID=%s' % (self.CONTENT_TYPE, self.key) - return self.fetchItems(key, **kwargs) + return self._server.playlists( + playlistType=self.CONTENT_TYPE, sectionId=self.key, sort=sort, **kwargs) @deprecated('use "listFields" instead') def filterFields(self, mediaType=None): @@ -1448,6 +1493,23 @@ class LibrarySection(PlexObject): def listChoices(self, category, libtype=None, **kwargs): return self.listFilterChoices(field=category, libtype=libtype) + def getWebURL(self, base=None, tab=None, key=None): + """ Returns the Plex Web URL for the library. + + Parameters: + base (str): The base URL before the fragment (``#!``). + Default is https://app.plex.tv/desktop. + tab (str): The library tab (recommended, library, collections, playlists, timeline). + key (str): A hub key. + """ + params = {'source': self.key} + if tab is not None: + params['pivot'] = tab + if key is not None: + params['key'] = key + params['pageType'] = 'list' + return self._server._buildWebURL(base=base, **params) + class MovieSection(LibrarySection): """ Represents a :class:`~plexapi.library.LibrarySection` section containing movies. @@ -1849,6 +1911,7 @@ class Hub(PlexObject): self.style = data.attrib.get('style') self.title = data.attrib.get('title') self.type = data.attrib.get('type') + self._section = None # cache for self.section def __len__(self): return self.size @@ -1860,6 +1923,13 @@ class Hub(PlexObject): self.more = False self.size = len(self.items) + def section(self): + """ Returns the :class:`~plexapi.library.LibrarySection` this hub belongs to. + """ + if self._section is None: + self._section = self._server.library.sectionByID(self.librarySectionID) + return self._section + class HubMediaTag(PlexObject): """ Base class of hub media tag search results. diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index addc3fdf..82766e77 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -174,7 +174,7 @@ class MediaPart(PlexObject): return [stream for stream in self.streams if isinstance(stream, SubtitleStream)] def lyricStreams(self): - """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """ + """ Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """ return [stream for stream in self.streams if isinstance(stream, LyricStream)] def setDefaultAudioStream(self, stream): @@ -731,6 +731,18 @@ class Director(MediaTag): FILTER = 'director' +@utils.registerPlexObject +class Format(MediaTag): + """ Represents a single Format media tag. + + Attributes: + TAG (str): 'Format' + FILTER (str): 'format' + """ + TAG = 'Format' + FILTER = 'format' + + @utils.registerPlexObject class Genre(MediaTag): """ Represents a single Genre media tag. @@ -815,6 +827,18 @@ class Style(MediaTag): FILTER = 'style' +@utils.registerPlexObject +class Subformat(MediaTag): + """ Represents a single Subformat media tag. + + Attributes: + TAG (str): 'Subformat' + FILTER (str): 'subformat' + """ + TAG = 'Subformat' + FILTER = 'subformat' + + @utils.registerPlexObject class Tag(MediaTag): """ Represents a single Tag media tag. diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py index b5e7e649..af5d1da5 100644 --- a/lib/plexapi/mixins.py +++ b/lib/plexapi/mixins.py @@ -97,6 +97,14 @@ class ArtMixin(ArtUrlMixin): """ art.select() + def lockArt(self): + """ Lock the background artwork for a Plex object. """ + self._edit(**{'art.locked': 1}) + + def unlockArt(self): + """ Unlock the background artwork for a Plex object. """ + self._edit(**{'art.locked': 0}) + class BannerUrlMixin(object): """ Mixin for Plex objects that can have a banner url. """ @@ -138,6 +146,14 @@ class BannerMixin(BannerUrlMixin): """ banner.select() + def lockBanner(self): + """ Lock the banner for a Plex object. """ + self._edit(**{'banner.locked': 1}) + + def unlockBanner(self): + """ Unlock the banner for a Plex object. """ + self._edit(**{'banner.locked': 0}) + class PosterUrlMixin(object): """ Mixin for Plex objects that can have a poster url. """ @@ -184,6 +200,14 @@ class PosterMixin(PosterUrlMixin): """ poster.select() + def lockPoster(self): + """ Lock the poster for a Plex object. """ + self._edit(**{'thumb.locked': 1}) + + def unlockPoster(self): + """ Unlock the poster for a Plex object. """ + self._edit(**{'thumb.locked': 0}) + class RatingMixin(object): """ Mixin for Plex objects that can have user star ratings. """ @@ -577,7 +601,9 @@ class SmartFilterMixin(object): key += '=' value = value[1:] - if key == 'type': + if key == 'includeGuids': + filters['includeGuids'] = int(value) + elif key == 'type': filters['libtype'] = utils.reverseSearchType(value) elif key == 'sort': filters['sort'] = value.split(',') diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py index f3196663..6a60db81 100644 --- a/lib/plexapi/photo.py +++ b/lib/plexapi/photo.py @@ -137,6 +137,10 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin, RatingMixin): filepaths.append(filepath) return filepaths + def _getWebURL(self, base=None): + """ Get the Plex Web URL with the correct parameters. """ + return self._server._buildWebURL(base=base, endpoint='details', key=self.key, legacy=1) + @utils.registerPlexObject class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin, TagMixin): @@ -301,3 +305,7 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixi if filepath: filepaths.append(filepath) return filepaths + + def _getWebURL(self, base=None): + """ Get the Plex Web URL with the correct parameters. """ + return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1) diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index 4bcd72c5..e470b76d 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -2,7 +2,7 @@ import re from urllib.parse import quote_plus, unquote -from plexapi import utils +from plexapi import media, utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection @@ -24,6 +24,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMi content (str): The filter URI string for smart playlists. duration (int): Duration of the playlist in milliseconds. durationInSeconds (int): Duration of the playlist in seconds. + fields (List<:class:`~plexapi.media.Field`>): List of field objects. guid (str): Plex GUID for the playlist (com.plexapp.agents.none://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX). icon (str): Icon URI string for smart playlists. key (str): API URL (/playlist/). @@ -48,8 +49,9 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMi self.content = data.attrib.get('content') self.duration = utils.cast(int, data.attrib.get('duration')) self.durationInSeconds = utils.cast(int, data.attrib.get('durationInSeconds')) - self.icon = data.attrib.get('icon') + self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') + self.icon = data.attrib.get('icon') self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50 self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.playlistType = data.attrib.get('playlistType') @@ -288,6 +290,11 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMi })) self._server.query(key, method=self._server._session.put) + def _edit(self, **kwargs): + """ Actually edit the playlist. """ + key = '%s%s' % (self.key, utils.joinArgs(kwargs)) + self._server.query(key, method=self._server._session.put) + def edit(self, title=None, summary=None): """ Edit the playlist. @@ -300,9 +307,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMi args['title'] = title if summary: args['summary'] = summary - - key = '%s%s' % (self.key, utils.joinArgs(args)) - self._server.query(key, method=self._server._session.put) + self._edit(**args) def delete(self): """ Delete the playlist. """ @@ -341,13 +346,15 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMi return cls(server, data, initpath=key) @classmethod - def _createSmart(cls, server, title, section, limit=None, sort=None, filters=None, **kwargs): + def _createSmart(cls, server, title, section, limit=None, libtype=None, sort=None, filters=None, **kwargs): """ Create a smart playlist. """ if not isinstance(section, LibrarySection): section = server.library.section(section) + libtype = libtype or section.METADATA_TYPE + searchKey = section._buildSearchKey( - sort=sort, libtype=section.METADATA_TYPE, limit=limit, filters=filters, **kwargs) + sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs) uri = '%s%s' % (server._uriRoot(), searchKey) key = '/playlists%s' % utils.joinArgs({ @@ -361,7 +368,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMi @classmethod def create(cls, server, title, section=None, items=None, smart=False, limit=None, - sort=None, filters=None, **kwargs): + libtype=None, sort=None, filters=None, **kwargs): """ Create a playlist. Parameters: @@ -373,6 +380,8 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMi :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. limit (int): Smart playlists only, limit the number of items in the playlist. + libtype (str): Smart playlists only, the specific type of content to filter + (movie, show, season, episode, artist, album, track, photoalbum, photo). sort (str or list, optional): Smart playlists only, a string of comma separated sort fields or a list of sort fields in the format ``column:dir``. See :func:`~plexapi.library.LibrarySection.search` for more info. @@ -389,7 +398,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMi :class:`~plexapi.playlist.Playlist`: A new instance of the created Playlist. """ if smart: - return cls._createSmart(server, title, section, limit, sort, filters, **kwargs) + return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs) else: return cls._create(server, title, items) @@ -455,3 +464,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMi raise Unsupported('Unsupported playlist content') return myplex.sync(sync_item, client=client, clientId=clientId) + + def _getWebURL(self, base=None): + """ Get the Plex Web URL with the correct parameters. """ + return self._server._buildWebURL(base=base, endpoint='playlist', key=self.key) diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index 2ec8c422..5c0beaed 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -427,7 +427,7 @@ class PlexServer(PlexObject): smart (bool): True to create a smart collection. Default False. limit (int): Smart collections only, limit the number of items in the collection. libtype (str): Smart collections only, the specific type of content to filter - (movie, show, season, episode, artist, album, track, photoalbum, photo, collection). + (movie, show, season, episode, artist, album, track, photoalbum, photo). sort (str or list, optional): Smart collections only, a string of comma separated sort fields or a list of sort fields in the format ``column:dir``. See :func:`~plexapi.library.LibrarySection.search` for more info. @@ -448,7 +448,7 @@ class PlexServer(PlexObject): libtype=libtype, sort=sort, filters=filters, **kwargs) def createPlaylist(self, title, section=None, items=None, smart=False, limit=None, - sort=None, filters=None, **kwargs): + libtype=None, sort=None, filters=None, **kwargs): """ Creates and returns a new :class:`~plexapi.playlist.Playlist`. Parameters: @@ -459,6 +459,8 @@ class PlexServer(PlexObject): :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. limit (int): Smart playlists only, limit the number of items in the playlist. + libtype (str): Smart playlists only, the specific type of content to filter + (movie, show, season, episode, artist, album, track, photoalbum, photo). sort (str or list, optional): Smart playlists only, a string of comma separated sort fields or a list of sort fields in the format ``column:dir``. See :func:`~plexapi.library.LibrarySection.search` for more info. @@ -476,7 +478,7 @@ class PlexServer(PlexObject): """ return Playlist.create( self, title, section=section, items=items, smart=smart, limit=limit, - sort=sort, filters=filters, **kwargs) + libtype=libtype, sort=sort, filters=filters, **kwargs) def createPlayQueue(self, item, **kwargs): """ Creates and returns a new :class:`~plexapi.playqueue.PlayQueue`. @@ -575,17 +577,29 @@ class PlexServer(PlexObject): args['X-Plex-Container-Start'] += args['X-Plex-Container-Size'] return results - def playlists(self, playlistType=None): + def playlists(self, playlistType=None, sectionId=None, title=None, sort=None, **kwargs): """ Returns a list of all :class:`~plexapi.playlist.Playlist` objects on the server. Parameters: playlistType (str, optional): The type of playlists to return (audio, video, photo). Default returns all playlists. + sectionId (int, optional): The section ID (key) of the library to search within. + title (str, optional): General string query to search for. Partial string matches are allowed. + sort (str or list, optional): A string of comma separated sort fields in the format ``column:dir``. """ - key = '/playlists' - if playlistType: - key = '%s?playlistType=%s' % (key, playlistType) - return self.fetchItems(key) + args = {} + if playlistType is not None: + args['playlistType'] = playlistType + if sectionId is not None: + args['sectionID'] = sectionId + if title is not None: + args['title'] = title + if sort is not None: + # TODO: Automatically retrieve and validate sort field similar to LibrarySection.search() + args['sort'] = sort + + key = '/playlists%s' % utils.joinArgs(args) + return self.fetchItems(key, **kwargs) def playlist(self, title): """ Returns the :class:`~plexapi.client.Playlist` that matches the specified title. @@ -594,9 +608,12 @@ class PlexServer(PlexObject): title (str): Title of the playlist to return. Raises: - :exc:`~plexapi.exceptions.NotFound`: Invalid playlist title. + :exc:`~plexapi.exceptions.NotFound`: Unable to find playlist. """ - return self.fetchItem('/playlists', title=title) + try: + return self.playlists(title=title, title__iexact=title)[0] + except IndexError: + raise NotFound('Unable to find playlist with title "%s".' % title) from None def optimizedItems(self, removeAll=None): """ Returns list of all :class:`~plexapi.media.Optimized` objects connected to server. """ @@ -873,6 +890,42 @@ class PlexServer(PlexObject): key = '/statistics/resources?timespan=6' return self.fetchItems(key, StatisticsResources) + def _buildWebURL(self, base=None, endpoint=None, **kwargs): + """ Build the Plex Web URL for the object. + + Parameters: + base (str): The base URL before the fragment (``#!``). + Default is https://app.plex.tv/desktop. + endpoint (str): The Plex Web URL endpoint. + None for server, 'playlist' for playlists, 'details' for all other media types. + **kwargs (dict): Dictionary of URL parameters. + """ + if base is None: + base = 'https://app.plex.tv/desktop/' + + if endpoint: + return '%s#!/server/%s/%s%s' % ( + base, self.machineIdentifier, endpoint, utils.joinArgs(kwargs) + ) + else: + return '%s#!/media/%s/com.plexapp.plugins.library%s' % ( + base, self.machineIdentifier, utils.joinArgs(kwargs) + ) + + def getWebURL(self, base=None, playlistTab=None): + """ Returns the Plex Web URL for the server. + + Parameters: + base (str): The base URL before the fragment (``#!``). + Default is https://app.plex.tv/desktop. + playlistTab (str): The playlist tab (audio, video, photo). Only used for the playlist URL. + """ + if playlistTab is not None: + params = {'source': 'playlists', 'pivot': 'playlists.%s' % playlistTab} + else: + params = {'key': '/hubs', 'pageType': 'hub'} + return self._buildWebURL(base=base, **params) + class Account(PlexObject): """ Contains the locally cached MyPlex account information. The properties provided don't