Merge branch 'master' of github.com:pkkid/python-plexapi

This commit is contained in:
Michael Shepanski 2019-02-06 19:14:57 -05:00
commit 9d4966d842
15 changed files with 164 additions and 35 deletions

View file

@ -17,7 +17,7 @@ Plex Web Client. A few of the many features we currently support are:
* Perform library actions such as scan, analyze, empty trash.
* Remote control and play media on connected clients.
* Listen in on all Plex Server notifications.
Installation & Documentation
----------------------------

View file

@ -168,12 +168,12 @@ class Artist(Audio):
""" Alias of :func:`~plexapi.audio.Artist.track`. """
return self.track(title)
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Downloads all tracks for this artist to the specified location.
Parameters:
savepath (str): Title of the track to return.
keep_orginal_name (bool): Set True to keep the original filename as stored in
keep_original_name (bool): Set True to keep the original filename as stored in
the Plex server. False will create a new filename with the format
"<Atrist> - <Album> <Track>".
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will
@ -184,7 +184,7 @@ class Artist(Audio):
filepaths = []
for album in self.albums():
for track in album.tracks():
filepaths += track.download(savepath, keep_orginal_name, **kwargs)
filepaths += track.download(savepath, keep_original_name, **kwargs)
return filepaths
@ -251,12 +251,12 @@ class Album(Audio):
""" Return :func:`~plexapi.audio.Artist` of this album. """
return self.fetchItem(self.parentKey)
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Downloads all tracks for this artist to the specified location.
Parameters:
savepath (str): Title of the track to return.
keep_orginal_name (bool): Set True to keep the original filename as stored in
keep_original_name (bool): Set True to keep the original filename as stored in
the Plex server. False will create a new filename with the format
"<Atrist> - <Album> <Track>".
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will
@ -266,7 +266,7 @@ class Album(Audio):
"""
filepaths = []
for track in self.tracks():
filepaths += track.download(savepath, keep_orginal_name, **kwargs)
filepaths += track.download(savepath, keep_original_name, **kwargs)
return filepaths
def _defaultSyncTitle(self):

View file

@ -519,13 +519,13 @@ class Playable(object):
"""
client.playMedia(self)
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Downloads this items media to the specified location. Returns a list of
filepaths that have been saved to disk.
Parameters:
savepath (str): Title of the track to return.
keep_orginal_name (bool): Set True to keep the original filename as stored in
keep_original_name (bool): Set True to keep the original filename as stored in
the Plex server. False will create a new filename with the format
"<Artist> - <Album> <Track>".
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will
@ -537,7 +537,7 @@ class Playable(object):
locations = [i for i in self.iterParts() if i]
for location in locations:
filename = location.file
if keep_orginal_name is False:
if keep_original_name is False:
filename = '%s.%s' % (self._prettyfilename(), location.container)
# So this seems to be a alot slower but allows transcode.
if kwargs:

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
import time
import requests
from requests.status_codes import _codes as codes
@ -70,6 +70,7 @@ class PlexClient(PlexObject):
self._session = session or server_session or requests.Session()
self._proxyThroughServer = False
self._commandId = 0
self._last_call = 0
if not any([data, initpath, baseurl, token]):
self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433')
self._token = logfilter.add_secret(CONFIG.get('auth.client_token'))
@ -181,14 +182,26 @@ class PlexClient(PlexObject):
"""
command = command.strip('/')
controller = command.split('/')[0]
headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier}
if controller not in self.protocolCapabilities:
log.debug('Client %s doesnt support %s controller.'
'What your trying might not work' % (self.title, controller))
# Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244
t = time.time()
if t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'):
url = '/player/timeline/poll?wait=0&commandID=%s' % self._nextCommandId()
if proxy:
self._server.query(url, headers=headers)
else:
self.query(url, headers=headers)
self._last_call = t
params['commandID'] = self._nextCommandId()
key = '/player/%s%s' % (command, utils.joinArgs(params))
headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier}
proxy = self._proxyThroughServer if proxy is None else proxy
if proxy:
return self._server.query(key, headers=headers)
return self.query(key, headers=headers)

View file

@ -377,9 +377,17 @@ class LibrarySection(PlexObject):
key = '/library/sections/%s/all' % self.key
return self.fetchItem(key, title__iexact=title)
def all(self, **kwargs):
""" Returns a list of media from this library section. """
key = '/library/sections/%s/all' % self.key
def all(self, sort=None, **kwargs):
""" Returns a list of media from this library section.
Parameters:
sort (string): The sort string
"""
sortStr = ''
if sort != None:
sortStr = '?sort=' + sort
key = '/library/sections/%s/all%s' % (self.key, sortStr)
return self.fetchItems(key, **kwargs)
def onDeck(self):
@ -776,8 +784,8 @@ class MusicSection(LibrarySection):
TAG (str): 'Directory'
TYPE (str): 'artist'
"""
ALLOWED_FILTERS = ('genre', 'country', 'collection', 'mood')
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort')
ALLOWED_FILTERS = ('genre', 'country', 'collection', 'mood', 'track.userRating')
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort', 'userRating')
TAG = 'Directory'
TYPE = 'artist'

