mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-08-20 05:13:21 -07:00
Update plexapi==4.8.0
This commit is contained in:
parent
36b55398a8
commit
3a50981976
20 changed files with 522 additions and 314 deletions
|
@ -29,14 +29,18 @@ class AlertListener(threading.Thread):
|
|||
callback (func): Callback function to call on received messages. The callback function
|
||||
will be sent a single argument 'data' which will contain a dictionary of data
|
||||
received from the server. :samp:`def my_callback(data): ...`
|
||||
callbackError (func): Callback function to call on errors. The callback function
|
||||
will be sent a single argument 'error' which will contain the Error object.
|
||||
:samp:`def my_callback(error): ...`
|
||||
"""
|
||||
key = '/:/websockets/notifications'
|
||||
|
||||
def __init__(self, server, callback=None):
|
||||
def __init__(self, server, callback=None, callbackError=None):
|
||||
super(AlertListener, self).__init__()
|
||||
self.daemon = True
|
||||
self._server = server
|
||||
self._callback = callback
|
||||
self._callbackError = callbackError
|
||||
self._ws = None
|
||||
|
||||
def run(self):
|
||||
|
@ -84,4 +88,9 @@ class AlertListener(threading.Thread):
|
|||
This is to support compatibility with current and previous releases of websocket-client.
|
||||
"""
|
||||
err = args[-1]
|
||||
log.error('AlertListener Error: %s', err)
|
||||
try:
|
||||
log.error('AlertListener Error: %s', err)
|
||||
if self._callbackError:
|
||||
self._callbackError(err)
|
||||
except Exception as err: # pragma: no cover
|
||||
log.error('AlertListener Error: Error: %s', err)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import library, media, utils
|
||||
|
@ -205,23 +206,20 @@ class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, S
|
|||
""" Alias of :func:`~plexapi.audio.Artist.track`. """
|
||||
return self.track(title, album, track)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
||||
""" Downloads all tracks for the artist to the specified location.
|
||||
def download(self, savepath=None, keep_original_name=False, subfolders=False, **kwargs):
|
||||
""" Download all tracks from the artist. See :func:`~plexapi.base.Playable.download` for details.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Title of the track to return.
|
||||
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
|
||||
be returned and the additional arguments passed in will be sent to that
|
||||
function. If kwargs is not specified, the media items will be downloaded
|
||||
and saved to disk.
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_original_name (bool): True to keep the original filename otherwise
|
||||
a friendlier filename is generated.
|
||||
subfolders (bool): True to separate tracks in to album folders.
|
||||
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
|
||||
"""
|
||||
filepaths = []
|
||||
for album in self.albums():
|
||||
for track in album.tracks():
|
||||
filepaths += track.download(savepath, keep_original_name, **kwargs)
|
||||
for track in self.tracks():
|
||||
_savepath = os.path.join(savepath, track.parentTitle) if subfolders else savepath
|
||||
filepaths += track.download(_savepath, keep_original_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
|
||||
|
@ -314,17 +312,13 @@ class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin,
|
|||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
||||
""" Downloads all tracks for the artist to the specified location.
|
||||
""" Download all tracks from the album. See :func:`~plexapi.base.Playable.download` for details.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Title of the track to return.
|
||||
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
|
||||
be returned and the additional arguments passed in will be sent to that
|
||||
function. If kwargs is not specified, the media items will be downloaded
|
||||
and saved to disk.
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_original_name (bool): True to keep the original filename otherwise
|
||||
a friendlier filename is generated.
|
||||
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
|
||||
"""
|
||||
filepaths = []
|
||||
for track in self.tracks():
|
||||
|
@ -398,7 +392,8 @@ class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin,
|
|||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return '%s - %s %s' % (self.grandparentTitle, self.parentTitle, self.title)
|
||||
return '%s - %s - %s - %s' % (
|
||||
self.grandparentTitle, self.parentTitle, str(self.trackNumber).zfill(2), self.title)
|
||||
|
||||
def album(self):
|
||||
""" Return the track's :class:`~plexapi.audio.Album`. """
|
||||
|
|
|
@ -681,34 +681,50 @@ class Playable(object):
|
|||
client.playMedia(self)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
||||
""" Downloads this items media to the specified location. Returns a list of
|
||||
""" Downloads the media item 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_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
|
||||
be returned and the additional arguments passed in will be sent to that
|
||||
function. If kwargs is not specified, the media items will be downloaded
|
||||
and saved to disk.
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_original_name (bool): True to keep the original filename otherwise
|
||||
a friendlier filename is generated. See filenames below.
|
||||
**kwargs (dict): Additional options passed into :func:`~plexapi.audio.Track.getStreamURL`
|
||||
to download a transcoded stream, otherwise the media item will be downloaded
|
||||
as-is and saved to disk.
|
||||
|
||||
**Filenames**
|
||||
|
||||
* Movie: ``<title> (<year>)``
|
||||
* Episode: ``<show title> - s00e00 - <episode title>``
|
||||
* Track: ``<artist title> - <album title> - 00 - <track title>``
|
||||
* Photo: ``<photoalbum title> - <photo/clip title>`` or ``<photo/clip title>``
|
||||
"""
|
||||
filepaths = []
|
||||
locations = [i for i in self.iterParts() if i]
|
||||
for location in locations:
|
||||
filename = location.file
|
||||
if keep_original_name is False:
|
||||
filename = '%s.%s' % (self._prettyfilename(), location.container)
|
||||
# So this seems to be a alot slower but allows transcode.
|
||||
parts = [i for i in self.iterParts() if i]
|
||||
|
||||
for part in parts:
|
||||
if not keep_original_name:
|
||||
filename = utils.cleanFilename('%s.%s' % (self._prettyfilename(), part.container))
|
||||
else:
|
||||
filename = part.file
|
||||
|
||||
if kwargs:
|
||||
# So this seems to be a alot slower but allows transcode.
|
||||
download_url = self.getStreamURL(**kwargs)
|
||||
else:
|
||||
download_url = self._server.url('%s?download=1' % location.key)
|
||||
filepath = utils.download(download_url, self._server._token, filename=filename,
|
||||
savepath=savepath, session=self._server._session)
|
||||
download_url = self._server.url('%s?download=1' % part.key)
|
||||
|
||||
filepath = utils.download(
|
||||
download_url,
|
||||
self._server._token,
|
||||
filename=filename,
|
||||
savepath=savepath,
|
||||
session=self._server._session
|
||||
)
|
||||
|
||||
if filepath:
|
||||
filepaths.append(filepath)
|
||||
|
||||
return filepaths
|
||||
|
||||
def stop(self, reason=''):
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
# Library version
|
||||
MAJOR_VERSION = 4
|
||||
MINOR_VERSION = 7
|
||||
PATCH_VERSION = 2
|
||||
MINOR_VERSION = 8
|
||||
PATCH_VERSION = 0
|
||||
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
||||
|
|
|
@ -26,47 +26,61 @@ class Library(PlexObject):
|
|||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self._sectionsByID = {} # cached Section UUIDs
|
||||
self.identifier = data.attrib.get('identifier')
|
||||
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
|
||||
self.title1 = data.attrib.get('title1')
|
||||
self.title2 = data.attrib.get('title2')
|
||||
self._sectionsByID = {} # cached sections by key
|
||||
self._sectionsByTitle = {} # cached sections by title
|
||||
|
||||
def _loadSections(self):
|
||||
""" Loads and caches all the library sections. """
|
||||
key = '/library/sections'
|
||||
self._sectionsByID = {}
|
||||
self._sectionsByTitle = {}
|
||||
for elem in self._server.query(key):
|
||||
for cls in (MovieSection, ShowSection, MusicSection, PhotoSection):
|
||||
if elem.attrib.get('type') == cls.TYPE:
|
||||
section = cls(self._server, elem, key)
|
||||
self._sectionsByID[section.key] = section
|
||||
self._sectionsByTitle[section.title.lower()] = section
|
||||
|
||||
def sections(self):
|
||||
""" Returns a list of all media sections in this library. Library sections may be any of
|
||||
:class:`~plexapi.library.MovieSection`, :class:`~plexapi.library.ShowSection`,
|
||||
:class:`~plexapi.library.MusicSection`, :class:`~plexapi.library.PhotoSection`.
|
||||
"""
|
||||
key = '/library/sections'
|
||||
sections = []
|
||||
for elem in self._server.query(key):
|
||||
for cls in (MovieSection, ShowSection, MusicSection, PhotoSection):
|
||||
if elem.attrib.get('type') == cls.TYPE:
|
||||
section = cls(self._server, elem, key)
|
||||
self._sectionsByID[section.key] = section
|
||||
sections.append(section)
|
||||
return sections
|
||||
self._loadSections()
|
||||
return list(self._sectionsByID.values())
|
||||
|
||||
def section(self, title=None):
|
||||
def section(self, title):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the section to return.
|
||||
"""
|
||||
for section in self.sections():
|
||||
if section.title.lower() == title.lower():
|
||||
return section
|
||||
raise NotFound('Invalid library section: %s' % title)
|
||||
if not self._sectionsByTitle or title not in self._sectionsByTitle:
|
||||
self._loadSections()
|
||||
try:
|
||||
return self._sectionsByTitle[title.lower()]
|
||||
except KeyError:
|
||||
raise NotFound('Invalid library section: %s' % title) from None
|
||||
|
||||
def sectionByID(self, sectionID):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` that matches the specified sectionID.
|
||||
|
||||
Parameters:
|
||||
sectionID (int): ID of the section to return.
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.NotFound`: The library section ID is not found on the server.
|
||||
"""
|
||||
if not self._sectionsByID or sectionID not in self._sectionsByID:
|
||||
self.sections()
|
||||
return self._sectionsByID[sectionID]
|
||||
self._loadSections()
|
||||
try:
|
||||
return self._sectionsByID[sectionID]
|
||||
except KeyError:
|
||||
raise NotFound('Invalid library sectionID: %s' % sectionID) from None
|
||||
|
||||
def all(self, **kwargs):
|
||||
""" Returns a list of all media from all library sections.
|
||||
|
@ -356,6 +370,9 @@ class LibrarySection(PlexObject):
|
|||
self._filterTypes = None
|
||||
self._fieldTypes = None
|
||||
self._totalViewSize = None
|
||||
self._totalSize = None
|
||||
self._totalDuration = None
|
||||
self._totalStorage = None
|
||||
|
||||
def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs):
|
||||
""" Load the specified key to find and build all items with the specified tag
|
||||
|
@ -394,7 +411,36 @@ class LibrarySection(PlexObject):
|
|||
@property
|
||||
def totalSize(self):
|
||||
""" Returns the total number of items in the library for the default library type. """
|
||||
return self.totalViewSize(includeCollections=False)
|
||||
if self._totalSize is None:
|
||||
self._totalSize = self.totalViewSize(includeCollections=False)
|
||||
return self._totalSize
|
||||
|
||||
@property
|
||||
def totalDuration(self):
|
||||
""" Returns the total duration (in milliseconds) of items in the library. """
|
||||
if self._totalDuration is None:
|
||||
self._getTotalDurationStorage()
|
||||
return self._totalDuration
|
||||
|
||||
@property
|
||||
def totalStorage(self):
|
||||
""" Returns the total storage (in bytes) of items in the library. """
|
||||
if self._totalStorage is None:
|
||||
self._getTotalDurationStorage()
|
||||
return self._totalStorage
|
||||
|
||||
def _getTotalDurationStorage(self):
|
||||
""" Queries the Plex server for the total library duration and storage and caches the values. """
|
||||
data = self._server.query('/media/providers?includeStorage=1')
|
||||
xpath = (
|
||||
'./MediaProvider[@identifier="com.plexapp.plugins.library"]'
|
||||
'/Feature[@type="content"]'
|
||||
'/Directory[@id="%s"]'
|
||||
) % self.key
|
||||
directory = next(iter(data.findall(xpath)), None)
|
||||
if directory:
|
||||
self._totalDuration = utils.cast(int, directory.attrib.get('durationTotal'))
|
||||
self._totalStorage = utils.cast(int, directory.attrib.get('storageTotal'))
|
||||
|
||||
def totalViewSize(self, libtype=None, includeCollections=True):
|
||||
""" Returns the total number of items in the library for a specified libtype.
|
||||
|
@ -432,8 +478,12 @@ class LibrarySection(PlexObject):
|
|||
log.error(msg)
|
||||
raise
|
||||
|
||||
def reload(self, key=None):
|
||||
return self._server.library.section(self.title)
|
||||
def reload(self):
|
||||
""" Reload the data for the library section. """
|
||||
self._server.library._loadSections()
|
||||
newLibrary = self._server.library.sectionByID(self.key)
|
||||
self.__dict__.update(newLibrary.__dict__)
|
||||
return self
|
||||
|
||||
def edit(self, agent=None, **kwargs):
|
||||
""" Edit a library (Note: agent is required). See :class:`~plexapi.library.Library` for example usage.
|
||||
|
@ -446,11 +496,6 @@ class LibrarySection(PlexObject):
|
|||
part = '/library/sections/%s?agent=%s&%s' % (self.key, agent, urlencode(kwargs))
|
||||
self._server.query(part, method=self._server._session.put)
|
||||
|
||||
# Reload this way since the self.key dont have a full path, but is simply a id.
|
||||
for s in self._server.library.sections():
|
||||
if s.key == self.key:
|
||||
return s
|
||||
|
||||
def get(self, title):
|
||||
""" Returns the media item with the specified title.
|
||||
|
||||
|
|
|
@ -79,13 +79,16 @@ class Media(PlexObject):
|
|||
self.make = data.attrib.get('make')
|
||||
self.model = data.attrib.get('model')
|
||||
|
||||
parent = self._parent()
|
||||
self._parentKey = parent.key
|
||||
|
||||
@property
|
||||
def isOptimizedVersion(self):
|
||||
""" Returns True if the media is a Plex optimized version. """
|
||||
return self.proxyType == utils.SEARCHTYPES['optimizedVersion']
|
||||
|
||||
def delete(self):
|
||||
part = self._initpath + '/media/%s' % self.id
|
||||
part = '%s/media/%s' % (self._parentKey, self.id)
|
||||
try:
|
||||
return self._server.query(part, method=self._server._session.delete)
|
||||
except BadRequest:
|
||||
|
|
|
@ -70,9 +70,6 @@ class MyPlexAccount(PlexObject):
|
|||
PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get
|
||||
FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete
|
||||
REMOVEHOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete
|
||||
REMOVEINVITE = 'https://plex.tv/api/invites/requested/{userId}?friend=1&server=1&home=1' # delete
|
||||
REQUESTED = 'https://plex.tv/api/invites/requested' # get
|
||||
REQUESTS = 'https://plex.tv/api/invites/requests' # get
|
||||
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
|
||||
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
|
||||
OPTOUTS = 'https://plex.tv/api/v2/user/%(userUUID)s/settings/opt_outs' # get
|
||||
|
@ -365,26 +362,55 @@ class MyPlexAccount(PlexObject):
|
|||
return self.query(url, self._session.post, headers=headers)
|
||||
|
||||
def removeFriend(self, user):
|
||||
""" Remove the specified user from all sharing.
|
||||
""" Remove the specified user from your friends.
|
||||
|
||||
Parameters:
|
||||
user (str): MyPlexUser, username, email of the user to be added.
|
||||
user (str): :class:`~plexapi.myplex.MyPlexUser`, username, or email of the user to be removed.
|
||||
"""
|
||||
user = self.user(user)
|
||||
url = self.FRIENDUPDATE if user.friend else self.REMOVEINVITE
|
||||
url = url.format(userId=user.id)
|
||||
user = user if isinstance(user, MyPlexUser) else self.user(user)
|
||||
url = self.FRIENDUPDATE.format(userId=user.id)
|
||||
return self.query(url, self._session.delete)
|
||||
|
||||
def removeHomeUser(self, user):
|
||||
""" Remove the specified managed user from home.
|
||||
""" Remove the specified user from your home users.
|
||||
|
||||
Parameters:
|
||||
user (str): MyPlexUser, username, email of the user to be removed from home.
|
||||
user (str): :class:`~plexapi.myplex.MyPlexUser`, username, or email of the user to be removed.
|
||||
"""
|
||||
user = self.user(user)
|
||||
user = user if isinstance(user, MyPlexUser) else self.user(user)
|
||||
url = self.REMOVEHOMEUSER.format(userId=user.id)
|
||||
return self.query(url, self._session.delete)
|
||||
|
||||
def acceptInvite(self, user):
|
||||
""" Accept a pending firend invite from the specified user.
|
||||
|
||||
Parameters:
|
||||
user (str): :class:`~plexapi.myplex.MyPlexInvite`, username, or email of the friend invite to accept.
|
||||
"""
|
||||
invite = user if isinstance(user, MyPlexInvite) else self.pendingInvite(user, includeSent=False)
|
||||
params = {
|
||||
'friend': int(invite.friend),
|
||||
'home': int(invite.home),
|
||||
'server': int(invite.server)
|
||||
}
|
||||
url = MyPlexInvite.REQUESTS + '/%s' % invite.id + utils.joinArgs(params)
|
||||
return self.query(url, self._session.put)
|
||||
|
||||
def cancelInvite(self, user):
|
||||
""" Cancel a pending firend invite for the specified user.
|
||||
|
||||
Parameters:
|
||||
user (str): :class:`~plexapi.myplex.MyPlexInvite`, username, or email of the friend invite to cancel.
|
||||
"""
|
||||
invite = user if isinstance(user, MyPlexInvite) else self.pendingInvite(user, includeReceived=False)
|
||||
params = {
|
||||
'friend': int(invite.friend),
|
||||
'home': int(invite.home),
|
||||
'server': int(invite.server)
|
||||
}
|
||||
url = MyPlexInvite.REQUESTED + '/%s' % invite.id + utils.joinArgs(params)
|
||||
return self.query(url, self._session.delete)
|
||||
|
||||
def updateFriend(self, user, server, sections=None, removeSections=False, allowSync=None, allowCameraUpload=None,
|
||||
allowChannels=None, filterMovies=None, filterTelevision=None, filterMusic=None):
|
||||
""" Update the specified user's share settings.
|
||||
|
@ -455,7 +481,7 @@ class MyPlexAccount(PlexObject):
|
|||
return response_servers, response_filters
|
||||
|
||||
def user(self, username):
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexUser` that matches the email or username specified.
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexUser` that matches the specified username or email.
|
||||
|
||||
Parameters:
|
||||
username (str): Username, email or id of the user to return.
|
||||
|
@ -467,19 +493,50 @@ class MyPlexAccount(PlexObject):
|
|||
return user
|
||||
|
||||
elif (user.username and user.email and user.id and username.lower() in
|
||||
(user.username.lower(), user.email.lower(), str(user.id))):
|
||||
(user.username.lower(), user.email.lower(), str(user.id))):
|
||||
return user
|
||||
|
||||
raise NotFound('Unable to find user %s' % username)
|
||||
|
||||
def users(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexUser` objects connected to your account.
|
||||
This includes both friends and pending invites. You can reference the user.friend to
|
||||
distinguish between the two.
|
||||
"""
|
||||
friends = [MyPlexUser(self, elem) for elem in self.query(MyPlexUser.key)]
|
||||
requested = [MyPlexUser(self, elem, self.REQUESTED) for elem in self.query(self.REQUESTED)]
|
||||
return friends + requested
|
||||
elem = self.query(MyPlexUser.key)
|
||||
return self.findItems(elem, cls=MyPlexUser)
|
||||
|
||||
def pendingInvite(self, username, includeSent=True, includeReceived=True):
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexInvite` that matches the specified username or email.
|
||||
Note: This can be a pending invite sent from your account or received to your account.
|
||||
|
||||
Parameters:
|
||||
username (str): Username, email or id of the user to return.
|
||||
includeSent (bool): True to include sent invites.
|
||||
includeReceived (bool): True to include received invites.
|
||||
"""
|
||||
username = str(username)
|
||||
for invite in self.pendingInvites(includeSent, includeReceived):
|
||||
if (invite.username and invite.email and invite.id and username.lower() in
|
||||
(invite.username.lower(), invite.email.lower(), str(invite.id))):
|
||||
return invite
|
||||
|
||||
raise NotFound('Unable to find invite %s' % username)
|
||||
|
||||
def pendingInvites(self, includeSent=True, includeReceived=True):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexInvite` objects connected to your account.
|
||||
Note: This includes all pending invites sent from your account and received to your account.
|
||||
|
||||
Parameters:
|
||||
includeSent (bool): True to include sent invites.
|
||||
includeReceived (bool): True to include received invites.
|
||||
"""
|
||||
invites = []
|
||||
if includeSent:
|
||||
elem = self.query(MyPlexInvite.REQUESTED)
|
||||
invites += self.findItems(elem, cls=MyPlexInvite)
|
||||
if includeReceived:
|
||||
elem = self.query(MyPlexInvite.REQUESTS)
|
||||
invites += self.findItems(elem, cls=MyPlexInvite)
|
||||
return invites
|
||||
|
||||
def _getSectionIds(self, server, sections):
|
||||
""" Converts a list of section objects or names to sectionIds needed for library sharing. """
|
||||
|
@ -731,10 +788,10 @@ class MyPlexUser(PlexObject):
|
|||
protected (False): Unknown (possibly SSL enabled?).
|
||||
recommendationsPlaylistId (str): Unknown.
|
||||
restricted (str): Unknown.
|
||||
servers (List<:class:`~plexapi.myplex.<MyPlexServerShare`>)): Servers shared with the user.
|
||||
thumb (str): Link to the users avatar.
|
||||
title (str): Seems to be an aliad for username.
|
||||
username (str): User's username.
|
||||
servers: Servers shared between user and friend
|
||||
"""
|
||||
TAG = 'User'
|
||||
key = 'https://plex.tv/api/users/'
|
||||
|
@ -796,6 +853,43 @@ class MyPlexUser(PlexObject):
|
|||
return hist
|
||||
|
||||
|
||||
class MyPlexInvite(PlexObject):
|
||||
""" This object represents pending friend invites.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Invite'
|
||||
createdAt (datetime): Datetime the user was invited.
|
||||
email (str): User's email address (user@gmail.com).
|
||||
friend (bool): True or False if the user is invited as a friend.
|
||||
friendlyName (str): The user's friendly name.
|
||||
home (bool): True or False if the user is invited to a Plex Home.
|
||||
id (int): User's Plex account ID.
|
||||
server (bool): True or False if the user is invited to any servers.
|
||||
servers (List<:class:`~plexapi.myplex.<MyPlexServerShare`>)): Servers shared with the user.
|
||||
thumb (str): Link to the users avatar.
|
||||
username (str): User's username.
|
||||
"""
|
||||
TAG = 'Invite'
|
||||
REQUESTS = 'https://plex.tv/api/invites/requests'
|
||||
REQUESTED = 'https://plex.tv/api/invites/requested'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
|
||||
self.email = data.attrib.get('email')
|
||||
self.friend = utils.cast(bool, data.attrib.get('friend'))
|
||||
self.friendlyName = data.attrib.get('friendlyName')
|
||||
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||
self.id = utils.cast(int, data.attrib.get('id'))
|
||||
self.server = utils.cast(bool, data.attrib.get('server'))
|
||||
self.servers = self.findItems(data, MyPlexServerShare)
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.username = data.attrib.get('username', '')
|
||||
for server in self.servers:
|
||||
server.accountID = self.id
|
||||
|
||||
|
||||
class Section(PlexObject):
|
||||
""" This refers to a shared section. The raw xml for the data presented here
|
||||
can be found at: https://plex.tv/api/servers/{machineId}/shared_servers
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import media, utils, video
|
||||
|
@ -107,34 +108,21 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin, RatingMixin):
|
|||
""" Alias to :func:`~plexapi.photo.Photoalbum.photo`. """
|
||||
return self.episode(title)
|
||||
|
||||
def iterParts(self):
|
||||
""" Iterates over the parts of the media item. """
|
||||
for album in self.albums():
|
||||
for photo in album.photos():
|
||||
for part in photo.iterParts():
|
||||
yield part
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, showstatus=False):
|
||||
""" Download photo files to specified directory.
|
||||
def download(self, savepath=None, keep_original_name=False, subfolders=False):
|
||||
""" Download all photos and clips from the photo ablum. See :func:`~plexapi.base.Playable.download` for details.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_original_name (bool): True to keep the original file name otherwise
|
||||
a friendlier is generated.
|
||||
showstatus(bool): Display a progressbar.
|
||||
keep_original_name (bool): True to keep the original filename otherwise
|
||||
a friendlier filename is generated.
|
||||
subfolders (bool): True to separate photos/clips in to photo album folders.
|
||||
"""
|
||||
filepaths = []
|
||||
locations = [i for i in self.iterParts() if i]
|
||||
for location in locations:
|
||||
name = location.file
|
||||
if not keep_original_name:
|
||||
title = self.title.replace(' ', '.')
|
||||
name = '%s.%s' % (title, location.container)
|
||||
url = self._server.url('%s?download=1' % location.key)
|
||||
filepath = utils.download(url, self._server._token, filename=name, showstatus=showstatus,
|
||||
savepath=savepath, session=self._server._session)
|
||||
if filepath:
|
||||
filepaths.append(filepath)
|
||||
for album in self.albums():
|
||||
_savepath = os.path.join(savepath, album.title) if subfolders else savepath
|
||||
filepaths += album.download(_savepath, keep_original_name)
|
||||
for photo in self.photos() + self.clips():
|
||||
filepaths += photo.download(savepath, keep_original_name)
|
||||
return filepaths
|
||||
|
||||
def _getWebURL(self, base=None):
|
||||
|
@ -218,6 +206,12 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixi
|
|||
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
if self.parentTitle:
|
||||
return '%s - %s' % (self.parentTitle, self.title)
|
||||
return self.title
|
||||
|
||||
def photoalbum(self):
|
||||
""" Return the photo's :class:`~plexapi.photo.Photoalbum`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
@ -241,12 +235,6 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixi
|
|||
"""
|
||||
return [part.file for item in self.media for part in item.parts if part]
|
||||
|
||||
def iterParts(self):
|
||||
""" Iterates over the parts of the media item. """
|
||||
for item in self.media:
|
||||
for part in item.parts:
|
||||
yield part
|
||||
|
||||
def sync(self, resolution, client=None, clientId=None, limit=None, title=None):
|
||||
""" Add current photo as sync item for specified device.
|
||||
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
|
||||
|
@ -283,29 +271,6 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixi
|
|||
|
||||
return myplex.sync(sync_item, client=client, clientId=clientId)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, showstatus=False):
|
||||
""" Download photo files to specified directory.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_original_name (bool): True to keep the original file name otherwise
|
||||
a friendlier is generated.
|
||||
showstatus(bool): Display a progressbar.
|
||||
"""
|
||||
filepaths = []
|
||||
locations = [i for i in self.iterParts() if i]
|
||||
for location in locations:
|
||||
name = location.file
|
||||
if not keep_original_name:
|
||||
title = self.title.replace(' ', '.')
|
||||
name = '%s.%s' % (title, location.container)
|
||||
url = self._server.url('%s?download=1' % location.key)
|
||||
filepath = utils.download(url, self._server._token, filename=name, showstatus=showstatus,
|
||||
savepath=savepath, session=self._server._session)
|
||||
if filepath:
|
||||
filepaths.append(filepath)
|
||||
return filepaths
|
||||
|
||||
def _getWebURL(self, base=None):
|
||||
""" Get the Plex Web URL with the correct parameters. """
|
||||
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1)
|
||||
|
|
|
@ -734,21 +734,46 @@ class PlexServer(PlexObject):
|
|||
notifier.start()
|
||||
return notifier
|
||||
|
||||
def transcodeImage(self, media, height, width, opacity=100, saturation=100):
|
||||
""" Returns the URL for a transcoded image from the specified media object.
|
||||
Returns None if no media specified (needed if user tries to pass thumb
|
||||
or art directly).
|
||||
def transcodeImage(self, imageUrl, height, width,
|
||||
opacity=None, saturation=None, blur=None, background=None,
|
||||
minSize=True, upscale=True, imageFormat=None):
|
||||
""" Returns the URL for a transcoded image.
|
||||
|
||||
Parameters:
|
||||
imageUrl (str): The URL to the image
|
||||
(eg. returned by :func:`~plexapi.mixins.PosterUrlMixin.thumbUrl`
|
||||
or :func:`~plexapi.mixins.ArtUrlMixin.artUrl`).
|
||||
The URL can be an online image.
|
||||
height (int): Height to transcode the image to.
|
||||
width (int): Width to transcode the image to.
|
||||
opacity (int): Opacity of the resulting image (possibly deprecated).
|
||||
saturation (int): Saturating of the resulting image.
|
||||
opacity (int, optional): Change the opacity of the image (0 to 100)
|
||||
saturation (int, optional): Change the saturation of the image (0 to 100).
|
||||
blur (int, optional): The blur to apply to the image in pixels (e.g. 3).
|
||||
background (str, optional): The background hex colour to apply behind the opacity (e.g. '000000').
|
||||
minSize (bool, optional): Maintain smallest dimension. Default True.
|
||||
upscale (bool, optional): Upscale the image if required. Default True.
|
||||
imageFormat (str, optional): 'jpeg' (default) or 'png'.
|
||||
"""
|
||||
if media:
|
||||
transcode_url = '/photo/:/transcode?height=%s&width=%s&opacity=%s&saturation=%s&url=%s' % (
|
||||
height, width, opacity, saturation, media)
|
||||
return self.url(transcode_url, includeToken=True)
|
||||
params = {
|
||||
'url': imageUrl,
|
||||
'height': height,
|
||||
'width': width,
|
||||
'minSize': int(bool(minSize)),
|
||||
'upscale': int(bool(upscale))
|
||||
}
|
||||
if opacity is not None:
|
||||
params['opacity'] = opacity
|
||||
if saturation is not None:
|
||||
params['saturation'] = saturation
|
||||
if blur is not None:
|
||||
params['blur'] = blur
|
||||
if background is not None:
|
||||
params['background'] = str(background).strip('#')
|
||||
if imageFormat is not None:
|
||||
params['format'] = imageFormat.lower()
|
||||
|
||||
key = '/photo/:/transcode%s' % utils.joinArgs(params)
|
||||
return self.url(key, includeToken=True)
|
||||
|
||||
def url(self, key, includeToken=None):
|
||||
""" Build a URL string with proper token argument. Token will be appended to the URL
|
||||
|
|
|
@ -4,7 +4,9 @@ import functools
|
|||
import logging
|
||||
import os
|
||||
import re
|
||||
import string
|
||||
import time
|
||||
import unicodedata
|
||||
import warnings
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
|
@ -251,6 +253,13 @@ def toList(value, itemcast=None, delim=','):
|
|||
return [itemcast(item) for item in value.split(delim) if item != '']
|
||||
|
||||
|
||||
def cleanFilename(filename, replace='_'):
|
||||
whitelist = "-_.()[] {}{}".format(string.ascii_letters, string.digits)
|
||||
cleaned_filename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode()
|
||||
cleaned_filename = ''.join(c if c in whitelist else replace for c in cleaned_filename)
|
||||
return cleaned_filename
|
||||
|
||||
|
||||
def downloadSessionImages(server, filename=None, height=150, width=150,
|
||||
opacity=100, saturation=100): # pragma: no cover
|
||||
""" Helper to download a bif image or thumb.url from plex.server.sessions.
|
||||
|
|
|
@ -357,8 +357,8 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Ratin
|
|||
return any(part.hasPreviewThumbnails for media in self.media for part in media.parts)
|
||||
|
||||
def _prettyfilename(self):
|
||||
# This is just for compat.
|
||||
return self.title
|
||||
""" Returns a filename for use in download. """
|
||||
return '%s (%s)' % (self.title, self.year)
|
||||
|
||||
def reviews(self):
|
||||
""" Returns a list of :class:`~plexapi.media.Review` objects. """
|
||||
|
@ -375,32 +375,6 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Ratin
|
|||
data = self._server.query(self._details_key)
|
||||
return self.findItems(data, library.Hub, rtag='Related')
|
||||
|
||||
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_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 = []
|
||||
locations = [i for i in self.iterParts() if i]
|
||||
for location in locations:
|
||||
name = location.file
|
||||
if not keep_original_name:
|
||||
title = self.title.replace(' ', '.')
|
||||
name = '%s.%s' % (title, location.container)
|
||||
if kwargs is not None:
|
||||
url = self.getStreamURL(**kwargs)
|
||||
else:
|
||||
self._server.url('%s?download=1' % location.key)
|
||||
filepath = utils.download(url, self._server._token, filename=name,
|
||||
savepath=savepath, session=self._server._session)
|
||||
if filepath:
|
||||
filepaths.append(filepath)
|
||||
return filepaths
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin,
|
||||
|
@ -582,18 +556,20 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Rat
|
|||
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
|
||||
return self.episodes(viewCount=0)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
||||
""" Download video files to specified directory.
|
||||
def download(self, savepath=None, keep_original_name=False, subfolders=False, **kwargs):
|
||||
""" Download all episodes from the show. See :func:`~plexapi.base.Playable.download` for details.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_original_name (bool): True to keep the original file name otherwise
|
||||
a friendlier is generated.
|
||||
keep_original_name (bool): True to keep the original filename otherwise
|
||||
a friendlier filename is generated.
|
||||
subfolders (bool): True to separate episodes in to season folders.
|
||||
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
|
||||
"""
|
||||
filepaths = []
|
||||
for episode in self.episodes():
|
||||
filepaths += episode.download(savepath, keep_original_name, **kwargs)
|
||||
_savepath = os.path.join(savepath, 'Season %s' % str(episode.seasonNumber).zfill(2)) if subfolders else savepath
|
||||
filepaths += episode.download(_savepath, keep_original_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
|
||||
|
@ -714,12 +690,12 @@ class Season(Video, ArtMixin, PosterMixin, RatingMixin, CollectionMixin):
|
|||
return self.episodes(viewCount=0)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
||||
""" Download video files to specified directory.
|
||||
""" Download all episodes from the season. See :func:`~plexapi.base.Playable.download` for details.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_original_name (bool): True to keep the original file name otherwise
|
||||
a friendlier is generated.
|
||||
keep_original_name (bool): True to keep the original filename otherwise
|
||||
a friendlier filename is generated.
|
||||
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
|
||||
"""
|
||||
filepaths = []
|
||||
|
@ -839,8 +815,8 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
|
|||
] if p])
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a human friendly filename. """
|
||||
return '%s.%s' % (self.grandparentTitle.replace(' ', '.'), self.seasonEpisode)
|
||||
""" Returns a filename for use in download. """
|
||||
return '%s - %s - %s' % (self.grandparentTitle, self.seasonEpisode, self.title)
|
||||
|
||||
@property
|
||||
def actors(self):
|
||||
|
@ -953,6 +929,7 @@ class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin):
|
|||
return [part.file for part in self.iterParts() if part]
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return self.title
|
||||
|
||||
|
||||
|
@ -968,4 +945,5 @@ class Extra(Clip):
|
|||
self.librarySectionTitle = parent.librarySectionTitle
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return '%s (%s)' % (self.title, self.subtype)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue