mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-05 20:51: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_NAME = CONFIG.get('header.device_name', uname()[1])
|
||||
X_PLEX_IDENTIFIER = CONFIG.get('header.identifier', str(hex(getnode())))
|
||||
X_PLEX_LANGUAGE = CONFIG.get('header.language', 'en')
|
||||
BASE_HEADERS = reset_base_headers()
|
||||
|
||||
# Logging Configuration
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import socket
|
||||
from typing import Callable
|
||||
import threading
|
||||
|
||||
from plexapi import log
|
||||
|
@ -32,15 +34,17 @@ class AlertListener(threading.Thread):
|
|||
callbackError (func): Callback function to call on errors. The callback function
|
||||
will be sent a single argument 'error' which will contain the Error object.
|
||||
:samp:`def my_callback(error): ...`
|
||||
ws_socket (socket): Socket to use for the connection. If not specified, a new socket will be created.
|
||||
"""
|
||||
key = '/:/websockets/notifications'
|
||||
|
||||
def __init__(self, server, callback=None, callbackError=None):
|
||||
def __init__(self, server, callback: Callable = None, callbackError: Callable = None, ws_socket: socket = None):
|
||||
super(AlertListener, self).__init__()
|
||||
self.daemon = True
|
||||
self._server = server
|
||||
self._callback = callback
|
||||
self._callbackError = callbackError
|
||||
self._socket = ws_socket
|
||||
self._ws = None
|
||||
|
||||
def run(self):
|
||||
|
@ -52,8 +56,9 @@ class AlertListener(threading.Thread):
|
|||
# create the websocket connection
|
||||
url = self._server.url(self.key, includeToken=True).replace('http', 'ws')
|
||||
log.info('Starting AlertListener: %s', url)
|
||||
self._ws = websocket.WebSocketApp(url, on_message=self._onMessage,
|
||||
on_error=self._onError)
|
||||
|
||||
self._ws = websocket.WebSocketApp(url, on_message=self._onMessage, on_error=self._onError, socket=self._socket)
|
||||
|
||||
self._ws.run_forever()
|
||||
|
||||
def stop(self):
|
||||
|
@ -66,10 +71,8 @@ class AlertListener(threading.Thread):
|
|||
|
||||
def _onMessage(self, *args):
|
||||
""" Called when websocket message is received.
|
||||
In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp
|
||||
object and the message as a STR. Current releases appear to only return the message.
|
||||
|
||||
We are assuming the last argument in the tuple is the message.
|
||||
This is to support compatibility with current and previous releases of websocket-client.
|
||||
"""
|
||||
message = args[-1]
|
||||
try:
|
||||
|
@ -82,10 +85,8 @@ class AlertListener(threading.Thread):
|
|||
|
||||
def _onError(self, *args): # pragma: no cover
|
||||
""" Called when websocket error is received.
|
||||
In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp
|
||||
object and the error. Current releases appear to only return the error.
|
||||
|
||||
We are assuming the last argument in the tuple is the message.
|
||||
This is to support compatibility with current and previous releases of websocket-client.
|
||||
"""
|
||||
err = args[-1]
|
||||
try:
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import media, utils
|
||||
|
@ -240,6 +241,12 @@ class Artist(
|
|||
key = f'{self.key}?includeStations=1'
|
||||
return next(iter(self.fetchItems(key, cls=Playlist, rtag="Stations")), None)
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'Artists' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Album(
|
||||
|
@ -359,6 +366,12 @@ class Album(
|
|||
""" Returns str, default title for a new syncItem. """
|
||||
return f'{self.parentTitle} - {self.title}'
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Track(
|
||||
|
@ -470,6 +483,12 @@ class Track(
|
|||
""" Get the Plex Web URL with the correct parameters. """
|
||||
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey)
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.parentGuid)
|
||||
return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class TrackSession(PlexSession, Track):
|
||||
|
|
|
@ -227,7 +227,7 @@ class PlexObject:
|
|||
|
||||
fetchItem(ekey, viewCount__gte=0)
|
||||
fetchItem(ekey, Media__container__in=["mp4", "mkv"])
|
||||
fetchItem(ekey, guid__iregex=r"(imdb:\/\/|themoviedb:\/\/)")
|
||||
fetchItem(ekey, guid__iregex=r"(imdb://|themoviedb://)")
|
||||
fetchItem(ekey, Media__Part__file__startswith="D:\\Movies")
|
||||
|
||||
"""
|
||||
|
@ -502,7 +502,7 @@ class PlexPartialObject(PlexObject):
|
|||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, PlexPartialObject):
|
||||
return other not in [None, []] and self.key == other.key
|
||||
return self.key == other.key
|
||||
return NotImplemented
|
||||
|
||||
def __hash__(self):
|
||||
|
@ -626,7 +626,8 @@ class PlexPartialObject(PlexObject):
|
|||
return self
|
||||
|
||||
def saveEdits(self):
|
||||
""" Save all the batch edits and automatically reload the object.
|
||||
""" Save all the batch edits. The object needs to be reloaded manually,
|
||||
if required.
|
||||
See :func:`~plexapi.base.PlexPartialObject.batchEdits` for details.
|
||||
"""
|
||||
if not isinstance(self._edits, dict):
|
||||
|
@ -635,7 +636,7 @@ class PlexPartialObject(PlexObject):
|
|||
edits = self._edits
|
||||
self._edits = None
|
||||
self._edit(**edits)
|
||||
return self.reload()
|
||||
return self
|
||||
|
||||
def refresh(self):
|
||||
""" Refreshing a Library or individual item causes the metadata for the item to be
|
||||
|
@ -919,7 +920,7 @@ class PlexSession(object):
|
|||
|
||||
def stop(self, reason=''):
|
||||
""" Stop playback for the session.
|
||||
|
||||
|
||||
Parameters:
|
||||
reason (str): Message displayed to the user for stopping playback.
|
||||
"""
|
||||
|
|
|
@ -70,6 +70,7 @@ class PlexClient(PlexObject):
|
|||
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
|
||||
server_session = server._session if server else None
|
||||
self._session = session or server_session or requests.Session()
|
||||
self._timeout = timeout or TIMEOUT
|
||||
self._proxyThroughServer = False
|
||||
self._commandId = 0
|
||||
self._last_call = 0
|
||||
|
@ -94,7 +95,7 @@ class PlexClient(PlexObject):
|
|||
raise Unsupported('Cannot reload an object not built from a URL.')
|
||||
self._initpath = self.key
|
||||
data = self.query(self.key, timeout=timeout)
|
||||
if not data:
|
||||
if data is None:
|
||||
raise NotFound(f"Client not found at {self._baseurl}")
|
||||
if self._clientIdentifier:
|
||||
client = next(
|
||||
|
@ -179,7 +180,7 @@ class PlexClient(PlexObject):
|
|||
"""
|
||||
url = self.url(path)
|
||||
method = method or self._session.get
|
||||
timeout = timeout or TIMEOUT
|
||||
timeout = timeout or self._timeout
|
||||
log.debug('%s %s', method.__name__.upper(), url)
|
||||
headers = self._headers(**headers or {})
|
||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import media, utils
|
||||
|
@ -399,7 +400,7 @@ class Collection(
|
|||
@deprecated('use editTitle, editSortTitle, editContentRating, and editSummary instead')
|
||||
def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs):
|
||||
""" Edit the collection.
|
||||
|
||||
|
||||
Parameters:
|
||||
title (str, optional): The title of the collection.
|
||||
titleSort (str, optional): The sort title of the collection.
|
||||
|
@ -560,3 +561,9 @@ class Collection(
|
|||
raise Unsupported('Unsupported collection content')
|
||||
|
||||
return myplex.sync(sync_item, client=client, clientId=clientId)
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'Collections' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
|
|
@ -63,6 +63,7 @@ def reset_base_headers():
|
|||
'X-Plex-Device': plexapi.X_PLEX_DEVICE,
|
||||
'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME,
|
||||
'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER,
|
||||
'X-Plex-Language': plexapi.X_PLEX_LANGUAGE,
|
||||
'X-Plex-Sync-Version': '2',
|
||||
'X-Plex-Features': 'external-media',
|
||||
}
|
||||
|
|
|
@ -4,6 +4,6 @@
|
|||
# Library version
|
||||
MAJOR_VERSION = 4
|
||||
MINOR_VERSION = 15
|
||||
PATCH_VERSION = 0
|
||||
PATCH_VERSION = 4
|
||||
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
||||
|
|
|
@ -542,7 +542,7 @@ class LibrarySection(PlexObject):
|
|||
|
||||
def addLocations(self, location):
|
||||
""" Add a location to a library.
|
||||
|
||||
|
||||
Parameters:
|
||||
location (str or list): A single folder path, list of paths.
|
||||
|
||||
|
@ -565,7 +565,7 @@ class LibrarySection(PlexObject):
|
|||
|
||||
def removeLocations(self, location):
|
||||
""" Remove a location from a library.
|
||||
|
||||
|
||||
Parameters:
|
||||
location (str or list): A single folder path, list of paths.
|
||||
|
||||
|
@ -744,7 +744,7 @@ class LibrarySection(PlexObject):
|
|||
|
||||
def lockAllField(self, field, libtype=None):
|
||||
""" Lock a field for all items in the library.
|
||||
|
||||
|
||||
Parameters:
|
||||
field (str): The field to lock (e.g. thumb, rating, collection).
|
||||
libtype (str, optional): The library type to lock (movie, show, season, episode,
|
||||
|
@ -754,7 +754,7 @@ class LibrarySection(PlexObject):
|
|||
|
||||
def unlockAllField(self, field, libtype=None):
|
||||
""" Unlock a field for all items in the library.
|
||||
|
||||
|
||||
Parameters:
|
||||
field (str): The field to unlock (e.g. thumb, rating, collection).
|
||||
libtype (str, optional): The library type to lock (movie, show, season, episode,
|
||||
|
@ -847,7 +847,7 @@ class LibrarySection(PlexObject):
|
|||
"""
|
||||
_key = ('/library/sections/{key}/{filter}?includeMeta=1&includeAdvanced=1'
|
||||
'&X-Plex-Container-Start=0&X-Plex-Container-Size=0')
|
||||
|
||||
|
||||
key = _key.format(key=self.key, filter='all')
|
||||
data = self._server.query(key)
|
||||
self._filterTypes = self.findItems(data, FilteringType, rtag='Meta')
|
||||
|
@ -894,7 +894,7 @@ class LibrarySection(PlexObject):
|
|||
|
||||
def getFieldType(self, fieldType):
|
||||
""" Returns a :class:`~plexapi.library.FilteringFieldType` for a specified fieldType.
|
||||
|
||||
|
||||
Parameters:
|
||||
fieldType (str): The data type for the field (tag, integer, string, boolean, date,
|
||||
subtitleLanguage, audioLanguage, resolution).
|
||||
|
@ -927,7 +927,7 @@ class LibrarySection(PlexObject):
|
|||
|
||||
"""
|
||||
return self.getFilterType(libtype).filters
|
||||
|
||||
|
||||
def listSorts(self, libtype=None):
|
||||
""" Returns a list of available :class:`~plexapi.library.FilteringSort` for a specified libtype.
|
||||
This is the list of options in the sorting dropdown menu
|
||||
|
@ -970,7 +970,7 @@ class LibrarySection(PlexObject):
|
|||
""" Returns a list of available :class:`~plexapi.library.FilteringOperator` for a specified fieldType.
|
||||
This is the list of options in the custom filter operator dropdown menu
|
||||
(`screenshot <../_static/images/LibrarySection.search.png>`__).
|
||||
|
||||
|
||||
Parameters:
|
||||
fieldType (str): The data type for the field (tag, integer, string, boolean, date,
|
||||
subtitleLanguage, audioLanguage, resolution).
|
||||
|
@ -992,7 +992,7 @@ class LibrarySection(PlexObject):
|
|||
:class:`~plexapi.library.FilteringFilter` or filter field.
|
||||
This is the list of available values for a custom filter
|
||||
(`screenshot <../_static/images/LibrarySection.search.png>`__).
|
||||
|
||||
|
||||
Parameters:
|
||||
field (str): :class:`~plexapi.library.FilteringFilter` object,
|
||||
or the name of the field (genre, year, contentRating, etc.).
|
||||
|
@ -1024,7 +1024,7 @@ class LibrarySection(PlexObject):
|
|||
availableFilters = [f.filter for f in self.listFilters(libtype)]
|
||||
raise NotFound(f'Unknown filter field "{field}" for libtype "{libtype}". '
|
||||
f'Available filters: {availableFilters}') from None
|
||||
|
||||
|
||||
data = self._server.query(field.key)
|
||||
return self.findItems(data, FilterChoice)
|
||||
|
||||
|
@ -1111,7 +1111,7 @@ class LibrarySection(PlexObject):
|
|||
except (ValueError, AttributeError):
|
||||
raise BadRequest(f'Invalid value "{value}" for filter field "{filterField.key}", '
|
||||
f'value should be type {fieldType.type}') from None
|
||||
|
||||
|
||||
return results
|
||||
|
||||
def _validateFieldValueDate(self, value):
|
||||
|
@ -1345,7 +1345,7 @@ class LibrarySection(PlexObject):
|
|||
Tag type filter values can be a :class:`~plexapi.library.FilterChoice` object,
|
||||
:class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*),
|
||||
or the exact id :attr:`MediaTag.id` (*int*).
|
||||
|
||||
|
||||
Date type filter values can be a ``datetime`` object, a relative date using a one of the
|
||||
available date suffixes (e.g. ``30d``) (*str*), or a date in ``YYYY-MM-DD`` (*str*) format.
|
||||
|
||||
|
@ -1358,7 +1358,7 @@ class LibrarySection(PlexObject):
|
|||
* ``w``: ``weeks``
|
||||
* ``mon``: ``months``
|
||||
* ``y``: ``years``
|
||||
|
||||
|
||||
Multiple values can be ``OR`` together by providing a list of values.
|
||||
|
||||
Examples:
|
||||
|
@ -1684,12 +1684,12 @@ class LibrarySection(PlexObject):
|
|||
|
||||
def _validateItems(self, items):
|
||||
""" Validates the specified items are from this library and of the same type. """
|
||||
if not items:
|
||||
if items is None or items == []:
|
||||
raise BadRequest('No items specified.')
|
||||
|
||||
|
||||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
|
||||
|
||||
itemType = items[0].type
|
||||
for item in items:
|
||||
if item.librarySectionID != self.key:
|
||||
|
@ -3102,6 +3102,7 @@ class FirstCharacter(PlexObject):
|
|||
size (str): Total amount of library items starting with this character.
|
||||
title (str): Character (#, !, A, B, C, ...).
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import xml
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import log, settings, utils
|
||||
|
@ -121,6 +121,7 @@ class MediaPart(PlexObject):
|
|||
optimizedForStreaming (bool): True if the file is optimized for streaming.
|
||||
packetLength (int): The packet length of the file.
|
||||
requiredBandwidths (str): The required bandwidths to stream the file.
|
||||
selected (bool): True if this media part is selected.
|
||||
size (int): The size of the file in bytes (ex: 733884416).
|
||||
streams (List<:class:`~plexapi.media.MediaPartStream`>): List of stream objects.
|
||||
syncItemId (int): The unique ID for this media part if it is synced.
|
||||
|
@ -184,38 +185,60 @@ class MediaPart(PlexObject):
|
|||
""" Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """
|
||||
return [stream for stream in self.streams if isinstance(stream, LyricStream)]
|
||||
|
||||
def setDefaultAudioStream(self, stream):
|
||||
""" Set the default :class:`~plexapi.media.AudioStream` for this MediaPart.
|
||||
def setSelectedAudioStream(self, stream):
|
||||
""" Set the selected :class:`~plexapi.media.AudioStream` for this MediaPart.
|
||||
|
||||
Parameters:
|
||||
stream (:class:`~plexapi.media.AudioStream`): AudioStream to set as default
|
||||
stream (:class:`~plexapi.media.AudioStream`): Audio stream to set as selected
|
||||
"""
|
||||
key = f'/library/parts/{self.id}'
|
||||
params = {'allParts': 1}
|
||||
|
||||
if isinstance(stream, AudioStream):
|
||||
key = f"/library/parts/{self.id}?audioStreamID={stream.id}&allParts=1"
|
||||
params['audioStreamID'] = stream.id
|
||||
else:
|
||||
key = f"/library/parts/{self.id}?audioStreamID={stream}&allParts=1"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
params['audioStreamID'] = stream
|
||||
|
||||
self._server.query(key, method=self._server._session.put, params=params)
|
||||
return self
|
||||
|
||||
def setDefaultSubtitleStream(self, stream):
|
||||
""" Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart.
|
||||
def setSelectedSubtitleStream(self, stream):
|
||||
""" Set the selected :class:`~plexapi.media.SubtitleStream` for this MediaPart.
|
||||
|
||||
Parameters:
|
||||
stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default.
|
||||
stream (:class:`~plexapi.media.SubtitleStream`): Subtitle stream to set as selected.
|
||||
"""
|
||||
key = f'/library/parts/{self.id}'
|
||||
params = {'allParts': 1}
|
||||
|
||||
if isinstance(stream, SubtitleStream):
|
||||
key = f"/library/parts/{self.id}?subtitleStreamID={stream.id}&allParts=1"
|
||||
params['subtitleStreamID'] = stream.id
|
||||
else:
|
||||
key = f"/library/parts/{self.id}?subtitleStreamID={stream}&allParts=1"
|
||||
params['subtitleStreamID'] = stream
|
||||
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def resetDefaultSubtitleStream(self):
|
||||
""" Set default subtitle of this MediaPart to 'none'. """
|
||||
key = f"/library/parts/{self.id}?subtitleStreamID=0&allParts=1"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
def resetSelectedSubtitleStream(self):
|
||||
""" Set the selected subtitle of this MediaPart to 'None'. """
|
||||
key = f'/library/parts/{self.id}'
|
||||
params = {'subtitleStreamID': 0, 'allParts': 1}
|
||||
|
||||
self._server.query(key, method=self._server._session.put, params=params)
|
||||
return self
|
||||
|
||||
@deprecated('Use "setSelectedAudioStream" instead.')
|
||||
def setDefaultAudioStream(self, stream):
|
||||
return self.setSelectedAudioStream(stream)
|
||||
|
||||
@deprecated('Use "setSelectedSubtitleStream" instead.')
|
||||
def setDefaultSubtitleStream(self, stream):
|
||||
return self.setSelectedSubtitleStream(stream)
|
||||
|
||||
@deprecated('Use "resetSelectedSubtitleStream" instead.')
|
||||
def resetDefaultSubtitleStream(self):
|
||||
return self.resetSelectedSubtitleStream()
|
||||
|
||||
|
||||
class MediaPartStream(PlexObject):
|
||||
""" Base class for media streams. These consist of video, audio, subtitles, and lyrics.
|
||||
|
@ -399,9 +422,15 @@ class AudioStream(MediaPartStream):
|
|||
self.peak = utils.cast(float, data.attrib.get('peak'))
|
||||
self.startRamp = data.attrib.get('startRamp')
|
||||
|
||||
def setSelected(self):
|
||||
""" Sets this audio stream as the selected audio stream.
|
||||
Alias for :func:`~plexapi.media.MediaPart.setSelectedAudioStream`.
|
||||
"""
|
||||
return self._parent().setSelectedAudioStream(self)
|
||||
|
||||
@deprecated('Use "setSelected" instead.')
|
||||
def setDefault(self):
|
||||
""" Sets this audio stream as the default audio stream. """
|
||||
return self._parent().setDefaultAudioStream(self)
|
||||
return self.setSelected()
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
|
@ -437,9 +466,15 @@ class SubtitleStream(MediaPartStream):
|
|||
self.transient = data.attrib.get('transient')
|
||||
self.userID = utils.cast(int, data.attrib.get('userID'))
|
||||
|
||||
def setSelected(self):
|
||||
""" Sets this subtitle stream as the selected subtitle stream.
|
||||
Alias for :func:`~plexapi.media.MediaPart.setSelectedSubtitleStream`.
|
||||
"""
|
||||
return self._parent().setSelectedSubtitleStream(self)
|
||||
|
||||
@deprecated('Use "setSelected" instead.')
|
||||
def setDefault(self):
|
||||
""" Sets this subtitle stream as the default subtitle stream. """
|
||||
return self._parent().setDefaultSubtitleStream(self)
|
||||
return self.setSelected()
|
||||
|
||||
|
||||
class LyricStream(MediaPartStream):
|
||||
|
@ -973,6 +1008,7 @@ class BaseResource(PlexObject):
|
|||
selected (bool): True if the resource is currently selected.
|
||||
thumb (str): The URL to retrieve the resource thumbnail.
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.key = data.attrib.get('key')
|
||||
|
@ -989,6 +1025,20 @@ class BaseResource(PlexObject):
|
|||
except xml.etree.ElementTree.ParseError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def resourceFilepath(self):
|
||||
""" Returns the file path to the resource in the Plex Media Server data directory.
|
||||
Note: Returns the URL if the resource is not stored locally.
|
||||
"""
|
||||
if self.ratingKey.startswith('media://'):
|
||||
return str(Path('Media') / 'localhost' / self.ratingKey.split('://')[-1])
|
||||
elif self.ratingKey.startswith('metadata://'):
|
||||
return str(Path(self._parent().metadataDirectory) / 'Contents' / '_combined' / self.ratingKey.split('://')[-1])
|
||||
elif self.ratingKey.startswith('upload://'):
|
||||
return str(Path(self._parent().metadataDirectory) / 'Uploads' / self.ratingKey.split('://')[-1])
|
||||
else:
|
||||
return self.ratingKey
|
||||
|
||||
|
||||
class Art(BaseResource):
|
||||
""" Represents a single Art object. """
|
||||
|
|
|
@ -39,7 +39,7 @@ class AdvancedSettingsMixin:
|
|||
pref = preferences[settingID]
|
||||
except KeyError:
|
||||
raise NotFound(f'{value} not found in {list(preferences.keys())}')
|
||||
|
||||
|
||||
enumValues = pref.enumValues
|
||||
if enumValues.get(value, enumValues.get(str(value))):
|
||||
data[settingID] = value
|
||||
|
@ -69,7 +69,7 @@ class SmartFilterMixin:
|
|||
filters = {}
|
||||
filterOp = 'and'
|
||||
filterGroups = [[]]
|
||||
|
||||
|
||||
for key, value in parse_qsl(content.query):
|
||||
# Move = sign to key when operator is ==
|
||||
if value.startswith('='):
|
||||
|
@ -96,11 +96,11 @@ class SmartFilterMixin:
|
|||
filterGroups.pop()
|
||||
else:
|
||||
filterGroups[-1].append({key: value})
|
||||
|
||||
|
||||
if filterGroups:
|
||||
filters['filters'] = self._formatFilterGroups(filterGroups.pop())
|
||||
return filters
|
||||
|
||||
|
||||
def _formatFilterGroups(self, groups):
|
||||
""" Formats the filter groups into the advanced search rules. """
|
||||
if len(groups) == 1 and isinstance(groups[0], list):
|
||||
|
@ -131,7 +131,7 @@ class SplitMergeMixin:
|
|||
|
||||
def merge(self, ratingKeys):
|
||||
""" Merge other Plex objects into the current object.
|
||||
|
||||
|
||||
Parameters:
|
||||
ratingKeys (list): A list of rating keys to merge.
|
||||
"""
|
||||
|
@ -320,7 +320,7 @@ class RatingMixin:
|
|||
|
||||
class ArtUrlMixin:
|
||||
""" Mixin for Plex objects that can have a background artwork url. """
|
||||
|
||||
|
||||
@property
|
||||
def artUrl(self):
|
||||
""" Return the art url for the Plex object. """
|
||||
|
@ -349,7 +349,7 @@ class ArtMixin(ArtUrlMixin, ArtLockMixin):
|
|||
|
||||
def uploadArt(self, url=None, filepath=None):
|
||||
""" Upload a background artwork from a url or filepath.
|
||||
|
||||
|
||||
Parameters:
|
||||
url (str): The full URL to the image to upload.
|
||||
filepath (str): The full file path the the image to upload or file-like object.
|
||||
|
@ -365,7 +365,7 @@ class ArtMixin(ArtUrlMixin, ArtLockMixin):
|
|||
|
||||
def setArt(self, art):
|
||||
""" Set the background artwork for a Plex object.
|
||||
|
||||
|
||||
Parameters:
|
||||
art (:class:`~plexapi.media.Art`): The art object to select.
|
||||
"""
|
||||
|
@ -425,7 +425,7 @@ class PosterMixin(PosterUrlMixin, PosterLockMixin):
|
|||
|
||||
def setPoster(self, poster):
|
||||
""" Set the poster for a Plex object.
|
||||
|
||||
|
||||
Parameters:
|
||||
poster (:class:`~plexapi.media.Poster`): The poster object to select.
|
||||
"""
|
||||
|
@ -491,11 +491,11 @@ class ThemeMixin(ThemeUrlMixin, ThemeLockMixin):
|
|||
|
||||
class EditFieldMixin:
|
||||
""" Mixin for editing Plex object fields. """
|
||||
|
||||
|
||||
def editField(self, field, value, locked=True, **kwargs):
|
||||
""" Edit the field of a Plex object. All field editing methods can be chained together.
|
||||
Also see :func:`~plexapi.base.PlexPartialObject.batchEdits` for batch editing fields.
|
||||
|
||||
|
||||
Parameters:
|
||||
field (str): The name of the field to edit.
|
||||
value (str): The value to edit the field to.
|
||||
|
|
|
@ -111,12 +111,14 @@ class MyPlexAccount(PlexObject):
|
|||
# Hub sections
|
||||
VOD = 'https://vod.provider.plex.tv' # get
|
||||
MUSIC = 'https://music.provider.plex.tv' # get
|
||||
DISCOVER = 'https://discover.provider.plex.tv'
|
||||
METADATA = 'https://metadata.provider.plex.tv'
|
||||
key = 'https://plex.tv/api/v2/user'
|
||||
|
||||
def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None, remember=True):
|
||||
self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
|
||||
self._session = session or requests.Session()
|
||||
self._timeout = timeout or TIMEOUT
|
||||
self._sonos_cache = []
|
||||
self._sonos_cache_timestamp = 0
|
||||
data, initpath = self._signin(username, password, code, remember, timeout)
|
||||
|
@ -186,7 +188,9 @@ class MyPlexAccount(PlexObject):
|
|||
self.subscriptionPaymentService = subscription.attrib.get('paymentService')
|
||||
self.subscriptionPlan = subscription.attrib.get('plan')
|
||||
self.subscriptionStatus = subscription.attrib.get('status')
|
||||
self.subscriptionSubscribedAt = utils.toDatetime(subscription.attrib.get('subscribedAt'), '%Y-%m-%d %H:%M:%S %Z')
|
||||
self.subscriptionSubscribedAt = utils.toDatetime(
|
||||
subscription.attrib.get('subscribedAt') or None, '%Y-%m-%d %H:%M:%S %Z'
|
||||
)
|
||||
|
||||
profile = data.find('profile')
|
||||
self.profileAutoSelectAudio = utils.cast(bool, profile.attrib.get('autoSelectAudio'))
|
||||
|
@ -223,7 +227,7 @@ class MyPlexAccount(PlexObject):
|
|||
|
||||
def query(self, url, method=None, headers=None, timeout=None, **kwargs):
|
||||
method = method or self._session.get
|
||||
timeout = timeout or TIMEOUT
|
||||
timeout = timeout or self._timeout
|
||||
log.debug('%s %s %s', method.__name__.upper(), url, kwargs.get('json', ''))
|
||||
headers = self._headers(**headers or {})
|
||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
||||
|
@ -239,8 +243,10 @@ class MyPlexAccount(PlexObject):
|
|||
raise Unauthorized(message)
|
||||
else:
|
||||
raise BadRequest(message)
|
||||
if headers.get('Accept') == 'application/json':
|
||||
if 'application/json' in response.headers.get('Content-Type', ''):
|
||||
return response.json()
|
||||
elif 'text/plain' in response.headers.get('Content-Type', ''):
|
||||
return response.text.strip()
|
||||
data = response.text.encode('utf8')
|
||||
return ElementTree.fromstring(data) if data.strip() else None
|
||||
|
||||
|
@ -672,7 +678,7 @@ class MyPlexAccount(PlexObject):
|
|||
if (invite.username and invite.email and invite.id and username.lower() in
|
||||
(invite.username.lower(), invite.email.lower(), str(invite.id))):
|
||||
return invite
|
||||
|
||||
|
||||
raise NotFound(f'Unable to find invite {username}')
|
||||
|
||||
def pendingInvites(self, includeSent=True, includeReceived=True):
|
||||
|
@ -950,7 +956,7 @@ class MyPlexAccount(PlexObject):
|
|||
"""
|
||||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
|
||||
|
||||
for item in items:
|
||||
if self.onWatchlist(item):
|
||||
raise BadRequest(f'"{item.title}" is already on the watchlist')
|
||||
|
@ -971,7 +977,7 @@ class MyPlexAccount(PlexObject):
|
|||
"""
|
||||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
|
||||
|
||||
for item in items:
|
||||
if not self.onWatchlist(item):
|
||||
raise BadRequest(f'"{item.title}" is not on the watchlist')
|
||||
|
@ -1053,7 +1059,7 @@ class MyPlexAccount(PlexObject):
|
|||
'includeMetadata': 1
|
||||
}
|
||||
|
||||
data = self.query(f'{self.METADATA}/library/search', headers=headers, params=params)
|
||||
data = self.query(f'{self.DISCOVER}/library/search', headers=headers, params=params)
|
||||
searchResults = data['MediaContainer'].get('SearchResults', [])
|
||||
searchResult = next((s.get('SearchResult', []) for s in searchResults if s.get('id') == 'external'), [])
|
||||
|
||||
|
@ -1135,6 +1141,21 @@ class MyPlexAccount(PlexObject):
|
|||
|
||||
return objs
|
||||
|
||||
def publicIP(self):
|
||||
""" Returns your public IP address. """
|
||||
return self.query('https://plex.tv/:/ip')
|
||||
|
||||
def geoip(self, ip_address):
|
||||
""" Returns a :class:`~plexapi.myplex.GeoLocation` object with geolocation information
|
||||
for an IP address using Plex's GeoIP database.
|
||||
|
||||
Parameters:
|
||||
ip_address (str): IP address to lookup.
|
||||
"""
|
||||
params = {'ip_address': ip_address}
|
||||
data = self.query('https://plex.tv/api/v2/geoip', params=params)
|
||||
return GeoLocation(self, data)
|
||||
|
||||
|
||||
class MyPlexUser(PlexObject):
|
||||
""" This object represents non-signed in users such as friends and linked
|
||||
|
@ -1773,7 +1794,7 @@ class MyPlexPinLogin:
|
|||
params = None
|
||||
|
||||
response = self._query(url, self._session.post, params=params)
|
||||
if not response:
|
||||
if response is None:
|
||||
return None
|
||||
|
||||
self._id = response.attrib.get('id')
|
||||
|
@ -1790,7 +1811,7 @@ class MyPlexPinLogin:
|
|||
|
||||
url = self.CHECKPINS.format(pinid=self._id)
|
||||
response = self._query(url)
|
||||
if not response:
|
||||
if response is None:
|
||||
return False
|
||||
|
||||
token = response.attrib.get('authToken')
|
||||
|
@ -1927,7 +1948,7 @@ class AccountOptOut(PlexObject):
|
|||
|
||||
def optOutManaged(self):
|
||||
""" Sets the Online Media Source to "Disabled for Managed Users".
|
||||
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: When trying to opt out music.
|
||||
"""
|
||||
|
@ -1964,3 +1985,42 @@ class UserState(PlexObject):
|
|||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.viewState = data.attrib.get('viewState') == 'complete'
|
||||
self.watchlistedAt = utils.toDatetime(data.attrib.get('watchlistedAt'))
|
||||
|
||||
|
||||
class GeoLocation(PlexObject):
|
||||
""" Represents a signle IP address geolocation
|
||||
|
||||
Attributes:
|
||||
TAG (str): location
|
||||
city (str): City name
|
||||
code (str): Country code
|
||||
continentCode (str): Continent code
|
||||
coordinates (Tuple<float>): Latitude and longitude
|
||||
country (str): Country name
|
||||
europeanUnionMember (bool): True if the country is a member of the European Union
|
||||
inPrivacyRestrictedCountry (bool): True if the country is privacy restricted
|
||||
postalCode (str): Postal code
|
||||
subdivisions (str): Subdivision name
|
||||
timezone (str): Timezone
|
||||
"""
|
||||
TAG = 'location'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.city = data.attrib.get('city')
|
||||
self.code = data.attrib.get('code')
|
||||
self.continentCode = data.attrib.get('continent_code')
|
||||
self.coordinates = tuple(
|
||||
utils.cast(float, coord) for coord in (data.attrib.get('coordinates') or ',').split(','))
|
||||
self.country = data.attrib.get('country')
|
||||
self.postalCode = data.attrib.get('postal_code')
|
||||
self.subdivisions = data.attrib.get('subdivisions')
|
||||
self.timezone = data.attrib.get('time_zone')
|
||||
|
||||
europeanUnionMember = data.attrib.get('european_union_member')
|
||||
self.europeanUnionMember = (
|
||||
False if europeanUnionMember == 'Unknown' else utils.cast(bool, europeanUnionMember))
|
||||
|
||||
inPrivacyRestrictedCountry = data.attrib.get('in_privacy_restricted_country')
|
||||
self.inPrivacyRestrictedCountry = (
|
||||
False if inPrivacyRestrictedCountry == 'Unknown' else utils.cast(bool, inPrivacyRestrictedCountry))
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import media, utils, video
|
||||
|
@ -139,6 +140,12 @@ class Photoalbum(
|
|||
""" Get the Plex Web URL with the correct parameters. """
|
||||
return self._server._buildWebURL(base=base, endpoint='details', key=self.key, legacy=1)
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'Photos' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Photo(
|
||||
|
@ -249,7 +256,7 @@ class Photo(
|
|||
List<str> of file paths where the photo is found on disk.
|
||||
"""
|
||||
return [part.file for item in self.media for part in item.parts if part]
|
||||
|
||||
|
||||
def sync(self, resolution, client=None, clientId=None, limit=None, title=None):
|
||||
""" Add current photo as sync item for specified device.
|
||||
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
|
||||
|
@ -290,6 +297,12 @@ class Photo(
|
|||
""" Get the Plex Web URL with the correct parameters. """
|
||||
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1)
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.parentGuid)
|
||||
return str(Path('Metadata') / 'Photos' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class PhotoSession(PlexSession, Photo):
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus, unquote
|
||||
|
||||
from plexapi import media, utils
|
||||
|
@ -154,7 +155,7 @@ class Playlist(
|
|||
sectionKey = int(match.group(1))
|
||||
self._section = self._server.library.sectionByID(sectionKey)
|
||||
return self._section
|
||||
|
||||
|
||||
# Try to get the library section from the first item in the playlist
|
||||
if self.items():
|
||||
self._section = self.items()[0].section()
|
||||
|
@ -313,7 +314,7 @@ class Playlist(
|
|||
|
||||
def edit(self, title=None, summary=None):
|
||||
""" Edit the playlist.
|
||||
|
||||
|
||||
Parameters:
|
||||
title (str, optional): The title of the playlist.
|
||||
summary (str, optional): The summary of the playlist.
|
||||
|
@ -431,7 +432,7 @@ class Playlist(
|
|||
|
||||
def copyToUser(self, user):
|
||||
""" Copy playlist to another user account.
|
||||
|
||||
|
||||
Parameters:
|
||||
user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username,
|
||||
email, or user id of the user to copy the playlist to.
|
||||
|
@ -496,3 +497,9 @@ class Playlist(
|
|||
def _getWebURL(self, base=None):
|
||||
""" Get the Plex Web URL with the correct parameters. """
|
||||
return self._server._buildWebURL(base=base, endpoint='playlist', key=self.key)
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'Playlists' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
|
|
@ -109,7 +109,7 @@ class PlexServer(PlexObject):
|
|||
self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
|
||||
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
|
||||
self._session = session or requests.Session()
|
||||
self._timeout = timeout
|
||||
self._timeout = timeout or TIMEOUT
|
||||
self._myPlexAccount = None # cached myPlexAccount
|
||||
self._systemAccounts = None # cached list of SystemAccount
|
||||
self._systemDevices = None # cached list of SystemDevice
|
||||
|
@ -189,6 +189,11 @@ class PlexServer(PlexObject):
|
|||
data = self.query(Settings.key)
|
||||
return Settings(self, data)
|
||||
|
||||
def identity(self):
|
||||
""" Returns the Plex server identity. """
|
||||
data = self.query('/identity')
|
||||
return Identity(self, data)
|
||||
|
||||
def account(self):
|
||||
""" Returns the :class:`~plexapi.server.Account` object this server belongs to. """
|
||||
data = self.query(Account.key)
|
||||
|
@ -197,7 +202,7 @@ class PlexServer(PlexObject):
|
|||
def claim(self, account):
|
||||
""" Claim the Plex server using a :class:`~plexapi.myplex.MyPlexAccount`.
|
||||
This will only work with an unclaimed server on localhost or the same subnet.
|
||||
|
||||
|
||||
Parameters:
|
||||
account (:class:`~plexapi.myplex.MyPlexAccount`): The account used to
|
||||
claim the server.
|
||||
|
@ -240,7 +245,7 @@ class PlexServer(PlexObject):
|
|||
def switchUser(self, user, session=None, timeout=None):
|
||||
""" Returns a new :class:`~plexapi.server.PlexServer` object logged in as the given username.
|
||||
Note: Only the admin account can switch to other users.
|
||||
|
||||
|
||||
Parameters:
|
||||
user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username,
|
||||
email, or user id of the user to log in to the server.
|
||||
|
@ -585,7 +590,7 @@ class PlexServer(PlexObject):
|
|||
def runButlerTask(self, task):
|
||||
""" Manually run a butler task immediately instead of waiting for the scheduled task to run.
|
||||
Note: The butler task is run asynchronously. Check Plex Web to monitor activity.
|
||||
|
||||
|
||||
Parameters:
|
||||
task (str): The name of the task to run. (e.g. 'BackupDatabase')
|
||||
|
||||
|
@ -597,7 +602,7 @@ class PlexServer(PlexObject):
|
|||
print("Available butler tasks:", availableTasks)
|
||||
|
||||
"""
|
||||
validTasks = [task.name for task in self.butlerTasks()]
|
||||
validTasks = [_task.name for _task in self.butlerTasks()]
|
||||
if task not in validTasks:
|
||||
raise BadRequest(
|
||||
f'Invalid butler task: {task}. Available tasks are: {validTasks}'
|
||||
|
@ -610,7 +615,8 @@ class PlexServer(PlexObject):
|
|||
return self.checkForUpdate(force=force, download=download)
|
||||
|
||||
def checkForUpdate(self, force=True, download=False):
|
||||
""" Returns a :class:`~plexapi.base.Release` object containing release info.
|
||||
""" Returns a :class:`~plexapi.server.Release` object containing release info
|
||||
if an update is available or None if no update is available.
|
||||
|
||||
Parameters:
|
||||
force (bool): Force server to check for new releases
|
||||
|
@ -624,12 +630,19 @@ class PlexServer(PlexObject):
|
|||
return releases[0]
|
||||
|
||||
def isLatest(self):
|
||||
""" Check if the installed version of PMS is the latest. """
|
||||
""" Returns True if the installed version of Plex Media Server is the latest. """
|
||||
release = self.checkForUpdate(force=True)
|
||||
return release is None
|
||||
|
||||
def canInstallUpdate(self):
|
||||
""" Returns True if the newest version of Plex Media Server can be installed automatically.
|
||||
(e.g. Windows and Mac can install updates automatically, but Docker and NAS devices cannot.)
|
||||
"""
|
||||
release = self.query('/updater/status')
|
||||
return utils.cast(bool, release.get('canInstall'))
|
||||
|
||||
def installUpdate(self):
|
||||
""" Install the newest version of Plex Media Server. """
|
||||
""" Automatically install the newest version of Plex Media Server. """
|
||||
# We can add this but dunno how useful this is since it sometimes
|
||||
# requires user action using a gui.
|
||||
part = '/updater/apply'
|
||||
|
@ -661,7 +674,7 @@ class PlexServer(PlexObject):
|
|||
args['librarySectionID'] = librarySectionID
|
||||
if mindate:
|
||||
args['viewedAt>'] = int(mindate.timestamp())
|
||||
|
||||
|
||||
key = f'/status/sessions/history/all{utils.joinArgs(args)}'
|
||||
return self.fetchItems(key, maxresults=maxresults)
|
||||
|
||||
|
@ -741,7 +754,7 @@ class PlexServer(PlexObject):
|
|||
"""
|
||||
url = self.url(key)
|
||||
method = method or self._session.get
|
||||
timeout = timeout or TIMEOUT
|
||||
timeout = timeout or self._timeout
|
||||
log.debug('%s %s', method.__name__.upper(), url)
|
||||
headers = self._headers(**headers or {})
|
||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
||||
|
@ -1253,7 +1266,7 @@ class StatisticsResources(PlexObject):
|
|||
@utils.registerPlexObject
|
||||
class ButlerTask(PlexObject):
|
||||
""" Represents a single scheduled butler task.
|
||||
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'ButlerTask'
|
||||
description (str): The description of the task.
|
||||
|
@ -1273,3 +1286,22 @@ class ButlerTask(PlexObject):
|
|||
self.name = data.attrib.get('name')
|
||||
self.scheduleRandomized = utils.cast(bool, data.attrib.get('scheduleRandomized'))
|
||||
self.title = data.attrib.get('title')
|
||||
|
||||
|
||||
class Identity(PlexObject):
|
||||
""" Represents a server identity.
|
||||
|
||||
Attributes:
|
||||
claimed (bool): True or False if the server is claimed.
|
||||
machineIdentifier (str): The Plex server machine identifier.
|
||||
version (str): The Plex server version.
|
||||
"""
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__}:{self.machineIdentifier}>"
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.claimed = utils.cast(bool, data.attrib.get('claimed'))
|
||||
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
||||
self.version = data.attrib.get('version')
|
||||
|
|
|
@ -23,7 +23,6 @@ you can set items to be synced to your app) you need to init some variables.
|
|||
You have to fake platform/device/model because transcoding profiles are hardcoded in Plex, and you obviously have
|
||||
to explicitly specify that your app supports `sync-target`.
|
||||
"""
|
||||
|
||||
import requests
|
||||
|
||||
import plexapi
|
||||
|
|
|
@ -11,13 +11,14 @@ import unicodedata
|
|||
import warnings
|
||||
import zipfile
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from getpass import getpass
|
||||
from hashlib import sha1
|
||||
from threading import Event, Thread
|
||||
from urllib.parse import quote
|
||||
from requests.status_codes import _codes as codes
|
||||
|
||||
import requests
|
||||
from requests.status_codes import _codes as codes
|
||||
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||
|
||||
|
@ -313,33 +314,44 @@ def toDatetime(value, format=None):
|
|||
value (str): value to return as a datetime
|
||||
format (str): Format to pass strftime (optional; if value is a str).
|
||||
"""
|
||||
if value and value is not None:
|
||||
if value is not None:
|
||||
if format:
|
||||
try:
|
||||
value = datetime.strptime(value, format)
|
||||
return datetime.strptime(value, format)
|
||||
except ValueError:
|
||||
log.info('Failed to parse %s to datetime, defaulting to None', value)
|
||||
log.info('Failed to parse "%s" to datetime as format "%s", defaulting to None', value, format)
|
||||
return None
|
||||
else:
|
||||
# https://bugs.python.org/issue30684
|
||||
# And platform support for before epoch seems to be flaky.
|
||||
# Also limit to max 32-bit integer
|
||||
value = min(max(int(value), 86400), 2**31 - 1)
|
||||
value = datetime.fromtimestamp(int(value))
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
log.info('Failed to parse "%s" to datetime as timestamp, defaulting to None', value)
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(value)
|
||||
except (OSError, OverflowError):
|
||||
try:
|
||||
return datetime.fromtimestamp(0) + timedelta(seconds=value)
|
||||
except OverflowError:
|
||||
log.info('Failed to parse "%s" to datetime as timestamp (out-of-bounds), defaulting to None', value)
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def millisecondToHumanstr(milliseconds):
|
||||
""" Returns human readable time duration from milliseconds.
|
||||
HH:MM:SS:MMMM
|
||||
""" Returns human readable time duration [D day[s], ]HH:MM:SS.UUU from milliseconds.
|
||||
|
||||
Parameters:
|
||||
milliseconds (str,int): time duration in milliseconds.
|
||||
milliseconds (str, int): time duration in milliseconds.
|
||||
"""
|
||||
milliseconds = int(milliseconds)
|
||||
r = datetime.utcfromtimestamp(milliseconds / 1000)
|
||||
f = r.strftime("%H:%M:%S.%f")
|
||||
return f[:-2]
|
||||
if milliseconds < 0:
|
||||
return '-' + millisecondToHumanstr(abs(milliseconds))
|
||||
secs, ms = divmod(milliseconds, 1000)
|
||||
mins, secs = divmod(secs, 60)
|
||||
hours, mins = divmod(mins, 60)
|
||||
days, hours = divmod(hours, 24)
|
||||
return ('' if days == 0 else f'{days} day{"s" if days > 1 else ""}, ') + f'{hours:02d}:{mins:02d}:{secs:02d}.{ms:03d}'
|
||||
|
||||
|
||||
def toList(value, itemcast=None, delim=','):
|
||||
|
@ -644,3 +656,8 @@ def openOrRead(file):
|
|||
return file.read()
|
||||
with open(file, 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def sha1hash(guid):
|
||||
""" Return the SHA1 hash of a guid. """
|
||||
return sha1(guid.encode('utf-8')).hexdigest()
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import media, utils
|
||||
|
@ -445,6 +447,12 @@ class Movie(
|
|||
self._server.query(key, params=params, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'Movies' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Show(
|
||||
|
@ -655,6 +663,12 @@ class Show(
|
|||
filepaths += episode.download(_savepath, keep_original_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Season(
|
||||
|
@ -663,7 +677,7 @@ class Season(
|
|||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||
SeasonEditMixins
|
||||
):
|
||||
""" Represents a single Show Season (including all episodes).
|
||||
""" Represents a single Season.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
|
@ -808,6 +822,12 @@ class Season(
|
|||
""" Returns str, default title for a new syncItem. """
|
||||
return f'{self.parentTitle} - {self.title}'
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.parentGuid)
|
||||
return str(Path('Metadata') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Episode(
|
||||
|
@ -816,7 +836,7 @@ class Episode(
|
|||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||
EpisodeEditMixins
|
||||
):
|
||||
""" Represents a single Shows Episode.
|
||||
""" Represents a single Episode.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Video'
|
||||
|
@ -845,7 +865,7 @@ class Episode(
|
|||
parentGuid (str): Plex GUID for the season (plex://season/5d9c09e42df347001e3c2a72).
|
||||
parentIndex (int): Season number of episode.
|
||||
parentKey (str): API URL of the season (/library/metadata/<parentRatingKey>).
|
||||
parentRatingKey (int): Unique key identifying the season.
|
||||
parentRatingKey (int): Unique key identifying the season.
|
||||
parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||
parentTitle (str): Name of the season for the episode.
|
||||
parentYear (int): Year the season was released.
|
||||
|
@ -866,7 +886,6 @@ class Episode(
|
|||
""" Load attribute values from Plex XML response. """
|
||||
Video._loadData(self, data)
|
||||
Playable._loadData(self, data)
|
||||
self._seasonNumber = None # cached season number
|
||||
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
||||
self.audienceRatingImage = data.attrib.get('audienceRatingImage')
|
||||
self.chapters = self.findItems(data, media.Chapter)
|
||||
|
@ -890,9 +909,6 @@ class Episode(
|
|||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.parentGuid = data.attrib.get('parentGuid')
|
||||
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.parentYear = utils.cast(int, data.attrib.get('parentYear'))
|
||||
self.producers = self.findItems(data, media.Producer)
|
||||
|
@ -906,15 +922,50 @@ class Episode(
|
|||
|
||||
# If seasons are hidden, parentKey and parentRatingKey are missing from the XML response.
|
||||
# https://forums.plex.tv/t/parentratingkey-not-in-episode-xml-when-seasons-are-hidden/300553
|
||||
if self.skipParent and data.attrib.get('parentRatingKey') is None:
|
||||
# Parse the parentRatingKey from the parentThumb
|
||||
if self.parentThumb and self.parentThumb.startswith('/library/metadata/'):
|
||||
self.parentRatingKey = utils.cast(int, self.parentThumb.split('/')[3])
|
||||
# Get the parentRatingKey from the season's ratingKey
|
||||
if not self.parentRatingKey and self.grandparentRatingKey:
|
||||
self.parentRatingKey = self.show().season(season=self.parentIndex).ratingKey
|
||||
if self.parentRatingKey:
|
||||
self.parentKey = f'/library/metadata/{self.parentRatingKey}'
|
||||
# Use cached properties below to return the correct values if they are missing to avoid auto-reloading.
|
||||
self._parentKey = data.attrib.get('parentKey')
|
||||
self._parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self._parentThumb = data.attrib.get('parentThumb')
|
||||
|
||||
@cached_property
|
||||
def parentKey(self):
|
||||
""" Returns the parentKey. Refer to the Episode attributes. """
|
||||
if self._parentKey:
|
||||
return self._parentKey
|
||||
if self.parentRatingKey:
|
||||
return f'/library/metadata/{self.parentRatingKey}'
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def parentRatingKey(self):
|
||||
""" Returns the parentRatingKey. Refer to the Episode attributes. """
|
||||
if self._parentRatingKey is not None:
|
||||
return self._parentRatingKey
|
||||
# Parse the parentRatingKey from the parentThumb
|
||||
if self._parentThumb and self._parentThumb.startswith('/library/metadata/'):
|
||||
return utils.cast(int, self._parentThumb.split('/')[3])
|
||||
# Get the parentRatingKey from the season's ratingKey if available
|
||||
if self._season:
|
||||
return self._season.ratingKey
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def parentThumb(self):
|
||||
""" Returns the parentThumb. Refer to the Episode attributes. """
|
||||
if self._parentThumb:
|
||||
return self._parentThumb
|
||||
if self._season:
|
||||
return self._season.thumb
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def _season(self):
|
||||
""" Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """
|
||||
if not self.grandparentKey:
|
||||
return None
|
||||
return self.fetchItem(
|
||||
f'{self.grandparentKey}/children?excludeAllLeaves=1&index={self.parentIndex}'
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return '<{}>'.format(
|
||||
|
@ -949,12 +1000,10 @@ class Episode(
|
|||
""" Returns the episode number. """
|
||||
return self.index
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def seasonNumber(self):
|
||||
""" Returns the episode's season number. """
|
||||
if self._seasonNumber is None:
|
||||
self._seasonNumber = self.parentIndex if isinstance(self.parentIndex, int) else self.season().seasonNumber
|
||||
return utils.cast(int, self._seasonNumber)
|
||||
return self.parentIndex if isinstance(self.parentIndex, int) else self._season.seasonNumber
|
||||
|
||||
@property
|
||||
def seasonEpisode(self):
|
||||
|
@ -1000,6 +1049,12 @@ class Episode(
|
|||
self._server.query(key, params=params, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.grandparentGuid)
|
||||
return str(Path('Metadata') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Clip(
|
||||
|
@ -1058,6 +1113,12 @@ class Clip(
|
|||
""" Returns a filename for use in download. """
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'Movies' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
class Extra(Clip):
|
||||
""" Represents a single Extra (trailer, behindTheScenes, etc). """
|
||||
|
|
|
@ -28,7 +28,7 @@ MarkupSafe==2.1.3
|
|||
musicbrainzngs==0.7.1
|
||||
packaging==23.1
|
||||
paho-mqtt==1.6.1
|
||||
plexapi==4.15.0
|
||||
plexapi==4.15.4
|
||||
portend==3.2.0
|
||||
profilehooks==1.12.0
|
||||
PyJWT==2.8.0
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue