mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-06 21:21:15 -07:00
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:
parent
fdc1dd3525
commit
aa4d98ee34
19 changed files with 399 additions and 128 deletions
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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',
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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. """
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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). """
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue