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
This commit is contained in:
jjlawren 2020-09-11 16:23:27 -05:00 committed by GitHub
parent ad8fd58c66
commit fb82bc402b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 367 additions and 76 deletions

View file

@ -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

View file

@ -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 (<type 'str'>): 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)

View file

@ -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)

View file

@ -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)

140
tests/test_playqueue.py Normal file
View file

@ -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()