Bump plexapi from 4.15.0 to 4.15.4 (#2175)

* Bump plexapi from 4.15.0 to 4.15.4

Bumps [plexapi](https://github.com/pkkid/python-plexapi) from 4.15.0 to 4.15.4.
- [Release notes](https://github.com/pkkid/python-plexapi/releases)
- [Commits](https://github.com/pkkid/python-plexapi/compare/4.15.0...4.15.4)

---
updated-dependencies:
- dependency-name: plexapi
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update plexapi==4.15.4

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>

[skip ci]
This commit is contained in:
dependabot[bot] 2023-10-10 14:22:10 -07:00 committed by GitHub
parent fdc1dd3525
commit aa4d98ee34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 399 additions and 128 deletions

View file

@ -30,6 +30,7 @@ X_PLEX_VERSION = CONFIG.get('header.version', VERSION)
X_PLEX_DEVICE = CONFIG.get('header.device', X_PLEX_PLATFORM)
X_PLEX_DEVICE_NAME = CONFIG.get('header.device_name', uname()[1])
X_PLEX_IDENTIFIER = CONFIG.get('header.identifier', str(hex(getnode())))
X_PLEX_LANGUAGE = CONFIG.get('header.language', 'en')
BASE_HEADERS = reset_base_headers()
# Logging Configuration

View file

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
import json
import socket
from typing import Callable
import threading
from plexapi import log
@ -32,15 +34,17 @@ class AlertListener(threading.Thread):
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): ...`
ws_socket (socket): Socket to use for the connection. If not specified, a new socket will be created.
"""
key = '/:/websockets/notifications'
def __init__(self, server, callback=None, callbackError=None):
def __init__(self, server, callback: Callable = None, callbackError: Callable = None, ws_socket: socket = None):
super(AlertListener, self).__init__()
self.daemon = True
self._server = server
self._callback = callback
self._callbackError = callbackError
self._socket = ws_socket
self._ws = None
def run(self):
@ -52,8 +56,9 @@ class AlertListener(threading.Thread):
# create the websocket connection
url = self._server.url(self.key, includeToken=True).replace('http', 'ws')
log.info('Starting AlertListener: %s', url)
self._ws = websocket.WebSocketApp(url, on_message=self._onMessage,
on_error=self._onError)
self._ws = websocket.WebSocketApp(url, on_message=self._onMessage, on_error=self._onError, socket=self._socket)
self._ws.run_forever()
def stop(self):
@ -66,10 +71,8 @@ class AlertListener(threading.Thread):
def _onMessage(self, *args):
""" Called when websocket message is received.
In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp
object and the message as a STR. Current releases appear to only return the message.
We are assuming the last argument in the tuple is the message.
This is to support compatibility with current and previous releases of websocket-client.
"""
message = args[-1]
try:
@ -82,10 +85,8 @@ class AlertListener(threading.Thread):
def _onError(self, *args): # pragma: no cover
""" Called when websocket error is received.
In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp
object and the error. Current releases appear to only return the error.
We are assuming the last argument in the tuple is the message.
This is to support compatibility with current and previous releases of websocket-client.
"""
err = args[-1]
try:

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
import os
from pathlib import Path
from urllib.parse import quote_plus
from plexapi import media, utils
@ -240,6 +241,12 @@ class Artist(
key = f'{self.key}?includeStations=1'
return next(iter(self.fetchItems(key, cls=Playlist, rtag="Stations")), None)
@property
def metadataDirectory(self):
""" Returns the Plex Media Server data directory where the metadata is stored. """
guid_hash = utils.sha1hash(self.guid)
return str(Path('Metadata') / 'Artists' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
@utils.registerPlexObject
class Album(
@ -359,6 +366,12 @@ class Album(
""" Returns str, default title for a new syncItem. """
return f'{self.parentTitle} - {self.title}'
@property
def metadataDirectory(self):
""" Returns the Plex Media Server data directory where the metadata is stored. """
guid_hash = utils.sha1hash(self.guid)
return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
@utils.registerPlexObject
class Track(
@ -470,6 +483,12 @@ class Track(
""" Get the Plex Web URL with the correct parameters. """
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey)
@property
def metadataDirectory(self):
""" Returns the Plex Media Server data directory where the metadata is stored. """
guid_hash = utils.sha1hash(self.parentGuid)
return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
@utils.registerPlexObject
class TrackSession(PlexSession, Track):

View file

@ -227,7 +227,7 @@ class PlexObject:
fetchItem(ekey, viewCount__gte=0)
fetchItem(ekey, Media__container__in=["mp4", "mkv"])
fetchItem(ekey, guid__iregex=r"(imdb:\/\/|themoviedb:\/\/)")
fetchItem(ekey, guid__iregex=r"(imdb://|themoviedb://)")
fetchItem(ekey, Media__Part__file__startswith="D:\\Movies")
"""
@ -502,7 +502,7 @@ class PlexPartialObject(PlexObject):
def __eq__(self, other):
if isinstance(other, PlexPartialObject):
return other not in [None, []] and self.key == other.key
return self.key == other.key
return NotImplemented
def __hash__(self):
@ -626,7 +626,8 @@ class PlexPartialObject(PlexObject):
return self
def saveEdits(self):
""" Save all the batch edits and automatically reload the object.
""" Save all the batch edits. The object needs to be reloaded manually,
if required.
See :func:`~plexapi.base.PlexPartialObject.batchEdits` for details.
"""
if not isinstance(self._edits, dict):
@ -635,7 +636,7 @@ class PlexPartialObject(PlexObject):
edits = self._edits
self._edits = None
self._edit(**edits)
return self.reload()
return self
def refresh(self):
""" Refreshing a Library or individual item causes the metadata for the item to be
@ -919,7 +920,7 @@ class PlexSession(object):
def stop(self, reason=''):
""" Stop playback for the session.
Parameters:
reason (str): Message displayed to the user for stopping playback.
"""

View file

@ -70,6 +70,7 @@ class PlexClient(PlexObject):
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
server_session = server._session if server else None
self._session = session or server_session or requests.Session()
self._timeout = timeout or TIMEOUT
self._proxyThroughServer = False
self._commandId = 0
self._last_call = 0
@ -94,7 +95,7 @@ class PlexClient(PlexObject):
raise Unsupported('Cannot reload an object not built from a URL.')
self._initpath = self.key
data = self.query(self.key, timeout=timeout)
if not data:
if data is None:
raise NotFound(f"Client not found at {self._baseurl}")
if self._clientIdentifier:
client = next(
@ -179,7 +180,7 @@ class PlexClient(PlexObject):
"""
url = self.url(path)
method = method or self._session.get
timeout = timeout or TIMEOUT
timeout = timeout or self._timeout
log.debug('%s %s', method.__name__.upper(), url)
headers = self._headers(**headers or {})
response = method(url, headers=headers, timeout=timeout, **kwargs)

View file

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
from pathlib import Path
from urllib.parse import quote_plus
from plexapi import media, utils
@ -399,7 +400,7 @@ class Collection(
@deprecated('use editTitle, editSortTitle, editContentRating, and editSummary instead')
def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs):
""" Edit the collection.
Parameters:
title (str, optional): The title of the collection.
titleSort (str, optional): The sort title of the collection.
@ -560,3 +561,9 @@ class Collection(
raise Unsupported('Unsupported collection content')
return myplex.sync(sync_item, client=client, clientId=clientId)
@property
def metadataDirectory(self):
""" Returns the Plex Media Server data directory where the metadata is stored. """
guid_hash = utils.sha1hash(self.guid)
return str(Path('Metadata') / 'Collections' / guid_hash[0] / f'{guid_hash[1:]}.bundle')

View file

@ -63,6 +63,7 @@ def reset_base_headers():
'X-Plex-Device': plexapi.X_PLEX_DEVICE,
'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME,
'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER,
'X-Plex-Language': plexapi.X_PLEX_LANGUAGE,
'X-Plex-Sync-Version': '2',
'X-Plex-Features': 'external-media',
}

View file

@ -4,6 +4,6 @@
# Library version
MAJOR_VERSION = 4
MINOR_VERSION = 15
PATCH_VERSION = 0
PATCH_VERSION = 4
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__ = f"{__short_version__}.{PATCH_VERSION}"

View file

@ -542,7 +542,7 @@ class LibrarySection(PlexObject):
def addLocations(self, location):
""" Add a location to a library.
Parameters:
location (str or list): A single folder path, list of paths.
@ -565,7 +565,7 @@ class LibrarySection(PlexObject):
def removeLocations(self, location):
""" Remove a location from a library.
Parameters:
location (str or list): A single folder path, list of paths.
@ -744,7 +744,7 @@ class LibrarySection(PlexObject):
def lockAllField(self, field, libtype=None):
""" Lock a field for all items in the library.
Parameters:
field (str): The field to lock (e.g. thumb, rating, collection).
libtype (str, optional): The library type to lock (movie, show, season, episode,
@ -754,7 +754,7 @@ class LibrarySection(PlexObject):
def unlockAllField(self, field, libtype=None):
""" Unlock a field for all items in the library.
Parameters:
field (str): The field to unlock (e.g. thumb, rating, collection).
libtype (str, optional): The library type to lock (movie, show, season, episode,
@ -847,7 +847,7 @@ class LibrarySection(PlexObject):
"""
_key = ('/library/sections/{key}/{filter}?includeMeta=1&includeAdvanced=1'
'&X-Plex-Container-Start=0&X-Plex-Container-Size=0')
key = _key.format(key=self.key, filter='all')
data = self._server.query(key)
self._filterTypes = self.findItems(data, FilteringType, rtag='Meta')
@ -894,7 +894,7 @@ class LibrarySection(PlexObject):
def getFieldType(self, fieldType):
""" Returns a :class:`~plexapi.library.FilteringFieldType` for a specified fieldType.
Parameters:
fieldType (str): The data type for the field (tag, integer, string, boolean, date,
subtitleLanguage, audioLanguage, resolution).
@ -927,7 +927,7 @@ class LibrarySection(PlexObject):
"""
return self.getFilterType(libtype).filters
def listSorts(self, libtype=None):
""" Returns a list of available :class:`~plexapi.library.FilteringSort` for a specified libtype.
This is the list of options in the sorting dropdown menu
@ -970,7 +970,7 @@ class LibrarySection(PlexObject):
""" Returns a list of available :class:`~plexapi.library.FilteringOperator` for a specified fieldType.
This is the list of options in the custom filter operator dropdown menu
(`screenshot <../_static/images/LibrarySection.search.png>`__).
Parameters:
fieldType (str): The data type for the field (tag, integer, string, boolean, date,
subtitleLanguage, audioLanguage, resolution).
@ -992,7 +992,7 @@ class LibrarySection(PlexObject):
:class:`~plexapi.library.FilteringFilter` or filter field.
This is the list of available values for a custom filter
(`screenshot <../_static/images/LibrarySection.search.png>`__).
Parameters:
field (str): :class:`~plexapi.library.FilteringFilter` object,
or the name of the field (genre, year, contentRating, etc.).
@ -1024,7 +1024,7 @@ class LibrarySection(PlexObject):
availableFilters = [f.filter for f in self.listFilters(libtype)]
raise NotFound(f'Unknown filter field "{field}" for libtype "{libtype}". '
f'Available filters: {availableFilters}') from None
data = self._server.query(field.key)
return self.findItems(data, FilterChoice)
@ -1111,7 +1111,7 @@ class LibrarySection(PlexObject):
except (ValueError, AttributeError):
raise BadRequest(f'Invalid value "{value}" for filter field "{filterField.key}", '
f'value should be type {fieldType.type}') from None
return results
def _validateFieldValueDate(self, value):
@ -1345,7 +1345,7 @@ class LibrarySection(PlexObject):
Tag type filter values can be a :class:`~plexapi.library.FilterChoice` object,
:class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*),
or the exact id :attr:`MediaTag.id` (*int*).
Date type filter values can be a ``datetime`` object, a relative date using a one of the
available date suffixes (e.g. ``30d``) (*str*), or a date in ``YYYY-MM-DD`` (*str*) format.
@ -1358,7 +1358,7 @@ class LibrarySection(PlexObject):
* ``w``: ``weeks``
* ``mon``: ``months``
* ``y``: ``years``
Multiple values can be ``OR`` together by providing a list of values.
Examples:
@ -1684,12 +1684,12 @@ class LibrarySection(PlexObject):
def _validateItems(self, items):
""" Validates the specified items are from this library and of the same type. """
if not items:
if items is None or items == []:
raise BadRequest('No items specified.')
if not isinstance(items, list):
items = [items]
itemType = items[0].type
for item in items:
if item.librarySectionID != self.key:
@ -3102,6 +3102,7 @@ class FirstCharacter(PlexObject):
size (str): Total amount of library items starting with this character.
title (str): Character (#, !, A, B, C, ...).
"""
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
import xml
from pathlib import Path
from urllib.parse import quote_plus
from plexapi import log, settings, utils
@ -121,6 +121,7 @@ class MediaPart(PlexObject):
optimizedForStreaming (bool): True if the file is optimized for streaming.
packetLength (int): The packet length of the file.
requiredBandwidths (str): The required bandwidths to stream the file.
selected (bool): True if this media part is selected.
size (int): The size of the file in bytes (ex: 733884416).
streams (List<:class:`~plexapi.media.MediaPartStream`>): List of stream objects.
syncItemId (int): The unique ID for this media part if it is synced.
@ -184,38 +185,60 @@ class MediaPart(PlexObject):
""" Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """
return [stream for stream in self.streams if isinstance(stream, LyricStream)]
def setDefaultAudioStream(self, stream):
""" Set the default :class:`~plexapi.media.AudioStream` for this MediaPart.
def setSelectedAudioStream(self, stream):
""" Set the selected :class:`~plexapi.media.AudioStream` for this MediaPart.
Parameters:
stream (:class:`~plexapi.media.AudioStream`): AudioStream to set as default
stream (:class:`~plexapi.media.AudioStream`): Audio stream to set as selected
"""
key = f'/library/parts/{self.id}'
params = {'allParts': 1}
if isinstance(stream, AudioStream):
key = f"/library/parts/{self.id}?audioStreamID={stream.id}&allParts=1"
params['audioStreamID'] = stream.id
else:
key = f"/library/parts/{self.id}?audioStreamID={stream}&allParts=1"
self._server.query(key, method=self._server._session.put)
params['audioStreamID'] = stream
self._server.query(key, method=self._server._session.put, params=params)
return self
def setDefaultSubtitleStream(self, stream):
""" Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart.
def setSelectedSubtitleStream(self, stream):
""" Set the selected :class:`~plexapi.media.SubtitleStream` for this MediaPart.
Parameters:
stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default.
stream (:class:`~plexapi.media.SubtitleStream`): Subtitle stream to set as selected.
"""
key = f'/library/parts/{self.id}'
params = {'allParts': 1}
if isinstance(stream, SubtitleStream):
key = f"/library/parts/{self.id}?subtitleStreamID={stream.id}&allParts=1"
params['subtitleStreamID'] = stream.id
else:
key = f"/library/parts/{self.id}?subtitleStreamID={stream}&allParts=1"
params['subtitleStreamID'] = stream
self._server.query(key, method=self._server._session.put)
return self
def resetDefaultSubtitleStream(self):
""" Set default subtitle of this MediaPart to 'none'. """
key = f"/library/parts/{self.id}?subtitleStreamID=0&allParts=1"
self._server.query(key, method=self._server._session.put)
def resetSelectedSubtitleStream(self):
""" Set the selected subtitle of this MediaPart to 'None'. """
key = f'/library/parts/{self.id}'
params = {'subtitleStreamID': 0, 'allParts': 1}
self._server.query(key, method=self._server._session.put, params=params)
return self
@deprecated('Use "setSelectedAudioStream" instead.')
def setDefaultAudioStream(self, stream):
return self.setSelectedAudioStream(stream)
@deprecated('Use "setSelectedSubtitleStream" instead.')
def setDefaultSubtitleStream(self, stream):
return self.setSelectedSubtitleStream(stream)
@deprecated('Use "resetSelectedSubtitleStream" instead.')
def resetDefaultSubtitleStream(self):
return self.resetSelectedSubtitleStream()
class MediaPartStream(PlexObject):
""" Base class for media streams. These consist of video, audio, subtitles, and lyrics.
@ -399,9 +422,15 @@ class AudioStream(MediaPartStream):
self.peak = utils.cast(float, data.attrib.get('peak'))
self.startRamp = data.attrib.get('startRamp')
def setSelected(self):
""" Sets this audio stream as the selected audio stream.
Alias for :func:`~plexapi.media.MediaPart.setSelectedAudioStream`.
"""
return self._parent().setSelectedAudioStream(self)
@deprecated('Use "setSelected" instead.')
def setDefault(self):
""" Sets this audio stream as the default audio stream. """
return self._parent().setDefaultAudioStream(self)
return self.setSelected()
@utils.registerPlexObject
@ -437,9 +466,15 @@ class SubtitleStream(MediaPartStream):
self.transient = data.attrib.get('transient')
self.userID = utils.cast(int, data.attrib.get('userID'))
def setSelected(self):
""" Sets this subtitle stream as the selected subtitle stream.
Alias for :func:`~plexapi.media.MediaPart.setSelectedSubtitleStream`.
"""
return self._parent().setSelectedSubtitleStream(self)
@deprecated('Use "setSelected" instead.')
def setDefault(self):
""" Sets this subtitle stream as the default subtitle stream. """
return self._parent().setDefaultSubtitleStream(self)
return self.setSelected()
class LyricStream(MediaPartStream):
@ -973,6 +1008,7 @@ class BaseResource(PlexObject):
selected (bool): True if the resource is currently selected.
thumb (str): The URL to retrieve the resource thumbnail.
"""
def _loadData(self, data):
self._data = data
self.key = data.attrib.get('key')
@ -989,6 +1025,20 @@ class BaseResource(PlexObject):
except xml.etree.ElementTree.ParseError:
pass
@property
def resourceFilepath(self):
""" Returns the file path to the resource in the Plex Media Server data directory.
Note: Returns the URL if the resource is not stored locally.
"""
if self.ratingKey.startswith('media://'):
return str(Path('Media') / 'localhost' / self.ratingKey.split('://')[-1])
elif self.ratingKey.startswith('metadata://'):
return str(Path(self._parent().metadataDirectory) / 'Contents' / '_combined' / self.ratingKey.split('://')[-1])
elif self.ratingKey.startswith('upload://'):
return str(Path(self._parent().metadataDirectory) / 'Uploads' / self.ratingKey.split('://')[-1])
else:
return self.ratingKey
class Art(BaseResource):
""" Represents a single Art object. """

View file

@ -39,7 +39,7 @@ class AdvancedSettingsMixin:
pref = preferences[settingID]
except KeyError:
raise NotFound(f'{value} not found in {list(preferences.keys())}')
enumValues = pref.enumValues
if enumValues.get(value, enumValues.get(str(value))):
data[settingID] = value
@ -69,7 +69,7 @@ class SmartFilterMixin:
filters = {}
filterOp = 'and'
filterGroups = [[]]
for key, value in parse_qsl(content.query):
# Move = sign to key when operator is ==
if value.startswith('='):
@ -96,11 +96,11 @@ class SmartFilterMixin:
filterGroups.pop()
else:
filterGroups[-1].append({key: value})
if filterGroups:
filters['filters'] = self._formatFilterGroups(filterGroups.pop())
return filters
def _formatFilterGroups(self, groups):
""" Formats the filter groups into the advanced search rules. """
if len(groups) == 1 and isinstance(groups[0], list):
@ -131,7 +131,7 @@ class SplitMergeMixin:
def merge(self, ratingKeys):
""" Merge other Plex objects into the current object.
Parameters:
ratingKeys (list): A list of rating keys to merge.
"""
@ -320,7 +320,7 @@ class RatingMixin:
class ArtUrlMixin:
""" Mixin for Plex objects that can have a background artwork url. """
@property
def artUrl(self):
""" Return the art url for the Plex object. """
@ -349,7 +349,7 @@ class ArtMixin(ArtUrlMixin, ArtLockMixin):
def uploadArt(self, url=None, filepath=None):
""" Upload a background artwork from a url or filepath.
Parameters:
url (str): The full URL to the image to upload.
filepath (str): The full file path the the image to upload or file-like object.
@ -365,7 +365,7 @@ class ArtMixin(ArtUrlMixin, ArtLockMixin):
def setArt(self, art):
""" Set the background artwork for a Plex object.
Parameters:
art (:class:`~plexapi.media.Art`): The art object to select.
"""
@ -425,7 +425,7 @@ class PosterMixin(PosterUrlMixin, PosterLockMixin):
def setPoster(self, poster):
""" Set the poster for a Plex object.
Parameters:
poster (:class:`~plexapi.media.Poster`): The poster object to select.
"""
@ -491,11 +491,11 @@ class ThemeMixin(ThemeUrlMixin, ThemeLockMixin):
class EditFieldMixin:
""" Mixin for editing Plex object fields. """
def editField(self, field, value, locked=True, **kwargs):
""" Edit the field of a Plex object. All field editing methods can be chained together.
Also see :func:`~plexapi.base.PlexPartialObject.batchEdits` for batch editing fields.
Parameters:
field (str): The name of the field to edit.
value (str): The value to edit the field to.

View file

@ -111,12 +111,14 @@ class MyPlexAccount(PlexObject):
# Hub sections
VOD = 'https://vod.provider.plex.tv' # get
MUSIC = 'https://music.provider.plex.tv' # get
DISCOVER = 'https://discover.provider.plex.tv'
METADATA = 'https://metadata.provider.plex.tv'
key = 'https://plex.tv/api/v2/user'
def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None, remember=True):
self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
self._session = session or requests.Session()
self._timeout = timeout or TIMEOUT
self._sonos_cache = []
self._sonos_cache_timestamp = 0
data, initpath = self._signin(username, password, code, remember, timeout)
@ -186,7 +188,9 @@ class MyPlexAccount(PlexObject):
self.subscriptionPaymentService = subscription.attrib.get('paymentService')
self.subscriptionPlan = subscription.attrib.get('plan')
self.subscriptionStatus = subscription.attrib.get('status')
self.subscriptionSubscribedAt = utils.toDatetime(subscription.attrib.get('subscribedAt'), '%Y-%m-%d %H:%M:%S %Z')
self.subscriptionSubscribedAt = utils.toDatetime(
subscription.attrib.get('subscribedAt') or None, '%Y-%m-%d %H:%M:%S %Z'
)
profile = data.find('profile')
self.profileAutoSelectAudio = utils.cast(bool, profile.attrib.get('autoSelectAudio'))
@ -223,7 +227,7 @@ class MyPlexAccount(PlexObject):
def query(self, url, method=None, headers=None, timeout=None, **kwargs):
method = method or self._session.get
timeout = timeout or TIMEOUT
timeout = timeout or self._timeout
log.debug('%s %s %s', method.__name__.upper(), url, kwargs.get('json', ''))
headers = self._headers(**headers or {})
response = method(url, headers=headers, timeout=timeout, **kwargs)
@ -239,8 +243,10 @@ class MyPlexAccount(PlexObject):
raise Unauthorized(message)
else:
raise BadRequest(message)
if headers.get('Accept') == 'application/json':
if 'application/json' in response.headers.get('Content-Type', ''):
return response.json()
elif 'text/plain' in response.headers.get('Content-Type', ''):
return response.text.strip()
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
@ -672,7 +678,7 @@ class MyPlexAccount(PlexObject):
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(f'Unable to find invite {username}')
def pendingInvites(self, includeSent=True, includeReceived=True):
@ -950,7 +956,7 @@ class MyPlexAccount(PlexObject):
"""
if not isinstance(items, list):
items = [items]
for item in items:
if self.onWatchlist(item):
raise BadRequest(f'"{item.title}" is already on the watchlist')
@ -971,7 +977,7 @@ class MyPlexAccount(PlexObject):
"""
if not isinstance(items, list):
items = [items]
for item in items:
if not self.onWatchlist(item):
raise BadRequest(f'"{item.title}" is not on the watchlist')
@ -1053,7 +1059,7 @@ class MyPlexAccount(PlexObject):
'includeMetadata': 1
}
data = self.query(f'{self.METADATA}/library/search', headers=headers, params=params)
data = self.query(f'{self.DISCOVER}/library/search', headers=headers, params=params)
searchResults = data['MediaContainer'].get('SearchResults', [])
searchResult = next((s.get('SearchResult', []) for s in searchResults if s.get('id') == 'external'), [])
@ -1135,6 +1141,21 @@ class MyPlexAccount(PlexObject):
return objs
def publicIP(self):
""" Returns your public IP address. """
return self.query('https://plex.tv/:/ip')
def geoip(self, ip_address):
""" Returns a :class:`~plexapi.myplex.GeoLocation` object with geolocation information
for an IP address using Plex's GeoIP database.
Parameters:
ip_address (str): IP address to lookup.
"""
params = {'ip_address': ip_address}
data = self.query('https://plex.tv/api/v2/geoip', params=params)
return GeoLocation(self, data)
class MyPlexUser(PlexObject):
""" This object represents non-signed in users such as friends and linked
@ -1773,7 +1794,7 @@ class MyPlexPinLogin:
params = None
response = self._query(url, self._session.post, params=params)
if not response:
if response is None:
return None
self._id = response.attrib.get('id')
@ -1790,7 +1811,7 @@ class MyPlexPinLogin:
url = self.CHECKPINS.format(pinid=self._id)
response = self._query(url)
if not response:
if response is None:
return False
token = response.attrib.get('authToken')
@ -1927,7 +1948,7 @@ class AccountOptOut(PlexObject):
def optOutManaged(self):
""" Sets the Online Media Source to "Disabled for Managed Users".
Raises:
:exc:`~plexapi.exceptions.BadRequest`: When trying to opt out music.
"""
@ -1964,3 +1985,42 @@ class UserState(PlexObject):
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.viewState = data.attrib.get('viewState') == 'complete'
self.watchlistedAt = utils.toDatetime(data.attrib.get('watchlistedAt'))
class GeoLocation(PlexObject):
""" Represents a signle IP address geolocation
Attributes:
TAG (str): location
city (str): City name
code (str): Country code
continentCode (str): Continent code
coordinates (Tuple<float>): Latitude and longitude
country (str): Country name
europeanUnionMember (bool): True if the country is a member of the European Union
inPrivacyRestrictedCountry (bool): True if the country is privacy restricted
postalCode (str): Postal code
subdivisions (str): Subdivision name
timezone (str): Timezone
"""
TAG = 'location'
def _loadData(self, data):
self._data = data
self.city = data.attrib.get('city')
self.code = data.attrib.get('code')
self.continentCode = data.attrib.get('continent_code')
self.coordinates = tuple(
utils.cast(float, coord) for coord in (data.attrib.get('coordinates') or ',').split(','))
self.country = data.attrib.get('country')
self.postalCode = data.attrib.get('postal_code')
self.subdivisions = data.attrib.get('subdivisions')
self.timezone = data.attrib.get('time_zone')
europeanUnionMember = data.attrib.get('european_union_member')
self.europeanUnionMember = (
False if europeanUnionMember == 'Unknown' else utils.cast(bool, europeanUnionMember))
inPrivacyRestrictedCountry = data.attrib.get('in_privacy_restricted_country')
self.inPrivacyRestrictedCountry = (
False if inPrivacyRestrictedCountry == 'Unknown' else utils.cast(bool, inPrivacyRestrictedCountry))

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
import os
from pathlib import Path
from urllib.parse import quote_plus
from plexapi import media, utils, video
@ -139,6 +140,12 @@ class Photoalbum(
""" Get the Plex Web URL with the correct parameters. """
return self._server._buildWebURL(base=base, endpoint='details', key=self.key, legacy=1)
@property
def metadataDirectory(self):
""" Returns the Plex Media Server data directory where the metadata is stored. """
guid_hash = utils.sha1hash(self.guid)
return str(Path('Metadata') / 'Photos' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
@utils.registerPlexObject
class Photo(
@ -249,7 +256,7 @@ class Photo(
List<str> of file paths where the photo is found on disk.
"""
return [part.file for item in self.media for part in item.parts if 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.
@ -290,6 +297,12 @@ class Photo(
""" Get the Plex Web URL with the correct parameters. """
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1)
@property
def metadataDirectory(self):
""" Returns the Plex Media Server data directory where the metadata is stored. """
guid_hash = utils.sha1hash(self.parentGuid)
return str(Path('Metadata') / 'Photos' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
@utils.registerPlexObject
class PhotoSession(PlexSession, Photo):

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
import re
from pathlib import Path
from urllib.parse import quote_plus, unquote
from plexapi import media, utils
@ -154,7 +155,7 @@ class Playlist(
sectionKey = int(match.group(1))
self._section = self._server.library.sectionByID(sectionKey)
return self._section
# Try to get the library section from the first item in the playlist
if self.items():
self._section = self.items()[0].section()
@ -313,7 +314,7 @@ class Playlist(
def edit(self, title=None, summary=None):
""" Edit the playlist.
Parameters:
title (str, optional): The title of the playlist.
summary (str, optional): The summary of the playlist.
@ -431,7 +432,7 @@ class Playlist(
def copyToUser(self, user):
""" Copy playlist to another user account.
Parameters:
user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username,
email, or user id of the user to copy the playlist to.
@ -496,3 +497,9 @@ class Playlist(
def _getWebURL(self, base=None):
""" Get the Plex Web URL with the correct parameters. """
return self._server._buildWebURL(base=base, endpoint='playlist', key=self.key)
@property
def metadataDirectory(self):
""" Returns the Plex Media Server data directory where the metadata is stored. """
guid_hash = utils.sha1hash(self.guid)
return str(Path('Metadata') / 'Playlists' / guid_hash[0] / f'{guid_hash[1:]}.bundle')

View file

@ -109,7 +109,7 @@ class PlexServer(PlexObject):
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()
self._timeout = timeout
self._timeout = timeout or TIMEOUT
self._myPlexAccount = None # cached myPlexAccount
self._systemAccounts = None # cached list of SystemAccount
self._systemDevices = None # cached list of SystemDevice
@ -189,6 +189,11 @@ class PlexServer(PlexObject):
data = self.query(Settings.key)
return Settings(self, data)
def identity(self):
""" Returns the Plex server identity. """
data = self.query('/identity')
return Identity(self, data)
def account(self):
""" Returns the :class:`~plexapi.server.Account` object this server belongs to. """
data = self.query(Account.key)
@ -197,7 +202,7 @@ class PlexServer(PlexObject):
def claim(self, account):
""" Claim the Plex server using a :class:`~plexapi.myplex.MyPlexAccount`.
This will only work with an unclaimed server on localhost or the same subnet.
Parameters:
account (:class:`~plexapi.myplex.MyPlexAccount`): The account used to
claim the server.
@ -240,7 +245,7 @@ class PlexServer(PlexObject):
def switchUser(self, user, session=None, timeout=None):
""" Returns a new :class:`~plexapi.server.PlexServer` object logged in as the given username.
Note: Only the admin account can switch to other users.
Parameters:
user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username,
email, or user id of the user to log in to the server.
@ -585,7 +590,7 @@ class PlexServer(PlexObject):
def runButlerTask(self, task):
""" Manually run a butler task immediately instead of waiting for the scheduled task to run.
Note: The butler task is run asynchronously. Check Plex Web to monitor activity.
Parameters:
task (str): The name of the task to run. (e.g. 'BackupDatabase')
@ -597,7 +602,7 @@ class PlexServer(PlexObject):
print("Available butler tasks:", availableTasks)
"""
validTasks = [task.name for task in self.butlerTasks()]
validTasks = [_task.name for _task in self.butlerTasks()]
if task not in validTasks:
raise BadRequest(
f'Invalid butler task: {task}. Available tasks are: {validTasks}'
@ -610,7 +615,8 @@ class PlexServer(PlexObject):
return self.checkForUpdate(force=force, download=download)
def checkForUpdate(self, force=True, download=False):
""" Returns a :class:`~plexapi.base.Release` object containing release info.
""" Returns a :class:`~plexapi.server.Release` object containing release info
if an update is available or None if no update is available.
Parameters:
force (bool): Force server to check for new releases
@ -624,12 +630,19 @@ class PlexServer(PlexObject):
return releases[0]
def isLatest(self):
""" Check if the installed version of PMS is the latest. """
""" Returns True if the installed version of Plex Media Server is the latest. """
release = self.checkForUpdate(force=True)
return release is None
def canInstallUpdate(self):
""" Returns True if the newest version of Plex Media Server can be installed automatically.
(e.g. Windows and Mac can install updates automatically, but Docker and NAS devices cannot.)
"""
release = self.query('/updater/status')
return utils.cast(bool, release.get('canInstall'))
def installUpdate(self):
""" Install the newest version of Plex Media Server. """
""" Automatically install the newest version of Plex Media Server. """
# We can add this but dunno how useful this is since it sometimes
# requires user action using a gui.
part = '/updater/apply'
@ -661,7 +674,7 @@ class PlexServer(PlexObject):
args['librarySectionID'] = librarySectionID
if mindate:
args['viewedAt>'] = int(mindate.timestamp())
key = f'/status/sessions/history/all{utils.joinArgs(args)}'
return self.fetchItems(key, maxresults=maxresults)
@ -741,7 +754,7 @@ class PlexServer(PlexObject):
"""
url = self.url(key)
method = method or self._session.get
timeout = timeout or TIMEOUT
timeout = timeout or self._timeout
log.debug('%s %s', method.__name__.upper(), url)
headers = self._headers(**headers or {})
response = method(url, headers=headers, timeout=timeout, **kwargs)
@ -1253,7 +1266,7 @@ class StatisticsResources(PlexObject):
@utils.registerPlexObject
class ButlerTask(PlexObject):
""" Represents a single scheduled butler task.
Attributes:
TAG (str): 'ButlerTask'
description (str): The description of the task.
@ -1273,3 +1286,22 @@ class ButlerTask(PlexObject):
self.name = data.attrib.get('name')
self.scheduleRandomized = utils.cast(bool, data.attrib.get('scheduleRandomized'))
self.title = data.attrib.get('title')
class Identity(PlexObject):
""" Represents a server identity.
Attributes:
claimed (bool): True or False if the server is claimed.
machineIdentifier (str): The Plex server machine identifier.
version (str): The Plex server version.
"""
def __repr__(self):
return f"<{self.__class__.__name__}:{self.machineIdentifier}>"
def _loadData(self, data):
self._data = data
self.claimed = utils.cast(bool, data.attrib.get('claimed'))
self.machineIdentifier = data.attrib.get('machineIdentifier')
self.version = data.attrib.get('version')

View file

@ -23,7 +23,6 @@ you can set items to be synced to your app) you need to init some variables.
You have to fake platform/device/model because transcoding profiles are hardcoded in Plex, and you obviously have
to explicitly specify that your app supports `sync-target`.
"""
import requests
import plexapi

View file

@ -11,13 +11,14 @@ import unicodedata
import warnings
import zipfile
from collections import deque
from datetime import datetime
from datetime import datetime, timedelta
from getpass import getpass
from hashlib import sha1
from threading import Event, Thread
from urllib.parse import quote
from requests.status_codes import _codes as codes
import requests
from requests.status_codes import _codes as codes
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
@ -313,33 +314,44 @@ def toDatetime(value, format=None):
value (str): value to return as a datetime
format (str): Format to pass strftime (optional; if value is a str).
"""
if value and value is not None:
if value is not None:
if format:
try:
value = datetime.strptime(value, format)
return datetime.strptime(value, format)
except ValueError:
log.info('Failed to parse %s to datetime, defaulting to None', value)
log.info('Failed to parse "%s" to datetime as format "%s", defaulting to None', value, format)
return None
else:
# https://bugs.python.org/issue30684
# And platform support for before epoch seems to be flaky.
# Also limit to max 32-bit integer
value = min(max(int(value), 86400), 2**31 - 1)
value = datetime.fromtimestamp(int(value))
try:
value = int(value)
except ValueError:
log.info('Failed to parse "%s" to datetime as timestamp, defaulting to None', value)
return None
try:
return datetime.fromtimestamp(value)
except (OSError, OverflowError):
try:
return datetime.fromtimestamp(0) + timedelta(seconds=value)
except OverflowError:
log.info('Failed to parse "%s" to datetime as timestamp (out-of-bounds), defaulting to None', value)
return None
return value
def millisecondToHumanstr(milliseconds):
""" Returns human readable time duration from milliseconds.
HH:MM:SS:MMMM
""" Returns human readable time duration [D day[s], ]HH:MM:SS.UUU from milliseconds.
Parameters:
milliseconds (str,int): time duration in milliseconds.
milliseconds (str, int): time duration in milliseconds.
"""
milliseconds = int(milliseconds)
r = datetime.utcfromtimestamp(milliseconds / 1000)
f = r.strftime("%H:%M:%S.%f")
return f[:-2]
if milliseconds < 0:
return '-' + millisecondToHumanstr(abs(milliseconds))
secs, ms = divmod(milliseconds, 1000)
mins, secs = divmod(secs, 60)
hours, mins = divmod(mins, 60)
days, hours = divmod(hours, 24)
return ('' if days == 0 else f'{days} day{"s" if days > 1 else ""}, ') + f'{hours:02d}:{mins:02d}:{secs:02d}.{ms:03d}'
def toList(value, itemcast=None, delim=','):
@ -644,3 +656,8 @@ def openOrRead(file):
return file.read()
with open(file, 'rb') as f:
return f.read()
def sha1hash(guid):
""" Return the SHA1 hash of a guid. """
return sha1(guid.encode('utf-8')).hexdigest()

View file

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
import os
from functools import cached_property
from pathlib import Path
from urllib.parse import quote_plus
from plexapi import media, utils
@ -445,6 +447,12 @@ class Movie(
self._server.query(key, params=params, method=self._server._session.put)
return self
@property
def metadataDirectory(self):
""" Returns the Plex Media Server data directory where the metadata is stored. """
guid_hash = utils.sha1hash(self.guid)
return str(Path('Metadata') / 'Movies' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
@utils.registerPlexObject
class Show(
@ -655,6 +663,12 @@ class Show(
filepaths += episode.download(_savepath, keep_original_name, **kwargs)
return filepaths
@property
def metadataDirectory(self):
""" Returns the Plex Media Server data directory where the metadata is stored. """
guid_hash = utils.sha1hash(self.guid)
return str(Path('Metadata') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
@utils.registerPlexObject
class Season(
@ -663,7 +677,7 @@ class Season(
ArtMixin, PosterMixin, ThemeUrlMixin,
SeasonEditMixins
):
""" Represents a single Show Season (including all episodes).
""" Represents a single Season.
Attributes:
TAG (str): 'Directory'
@ -808,6 +822,12 @@ class Season(
""" Returns str, default title for a new syncItem. """
return f'{self.parentTitle} - {self.title}'
@property
def metadataDirectory(self):
""" Returns the Plex Media Server data directory where the metadata is stored. """
guid_hash = utils.sha1hash(self.parentGuid)
return str(Path('Metadata') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
@utils.registerPlexObject
class Episode(
@ -816,7 +836,7 @@ class Episode(
ArtMixin, PosterMixin, ThemeUrlMixin,
EpisodeEditMixins
):
""" Represents a single Shows Episode.
""" Represents a single Episode.
Attributes:
TAG (str): 'Video'
@ -845,7 +865,7 @@ class Episode(
parentGuid (str): Plex GUID for the season (plex://season/5d9c09e42df347001e3c2a72).
parentIndex (int): Season number of episode.
parentKey (str): API URL of the season (/library/metadata/<parentRatingKey>).
parentRatingKey (int): Unique key identifying the season.
parentRatingKey (int): Unique key identifying the season.
parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
parentTitle (str): Name of the season for the episode.
parentYear (int): Year the season was released.
@ -866,7 +886,6 @@ class Episode(
""" Load attribute values from Plex XML response. """
Video._loadData(self, data)
Playable._loadData(self, data)
self._seasonNumber = None # cached season number
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
self.audienceRatingImage = data.attrib.get('audienceRatingImage')
self.chapters = self.findItems(data, media.Chapter)
@ -890,9 +909,6 @@ class Episode(
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.parentYear = utils.cast(int, data.attrib.get('parentYear'))
self.producers = self.findItems(data, media.Producer)
@ -906,15 +922,50 @@ class Episode(
# If seasons are hidden, parentKey and parentRatingKey are missing from the XML response.
# https://forums.plex.tv/t/parentratingkey-not-in-episode-xml-when-seasons-are-hidden/300553
if self.skipParent and data.attrib.get('parentRatingKey') is None:
# Parse the parentRatingKey from the parentThumb
if self.parentThumb and self.parentThumb.startswith('/library/metadata/'):
self.parentRatingKey = utils.cast(int, self.parentThumb.split('/')[3])
# Get the parentRatingKey from the season's ratingKey
if not self.parentRatingKey and self.grandparentRatingKey:
self.parentRatingKey = self.show().season(season=self.parentIndex).ratingKey
if self.parentRatingKey:
self.parentKey = f'/library/metadata/{self.parentRatingKey}'
# Use cached properties below to return the correct values if they are missing to avoid auto-reloading.
self._parentKey = data.attrib.get('parentKey')
self._parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self._parentThumb = data.attrib.get('parentThumb')
@cached_property
def parentKey(self):
""" Returns the parentKey. Refer to the Episode attributes. """
if self._parentKey:
return self._parentKey
if self.parentRatingKey:
return f'/library/metadata/{self.parentRatingKey}'
return None
@cached_property
def parentRatingKey(self):
""" Returns the parentRatingKey. Refer to the Episode attributes. """
if self._parentRatingKey is not None:
return self._parentRatingKey
# Parse the parentRatingKey from the parentThumb
if self._parentThumb and self._parentThumb.startswith('/library/metadata/'):
return utils.cast(int, self._parentThumb.split('/')[3])
# Get the parentRatingKey from the season's ratingKey if available
if self._season:
return self._season.ratingKey
return None
@cached_property
def parentThumb(self):
""" Returns the parentThumb. Refer to the Episode attributes. """
if self._parentThumb:
return self._parentThumb
if self._season:
return self._season.thumb
return None
@cached_property
def _season(self):
""" Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """
if not self.grandparentKey:
return None
return self.fetchItem(
f'{self.grandparentKey}/children?excludeAllLeaves=1&index={self.parentIndex}'
)
def __repr__(self):
return '<{}>'.format(
@ -949,12 +1000,10 @@ class Episode(
""" Returns the episode number. """
return self.index
@property
@cached_property
def seasonNumber(self):
""" Returns the episode's season number. """
if self._seasonNumber is None:
self._seasonNumber = self.parentIndex if isinstance(self.parentIndex, int) else self.season().seasonNumber
return utils.cast(int, self._seasonNumber)
return self.parentIndex if isinstance(self.parentIndex, int) else self._season.seasonNumber
@property
def seasonEpisode(self):
@ -1000,6 +1049,12 @@ class Episode(
self._server.query(key, params=params, method=self._server._session.put)
return self
@property
def metadataDirectory(self):
""" Returns the Plex Media Server data directory where the metadata is stored. """
guid_hash = utils.sha1hash(self.grandparentGuid)
return str(Path('Metadata') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
@utils.registerPlexObject
class Clip(
@ -1058,6 +1113,12 @@ class Clip(
""" Returns a filename for use in download. """
return self.title
@property
def metadataDirectory(self):
""" Returns the Plex Media Server data directory where the metadata is stored. """
guid_hash = utils.sha1hash(self.guid)
return str(Path('Metadata') / 'Movies' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
class Extra(Clip):
""" Represents a single Extra (trailer, behindTheScenes, etc). """

View file

@ -28,7 +28,7 @@ MarkupSafe==2.1.3
musicbrainzngs==0.7.1
packaging==23.1
paho-mqtt==1.6.1
plexapi==4.15.0
plexapi==4.15.4
portend==3.2.0
profilehooks==1.12.0
PyJWT==2.8.0