Merge branch 'master' into patch-1

This commit is contained in:
Michael Shepanski 2019-02-04 14:48:06 -05:00 committed by GitHub
commit 3f6eede667
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 136 additions and 35 deletions

View file

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

View file

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

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import time
import requests import requests
from requests.status_codes import _codes as codes 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._session = session or server_session or requests.Session()
self._proxyThroughServer = False self._proxyThroughServer = False
self._commandId = 0 self._commandId = 0
self._last_call = 0
if not any([data, initpath, baseurl, token]): if not any([data, initpath, baseurl, token]):
self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433') self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433')
self._token = logfilter.add_secret(CONFIG.get('auth.client_token')) self._token = logfilter.add_secret(CONFIG.get('auth.client_token'))
@ -181,14 +182,26 @@ class PlexClient(PlexObject):
""" """
command = command.strip('/') command = command.strip('/')
controller = command.split('/')[0] controller = command.split('/')[0]
headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier}
if controller not in self.protocolCapabilities: if controller not in self.protocolCapabilities:
log.debug('Client %s doesnt support %s controller.' log.debug('Client %s doesnt support %s controller.'
'What your trying might not work' % (self.title, 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() params['commandID'] = self._nextCommandId()
key = '/player/%s%s' % (command, utils.joinArgs(params)) 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 proxy = self._proxyThroughServer if proxy is None else proxy
if proxy: if proxy:
return self._server.query(key, headers=headers) return self._server.query(key, headers=headers)
return self.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 key = '/library/sections/%s/all' % self.key
return self.fetchItem(key, title__iexact=title) return self.fetchItem(key, title__iexact=title)
def all(self, **kwargs): def all(self, sort=None, **kwargs):
""" Returns a list of media from this library section. """ """ Returns a list of media from this library section.
key = '/library/sections/%s/all' % self.key
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) return self.fetchItems(key, **kwargs)
def onDeck(self): def onDeck(self):
@ -776,8 +784,8 @@ class MusicSection(LibrarySection):
TAG (str): 'Directory' TAG (str): 'Directory'
TYPE (str): 'artist' TYPE (str): 'artist'
""" """
ALLOWED_FILTERS = ('genre', 'country', 'collection', 'mood', 'year') ALLOWED_FILTERS = ('genre', 'country', 'collection', 'mood', 'year', 'track.userRating')
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort') ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort', 'userRating')
TAG = 'Directory' TAG = 'Directory'
TYPE = 'artist' TYPE = 'artist'

View file

@ -256,6 +256,7 @@ class SubtitleStream(MediaPartStream):
Attributes: Attributes:
TAG (str): 'Stream' TAG (str): 'Stream'
STREAMTYPE (int): 3 STREAMTYPE (int): 3
forced (bool): True if this is a forced subtitle
format (str): Subtitle format (ex: srt). format (str): Subtitle format (ex: srt).
key (str): Key of this subtitle stream (ex: /library/streams/212284). key (str): Key of this subtitle stream (ex: /library/streams/212284).
title (str): Title of this subtitle stream. title (str): Title of this subtitle stream.
@ -266,6 +267,7 @@ class SubtitleStream(MediaPartStream):
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
super(SubtitleStream, self)._loadData(data) super(SubtitleStream, self)._loadData(data)
self.forced = cast(bool, data.attrib.get('forced', '0'))
self.format = data.attrib.get('format') self.format = data.attrib.get('format')
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')

View file

@ -2,6 +2,7 @@
from plexapi import utils from plexapi import utils
from plexapi.base import PlexPartialObject, Playable from plexapi.base import PlexPartialObject, Playable
from plexapi.exceptions import BadRequest, Unsupported from plexapi.exceptions import BadRequest, Unsupported
from plexapi.library import LibrarySection
from plexapi.playqueue import PlayQueue from plexapi.playqueue import PlayQueue
from plexapi.utils import cast, toDatetime from plexapi.utils import cast, toDatetime
from plexapi.compat import quote_plus from plexapi.compat import quote_plus
@ -127,9 +128,9 @@ class Playlist(PlexPartialObject, Playable):
return PlayQueue.create(self._server, self, *args, **kwargs) return PlayQueue.create(self._server, self, *args, **kwargs)
@classmethod @classmethod
def create(cls, server, title, items): def _create(cls, server, title, items):
""" Create a playlist. """ """ Create a playlist. """
if not isinstance(items, (list, tuple)): if items and not isinstance(items, (list, tuple)):
items = [items] items = [items]
ratingKeys = [] ratingKeys = []
for item in items: for item in items:
@ -147,6 +148,61 @@ class Playlist(PlexPartialObject, Playable):
data = server.query(key, method=server._session.post)[0] data = server.query(key, method=server._session.post)[0]
return cls(server, data, initpath=key) 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): def copyToUser(self, user):
""" Copy playlist to another user account. """ """ Copy playlist to another user account. """
from plexapi.server import PlexServer 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): 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 = 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._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true' self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
self._session = session or requests.Session() self._session = session or requests.Session()
@ -240,14 +241,14 @@ class PlexServer(PlexObject):
raise NotFound('Unknown client name: %s' % name) 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`. """ Creates and returns a new :class:`~plexapi.playlist.Playlist`.
Parameters: Parameters:
title (str): Title of the playlist to be created. title (str): Title of the playlist to be created.
items (list<Media>): List of media items to include in the playlist. 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): def createPlayQueue(self, item, **kwargs):
""" Creates and returns a new :class:`~plexapi.playqueue.PlayQueue`. """ 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_cast = lambda x: True if x == 'true' or x == '1' else False
_bool_str = lambda x: str(x).lower() _bool_str = lambda x: str(x).lower()
_str = lambda x: str(x).encode('utf-8')
TYPES = { TYPES = {
'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str}, 'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str},
'double': {'type': float, 'cast': float, 'tostr': string_type}, 'double': {'type': float, 'cast': float, 'tostr': _str},
'int': {'type': int, 'cast': int, 'tostr': string_type}, 'int': {'type': int, 'cast': int, 'tostr': _str},
'text': {'type': string_type, 'cast': string_type, 'tostr': string_type}, 'text': {'type': string_type, 'cast': _str, 'tostr': _str},
} }
def _loadData(self, data): def _loadData(self, data):

View file

@ -5,7 +5,7 @@ import re
import requests import requests
import time import time
import zipfile import zipfile
from datetime import datetime from datetime import datetime, timedelta
from getpass import getpass from getpass import getpass
from threading import Thread, Event from threading import Thread, Event
from tqdm import tqdm from tqdm import tqdm
@ -178,6 +178,11 @@ def toDatetime(value, format=None):
if format: if format:
value = datetime.strptime(value, format) value = datetime.strptime(value, format)
else: 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)) value = datetime.fromtimestamp(int(value))
return value return value

