From eaff00d7280c3d102912f2dcbf30bbaf3d21aa7d Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 7 Aug 2020 12:47:26 -0500 Subject: [PATCH 01/14] Fix Album iterator (#544) --- plexapi/audio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index d6ff5943..8a8d3862 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -213,7 +213,7 @@ class Album(Audio): TYPE = 'album' def __iter__(self): - for track in self.tracks: + for track in self.tracks(): yield track def _loadData(self, data): From dbc5adc744c3855805e8392e11ee52bfb9f47dd0 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 7 Aug 2020 15:31:54 -0500 Subject: [PATCH 02/14] Fix datetime import error in utils.py (#545) --- plexapi/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/utils.py b/plexapi/utils.py index 58e9be0b..f6fd21b5 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -212,7 +212,7 @@ def millisecondToHumanstr(milliseconds): milliseconds (str,int): time duration in milliseconds. """ milliseconds = int(milliseconds) - r = datetime.datetime.utcfromtimestamp(milliseconds / 1000) + r = datetime.utcfromtimestamp(milliseconds / 1000) f = r.strftime("%H:%M:%S.%f") return f[:-2] From d73cec3b7d1c1203906a464e48bb3b9887aad24e Mon Sep 17 00:00:00 2001 From: Steffen Fredriksen Date: Fri, 7 Aug 2020 22:34:57 +0200 Subject: [PATCH 03/14] Fix some flake issues (#540) --- plexapi/library.py | 1 + plexapi/utils.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/plexapi/library.py b/plexapi/library.py index b0fafa38..8a6204d6 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -1059,6 +1059,7 @@ class FilterChoice(PlexObject): self.title = data.attrib.get('title') self.type = data.attrib.get('type') + @utils.registerPlexObject class Location(PlexObject): """ Represents a single library Location. diff --git a/plexapi/utils.py b/plexapi/utils.py index f6fd21b5..24a9c7dd 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -4,7 +4,7 @@ import os import re import time import zipfile -from datetime import datetime, timedelta +from datetime import datetime from getpass import getpass from threading import Event, Thread from urllib.parse import quote From fe3907e770ea160cae3b40e681ea4a9998b7bb86 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 7 Aug 2020 15:47:46 -0500 Subject: [PATCH 04/14] Fix timestamp assertion in tests (#547) --- tests/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8587eca8..9febf0dc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -81,4 +81,4 @@ def test_utils_download(plex, episode): def test_millisecondToHumanstr(): res = utils.millisecondToHumanstr(1000) - assert res == "00:00:01:0000" + assert res == "00:00:01.0000" From fc54f7fe064d2f304e8a986490c41f6f82cb04e1 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 7 Aug 2020 16:57:04 -0500 Subject: [PATCH 05/14] Add guards for missing thumbs in tests (#548) --- tests/test_audio.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_audio.py b/tests/test_audio.py index 0cc2b1cc..ba4d42c8 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -113,7 +113,8 @@ def test_audio_Album_tracks(album): # assert utils.is_int(track.parentIndex) assert utils.is_metadata(track.parentKey) assert utils.is_int(track.parentRatingKey) - assert utils.is_metadata(track.parentThumb, contains="/thumb/") + if track.parentThumb: + assert utils.is_metadata(track.parentThumb, contains="/thumb/") assert track.parentTitle == "Layers" # assert track.ratingCount == 9 # Flaky assert utils.is_int(track.ratingKey) @@ -148,7 +149,8 @@ def test_audio_Album_track(album, track=None): assert utils.is_int(track.parentIndex) assert utils.is_metadata(track.parentKey) assert utils.is_int(track.parentRatingKey) - assert utils.is_metadata(track.parentThumb, contains="/thumb/") + if track.parentThumb: + assert utils.is_metadata(track.parentThumb, contains="/thumb/") assert track.parentTitle == "Layers" # assert track.ratingCount == 9 assert utils.is_int(track.ratingKey) @@ -228,7 +230,8 @@ def test_audio_Track_attrs(album): assert int(track.parentIndex) == 1 assert utils.is_metadata(track.parentKey) assert utils.is_int(track.parentRatingKey) - assert utils.is_metadata(track.parentThumb, contains="/thumb/") + if track.parentThumb: + assert utils.is_metadata(track.parentThumb, contains="/thumb/") assert track.parentTitle == "Layers" assert track.playlistItemID is None assert track.primaryExtraKey is None From fe27d7644f3e4f437916e3c3b58304a073626551 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 11 Aug 2020 12:11:53 -0500 Subject: [PATCH 06/14] Add iterator for Playlist objects (#549) --- plexapi/playlist.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plexapi/playlist.py b/plexapi/playlist.py index c640ae34..04912bbe 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -41,6 +41,10 @@ class Playlist(PlexPartialObject, Playable): def __len__(self): # pragma: no cover return len(self.items()) + def __iter__(self): # pragma: no cover + for item in self.items(): + yield item + @property def metadataType(self): if self.isVideo: From 5b7d48fc268ab58490a726ba7f4ac0b990b25ae0 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 20 Aug 2020 10:08:47 -0500 Subject: [PATCH 07/14] Improve clips handling (#541) * Improve clips handling * Remove year, add extraType * Don't bother refreshing for missing attribute * Update with attributes found in payload * Update docstrings for clips --- plexapi/video.py | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index 526bfcce..ef1a348e 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -10,7 +10,7 @@ from plexapi.exceptions import BadRequest, NotFound class Video(PlexPartialObject): """ Base class for all video objects including :class:`~plexapi.video.Movie`, :class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`, - :class:`~plexapi.video.Episode`. + :class:`~plexapi.video.Episode`, :class:`~plexapi.video.Clip`. Attributes: addedAt (datetime): Datetime this item was added to the library. @@ -774,24 +774,38 @@ class Episode(Playable, Video): @utils.registerPlexObject class Clip(Playable, Video): - """ Represents a single Clip.""" + """Represents a single Clip. + + Attributes: + TAG (str): 'Video' + TYPE (str): 'clip' + duration (int): Duration of movie in milliseconds. + extraType (int): Unknown + guid: Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en). + index (int): Plex index (?) + originallyAvailableAt (datetime): Datetime movie was released. + subtype (str): Type of clip + viewOffset (int): View offset in milliseconds. + """ TAG = 'Video' TYPE = 'clip' METADATA_TYPE = 'clip' def _loadData(self, data): - self._data = data - self.addedAt = data.attrib.get('addedAt') - self.duration = data.attrib.get('duration') + """Load attribute values from Plex XML response.""" + Video._loadData(self, data) + Playable._loadData(self, data) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.extraType = utils.cast(int, data.attrib.get('extraType')) self.guid = data.attrib.get('guid') - self.key = data.attrib.get('key') + self.index = utils.cast(int, data.attrib.get('index')) self.originallyAvailableAt = data.attrib.get('originallyAvailableAt') - self.ratingKey = data.attrib.get('ratingKey') - self.skipDetails = utils.cast(int, data.attrib.get('skipDetails')) self.subtype = data.attrib.get('subtype') - self.thumb = data.attrib.get('thumb') - self.thumbAspectRatio = data.attrib.get('thumbAspectRatio') - self.title = data.attrib.get('title') - self.type = data.attrib.get('type') - self.year = data.attrib.get('year') + self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) + + def section(self): + """Return the :class:`~plexapi.library.LibrarySection` this item belongs to.""" + # Clip payloads currently do not contain 'librarySectionID'. + # Return None to avoid unnecessary attribute lookup attempts. + return None From f0afbc69224774a40299ad84f5232ee9f0e9a4ca Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 20 Aug 2020 11:01:53 -0500 Subject: [PATCH 08/14] Bump to 4.1.0 release (#556) Change log: https://github.com/pkkid/python-plexapi/compare/4.0.0...79e99af --- plexapi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/__init__.py b/plexapi/__init__.py index 09f1953f..fa01cd75 100644 --- a/plexapi/__init__.py +++ b/plexapi/__init__.py @@ -15,7 +15,7 @@ CONFIG = PlexConfig(CONFIG_PATH) # PlexAPI Settings PROJECT = 'PlexAPI' -VERSION = '4.0.0' +VERSION = '4.1.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) From ad8fd58c66085eb58d3e7b40264a153a64a6389d Mon Sep 17 00:00:00 2001 From: Kyle Zimmerman Date: Sun, 6 Sep 2020 00:33:32 -0400 Subject: [PATCH 09/14] Add optional continuous arg to PlayQueue.create() (#561) --- plexapi/playqueue.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plexapi/playqueue.py b/plexapi/playqueue.py index 08aa774c..b501bbda 100644 --- a/plexapi/playqueue.py +++ b/plexapi/playqueue.py @@ -42,7 +42,7 @@ class PlayQueue(PlexObject): self.items = self.findItems(data) @classmethod - def create(cls, server, item, shuffle=0, repeat=0, includeChapters=1, includeRelated=1): + def create(cls, server, item, shuffle=0, repeat=0, includeChapters=1, includeRelated=1, continuous=0): """ Create and returns a new :class:`~plexapi.playqueue.PlayQueue`. Paramaters: @@ -52,12 +52,14 @@ class PlayQueue(PlexObject): repeat (int, optional): Start the playqueue shuffled. includeChapters (int, optional): include Chapters. includeRelated (int, optional): include Related. + continuous (int, optional): include additional items after the initial item. For a show this would be the next episodes, for a movie it does nothing. """ args = {} args['includeChapters'] = includeChapters args['includeRelated'] = includeRelated args['repeat'] = repeat args['shuffle'] = shuffle + args['continuous'] = continuous if item.type == 'playlist': args['playlistID'] = item.ratingKey args['type'] = item.playlistType From fb82bc402bd725220687b76c01a35008884b75ac Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 11 Sep 2020 16:23:27 -0500 Subject: [PATCH 10/14] Playqueue improvements (#563) * Allow creating PlayQueues with multiple items, appending items * Fix single-item playqueues, fix 'next', fix docstrings, run black * Docstring updates * More documentation fixes * Allow removing items from a PlayQueue * Use f-strings for readability * Add ability to move items within the PlayQueue * Cast attributes to proper types, update docs * Format with black * flake8 and sphinx fixes * Reformat with black * Update __contains__ to accept media objects * Operate using media items, use methods similar to playlists * Rename parameter to better match behavior * Help users by automatically finding appropriate playQueueItemID values * Add refresh method, auto-refresh before modifying playqueues * Reformat with black * Add TAG and TYPE to PlayQueue objects * Review comments, add playQueueSelectedMetadataItemKey for Chromecast convenience * Allow setting the playback start point in the PlayQueue * Add tests, simplify size check * Use camel case for helper function * Add a helper to provide the selected item media object --- plexapi/base.py | 2 + plexapi/playqueue.py | 281 ++++++++++++++++++++++++++++++++-------- plexapi/server.py | 2 +- tests/test_playlist.py | 18 --- tests/test_playqueue.py | 140 ++++++++++++++++++++ 5 files changed, 367 insertions(+), 76 deletions(-) create mode 100644 tests/test_playqueue.py diff --git a/plexapi/base.py b/plexapi/base.py index 101a0b43..09a25a4b 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -598,6 +598,7 @@ class Playable(object): if item is being transcoded (None otherwise). viewedAt (datetime): Datetime item was last viewed (history). playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items). + playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items). """ def _loadData(self, data): @@ -609,6 +610,7 @@ class Playable(object): self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history self.accountID = utils.cast(int, data.attrib.get('accountID')) # history self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist + self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue def isFullObject(self): """ Retruns True if this is already a full object. A full object means all attributes diff --git a/plexapi/playqueue.py b/plexapi/playqueue.py index b501bbda..89470c9a 100644 --- a/plexapi/playqueue.py +++ b/plexapi/playqueue.py @@ -1,77 +1,244 @@ # -*- coding: utf-8 -*- +from urllib.parse import quote_plus + from plexapi import utils from plexapi.base import PlexObject +from plexapi.exceptions import BadRequest, Unsupported class PlayQueue(PlexObject): - """ Control a PlayQueue. + """Control a PlayQueue. - Attributes: - key (str): This is only added to support playMedia - identifier (str): com.plexapp.plugins.library - initpath (str): Relative url where data was grabbed from. - items (list): List of :class:`~plexapi.media.Media` or class:`~plexapi.playlist.Playlist` - mediaTagPrefix (str): Fx /system/bundle/media/flags/ - mediaTagVersion (str): Fx 1485957738 - playQueueID (str): a id for the playqueue - playQueueSelectedItemID (str): playQueueSelectedItemID - playQueueSelectedItemOffset (str): playQueueSelectedItemOffset - playQueueSelectedMetadataItemID (): 7 - playQueueShuffled (bool): True if shuffled - playQueueSourceURI (str): Fx library://150425c9-0d99-4242-821e-e5ab81cd2221/item//library/metadata/7 - playQueueTotalCount (str): How many items in the play queue. - playQueueVersion (str): What version the playqueue is. - server (:class:`~plexapi.server.PlexServer`): Server you are connected to. - size (str): Seems to be a alias for playQueueTotalCount. + Attributes: + TAG (str): 'PlayQueue' + TYPE (str): 'playqueue' + identifier (str): com.plexapp.plugins.library + items (list): List of :class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist` + mediaTagPrefix (str): Fx /system/bundle/media/flags/ + mediaTagVersion (int): Fx 1485957738 + playQueueID (int): ID of the PlayQueue. + playQueueLastAddedItemID (int): + Defines where the "Up Next" region starts. Empty unless PlayQueue is modified after creation. + playQueueSelectedItemID (int): The queue item ID of the currently selected item. + playQueueSelectedItemOffset (int): + The offset of the selected item in the PlayQueue, from the beginning of the queue. + playQueueSelectedMetadataItemID (int): ID of the currently selected item, matches ratingKey. + playQueueShuffled (bool): True if shuffled. + playQueueSourceURI (str): Original URI used to create the PlayQueue. + playQueueTotalCount (int): How many items in the PlayQueue. + playQueueVersion (int): Version of the PlayQueue. Increments every time a change is made to the PlayQueue. + selectedItem (:class:`~plexapi.media.Media`): Media object for the currently selected item. + _server (:class:`~plexapi.server.PlexServer`): PlexServer associated with the PlayQueue. + size (int): Alias for playQueueTotalCount. """ + TAG = "PlayQueue" + TYPE = "playqueue" + def _loadData(self, data): self._data = data - self.identifier = data.attrib.get('identifier') - self.mediaTagPrefix = data.attrib.get('mediaTagPrefix') - self.mediaTagVersion = data.attrib.get('mediaTagVersion') - self.playQueueID = data.attrib.get('playQueueID') - self.playQueueSelectedItemID = data.attrib.get('playQueueSelectedItemID') - self.playQueueSelectedItemOffset = data.attrib.get('playQueueSelectedItemOffset') - self.playQueueSelectedMetadataItemID = data.attrib.get('playQueueSelectedMetadataItemID') - self.playQueueShuffled = utils.cast(bool, data.attrib.get('playQueueShuffled', 0)) - self.playQueueSourceURI = data.attrib.get('playQueueSourceURI') - self.playQueueTotalCount = data.attrib.get('playQueueTotalCount') - self.playQueueVersion = data.attrib.get('playQueueVersion') - self.size = utils.cast(int, data.attrib.get('size', 0)) + self.identifier = data.attrib.get("identifier") + self.mediaTagPrefix = data.attrib.get("mediaTagPrefix") + self.mediaTagVersion = utils.cast(int, data.attrib.get("mediaTagVersion")) + self.playQueueID = utils.cast(int, data.attrib.get("playQueueID")) + self.playQueueLastAddedItemID = utils.cast( + int, data.attrib.get("playQueueLastAddedItemID") + ) + self.playQueueSelectedItemID = utils.cast( + int, data.attrib.get("playQueueSelectedItemID") + ) + self.playQueueSelectedItemOffset = utils.cast( + int, data.attrib.get("playQueueSelectedItemOffset") + ) + self.playQueueSelectedMetadataItemID = utils.cast( + int, data.attrib.get("playQueueSelectedMetadataItemID") + ) + self.playQueueShuffled = utils.cast( + bool, data.attrib.get("playQueueShuffled", 0) + ) + self.playQueueSourceURI = data.attrib.get("playQueueSourceURI") + self.playQueueTotalCount = utils.cast( + int, data.attrib.get("playQueueTotalCount") + ) + self.playQueueVersion = utils.cast(int, data.attrib.get("playQueueVersion")) + self.size = utils.cast(int, data.attrib.get("size", 0)) self.items = self.findItems(data) + self.selectedItem = self[self.playQueueSelectedItemOffset] + + def __getitem__(self, key): + return self.items[key] + + def __len__(self): + return self.playQueueTotalCount + + def __iter__(self): + yield from self.items + + def __contains__(self, media): + """Returns True if the PlayQueue contains the provided media item.""" + return any(x.playQueueItemID == media.playQueueItemID for x in self.items) + + def getQueueItem(self, item): + """ + Accepts a media item and returns a similar object from this PlayQueue. + Useful for looking up playQueueItemIDs using items obtained from the Library. + """ + matches = [x for x in self.items if x == item] + if len(matches) == 1: + return matches[0] + elif len(matches) > 1: + raise BadRequest( + f"{item} occurs multiple times in this PlayQueue, provide exact item" + ) + else: + raise BadRequest(f"{item} not valid for this PlayQueue") @classmethod - def create(cls, server, item, shuffle=0, repeat=0, includeChapters=1, includeRelated=1, continuous=0): - """ Create and returns a new :class:`~plexapi.playqueue.PlayQueue`. + def create( + cls, + server, + items, + startItem=None, + shuffle=0, + repeat=0, + includeChapters=1, + includeRelated=1, + continuous=0, + ): + """Create and return a new :class:`~plexapi.playqueue.PlayQueue`. - Paramaters: - server (:class:`~plexapi.server.PlexServer`): Server you are connected to. - item (:class:`~plexapi.media.Media` or class:`~plexapi.playlist.Playlist`): A media or Playlist. - shuffle (int, optional): Start the playqueue shuffled. - repeat (int, optional): Start the playqueue shuffled. - includeChapters (int, optional): include Chapters. - includeRelated (int, optional): include Related. - continuous (int, optional): include additional items after the initial item. For a show this would be the next episodes, for a movie it does nothing. + Parameters: + server (:class:`~plexapi.server.PlexServer`): Server you are connected to. + items (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`): + A media item, list of media items, or Playlist. + startItem (:class:`~plexapi.media.Media`, optional): + Media item in the PlayQueue where playback should begin. + shuffle (int, optional): Start the playqueue shuffled. + repeat (int, optional): Start the playqueue shuffled. + includeChapters (int, optional): include Chapters. + includeRelated (int, optional): include Related. + continuous (int, optional): include additional items after the initial item. + For a show this would be the next episodes, for a movie it does nothing. """ - args = {} - args['includeChapters'] = includeChapters - args['includeRelated'] = includeRelated - args['repeat'] = repeat - args['shuffle'] = shuffle - args['continuous'] = continuous - if item.type == 'playlist': - args['playlistID'] = item.ratingKey - args['type'] = item.playlistType + args = { + "includeChapters": includeChapters, + "includeRelated": includeRelated, + "repeat": repeat, + "shuffle": shuffle, + "continuous": continuous, + } + + if isinstance(items, list): + item_keys = ",".join([str(x.ratingKey) for x in items]) + 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["playlistID"] = items.ratingKey + args["type"] = items.playlistType else: - uuid = item.section().uuid - args['key'] = item.key - args['type'] = item.listType - args['uri'] = 'library://%s/item/%s' % (uuid, item.key) - path = '/playQueues%s' % utils.joinArgs(args) + uuid = items.section().uuid + args["type"] = items.listType + args["uri"] = f"library://{uuid}/item/{items.key}" + + if startItem: + args["key"] = startItem.key + + path = f"/playQueues{utils.joinArgs(args)}" data = server.query(path, method=server._session.post) c = cls(server, data, initpath=path) - # we manually add a key so we can pass this to playMedia - # since the data, does not contain a key. - c.key = item.key + c.playQueueType = args["type"] + c._server = server return c + + def addItem(self, item, playNext=False, refresh=True): + """ + Append the provided item to the "Up Next" section of the PlayQueue. + Items can only be added to the section immediately following the current playing item. + + Parameters: + item (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`): Single media item or Playlist. + playNext (bool, optional): If True, add this item to the front of the "Up Next" section. + If False, the item will be appended to the end of the "Up Next" section. + Only has an effect if an item has already been added to the "Up Next" section. + See https://support.plex.tv/articles/202188298-play-queues/ for more details. + refresh (bool, optional): Refresh the PlayQueue from the server before updating. + """ + if refresh: + self.refresh() + + args = {} + if item.type == "playlist": + args["playlistID"] = item.ratingKey + itemType = item.playlistType + else: + uuid = item.section().uuid + itemType = item.listType + args["uri"] = f"library://{uuid}/item{item.key}" + + if itemType != self.playQueueType: + raise Unsupported("Item type does not match PlayQueue type") + + if playNext: + args["next"] = 1 + + path = f"/playQueues/{self.playQueueID}{utils.joinArgs(args)}" + data = self._server.query(path, method=self._server._session.put) + self._loadData(data) + + def moveItem(self, item, after=None, refresh=True): + """ + Moves an item to the beginning of the PlayQueue. If `after` is provided, + the item will be placed immediately after the specified item. + + Parameters: + item (:class:`~plexapi.base.Playable`): An existing item in the PlayQueue to move. + afterItemID (:class:`~plexapi.base.Playable`, optional): A different item in the PlayQueue. + If provided, `item` will be placed in the PlayQueue after this item. + refresh (bool, optional): Refresh the PlayQueue from the server before updating. + """ + args = {} + + if refresh: + self.refresh() + + if item not in self: + item = self.getQueueItem(item) + + if after: + if after not in self: + after = self.getQueueItem(after) + args["after"] = after.playQueueItemID + + path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}/move{utils.joinArgs(args)}" + data = self._server.query(path, method=self._server._session.put) + self._loadData(data) + + def removeItem(self, item, refresh=True): + """Remove an item from the PlayQueue. + + Parameters: + item (:class:`~plexapi.base.Playable`): An existing item in the PlayQueue to move. + refresh (bool, optional): Refresh the PlayQueue from the server before updating. + """ + if refresh: + self.refresh() + + if item not in self: + item = self.getQueueItem(item) + + path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}" + data = self._server.query(path, method=self._server._session.delete) + self._loadData(data) + + def clear(self): + """Remove all items from the PlayQueue.""" + path = f"/playQueues/{self.playQueueID}/items" + data = self._server.query(path, method=self._server._session.delete) + self._loadData(data) + + def refresh(self): + """Refresh the PlayQueue from the Plex server.""" + path = f"/playQueues/{self.playQueueID}" + data = self._server.query(path, method=self._server._session.get) + self._loadData(data) diff --git a/plexapi/server.py b/plexapi/server.py index e61eb5cb..7dad686f 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -284,7 +284,7 @@ class PlexServer(PlexObject): Parameters: item (Media or Playlist): Media or playlist to add to PlayQueue. - kwargs (dict): See `~plexapi.playerque.PlayQueue.create`. + kwargs (dict): See `~plexapi.playqueue.PlayQueue.create`. """ return PlayQueue.create(self, item, **kwargs) diff --git a/tests/test_playlist.py b/tests/test_playlist.py index 7303b7bb..b4a0ec57 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -74,16 +74,6 @@ def test_playlist_photos(plex, photoalbum): assert playlist_name not in [i.title for i in plex.playlists()] -def test_playlist_playQueue(plex, album): - try: - playlist = plex.createPlaylist('test_playlist', album) - playqueue = playlist.playQueue(**dict(shuffle=1)) - assert 'shuffle=1' in playqueue._initpath - assert playqueue.playQueueShuffled is True - finally: - playlist.delete() - - @pytest.mark.client def test_play_photos(plex, client, photoalbum): photos = photoalbum.photos() @@ -92,14 +82,6 @@ def test_play_photos(plex, client, photoalbum): time.sleep(2) -def test_playqueues(plex): - episode = plex.library.section('TV Shows').get('the 100').get('Pilot') - playqueue = plex.createPlayQueue(episode) - assert len(playqueue.items) == 1, 'No items in play queue.' - assert playqueue.items[0].title == episode.title, 'Wrong show queued.' - assert playqueue.playQueueID, 'Play queue ID not set.' - - def test_copyToUser(plex, show, fresh_plex, shared_username): episodes = show.episodes() playlist = plex.createPlaylist('shared_from_test_plexapi', episodes) diff --git a/tests/test_playqueue.py b/tests/test_playqueue.py new file mode 100644 index 00000000..946d24d7 --- /dev/null +++ b/tests/test_playqueue.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +from plexapi.exceptions import BadRequest +import pytest + + +def test_create_playqueue(plex, show): + # create the playlist + episodes = show.episodes() + pq = plex.createPlayQueue(episodes[:3]) + assert len(pq) == 3, "PlayQueue does not contain 3 items." + assert pq.playQueueLastAddedItemID is None + assert pq.playQueueSelectedMetadataItemID == episodes[0].ratingKey + assert ( + pq.items[0].ratingKey == episodes[0].ratingKey + ), "Items not in proper order [0a]." + assert ( + pq.items[1].ratingKey == episodes[1].ratingKey + ), "Items not in proper order [1a]." + assert ( + pq.items[2].ratingKey == episodes[2].ratingKey + ), "Items not in proper order [2a]." + + # Test move items around (b) + pq.moveItem(pq.items[1]) + assert pq.playQueueLastAddedItemID is None + assert pq.playQueueSelectedMetadataItemID == episodes[0].ratingKey + assert ( + pq.items[0].ratingKey == episodes[1].ratingKey + ), "Items not in proper order [0b]." + assert ( + pq.items[1].ratingKey == episodes[0].ratingKey + ), "Items not in proper order [1b]." + assert ( + pq.items[2].ratingKey == episodes[2].ratingKey + ), "Items not in proper order [2b]." + + # Test move items around (c) + pq.moveItem(pq.items[0], after=pq.items[1]) + assert pq.playQueueLastAddedItemID == pq.items[1].playQueueItemID + assert pq.playQueueSelectedMetadataItemID == episodes[0].ratingKey + assert ( + pq.items[0].ratingKey == episodes[0].ratingKey + ), "Items not in proper order [0c]." + assert ( + pq.items[1].ratingKey == episodes[1].ratingKey + ), "Items not in proper order [1c]." + assert ( + pq.items[2].ratingKey == episodes[2].ratingKey + ), "Items not in proper order [2c]." + + # Test adding an item to Up Next section + pq.addItem(episodes[3]) + assert pq.playQueueLastAddedItemID == pq.items[2].playQueueItemID + assert pq.playQueueSelectedMetadataItemID == episodes[0].ratingKey + assert pq.items[2].ratingKey == episodes[3].ratingKey, ( + "Missing added item: %s" % episodes[3] + ) + + # Test adding an item to play next + pq.addItem(episodes[4], playNext=True) + assert pq.playQueueLastAddedItemID == pq.items[3].playQueueItemID + assert pq.playQueueSelectedMetadataItemID == episodes[0].ratingKey + assert pq.items[1].ratingKey == episodes[4].ratingKey, ( + "Missing added item: %s" % episodes[4] + ) + + # Test add another item into Up Next section + pq.addItem(episodes[5]) + assert pq.playQueueLastAddedItemID == pq.items[4].playQueueItemID + assert pq.playQueueSelectedMetadataItemID == episodes[0].ratingKey + assert pq.items[4].ratingKey == episodes[5].ratingKey, ( + "Missing added item: %s" % episodes[5] + ) + + # Test removing an item + toremove = pq.items[3] + pq.removeItem(toremove) + assert pq.playQueueLastAddedItemID == pq.items[3].playQueueItemID + assert pq.playQueueSelectedMetadataItemID == episodes[0].ratingKey + assert toremove not in pq, "Removed item still in PlayQueue: %s" % toremove + assert len(pq) == 5, "PlayQueue should have 5 items, %s found" % len(pq) + + # Test clearing the PlayQueue + pq.clear() + assert pq.playQueueSelectedMetadataItemID == episodes[0].ratingKey + assert len(pq) == 1, "PlayQueue should have 1 item, %s found" % len(pq) + + # Test adding an item again + pq.addItem(episodes[7]) + assert pq.playQueueLastAddedItemID == pq.items[1].playQueueItemID + assert pq.playQueueSelectedMetadataItemID == episodes[0].ratingKey + assert pq.items[1].ratingKey == episodes[7].ratingKey, ( + "Missing added item: %s" % episodes[7] + ) + + +def test_create_playqueue_with_single_item(plex, movie): + pq = plex.createPlayQueue(movie) + assert len(pq) == 1 + assert pq.items[0].ratingKey == movie.ratingKey + + +def test_create_playqueue_with_start_choice(plex, show): + episodes = show.episodes() + pq = plex.createPlayQueue(episodes[:3], startItem=episodes[1]) + assert pq.playQueueSelectedMetadataItemID == pq.items[1].ratingKey + + +def test_modify_playqueue_with_library_media(plex, show): + episodes = show.episodes() + pq = plex.createPlayQueue(episodes[:3]) + assert len(pq) == 3, "PlayQueue does not contain 3 items." + # Test move PlayQueue using library items + pq.moveItem(episodes[1], after=episodes[2]) + assert pq.items[0].ratingKey == episodes[0].ratingKey, "Items not in proper order." + assert pq.items[2].ratingKey == episodes[1].ratingKey, "Items not in proper order." + assert pq.items[1].ratingKey == episodes[2].ratingKey, "Items not in proper order." + # Test too many mathcing library items + pq.addItem(episodes[0]) + pq.addItem(episodes[0]) + with pytest.raises(BadRequest): + pq.moveItem(episodes[2], after=episodes[0]) + # Test items not in PlayQueue + with pytest.raises(BadRequest): + pq.moveItem(episodes[9], after=episodes[0]) + with pytest.raises(BadRequest): + pq.removeItem(episodes[9]) + + +def test_create_playqueue_from_playlist(plex, album): + try: + playlist = plex.createPlaylist("test_playlist", album) + pq = playlist.playQueue(shuffle=1) + assert pq.playQueueShuffled is True + assert len(playlist) == len(album.tracks()) + assert len(pq) == len(playlist) + pq.addItem(playlist) + assert len(pq) == 2 * len(playlist) + finally: + playlist.delete() From 24901302d793975cc70ed027d21c64603df462a0 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 16 Sep 2020 17:58:36 -0500 Subject: [PATCH 11/14] New bootstrap music scanner (#571) * Use new Plex Music metadata scanner in tests * Update tests to match Plex Music scanner metadata * More tweaks to audio metadata matching * Another tweak --- tests/test_audio.py | 21 +++++++++++++-------- tools/plex-bootstraptest.py | 4 ++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/tests/test_audio.py b/tests/test_audio.py index ba4d42c8..3abfdbe5 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -7,8 +7,9 @@ from . import conftest as utils def test_audio_Artist_attr(artist): artist.reload() assert utils.is_datetime(artist.addedAt) - assert artist.countries == [] - assert "Electronic" in [i.tag for i in artist.genres] + if artist.countries: + assert "United States" in [i.tag for i in artist.countries] + #assert "Electronic" in [i.tag for i in artist.genres] assert utils.is_string(artist.guid, gte=5) assert artist.index == "1" assert utils.is_metadata(artist._initpath) @@ -20,7 +21,8 @@ def test_audio_Artist_attr(artist): assert artist.ratingKey >= 1 assert artist._server._baseurl == utils.SERVER_BASEURL assert isinstance(artist.similar, list) - assert "Alias" in artist.summary + if artist.summary: + assert "Alias" in artist.summary assert artist.title == "Broke For Free" assert artist.titleSort == "Broke For Free" assert artist.type == "artist" @@ -75,7 +77,7 @@ def test_audio_Album_attrs(album): assert album.parentTitle == "Broke For Free" assert album.ratingKey >= 1 assert album._server._baseurl == utils.SERVER_BASEURL - assert album.studio is None + assert album.studio == "[no label]" assert album.summary == "" if album.thumb: assert utils.is_metadata(album.thumb, contains="/thumb/") @@ -120,7 +122,8 @@ def test_audio_Album_tracks(album): assert utils.is_int(track.ratingKey) assert track._server._baseurl == utils.SERVER_BASEURL assert track.summary == "" - assert utils.is_metadata(track.thumb, contains="/thumb/") + if track.thumb: + assert utils.is_metadata(track.thumb, contains="/thumb/") assert track.title == "As Colourful as Ever" assert track.titleSort == "As Colourful as Ever" assert not track.transcodeSessions @@ -156,7 +159,8 @@ def test_audio_Album_track(album, track=None): assert utils.is_int(track.ratingKey) assert track._server._baseurl == utils.SERVER_BASEURL assert track.summary == "" - assert utils.is_metadata(track.thumb, contains="/thumb/") + if track.thumb: + assert utils.is_metadata(track.thumb, contains="/thumb/") assert track.title == "As Colourful as Ever" assert track.titleSort == "As Colourful as Ever" assert not track.transcodeSessions @@ -215,7 +219,7 @@ def test_audio_Track_attrs(album): if track.grandparentThumb: assert utils.is_metadata(track.grandparentThumb, contains="/thumb/") assert track.grandparentTitle == "Broke For Free" - assert track.guid.startswith("local://") + assert track.guid.startswith("mbid://") or track.guid.startswith("plex://track/") assert int(track.index) == 1 assert utils.is_metadata(track._initpath) assert utils.is_metadata(track.key) @@ -240,7 +244,8 @@ def test_audio_Track_attrs(album): assert track._server._baseurl == utils.SERVER_BASEURL assert track.sessionKey is None assert track.summary == "" - assert utils.is_metadata(track.thumb, contains="/thumb/") + if track.thumb: + assert utils.is_metadata(track.thumb, contains="/thumb/") assert track.title == "As Colourful as Ever" assert track.titleSort == "As Colourful as Ever" assert not track.transcodeSessions diff --git a/tools/plex-bootstraptest.py b/tools/plex-bootstraptest.py index 01bd77fe..e192e745 100755 --- a/tools/plex-bootstraptest.py +++ b/tools/plex-bootstraptest.py @@ -541,8 +541,8 @@ if __name__ == "__main__": name="Music", type="artist", location="/data/Music" if opts.no_docker is False else music_path, - agent="com.plexapp.agents.lastfm", - scanner="Plex Music Scanner", + agent="tv.plex.agents.music", + scanner="Plex Music", expected_media_count=song_c, ) ) From 8410d81520bfe6e9ba20718953baf14eecc18c6a Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 16 Sep 2020 18:16:01 -0500 Subject: [PATCH 12/14] Add 'activities' endpoint support (#569) * Add /activities endpoint support * Fix typos, return a list instead of iterator * Canary test to validate CI behavior * Increase timer of canary test to ensure proper run * Move test to front of run, provide auth and anon versions * Fix typo --- plexapi/server.py | 22 ++++++++++++++++++++++ tests/test__prepare.py | 25 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/test__prepare.py diff --git a/plexapi/server.py b/plexapi/server.py index 7dad686f..e2b4d631 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -190,6 +190,14 @@ class PlexServer(PlexObject): data = self.query(Account.key) return Account(self, data) + @property + def activities(self): + """Returns all current PMS activities.""" + activities = [] + for elem in self.query(Activity.key): + activities.append(Activity(self, elem)) + return activities + def agents(self, mediaType=None): """ Returns the `:class:`~plexapi.media.Agent` objects this server has available. """ key = '/system/agents' @@ -601,6 +609,20 @@ class Account(PlexObject): self.subscriptionState = data.attrib.get('subscriptionState') +class Activity(PlexObject): + """A currently running activity on the PlexServer.""" + key = '/activities' + + def _loadData(self, data): + self._data = data + self.cancellable = cast(bool, data.attrib.get('cancellable')) + self.progress = cast(int, data.attrib.get('progress')) + self.title = data.attrib.get('title') + self.subtitle = data.attrib.get('subtitle') + self.type = data.attrib.get('type') + self.uuid = data.attrib.get('uuid') + + class SystemAccount(PlexObject): """ Minimal api to list system accounts. """ key = '/accounts' diff --git a/tests/test__prepare.py b/tests/test__prepare.py new file mode 100644 index 00000000..9a764049 --- /dev/null +++ b/tests/test__prepare.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +import time + +import pytest + +MAX_ATTEMPTS = 60 + + +def wait_for_idle_server(server): + """Wait for PMS activities to complete with a timeout.""" + attempts = 0 + while server.activities and attempts < MAX_ATTEMPTS: + print(f"Waiting for activities to finish: {server.activities}") + time.sleep(1) + attempts += 1 + assert attempts < MAX_ATTEMPTS, f"Server still busy after {MAX_ATTEMPTS}s" + + +def test_ensure_metadata_scans_completed(plex): + wait_for_idle_server(plex) + + +@pytest.mark.authenticated +def test_ensure_metadata_scans_completed_authenticated(plex): + wait_for_idle_server(plex) From 860ad7bc3ea99f618deca11d9f146735b2dc73a8 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 21 Sep 2020 16:06:14 -0500 Subject: [PATCH 13/14] Add Library timeline support (#573) * Add Library timeline support * Retry intentional failure with different canary test * Temporarily disable activities tests * Set tests for normal runs * Add tests to validate library timeline attributes --- plexapi/library.py | 46 ++++++++++++++++++++++++++++++++++++++++++ tests/test__prepare.py | 31 ++++++++++++++++++++++++++-- tests/test_library.py | 20 ++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/plexapi/library.py b/plexapi/library.py index 8a6204d6..6589ef84 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -466,6 +466,12 @@ class LibrarySection(PlexObject): data = self._server.query(key) return self.findItems(data, cls=Setting) + def timeline(self): + """ Returns a timeline query for this library section. """ + key = '/library/sections/%s/timeline' % self.key + data = self._server.query(key) + return LibraryTimeline(self, data) + def onDeck(self): """ Returns a list of media items on deck from this library section. """ key = '/library/sections/%s/onDeck' % self.key @@ -1060,6 +1066,46 @@ class FilterChoice(PlexObject): self.type = data.attrib.get('type') +@utils.registerPlexObject +class LibraryTimeline(PlexObject): + """Represents a LibrarySection timeline. + + Attributes: + TAG (str): 'LibraryTimeline' + size (int): Unknown + allowSync (bool): Unknown + art (str): Relative path to art image. + content (str): "secondary" + identifier (str): "com.plexapp.plugins.library" + latestEntryTime (int): Epoch timestamp + mediaTagPrefix (str): "/system/bundle/media/flags/" + mediaTagVersion (int): Unknown + thumb (str): Relative path to library thumb image. + title1 (str): Name of library section. + updateQueueSize (int): Number of items queued to update. + viewGroup (str): "secondary" + viewMode (int): Unknown + """ + TAG = 'LibraryTimeline' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.size = utils.cast(int, data.attrib.get('size')) + self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) + self.art = data.attrib.get('art') + self.content = data.attrib.get('content') + self.identifier = data.attrib.get('identifier') + self.latestEntryTime = utils.cast(int, data.attrib.get('latestEntryTime')) + self.mediaTagPrefix = data.attrib.get('mediaTagPrefix') + self.mediaTagVersion = utils.cast(int, data.attrib.get('mediaTagVersion')) + self.thumb = data.attrib.get('thumb') + self.title1 = data.attrib.get('title1') + self.updateQueueSize = utils.cast(int, data.attrib.get('updateQueueSize')) + self.viewGroup = data.attrib.get('viewGroup') + self.viewMode = utils.cast(int, data.attrib.get('viewMode')) + + @utils.registerPlexObject class Location(PlexObject): """ Represents a single library Location. diff --git a/tests/test__prepare.py b/tests/test__prepare.py index 9a764049..500b9a49 100644 --- a/tests/test__prepare.py +++ b/tests/test__prepare.py @@ -16,10 +16,37 @@ def wait_for_idle_server(server): assert attempts < MAX_ATTEMPTS, f"Server still busy after {MAX_ATTEMPTS}s" -def test_ensure_metadata_scans_completed(plex): +def wait_for_metadata_processing(server): + """Wait for async metadata processing to complete.""" + attempts = 0 + + while True: + busy = False + for section in server.library.sections(): + tl = section.timeline() + if tl.updateQueueSize > 0: + busy = True + print(f"{section.title}: {tl.updateQueueSize} items left") + if not busy or attempts > MAX_ATTEMPTS: + break + time.sleep(1) + attempts += 1 + assert attempts < MAX_ATTEMPTS, f"Metadata still processing after {MAX_ATTEMPTS}s" + + +def test_ensure_activities_completed(plex): wait_for_idle_server(plex) @pytest.mark.authenticated -def test_ensure_metadata_scans_completed_authenticated(plex): +def test_ensure_activities_completed_authenticated(plex): wait_for_idle_server(plex) + + +def test_ensure_metadata_scans_completed(plex): + wait_for_metadata_processing(plex) + + +@pytest.mark.authenticated +def test_ensure_metadata_scans_completed_authenticated(plex): + wait_for_metadata_processing(plex) diff --git a/tests/test_library.py b/tests/test_library.py index 0fc29837..30d1d2b9 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from datetime import datetime import pytest from plexapi.exceptions import NotFound @@ -277,3 +278,22 @@ def test_crazy_search(plex, movie): assert len(movies.search(container_size=1)) == 4 assert len(movies.search(container_start=9999, container_size=1)) == 0 assert len(movies.search(container_start=2, container_size=1)) == 2 + + +def test_library_section_timeline(plex): + movies = plex.library.section("Movies") + tl = movies.timeline() + assert tl.TAG == "LibraryTimeline" + assert tl.size > 0 + assert tl.allowSync is False + assert tl.art == "/:/resources/movie-fanart.jpg" + assert tl.content == "secondary" + assert tl.identifier == "com.plexapp.plugins.library" + assert datetime.fromtimestamp(tl.latestEntryTime).date() == datetime.today().date() + assert tl.mediaTagPrefix == "/system/bundle/media/flags/" + assert tl.mediaTagVersion > 1 + assert tl.thumb == "/:/resources/movie.png" + assert tl.title1 == "Movies" + assert tl.updateQueueSize == 0 + assert tl.viewGroup == "secondary" + assert tl.viewMode == 65592 From 737401be0e35d6bb5ffeeb1743255ac66b69feba Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 21 Sep 2020 16:17:47 -0500 Subject: [PATCH 14/14] Bump to 4.1.1 release (#574) --- plexapi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/__init__.py b/plexapi/__init__.py index fa01cd75..3fd2a318 100644 --- a/plexapi/__init__.py +++ b/plexapi/__init__.py @@ -15,7 +15,7 @@ CONFIG = PlexConfig(CONFIG_PATH) # PlexAPI Settings PROJECT = 'PlexAPI' -VERSION = '4.1.0' +VERSION = '4.1.1' 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)