From 3138ad1087418820c83e59c2bead584b66bdf239 Mon Sep 17 00:00:00 2001 From: Michael Shepanski Date: Sun, 10 Apr 2016 23:49:23 -0400 Subject: [PATCH] Added playlist support --- plexapi/audio.py | 4 +++ plexapi/photo.py | 2 ++ plexapi/playlist.py | 63 +++++++++++++++++++++++++++++++++++++++++- plexapi/playqueue.py | 1 + plexapi/server.py | 6 ++++ plexapi/utils.py | 2 ++ plexapi/video.py | 4 +++ tests/tests.py | 66 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 147 insertions(+), 1 deletion(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index 274de16f..7cbc3a71 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -8,6 +8,7 @@ NA = utils.NA class Audio(PlexPartialObject): + TYPE = None def __init__(self, server, data, initpath): super(Audio, self).__init__(data, initpath, server) @@ -38,6 +39,7 @@ class Audio(PlexPartialObject): @utils.register_libtype class Artist(Audio): TYPE = 'artist' + LISTTYPE = 'audio' def _loadData(self, data): Audio._loadData(self, data) @@ -73,6 +75,7 @@ class Artist(Audio): @utils.register_libtype class Album(Audio): TYPE = 'album' + LISTTYPE = 'audio' def _loadData(self, data): Audio._loadData(self, data) @@ -112,6 +115,7 @@ class Album(Audio): @utils.register_libtype class Track(Audio, Playable): TYPE = 'track' + LISTTYPE = 'audio' def _loadData(self, data): Audio._loadData(self, data) diff --git a/plexapi/photo.py b/plexapi/photo.py index 357259d0..74d4d198 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -10,6 +10,7 @@ NA = utils.NA @utils.register_libtype class Photoalbum(PlexPartialObject): TYPE = 'photoalbum' + LISTTYPE = 'photo' def __init__(self, server, data, initpath): super(Photoalbum, self).__init__(data, initpath, server) @@ -41,6 +42,7 @@ class Photoalbum(PlexPartialObject): @utils.register_libtype class Photo(PlexPartialObject): TYPE = 'photo' + LISTTYPE = 'photo' def __init__(self, server, data, initpath): super(Photo, self).__init__(data, initpath, server) diff --git a/plexapi/playlist.py b/plexapi/playlist.py index 53643de4..b937f6ad 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -2,7 +2,9 @@ """ PlexPlaylist """ +import requests from plexapi import utils +from plexapi.exceptions import BadRequest from plexapi.utils import cast, toDatetime from plexapi.utils import PlexPartialObject, Playable NA = utils.NA @@ -23,7 +25,7 @@ class Playlist(PlexPartialObject, Playable): self.durationInSeconds = cast(int, data.attrib.get('durationInSeconds', NA)) self.guid = data.attrib.get('guid', NA) self.key = data.attrib.get('key', NA) - if self.key: self.key = self.key.replace('/items', '') # FIX_BUG_50 + self.key = self.key.replace('/items', '') if self.key else self.key # FIX_BUG_50 self.leafCount = cast(int, data.attrib.get('leafCount', NA)) self.playlistType = data.attrib.get('playlistType', NA) self.ratingKey = data.attrib.get('ratingKey', NA) @@ -36,3 +38,62 @@ class Playlist(PlexPartialObject, Playable): def items(self): path = '%s/items' % self.key return utils.listItems(self.server, path) + + def addItems(self, items): + # PUT /playlists/29988/items?uri=library%3A%2F%2F32268d7c-3e8c-4ab5-98ad-bad8a3b78c63%2Fitem%2F%252Flibrary%252Fmetadata%252F801 + if not isinstance(items, (list, tuple)): + items = [items] + ratingKeys = [] + for item in items: + if item.__class__.LISTTYPE != self.playlistType: + raise BadRequest('Can not mix media types when building a playlist: %s and %s' % (self.playlistType, item.__class__.LISTTYPE)) + ratingKeys.append(item.ratingKey) + path = '%s/items%s' % (self.key, utils.joinArgs({ + 'uri': 'library://__GID__/directory//library/metadata/%s' % ','.join(ratingKeys), + })) + return self.server.query(path, method=self.server.session.put) + + def removeItem(self, item): + # DELETE /playlists/29988/items/4866 + path = '%s/items/%s' % (self.key, item.playlistItemID) + return self.server.query(path, method=self.server.session.delete) + + def moveItem(self, item, after=None): + # PUT /playlists/29988/items/4556/move?after=4445 + # PUT /playlists/29988/items/4556/move (to first item) + path = '%s/items/%s/move' % (self.key, item.playlistItemID) + if after: + path += '?after=%s' % after.playlistItemID + return self.server.query(path, method=self.server.session.put) + + def edit(self, title=None, summary=None): + # PUT /library/metadata/29988?title=You%20Look%20Like%20Gollum2&summary=foobar + path = '/library/metadata/%s%s' % (self.ratingKey, utils.joinArgs({'title':title, 'summary':summary})) + return self.server.query(path, method=self.server.session.put) + + def delete(self): + # DELETE /library/metadata/29988 + return self.server.query(self.key, method=self.server.session.delete) + + @classmethod + def create(cls, server, title, items): + # NOTE: I have not yet figured out what __GID__ is below or where the proper value + # can be obtained. However, the good news is passing anything in seems to work. + if not isinstance(items, (list, tuple)): + items = [items] + # collect a list of itemkeys and make sure all items share the same listtype + listtype = items[0].__class__.LISTTYPE + ratingKeys = [] + for item in items: + if item.__class__.LISTTYPE != listtype: + raise BadRequest('Can not mix media types when building a playlist') + ratingKeys.append(item.ratingKey) + # build and send the request + path = '/playlists%s' % utils.joinArgs({ + 'uri': 'library://__GID__/directory//library/metadata/%s' % ','.join(ratingKeys), + 'type': listtype, + 'title': title, + 'smart': 0 + }) + data = server.query(path, method=server.session.post)[0] + return cls(server, data, initpath=path) diff --git a/plexapi/playqueue.py b/plexapi/playqueue.py index 130301e8..4ee2dcf1 100644 --- a/plexapi/playqueue.py +++ b/plexapi/playqueue.py @@ -23,6 +23,7 @@ class PlayQueue(object): @classmethod def create(cls, server, video, shuffle=0, continuous=0): + # TODO: Fix this up, create tests.. # NOTE: I have not yet figured out what __GID__ is below or where the proper value # can be obtained. However, the good news is passing anything in seems to work. path = '/playQueues%s' % utils.joinArgs({ diff --git a/plexapi/server.py b/plexapi/server.py index af1a8ece..63d87b55 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -11,6 +11,7 @@ from plexapi.compat import quote from plexapi.client import PlexClient from plexapi.exceptions import BadRequest, NotFound from plexapi.library import Library +from plexapi.playlist import Playlist from plexapi.playqueue import PlayQueue from xml.etree import ElementTree @@ -69,6 +70,9 @@ class PlexServer(object): return PlexClient(baseurl, server=self, data=elem) raise NotFound('Unknown client name: %s' % name) + def createPlaylist(self, title, items): + return Playlist.create(self, title, items) + def createPlayQueue(self, item): return PlayQueue.create(self, item) @@ -82,6 +86,8 @@ class PlexServer(object): return utils.listItems(self, '/status/sessions/history/all') def playlists(self): + # TODO: Add sort and type options? + # /playlists/all?type=15&sort=titleSort%3Aasc&playlistType=video&smart=0 return utils.listItems(self, '/playlists') def playlist(self, title=None): # noqa diff --git a/plexapi/utils.py b/plexapi/utils.py index 78360727..8d0a2569 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -92,6 +92,8 @@ class Playable(object): self.transcodeSession = findTranscodeSession(self.server, data) # data for history details (/status/sessions/history/all) self.viewedAt = toDatetime(data.attrib.get('viewedAt', NA)) + # data for playlist items + self.playlistItemID = cast(int, data.attrib.get('playlistItemID', NA)) def getStreamURL(self, **params): if self.TYPE not in ('movie', 'episode', 'track'): diff --git a/plexapi/video.py b/plexapi/video.py index 1e904864..39917288 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -55,6 +55,7 @@ class Video(PlexPartialObject): @utils.register_libtype class Movie(Video, Playable): TYPE = 'movie' + LISTTYPE = 'video' def _loadData(self, data): Video._loadData(self, data) @@ -101,6 +102,7 @@ class Movie(Video, Playable): @utils.register_libtype class Show(Video): TYPE = 'show' + LISTTYPE = 'video' def _loadData(self, data): Video._loadData(self, data) @@ -162,6 +164,7 @@ class Show(Video): @utils.register_libtype class Season(Video): TYPE = 'season' + LISTTYPE = 'video' def _loadData(self, data): Video._loadData(self, data) @@ -198,6 +201,7 @@ class Season(Video): @utils.register_libtype class Episode(Video, Playable): TYPE = 'episode' + LISTTYPE = 'video' def _loadData(self, data): Video._loadData(self, data) diff --git a/tests/tests.py b/tests/tests.py index 26b1e654..a6ef3aac 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -271,6 +271,72 @@ def test_refresh_video(plex, account=None): result[0].refresh() +#----------------------- +# Playlists +#----------------------- + +@register('playlist') +def test_list_playlists(plex, account=None): + playlists = plex.playlists() + for playlist in playlists: + log(2, playlist.title) + + +@register('playlist') +def test_create_playlist(plex, account=None): + try: + # create the playlist + title = 'test_create_playlist' + log(2, 'Creating playlist %s..' % title) + episodes = plex.library.section(SHOW_SECTION).get(SHOW_TITLE).episodes() + playlist = plex.createPlaylist(title, episodes[:3]) + items = playlist.items() + log(4, 'Title: %s' % playlist.title) + log(4, 'Items: %s' % items) + log(4, 'Duration: %s min' % int(playlist.duration / 60000.0)) + assert playlist.title == title, 'Playlist not created successfully.' + assert len(items) == 3, 'Playlist does not contain 3 items.' + assert items[0].ratingKey == episodes[0].ratingKey, 'Items not in proper order [0a].' + assert items[1].ratingKey == episodes[1].ratingKey, 'Items not in proper order [1a].' + assert items[2].ratingKey == episodes[2].ratingKey, 'Items not in proper order [2a].' + # move items around (b) + log(2, 'Testing move items..') + playlist.moveItem(items[1]) + items = playlist.items() + assert items[0].ratingKey == episodes[1].ratingKey, 'Items not in proper order [0b].' + assert items[1].ratingKey == episodes[0].ratingKey, 'Items not in proper order [1b].' + assert items[2].ratingKey == episodes[2].ratingKey, 'Items not in proper order [2b].' + # move items around (c) + playlist.moveItem(items[0], items[1]) + items = playlist.items() + assert items[0].ratingKey == episodes[0].ratingKey, 'Items not in proper order [0c].' + assert items[1].ratingKey == episodes[1].ratingKey, 'Items not in proper order [1c].' + assert items[2].ratingKey == episodes[2].ratingKey, 'Items not in proper order [2c].' + # add an item + log(2, 'Testing add item: %s' % episodes[3]) + playlist.addItems(episodes[3]) + items = playlist.items() + log(4, '4th Item: %s' % items[3]) + assert items[3].ratingKey == episodes[3].ratingKey, 'Missing added item: %s' % episodes[3] + # add two items + log(2, 'Testing add item: %s' % episodes[4:6]) + playlist.addItems(episodes[4:6]) + items = playlist.items() + log(4, '5th+ Items: %s' % items[4:]) + assert items[4].ratingKey == episodes[4].ratingKey, 'Missing added item: %s' % episodes[4] + assert items[5].ratingKey == episodes[5].ratingKey, 'Missing added item: %s' % episodes[5] + assert len(items) == 6, 'Playlist should have 6 items, %s found' % len(items) + # remove item + toremove = items[3] + log(2, 'Testing remove item: %s' % toremove) + playlist.removeItem(toremove) + items = playlist.items() + assert toremove not in items, 'Removed item still in playlist: %s' % items[3] + assert len(items) == 5, 'Playlist should have 5 items, %s found' % len(items) + finally: + playlist.delete() + + #----------------------- # Metadata #-----------------------