View file

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

View file

@ -36,7 +36,7 @@ AUDIOCHANNELS = {2, 6}
AUDIOLAYOUTS = {'5.1', '5.1(side)', 'stereo'} AUDIOLAYOUTS = {'5.1', '5.1(side)', 'stereo'}
CODECS = {'aac', 'ac3', 'dca', 'h264', 'mp3', 'mpeg4'} CODECS = {'aac', 'ac3', 'dca', 'h264', 'mp3', 'mpeg4'}
CONTAINERS = {'avi', 'mp4', 'mkv'} CONTAINERS = {'avi', 'mp4', 'mkv'}
CONTENTRATINGS = {'TV-14', 'TV-MA', 'G', 'NR'} CONTENTRATINGS = {'TV-14', 'TV-MA', 'G', 'NR', 'Not Rated'}
FRAMERATES = {'24p', 'PAL', 'NTSC'} FRAMERATES = {'24p', 'PAL', 'NTSC'}
PROFILES = {'advanced simple', 'main', 'constrained baseline'} PROFILES = {'advanced simple', 'main', 'constrained baseline'}
RESOLUTIONS = {'sd', '480', '576', '720', '1080'} 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()] assert playlist.title in [p.title for p in user_plex.playlists()]
finally: finally:
playlist.delete() 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.save()
plex._settings = None plex._settings = None
assert plex.settings.get('autoEmptyTrash').value == new_value 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 utils.is_metadata(movie.art)
assert movie.artUrl assert movie.artUrl
assert movie.audienceRating == 8.5 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' #assert movie.audienceRatingImage == 'rottentomatoes://image.rating.upright'
movie.reload() # RELOAD movie.reload() # RELOAD
assert movie.chapterSource is None assert movie.chapterSource is None