diff --git a/lib/plexapi/__init__.py b/lib/plexapi/__init__.py index eefc181d..1d4fb471 100644 --- a/lib/plexapi/__init__.py +++ b/lib/plexapi/__init__.py @@ -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 diff --git a/lib/plexapi/alert.py b/lib/plexapi/alert.py index 79ecc445..2d6a18e8 100644 --- a/lib/plexapi/alert.py +++ b/lib/plexapi/alert.py @@ -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: diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index e1382760..2a169877 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -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): diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index 88a31bbe..822e40ea 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -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. """ diff --git a/lib/plexapi/client.py b/lib/plexapi/client.py index 2b4283c7..279b4974 100644 --- a/lib/plexapi/client.py +++ b/lib/plexapi/client.py @@ -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) diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py index d4820fe2..8bc5f286 100644 --- a/lib/plexapi/collection.py +++ b/lib/plexapi/collection.py @@ -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') diff --git a/lib/plexapi/config.py b/lib/plexapi/config.py index 8bbf1f31..5cfa74c8 100644 --- a/lib/plexapi/config.py +++ b/lib/plexapi/config.py @@ -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', } diff --git a/lib/plexapi/const.py b/lib/plexapi/const.py index df86ff5d..8a172e98 100644 --- a/lib/plexapi/const.py +++ b/lib/plexapi/const.py @@ -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}" diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index cbca4246..87d59eac 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -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 diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index 8793463f..369bb759 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -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. """ diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py index f0c21cfe..e1cce54b 100644 --- a/lib/plexapi/mixins.py +++ b/lib/plexapi/mixins.py @@ -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. diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py index c90b5d33..ede2276d 100644 --- a/lib/plexapi/myplex.py +++ b/lib/plexapi/myplex.py @@ -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): 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)) diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py index 039ac80c..8737d814 100644 --- a/lib/plexapi/photo.py +++ b/lib/plexapi/photo.py @@ -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 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): diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index c435613a..44073ee7 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -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') diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index 69d5f89a..52a203a8 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -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') diff --git a/lib/plexapi/sync.py b/lib/plexapi/sync.py index 66468c30..f57e89d9 100644 --- a/lib/plexapi/sync.py +++ b/lib/plexapi/sync.py @@ -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 diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index d1882fbb..8478f2d4 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -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() diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index 486bb5ca..e95b12ff 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -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 (int): Unique key identifying the season. + parentRatingKey (int): Unique key identifying the season. parentThumb (str): URL to season thumbnail image (/library/metadata//thumb/). 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). """ diff --git a/requirements.txt b/requirements.txt index 8ca67fe6..04578ef9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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