View file

@ -124,7 +124,35 @@ class MediaPart(PlexObject):
def subtitleStreams(self):
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """
return [stream for stream in self.streams if stream.streamType == SubtitleStream.STREAMTYPE]
def setDefaultAudioStream(self, stream):
""" Set the default :class:`~plexapi.media.AudioStream` for this MediaPart.
Parameters:
stream (:class:`~plexapi.media.AudioStream`): AudioStream to set as default
"""
if isinstance(stream, AudioStream):
key = "/library/parts/%d?audioStreamID=%d&allParts=1" % (self.id, stream.id)
else:
key = "/library/parts/%d?audioStreamID=%d&allParts=1" % (self.id, stream)
self._server.query(key, method=self._server._session.put)
def setDefaultSubtitleStream(self, stream):
""" Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart.
Parameters:
stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default.
"""
if isinstance(stream, SubtitleStream):
key = "/library/parts/%d?subtitleStreamID=%d&allParts=1" % (self.id, stream.id)
else:
key = "/library/parts/%d?subtitleStreamID=%d&allParts=1" % (self.id, stream)
self._server.query(key, method=self._server._session.put)
def resetDefaultSubtitleStream(self):
""" Set default subtitle of this MediaPart to 'none'. """
key = "/library/parts/%d?subtitleStreamID=0&allParts=1" % (self.id)
self._server.query(key, method=self._server._session.put)
class MediaPartStream(PlexObject):
""" Base class for media streams. These consist of video, audio and subtitles.
@ -256,6 +284,7 @@ class SubtitleStream(MediaPartStream):
Attributes:
TAG (str): 'Stream'
STREAMTYPE (int): 3
forced (bool): True if this is a forced subtitle
format (str): Subtitle format (ex: srt).
key (str): Key of this subtitle stream (ex: /library/streams/212284).
title (str): Title of this subtitle stream.
@ -266,6 +295,7 @@ class SubtitleStream(MediaPartStream):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
super(SubtitleStream, self)._loadData(data)
self.forced = cast(bool, data.attrib.get('forced', '0'))
self.format = data.attrib.get('format')
self.key = data.attrib.get('key')
self.title = data.attrib.get('title')

View file

