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 = CONFIG.get('header.device', X_PLEX_PLATFORM)
X_PLEX_DEVICE_NAME = CONFIG.get('header.device_name', uname()[1]) X_PLEX_DEVICE_NAME = CONFIG.get('header.device_name', uname()[1])
X_PLEX_IDENTIFIER = CONFIG.get('header.identifier', str(hex(getnode()))) X_PLEX_IDENTIFIER = CONFIG.get('header.identifier', str(hex(getnode())))
X_PLEX_LANGUAGE = CONFIG.get('header.language', 'en')
BASE_HEADERS = reset_base_headers() BASE_HEADERS = reset_base_headers()
# Logging Configuration # Logging Configuration

View file

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import json import json
import socket
from typing import Callable
import threading import threading
from plexapi import log from plexapi import log
@ -32,15 +34,17 @@ class AlertListener(threading.Thread):
callbackError (func): Callback function to call on errors. The callback function callbackError (func): Callback function to call on errors. The callback function
will be sent a single argument 'error' which will contain the Error object. will be sent a single argument 'error' which will contain the Error object.
:samp:`def my_callback(error): ...` :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' 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__() super(AlertListener, self).__init__()
self.daemon = True self.daemon = True
self._server = server self._server = server
self._callback = callback self._callback = callback
self._callbackError = callbackError self._callbackError = callbackError
self._socket = ws_socket
self._ws = None self._ws = None
def run(self): def run(self):
@ -52,8 +56,9 @@ class AlertListener(threading.Thread):
# create the websocket connection # create the websocket connection
url = self._server.url(self.key, includeToken=True).replace('http', 'ws') url = self._server.url(self.key, includeToken=True).replace('http', 'ws')
log.info('Starting AlertListener: %s', url) 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() self._ws.run_forever()
def stop(self): def stop(self):
@ -66,10 +71,8 @@ class AlertListener(threading.Thread):
def _onMessage(self, *args): def _onMessage(self, *args):
""" Called when websocket message is received. """ 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. 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] message = args[-1]
try: try:
@ -82,10 +85,8 @@ class AlertListener(threading.Thread):
def _onError(self, *args): # pragma: no cover def _onError(self, *args): # pragma: no cover
""" Called when websocket error is received. """ 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. 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] err = args[-1]
try: try:

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os import os
from pathlib import Path
from urllib.parse import quote_plus from urllib.parse import quote_plus
from plexapi import media, utils from plexapi import media, utils
@ -240,6 +241,12 @@ class Artist(
key = f'{self.key}?includeStations=1' key = f'{self.key}?includeStations=1'
return next(iter(self.fetchItems(key, cls=Playlist, rtag="Stations")), None) 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 @utils.registerPlexObject
class Album( class Album(
@ -359,6 +366,12 @@ class Album(
""" Returns str, default title for a new syncItem. """ """ Returns str, default title for a new syncItem. """
return f'{self.parentTitle} - {self.title}' 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 @utils.registerPlexObject
class Track( class Track(
@ -470,6 +483,12 @@ class Track(
""" Get the Plex Web URL with the correct parameters. """ """ Get the Plex Web URL with the correct parameters. """
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey) 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 @utils.registerPlexObject
class TrackSession(PlexSession, Track): class TrackSession(PlexSession, Track):

View file

@ -227,7 +227,7 @@ class PlexObject:
fetchItem(ekey, viewCount__gte=0) fetchItem(ekey, viewCount__gte=0)
fetchItem(ekey, Media__container__in=["mp4", "mkv"]) 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") fetchItem(ekey, Media__Part__file__startswith="D:\\Movies")
""" """
@ -502,7 +502,7 @@ class PlexPartialObject(PlexObject):
def __eq__(self, other): def __eq__(self, other):
if isinstance(other, PlexPartialObject): if isinstance(other, PlexPartialObject):
return other not in [None, []] and self.key == other.key return self.key == other.key
return NotImplemented return NotImplemented
def __hash__(self): def __hash__(self):
@ -626,7 +626,8 @@ class PlexPartialObject(PlexObject):
return self return self
def saveEdits(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. See :func:`~plexapi.base.PlexPartialObject.batchEdits` for details.
""" """
if not isinstance(self._edits, dict): if not isinstance(self._edits, dict):
@ -635,7 +636,7 @@ class PlexPartialObject(PlexObject):
edits = self._edits edits = self._edits
self._edits = None self._edits = None
self._edit(**edits) self._edit(**edits)
return self.reload() return self
def refresh(self): def refresh(self):
""" Refreshing a Library or individual item causes the metadata for the item to be """ Refreshing a Library or individual item causes the metadata for the item to be

View file

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

View file

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from pathlib import Path
from urllib.parse import quote_plus from urllib.parse import quote_plus
from plexapi import media, utils from plexapi import media, utils
@ -560,3 +561,9 @@ class Collection(
raise Unsupported('Unsupported collection content') raise Unsupported('Unsupported collection content')
return myplex.sync(sync_item, client=client, clientId=clientId) 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': plexapi.X_PLEX_DEVICE,
'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME, 'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME,
'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER, 'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER,
'X-Plex-Language': plexapi.X_PLEX_LANGUAGE,
'X-Plex-Sync-Version': '2', 'X-Plex-Sync-Version': '2',
'X-Plex-Features': 'external-media', 'X-Plex-Features': 'external-media',
} }

View file

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

View file

@ -1684,7 +1684,7 @@ class LibrarySection(PlexObject):
def _validateItems(self, items): def _validateItems(self, items):
""" Validates the specified items are from this library and of the same type. """ """ 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.') raise BadRequest('No items specified.')
if not isinstance(items, list): if not isinstance(items, list):
@ -3102,6 +3102,7 @@ class FirstCharacter(PlexObject):
size (str): Total amount of library items starting with this character. size (str): Total amount of library items starting with this character.
title (str): Character (#, !, A, B, C, ...). title (str): Character (#, !, A, B, C, ...).
""" """
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
self._data = data self._data = data

View file

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

View file

@ -111,12 +111,14 @@ class MyPlexAccount(PlexObject):
# Hub sections # Hub sections
VOD = 'https://vod.provider.plex.tv' # get VOD = 'https://vod.provider.plex.tv' # get
MUSIC = 'https://music.provider.plex.tv' # get MUSIC = 'https://music.provider.plex.tv' # get
DISCOVER = 'https://discover.provider.plex.tv'
METADATA = 'https://metadata.provider.plex.tv' METADATA = 'https://metadata.provider.plex.tv'
key = 'https://plex.tv/api/v2/user' key = 'https://plex.tv/api/v2/user'
def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None, remember=True): 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._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
self._session = session or requests.Session() self._session = session or requests.Session()
self._timeout = timeout or TIMEOUT
self._sonos_cache = [] self._sonos_cache = []
self._sonos_cache_timestamp = 0 self._sonos_cache_timestamp = 0
data, initpath = self._signin(username, password, code, remember, timeout) data, initpath = self._signin(username, password, code, remember, timeout)
@ -186,7 +188,9 @@ class MyPlexAccount(PlexObject):
self.subscriptionPaymentService = subscription.attrib.get('paymentService') self.subscriptionPaymentService = subscription.attrib.get('paymentService')
self.subscriptionPlan = subscription.attrib.get('plan') self.subscriptionPlan = subscription.attrib.get('plan')
self.subscriptionStatus = subscription.attrib.get('status') 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') profile = data.find('profile')
self.profileAutoSelectAudio = utils.cast(bool, profile.attrib.get('autoSelectAudio')) 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): def query(self, url, method=None, headers=None, timeout=None, **kwargs):
method = method or self._session.get 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', '')) log.debug('%s %s %s', method.__name__.upper(), url, kwargs.get('json', ''))
headers = self._headers(**headers or {}) headers = self._headers(**headers or {})
response = method(url, headers=headers, timeout=timeout, **kwargs) response = method(url, headers=headers, timeout=timeout, **kwargs)
@ -239,8 +243,10 @@ class MyPlexAccount(PlexObject):
raise Unauthorized(message) raise Unauthorized(message)
else: else:
raise BadRequest(message) raise BadRequest(message)
if headers.get('Accept') == 'application/json': if 'application/json' in response.headers.get('Content-Type', ''):
return response.json() return response.json()
elif 'text/plain' in response.headers.get('Content-Type', ''):
return response.text.strip()
data = response.text.encode('utf8') data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None return ElementTree.fromstring(data) if data.strip() else None
@ -1053,7 +1059,7 @@ class MyPlexAccount(PlexObject):
'includeMetadata': 1 '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', []) searchResults = data['MediaContainer'].get('SearchResults', [])
searchResult = next((s.get('SearchResult', []) for s in searchResults if s.get('id') == 'external'), []) searchResult = next((s.get('SearchResult', []) for s in searchResults if s.get('id') == 'external'), [])
@ -1135,6 +1141,21 @@ class MyPlexAccount(PlexObject):
return objs 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): class MyPlexUser(PlexObject):
""" This object represents non-signed in users such as friends and linked """ This object represents non-signed in users such as friends and linked
@ -1773,7 +1794,7 @@ class MyPlexPinLogin:
params = None params = None
response = self._query(url, self._session.post, params=params) response = self._query(url, self._session.post, params=params)
if not response: if response is None:
return None return None
self._id = response.attrib.get('id') self._id = response.attrib.get('id')
@ -1790,7 +1811,7 @@ class MyPlexPinLogin:
url = self.CHECKPINS.format(pinid=self._id) url = self.CHECKPINS.format(pinid=self._id)
response = self._query(url) response = self._query(url)
if not response: if response is None:
return False return False
token = response.attrib.get('authToken') token = response.attrib.get('authToken')
@ -1964,3 +1985,42 @@ class UserState(PlexObject):
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.viewState = data.attrib.get('viewState') == 'complete' self.viewState = data.attrib.get('viewState') == 'complete'
self.watchlistedAt = utils.toDatetime(data.attrib.get('watchlistedAt')) 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 -*- # -*- coding: utf-8 -*-
import os import os
from pathlib import Path
from urllib.parse import quote_plus from urllib.parse import quote_plus
from plexapi import media, utils, video from plexapi import media, utils, video
@ -139,6 +140,12 @@ class Photoalbum(
""" Get the Plex Web URL with the correct parameters. """ """ Get the Plex Web URL with the correct parameters. """
return self._server._buildWebURL(base=base, endpoint='details', key=self.key, legacy=1) 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 @utils.registerPlexObject
class Photo( class Photo(
@ -290,6 +297,12 @@ class Photo(
""" Get the Plex Web URL with the correct parameters. """ """ Get the Plex Web URL with the correct parameters. """
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1) 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 @utils.registerPlexObject
class PhotoSession(PlexSession, Photo): class PhotoSession(PlexSession, Photo):

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import re import re
from pathlib import Path
from urllib.parse import quote_plus, unquote from urllib.parse import quote_plus, unquote
from plexapi import media, utils from plexapi import media, utils
@ -496,3 +497,9 @@ class Playlist(
def _getWebURL(self, base=None): def _getWebURL(self, base=None):
""" Get the Plex Web URL with the correct parameters. """ """ Get the Plex Web URL with the correct parameters. """
return self._server._buildWebURL(base=base, endpoint='playlist', key=self.key) 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._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()
self._timeout = timeout self._timeout = timeout or TIMEOUT
self._myPlexAccount = None # cached myPlexAccount self._myPlexAccount = None # cached myPlexAccount
self._systemAccounts = None # cached list of SystemAccount self._systemAccounts = None # cached list of SystemAccount
self._systemDevices = None # cached list of SystemDevice self._systemDevices = None # cached list of SystemDevice
@ -189,6 +189,11 @@ class PlexServer(PlexObject):
data = self.query(Settings.key) data = self.query(Settings.key)
return Settings(self, data) return Settings(self, data)
def identity(self):
""" Returns the Plex server identity. """
data = self.query('/identity')
return Identity(self, data)
def account(self): def account(self):
""" Returns the :class:`~plexapi.server.Account` object this server belongs to. """ """ Returns the :class:`~plexapi.server.Account` object this server belongs to. """
data = self.query(Account.key) data = self.query(Account.key)
@ -597,7 +602,7 @@ class PlexServer(PlexObject):
print("Available butler tasks:", availableTasks) 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: if task not in validTasks:
raise BadRequest( raise BadRequest(
f'Invalid butler task: {task}. Available tasks are: {validTasks}' f'Invalid butler task: {task}. Available tasks are: {validTasks}'
@ -610,7 +615,8 @@ class PlexServer(PlexObject):
return self.checkForUpdate(force=force, download=download) return self.checkForUpdate(force=force, download=download)
def checkForUpdate(self, force=True, download=False): 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: Parameters:
force (bool): Force server to check for new releases force (bool): Force server to check for new releases
@ -624,12 +630,19 @@ class PlexServer(PlexObject):
return releases[0] return releases[0]
def isLatest(self): 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) release = self.checkForUpdate(force=True)
return release is None 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): 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 # We can add this but dunno how useful this is since it sometimes
# requires user action using a gui. # requires user action using a gui.
part = '/updater/apply' part = '/updater/apply'
@ -741,7 +754,7 @@ class PlexServer(PlexObject):
""" """
url = self.url(key) url = self.url(key)
method = method or self._session.get method = method or self._session.get
timeout = timeout or TIMEOUT timeout = timeout or self._timeout
log.debug('%s %s', method.__name__.upper(), url) log.debug('%s %s', method.__name__.upper(), url)
headers = self._headers(**headers or {}) headers = self._headers(**headers or {})
response = method(url, headers=headers, timeout=timeout, **kwargs) response = method(url, headers=headers, timeout=timeout, **kwargs)
@ -1273,3 +1286,22 @@ class ButlerTask(PlexObject):
self.name = data.attrib.get('name') self.name = data.attrib.get('name')
self.scheduleRandomized = utils.cast(bool, data.attrib.get('scheduleRandomized')) self.scheduleRandomized = utils.cast(bool, data.attrib.get('scheduleRandomized'))
self.title = data.attrib.get('title') 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 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`. to explicitly specify that your app supports `sync-target`.
""" """
import requests import requests
import plexapi import plexapi

View file

@ -11,13 +11,14 @@ import unicodedata
import warnings import warnings
import zipfile import zipfile
from collections import deque from collections import deque
from datetime import datetime from datetime import datetime, timedelta
from getpass import getpass from getpass import getpass
from hashlib import sha1
from threading import Event, Thread from threading import Event, Thread
from urllib.parse import quote from urllib.parse import quote
from requests.status_codes import _codes as codes
import requests import requests
from requests.status_codes import _codes as codes
from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.exceptions import BadRequest, NotFound, Unauthorized
@ -313,33 +314,44 @@ def toDatetime(value, format=None):
value (str): value to return as a datetime value (str): value to return as a datetime
format (str): Format to pass strftime (optional; if value is a str). 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: if format:
try: try:
value = datetime.strptime(value, format) return datetime.strptime(value, format)
except ValueError: 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 return None
else: else:
# https://bugs.python.org/issue30684 try:
# And platform support for before epoch seems to be flaky. value = int(value)
# Also limit to max 32-bit integer except ValueError:
value = min(max(int(value), 86400), 2**31 - 1) log.info('Failed to parse "%s" to datetime as timestamp, defaulting to None', value)
value = datetime.fromtimestamp(int(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 return value
def millisecondToHumanstr(milliseconds): def millisecondToHumanstr(milliseconds):
""" Returns human readable time duration from milliseconds. """ Returns human readable time duration [D day[s], ]HH:MM:SS.UUU from milliseconds.
HH:MM:SS:MMMM
Parameters: Parameters:
milliseconds (str, int): time duration in milliseconds. milliseconds (str, int): time duration in milliseconds.
""" """
milliseconds = int(milliseconds) milliseconds = int(milliseconds)
r = datetime.utcfromtimestamp(milliseconds / 1000) if milliseconds < 0:
f = r.strftime("%H:%M:%S.%f") return '-' + millisecondToHumanstr(abs(milliseconds))
return f[:-2] 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=','): def toList(value, itemcast=None, delim=','):
@ -644,3 +656,8 @@ def openOrRead(file):
return file.read() return file.read()
with open(file, 'rb') as f: with open(file, 'rb') as f:
return f.read() 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 -*- # -*- coding: utf-8 -*-
import os import os
from functools import cached_property
from pathlib import Path
from urllib.parse import quote_plus from urllib.parse import quote_plus
from plexapi import media, utils from plexapi import media, utils
@ -445,6 +447,12 @@ class Movie(
self._server.query(key, params=params, method=self._server._session.put) self._server.query(key, params=params, method=self._server._session.put)
return self 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 @utils.registerPlexObject
class Show( class Show(
@ -655,6 +663,12 @@ class Show(
filepaths += episode.download(_savepath, keep_original_name, **kwargs) filepaths += episode.download(_savepath, keep_original_name, **kwargs)
return filepaths 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 @utils.registerPlexObject
class Season( class Season(
@ -663,7 +677,7 @@ class Season(
ArtMixin, PosterMixin, ThemeUrlMixin, ArtMixin, PosterMixin, ThemeUrlMixin,
SeasonEditMixins SeasonEditMixins
): ):
""" Represents a single Show Season (including all episodes). """ Represents a single Season.
Attributes: Attributes:
TAG (str): 'Directory' TAG (str): 'Directory'
@ -808,6 +822,12 @@ class Season(
""" Returns str, default title for a new syncItem. """ """ Returns str, default title for a new syncItem. """
return f'{self.parentTitle} - {self.title}' 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 @utils.registerPlexObject
class Episode( class Episode(
@ -816,7 +836,7 @@ class Episode(
ArtMixin, PosterMixin, ThemeUrlMixin, ArtMixin, PosterMixin, ThemeUrlMixin,
EpisodeEditMixins EpisodeEditMixins
): ):
""" Represents a single Shows Episode. """ Represents a single Episode.
Attributes: Attributes:
TAG (str): 'Video' TAG (str): 'Video'
@ -866,7 +886,6 @@ class Episode(
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
Video._loadData(self, data) Video._loadData(self, data)
Playable._loadData(self, data) Playable._loadData(self, data)
self._seasonNumber = None # cached season number
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
self.audienceRatingImage = data.attrib.get('audienceRatingImage') self.audienceRatingImage = data.attrib.get('audienceRatingImage')
self.chapters = self.findItems(data, media.Chapter) 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.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentGuid = data.attrib.get('parentGuid') self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex')) 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.parentTitle = data.attrib.get('parentTitle')
self.parentYear = utils.cast(int, data.attrib.get('parentYear')) self.parentYear = utils.cast(int, data.attrib.get('parentYear'))
self.producers = self.findItems(data, media.Producer) 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. # 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 # 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: # Use cached properties below to return the correct values if they are missing to avoid auto-reloading.
# Parse the parentRatingKey from the parentThumb self._parentKey = data.attrib.get('parentKey')
if self.parentThumb and self.parentThumb.startswith('/library/metadata/'): self._parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentRatingKey = utils.cast(int, self.parentThumb.split('/')[3]) self._parentThumb = data.attrib.get('parentThumb')
# Get the parentRatingKey from the season's ratingKey
if not self.parentRatingKey and self.grandparentRatingKey: @cached_property
self.parentRatingKey = self.show().season(season=self.parentIndex).ratingKey def parentKey(self):
""" Returns the parentKey. Refer to the Episode attributes. """
if self._parentKey:
return self._parentKey
if self.parentRatingKey: if self.parentRatingKey:
self.parentKey = f'/library/metadata/{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): def __repr__(self):
return '<{}>'.format( return '<{}>'.format(
@ -949,12 +1000,10 @@ class Episode(
""" Returns the episode number. """ """ Returns the episode number. """
return self.index return self.index
@property @cached_property
def seasonNumber(self): def seasonNumber(self):
""" Returns the episode's season number. """ """ Returns the episode's season number. """
if self._seasonNumber is None: return self.parentIndex if isinstance(self.parentIndex, int) else self._season.seasonNumber
self._seasonNumber = self.parentIndex if isinstance(self.parentIndex, int) else self.season().seasonNumber
return utils.cast(int, self._seasonNumber)
@property @property
def seasonEpisode(self): def seasonEpisode(self):
@ -1000,6 +1049,12 @@ class Episode(
self._server.query(key, params=params, method=self._server._session.put) self._server.query(key, params=params, method=self._server._session.put)
return self 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 @utils.registerPlexObject
class Clip( class Clip(
@ -1058,6 +1113,12 @@ class Clip(
""" Returns a filename for use in download. """ """ Returns a filename for use in download. """
return self.title 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): class Extra(Clip):
""" Represents a single Extra (trailer, behindTheScenes, etc). """ """ Represents a single Extra (trailer, behindTheScenes, etc). """

View file

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