@ -2,6 +2,7 @@
from plexapi import utils
from plexapi.base import PlexPartialObject, Playable
from plexapi.exceptions import BadRequest, Unsupported
from plexapi.library import LibrarySection
from plexapi.playqueue import PlayQueue
from plexapi.utils import cast, toDatetime
from plexapi.compat import quote_plus
@ -127,9 +128,9 @@ class Playlist(PlexPartialObject, Playable):
return PlayQueue.create(self._server, self, *args, **kwargs)
@classmethod
def create(cls, server, title, items):
def _create(cls, server, title, items):
""" Create a playlist. """
if not isinstance(items, (list, tuple)):
if items and not isinstance(items, (list, tuple)):
items = [items]
ratingKeys = []
for item in items:
@ -147,6 +148,61 @@ class Playlist(PlexPartialObject, Playable):
data = server.query(key, method=server._session.post)[0]
return cls(server, data, initpath=key)
@classmethod
def create(cls, server, title, items=None, section=None, limit=None, smart=False, **kwargs):
"""Create a playlist.
Parameters:
server (:class:`~plexapi.server.PlexServer`): Server your connected to.
title (str): Title of the playlist.
items (Iterable): Iterable of objects that should be in the playlist.
section (:class:`~plexapi.library.LibrarySection`, str):
limit (int): default None.
smart (bool): default False.
**kwargs (dict): is passed to the filters. For a example see the search method.
Returns:
:class:`plexapi.playlist.Playlist`: an instance of created Playlist.
"""
if smart:
return cls._createSmart(server, title, section, limit, **kwargs)
else:
return cls._create(server, title, items)
@classmethod
def _createSmart(cls, server, title, section, limit=None, **kwargs):
""" Create a Smart playlist. """
if not isinstance(section, LibrarySection):
section = server.library.section(section)
sectionType = utils.searchType(section.type)
sectionId = section.key
uuid = section.uuid
uri = 'library://%s/directory//library/sections/%s/all?type=%s' % (uuid,
sectionId,
sectionType)
if limit:
uri = uri + '&limit=%s' % str(limit)
for category, value in kwargs.items():
sectionChoices = section.listChoices(category)
for choice in sectionChoices:
if str(choice.title).lower() == str(value).lower():
uri = uri + '&%s=%s' % (category.lower(), str(choice.key))
uri = uri + '&sourceType=%s' % sectionType
key = '/playlists%s' % utils.joinArgs({
'uri': uri,
'type': section.CONTENT_TYPE,
'title': title,
'smart': 1,
})
data = server.query(key, method=server._session.post)[0]
return cls(server, data, initpath=key)
def copyToUser(self, user):
""" Copy playlist to another user account. """
from plexapi.server import PlexServer

View file

@ -93,6 +93,7 @@ class PlexServer(PlexObject):
def __init__(self, baseurl=None, token=None, session=None, timeout=None):
self._baseurl = baseurl or CONFIG.get('auth.server_baseurl', 'http://localhost:32400')
self._baseurl = self._baseurl.rstrip('/')
self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
self._session = session or requests.Session()
@ -240,14 +241,14 @@ class PlexServer(PlexObject):
raise NotFound('Unknown client name: %s' % name)
def createPlaylist(self, title, items):
def createPlaylist(self, title, items=None, section=None, limit=None, smart=None, **kwargs):
""" Creates and returns a new :class:`~plexapi.playlist.Playlist`.
Parameters:
title (str): Title of the playlist to be created.
items (list<Media>): List of media items to include in the playlist.
"""
return Playlist.create(self, title, items)
return Playlist.create(self, title, items=items, limit=limit, section=section, smart=smart, **kwargs)
def createPlayQueue(self, item, **kwargs):
""" Creates and returns a new :class:`~plexapi.playqueue.PlayQueue`.

View file

@ -101,11 +101,12 @@ class Setting(PlexObject):
"""
_bool_cast = lambda x: True if x == 'true' or x == '1' else False
_bool_str = lambda x: str(x).lower()
_str = lambda x: str(x).encode('utf-8')
TYPES = {
'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str},
'double': {'type': float, 'cast': float, 'tostr': string_type},
'int': {'type': int, 'cast': int, 'tostr': string_type},
'text': {'type': string_type, 'cast': string_type, 'tostr': string_type},
'double': {'type': float, 'cast': float, 'tostr': _str},
'int': {'type': int, 'cast': int, 'tostr': _str},
'text': {'type': string_type, 'cast': _str, 'tostr': _str},
}
def _loadData(self, data):

View file

@ -178,6 +178,11 @@ def toDatetime(value, format=None):
if format:
value = datetime.strptime(value, format)
else:
# https://bugs.python.org/issue30684
# And platform support for before epoch seems to be flaky.
# TODO check for others errors too.
if int(value) == 0:
value = 86400
value = datetime.fromtimestamp(int(value))
return value

View file

@ -224,12 +224,12 @@ class Movie(Playable, Video):
# This is just for compat.
return self.title
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Download video files to specified directory.
Parameters:
savepath (str): Defaults to current working dir.
keep_orginal_name (bool): True to keep the original file name otherwise
keep_original_name (bool): True to keep the original file name otherwise
a friendlier is generated.
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`.
"""
@ -237,7 +237,7 @@ class Movie(Playable, Video):
locations = [i for i in self.iterParts() if i]
for location in locations:
name = location.file
if not keep_orginal_name:
if not keep_original_name:
title = self.title.replace(' ', '.')
name = '%s.%s' % (title, location.container)
if kwargs is not None:
@ -376,18 +376,18 @@ class Show(Video):
""" Alias to :func:`~plexapi.video.Show.episode()`. """
return self.episode(title, season, episode)
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Download video files to specified directory.
Parameters:
savepath (str): Defaults to current working dir.
keep_orginal_name (bool): True to keep the original file name otherwise
keep_original_name (bool): True to keep the original file name otherwise
a friendlier is generated.
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`.
"""
filepaths = []
for episode in self.episodes():
filepaths += episode.download(savepath, keep_orginal_name, **kwargs)
filepaths += episode.download(savepath, keep_original_name, **kwargs)
return filepaths
@ -477,18 +477,18 @@ class Season(Video):
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
return self.episodes(watched=False)
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Download video files to specified directory.
Parameters:
savepath (str): Defaults to current working dir.
keep_orginal_name (bool): True to keep the original file name otherwise
keep_original_name (bool): True to keep the original file name otherwise
a friendlier is generated.
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`.
"""
filepaths = []
for episode in self.episodes():
filepaths += episode.download(savepath, keep_orginal_name, **kwargs)
filepaths += episode.download(savepath, keep_original_name, **kwargs)
return filepaths
def _defaultSyncTitle(self):

View file

@ -36,7 +36,7 @@ AUDIOCHANNELS = {2, 6}
AUDIOLAYOUTS = {'5.1', '5.1(side)', 'stereo'}
CODECS = {'aac', 'ac3', 'dca', 'h264', 'mp3', 'mpeg4'}
CONTAINERS = {'avi', 'mp4', 'mkv'}
CONTENTRATINGS = {'TV-14', 'TV-MA', 'G', 'NR'}
CONTENTRATINGS = {'TV-14', 'TV-MA', 'G', 'NR', 'Not Rated'}
FRAMERATES = {'24p', 'PAL', 'NTSC'}
PROFILES = {'advanced simple', 'main', 'constrained baseline'}
RESOLUTIONS = {'sd', '480', '576', '720', '1080'}

View file

@ -109,3 +109,9 @@ def test_copyToUser(plex, show, fresh_plex, shared_username):
assert playlist.title in [p.title for p in user_plex.playlists()]
finally:
playlist.delete()
def test_smart_playlist(plex, movies):
pl = plex.createPlaylist(title='smart_playlist', smart=True, limit=1, section=movies, year=2008)
assert len(pl.items()) == 1
assert pl.smart

View file

@ -17,3 +17,12 @@ def test_settings_set(plex):
plex.settings.save()
plex._settings = None
assert plex.settings.get('autoEmptyTrash').value == new_value
def test_settings_set_str(plex):
cd = plex.settings.get('OnDeckWindow')
new_value = 99
cd.set(new_value)
plex.settings.save()
plex._settings = None
assert plex.settings.get('OnDeckWindow').value == 99

View file

@ -91,7 +91,7 @@ def test_video_Movie_attrs(movies):
assert utils.is_metadata(movie.art)
assert movie.artUrl
assert movie.audienceRating == 8.5
# Disabled this since it failed on the last run, wasnt in the orginal xml result.
# Disabled this since it failed on the last run, wasnt in the original xml result.
#assert movie.audienceRatingImage == 'rottentomatoes://image.rating.upright'
movie.reload() # RELOAD
assert movie.chapterSource is None