Register a new device using a QR code, or configure an existing device by clicking the settings icon on the right.
Register a new device using a QR code, or configure an existing device by clicking on the item below.
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/plexpy/datafactory.py b/plexpy/datafactory.py
index 808916c1..dfe64992 100644
--- a/plexpy/datafactory.py
+++ b/plexpy/datafactory.py
@@ -22,7 +22,6 @@ from future.builtins import str
from future.builtins import object
import json
-from itertools import groupby
import plexpy
if plexpy.PYTHON2:
@@ -272,7 +271,7 @@ class DataFactory(object):
item['user_thumb'] = users_lookup.get(item['user_id'])
- filter_duration += int(item['play_duration'])
+ filter_duration += helpers.cast_to_int(item['play_duration'])
if item['media_type'] == 'episode' and item['parent_thumb']:
thumb = item['parent_thumb']
@@ -1218,7 +1217,7 @@ class DataFactory(object):
library_stats.append(library)
library_stats = session.mask_session_info(library_stats)
- library_stats = {k: list(v) for k, v in groupby(library_stats, key=lambda x: x['section_type'])}
+ library_stats = helpers.group_by_keys(library_stats, 'section_type')
return library_stats
diff --git a/plexpy/exporter.py b/plexpy/exporter.py
index f1c730a4..1232481c 100644
--- a/plexpy/exporter.py
+++ b/plexpy/exporter.py
@@ -371,6 +371,7 @@ class Export(object):
}
}
},
+ 'metadataDirectory': None,
'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
'originalTitle': None,
'producers': {
@@ -420,8 +421,6 @@ class Export(object):
'audioLanguage': None,
'autoDeletionItemPolicyUnwatchedLibrary': None,
'autoDeletionItemPolicyWatchedLibrary': None,
- 'banner': None,
- 'bannerFile': lambda o: self.get_image(o, 'banner'),
'childCount': None,
'collections': {
'id': None,
@@ -459,6 +458,7 @@ class Export(object):
'librarySectionKey': None,
'librarySectionTitle': None,
'locations': None,
+ 'metadataDirectory': None,
'network': None,
'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
'originalTitle': None,
@@ -525,6 +525,7 @@ class Export(object):
'librarySectionID': None,
'librarySectionKey': None,
'librarySectionTitle': None,
+ 'metadataDirectory': None,
'parentGuid': None,
'parentIndex': None,
'parentKey': None,
@@ -771,6 +772,7 @@ class Export(object):
}
}
},
+ 'metadataDirectory': None,
'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
'parentGuid': None,
'parentIndex': None,
@@ -851,6 +853,7 @@ class Export(object):
'librarySectionKey': None,
'librarySectionTitle': None,
'locations': None,
+ 'metadataDirectory': None,
'moods': {
'id': None,
'tag': None
@@ -920,6 +923,7 @@ class Export(object):
'librarySectionKey': None,
'librarySectionTitle': None,
'loudnessAnalysisVersion': None,
+ 'metadataDirectory': None,
'moods': {
'id': None,
'tag': None
@@ -1089,6 +1093,7 @@ class Export(object):
}
}
},
+ 'metadataDirectory': None,
'moods': {
'id': None,
'tag': None
@@ -1135,6 +1140,7 @@ class Export(object):
'librarySectionID': None,
'librarySectionKey': None,
'librarySectionTitle': None,
+ 'metadataDirectory': None,
'ratingKey': None,
'summary': None,
'thumb': None,
@@ -1166,6 +1172,7 @@ class Export(object):
'librarySectionKey': None,
'librarySectionTitle': None,
'locations': None,
+ 'metadataDirectory': None,
'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
'parentGuid': None,
'parentIndex': None,
@@ -1240,6 +1247,7 @@ class Export(object):
'librarySectionKey': None,
'librarySectionTitle': None,
'maxYear': None,
+ 'metadataDirectory': None,
'minYear': None,
'ratingKey': None,
'subtype': None,
@@ -1268,6 +1276,7 @@ class Export(object):
'icon': None,
'key': None,
'leafCount': None,
+ 'metadataDirectory': None,
'playlistType': None,
'ratingKey': None,
'smart': None,
@@ -1382,7 +1391,7 @@ class Export(object):
'fields.name', 'fields.locked', 'guids.id'
],
3: [
- 'art', 'thumb', 'banner', 'theme', 'key',
+ 'art', 'thumb', 'theme', 'key',
'updatedAt', 'lastViewedAt', 'viewCount', 'lastRatedAt'
],
9: self._get_all_metadata_attrs(_media_type)
@@ -2258,8 +2267,6 @@ class Export(object):
image_url = item.thumbUrl
elif image == 'art':
image_url = item.artUrl
- elif image == 'banner':
- image_url = item.bannerUrl
if not image_url:
return
diff --git a/plexpy/graphs.py b/plexpy/graphs.py
index 58a199c0..8758f71f 100644
--- a/plexpy/graphs.py
+++ b/plexpy/graphs.py
@@ -22,7 +22,6 @@ from future.builtins import object
import arrow
import datetime
-
import plexpy
if plexpy.PYTHON2:
import common
@@ -102,6 +101,8 @@ class Graphs(object):
logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_day: %s." % e)
return None
+ result_by_date_played = {item['date_played']: item for item in result}
+
# create our date range as some days may not have any data
# but we still want to display them
base = datetime.date.today()
@@ -116,22 +117,13 @@ class Graphs(object):
for date_item in sorted(date_list):
date_string = date_item.strftime('%Y-%m-%d')
categories.append(date_string)
- series_1_value = 0
- series_2_value = 0
- series_3_value = 0
- series_4_value = 0
- for item in result:
- if date_string == item['date_played']:
- series_1_value = item['tv_count']
- series_2_value = item['movie_count']
- series_3_value = item['music_count']
- series_4_value = item['live_count']
- break
- else:
- series_1_value = 0
- series_2_value = 0
- series_3_value = 0
- series_4_value = 0
+
+ result_date = result_by_date_played.get(date_string, {})
+
+ series_1_value = result_date.get('tv_count', 0)
+ series_2_value = result_date.get('movie_count', 0)
+ series_3_value = result_date.get('music_count', 0)
+ series_4_value = result_date.get('live_count', 0)
series_1.append(series_1_value)
series_2.append(series_2_value)
@@ -234,6 +226,8 @@ class Graphs(object):
logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_dayofweek: %s." % e)
return None
+ result_by_dayofweek = {item['dayofweek']: item for item in result}
+
if plexpy.CONFIG.WEEK_START_MONDAY:
days_list = ['Monday', 'Tuesday', 'Wednesday',
'Thursday', 'Friday', 'Saturday', 'Sunday']
@@ -249,22 +243,13 @@ class Graphs(object):
for day_item in days_list:
categories.append(day_item)
- series_1_value = 0
- series_2_value = 0
- series_3_value = 0
- series_4_value = 0
- for item in result:
- if day_item == item['dayofweek']:
- series_1_value = item['tv_count']
- series_2_value = item['movie_count']
- series_3_value = item['music_count']
- series_4_value = item['live_count']
- break
- else:
- series_1_value = 0
- series_2_value = 0
- series_3_value = 0
- series_4_value = 0
+
+ result_day = result_by_dayofweek.get(day_item, {})
+
+ series_1_value = result_day.get('tv_count', 0)
+ series_2_value = result_day.get('movie_count', 0)
+ series_3_value = result_day.get('music_count', 0)
+ series_4_value = result_day.get('live_count', 0)
series_1.append(series_1_value)
series_2.append(series_2_value)
@@ -351,6 +336,8 @@ class Graphs(object):
logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_hourofday: %s." % e)
return None
+ result_by_hourofday = {item['hourofday']: item for item in result}
+
hours_list = ['00', '01', '02', '03', '04', '05',
'06', '07', '08', '09', '10', '11',
'12', '13', '14', '15', '16', '17',
@@ -364,22 +351,13 @@ class Graphs(object):
for hour_item in hours_list:
categories.append(hour_item)
- series_1_value = 0
- series_2_value = 0
- series_3_value = 0
- series_4_value = 0
- for item in result:
- if hour_item == item['hourofday']:
- series_1_value = item['tv_count']
- series_2_value = item['movie_count']
- series_3_value = item['music_count']
- series_4_value = item['live_count']
- break
- else:
- series_1_value = 0
- series_2_value = 0
- series_3_value = 0
- series_4_value = 0
+
+ result_hour = result_by_hourofday.get(hour_item, {})
+
+ series_1_value = result_hour.get('tv_count', 0)
+ series_2_value = result_hour.get('movie_count', 0)
+ series_3_value = result_hour.get('music_count', 0)
+ series_4_value = result_hour.get('live_count', 0)
series_1.append(series_1_value)
series_2.append(series_2_value)
@@ -466,6 +444,8 @@ class Graphs(object):
logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_month: %s." % e)
return None
+ result_by_datestring = {item['datestring']: item for item in result}
+
# create our date range as some months may not have any data
# but we still want to display them
dt_today = datetime.date.today()
@@ -487,22 +467,13 @@ class Graphs(object):
for dt in sorted(month_range):
date_string = dt.strftime('%Y-%m')
categories.append(dt.strftime('%b %Y'))
- series_1_value = 0
- series_2_value = 0
- series_3_value = 0
- series_4_value = 0
- for item in result:
- if date_string == item['datestring']:
- series_1_value = item['tv_count']
- series_2_value = item['movie_count']
- series_3_value = item['music_count']
- series_4_value = item['live_count']
- break
- else:
- series_1_value = 0
- series_2_value = 0
- series_3_value = 0
- series_4_value = 0
+
+ result_date = result_by_datestring.get(date_string, {})
+
+ series_1_value = result_date.get('tv_count', 0)
+ series_2_value = result_date.get('movie_count', 0)
+ series_3_value = result_date.get('music_count', 0)
+ series_4_value = result_date.get('live_count', 0)
series_1.append(series_1_value)
series_2.append(series_2_value)
@@ -599,6 +570,7 @@ class Graphs(object):
for item in result:
categories.append(common.PLATFORM_NAME_OVERRIDES.get(item['platform'], item['platform']))
+
series_1.append(item['tv_count'])
series_2.append(item['movie_count'])
series_3.append(item['music_count'])
@@ -705,6 +677,7 @@ class Graphs(object):
categories.append(item['username'] if str(item['user_id']) == session_user_id else 'Plex User')
else:
categories.append(item['friendly_name'])
+
series_1.append(item['tv_count'])
series_2.append(item['movie_count'])
series_3.append(item['music_count'])
@@ -784,6 +757,8 @@ class Graphs(object):
logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_stream_type: %s." % e)
return None
+ result_by_date_played = {item['date_played']: item for item in result}
+
# create our date range as some days may not have any data
# but we still want to display them
base = datetime.date.today()
@@ -797,19 +772,12 @@ class Graphs(object):
for date_item in sorted(date_list):
date_string = date_item.strftime('%Y-%m-%d')
categories.append(date_string)
- series_1_value = 0
- series_2_value = 0
- series_3_value = 0
- for item in result:
- if date_string == item['date_played']:
- series_1_value = item['dp_count']
- series_2_value = item['ds_count']
- series_3_value = item['tc_count']
- break
- else:
- series_1_value = 0
- series_2_value = 0
- series_3_value = 0
+
+ result_date = result_by_date_played.get(date_string, {})
+
+ series_1_value = result_date.get('dp_count', 0)
+ series_2_value = result_date.get('ds_count', 0)
+ series_3_value = result_date.get('tc_count', 0)
series_1.append(series_1_value)
series_2.append(series_2_value)
@@ -826,6 +794,100 @@ class Graphs(object):
'series': [series_1_output, series_2_output, series_3_output]}
return output
+ def get_total_concurrent_streams_per_stream_type(self, time_range='30', user_id=None):
+ monitor_db = database.MonitorDatabase()
+
+ time_range = helpers.cast_to_int(time_range) or 30
+ timestamp = helpers.timestamp() - time_range * 24 * 60 * 60
+
+ user_cond = self._make_user_cond(user_id, 'WHERE')
+
+ def calc_most_concurrent(result):
+ times = []
+ for item in result:
+ times.append({'time': str(item['started']) + 'B', 'count': 1})
+ times.append({'time': str(item['stopped']) + 'A', 'count': -1})
+ times = sorted(times, key=lambda k: k['time'])
+
+ count = 0
+ final_count = 0
+ last_count = 0
+
+ for d in times:
+ if d['count'] == 1:
+ count += d['count']
+ else:
+ if count >= last_count:
+ last_count = count
+ final_count = count
+ count += d['count']
+
+ return final_count
+
+ try:
+ query = "SELECT sh.date_played, sh.started, sh.stopped, shmi.transcode_decision " \
+ "FROM (SELECT *, " \
+ "date(started, 'unixepoch', 'localtime') AS date_played " \
+ "FROM session_history %s " \
+ "GROUP BY id) AS sh " \
+ "JOIN session_history_media_info AS shmi ON sh.id = shmi.id " \
+ "WHERE sh.stopped >= %s " \
+ "ORDER BY sh.started" % (user_cond, timestamp)
+
+ result = monitor_db.select(query)
+ except Exception as e:
+ logger.warn("Tautulli Graphs :: Unable to execute database query for get_total_plays_per_stream_type: %s." % e)
+ return None
+
+ result_by_date_and_decision = helpers.group_by_keys(result, ('date_played', 'transcode_decision'))
+ result_by_date = helpers.group_by_keys(result, 'date_played')
+
+ # create our date range as some days may not have any data
+ # but we still want to display them
+ base = datetime.date.today()
+ date_list = [base - datetime.timedelta(days=x) for x in range(0, int(time_range))]
+
+ categories = []
+ series_1 = []
+ series_2 = []
+ series_3 = []
+ series_4 = []
+
+ for date_item in sorted(date_list):
+ date_string = date_item.strftime('%Y-%m-%d')
+ categories.append(date_string)
+
+ series_1_value = calc_most_concurrent(
+ result_by_date_and_decision.get((date_string, 'direct play'), [])
+ )
+ series_2_value = calc_most_concurrent(
+ result_by_date_and_decision.get((date_string, 'copy'), [])
+ )
+ series_3_value = calc_most_concurrent(
+ result_by_date_and_decision.get((date_string, 'transcode'), [])
+ )
+ series_4_value = calc_most_concurrent(
+ result_by_date.get(date_string, [])
+ )
+
+ series_1.append(series_1_value)
+ series_2.append(series_2_value)
+ series_3.append(series_3_value)
+ series_4.append(series_4_value)
+
+ series_1_output = {'name': 'Direct Play',
+ 'data': series_1}
+ series_2_output = {'name': 'Direct Stream',
+ 'data': series_2}
+ series_3_output = {'name': 'Transcode',
+ 'data': series_3}
+ series_4_output = {'name': 'Max. Concurrent Streams',
+ 'data': series_4}
+
+ output = {'categories': categories,
+ 'series': [series_1_output, series_2_output, series_3_output, series_4_output]}
+ return output
+
def get_total_plays_by_source_resolution(self, time_range='30', y_axis='plays', user_id=None, grouping=None):
monitor_db = database.MonitorDatabase()
@@ -888,6 +950,7 @@ class Graphs(object):
for item in result:
categories.append(item['resolution'])
+
series_1.append(item['dp_count'])
series_2.append(item['ds_count'])
series_3.append(item['tc_count'])
@@ -991,6 +1054,7 @@ class Graphs(object):
for item in result:
categories.append(item['resolution'])
+
series_1.append(item['dp_count'])
series_2.append(item['ds_count'])
series_3.append(item['tc_count'])
@@ -1066,6 +1130,7 @@ class Graphs(object):
for item in result:
categories.append(common.PLATFORM_NAME_OVERRIDES.get(item['platform'], item['platform']))
+
series_1.append(item['dp_count'])
series_2.append(item['ds_count'])
series_3.append(item['tc_count'])
@@ -1153,6 +1218,7 @@ class Graphs(object):
categories.append(item['username'] if str(item['user_id']) == session_user_id else 'Plex User')
else:
categories.append(item['friendly_name'])
+
series_1.append(item['dp_count'])
series_2.append(item['ds_count'])
series_3.append(item['tc_count'])
@@ -1169,15 +1235,16 @@ class Graphs(object):
return output
- def _make_user_cond(self, user_id):
+ def _make_user_cond(self, user_id, cond_prefix='AND'):
"""
Expects user_id to be a comma-separated list of ints.
"""
user_cond = ''
+
if session.get_session_user_id() and user_id and user_id != str(session.get_session_user_id()):
- user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
+ user_cond = cond_prefix + ' session_history.user_id = %s ' % session.get_session_user_id()
elif user_id:
user_ids = helpers.split_strip(user_id)
if all(id.isdigit() for id in user_ids):
- user_cond = 'AND session_history.user_id IN (%s) ' % ','.join(user_ids)
+ user_cond = cond_prefix + ' session_history.user_id IN (%s) ' % ','.join(user_ids)
return user_cond
diff --git a/plexpy/helpers.py b/plexpy/helpers.py
index 06b1932c..f5af7d28 100644
--- a/plexpy/helpers.py
+++ b/plexpy/helpers.py
@@ -32,6 +32,7 @@ import datetime
from functools import reduce, wraps
import hashlib
import imghdr
+from itertools import groupby
from future.moves.itertools import islice, zip_longest
from ipaddress import ip_address, ip_network, IPv4Address
import ipwhois
@@ -1193,18 +1194,20 @@ def get_plexpy_url(hostname=None):
scheme = 'http'
if hostname is None and plexpy.CONFIG.HTTP_HOST in ('0.0.0.0', '::'):
- import socket
+ # Only returns IPv4 address
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ s.settimeout(0)
try:
- # Only returns IPv4 address
- s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
- s.connect(('', 0))
+ s.connect(('', 1))
hostname = s.getsockname()[0]
except socket.error:
try:
hostname = socket.gethostbyname(socket.gethostname())
except socket.gaierror:
pass
+ finally:
+ s.close()
if not hostname:
hostname = 'localhost'
@@ -1242,6 +1245,15 @@ def grouper(iterable, n, fillvalue=None):
return zip_longest(fillvalue=fillvalue, *args)
+def group_by_keys(iterable, keys):
+ if not isinstance(keys, (list, tuple)):
+ keys = [keys]
+
+ key_function = operator.itemgetter(*keys)
+ sorted_iterable = sorted(iterable, key=key_function)
+ return {key: list(group) for key, group in groupby(sorted_iterable, key_function)}
+
+
def chunk(it, size):
it = iter(it)
return iter(lambda: tuple(islice(it, size)), ())
diff --git a/plexpy/newsletters.py b/plexpy/newsletters.py
index 6c07fb42..4253e67f 100644
--- a/plexpy/newsletters.py
+++ b/plexpy/newsletters.py
@@ -119,13 +119,22 @@ def get_newsletters(newsletter_id=None):
if newsletter_id:
where = "WHERE "
if newsletter_id:
- where_id += "id = ?"
+ where_id += "newsletters.id = ?"
args.append(newsletter_id)
where += " AND ".join([w for w in [where_id] if w])
db = database.MonitorDatabase()
- result = db.select("SELECT id, agent_id, agent_name, agent_label, "
- "friendly_name, cron, active FROM newsletters %s" % where, args=args)
+ result = db.select(
+ (
+ "SELECT newsletters.id, newsletters.agent_id, newsletters.agent_name, newsletters.agent_label, "
+ "newsletters.friendly_name, newsletters.cron, newsletters.active, "
+ "MAX(newsletter_log.timestamp) AS last_triggered, newsletter_log.success AS last_success "
+ "FROM newsletters "
+ "LEFT OUTER JOIN newsletter_log ON newsletters.id = newsletter_log.newsletter_id "
+ "%s "
+ "GROUP BY newsletters.id"
+ ) % where, args=args
+ )
return result
diff --git a/plexpy/notifiers.py b/plexpy/notifiers.py
index 77a5b112..af497e2b 100644
--- a/plexpy/notifiers.py
+++ b/plexpy/notifiers.py
@@ -499,7 +499,7 @@ def get_notifiers(notifier_id=None, notify_action=None):
if notifier_id or notify_action:
where = 'WHERE '
if notifier_id:
- where_id += 'id = ?'
+ where_id += 'notifiers.id = ?'
args.append(notifier_id)
if notify_action and notify_action in notify_actions:
where_action = '%s = ?' % notify_action
@@ -507,8 +507,16 @@ def get_notifiers(notifier_id=None, notify_action=None):
where += ' AND '.join([w for w in [where_id, where_action] if w])
db = database.MonitorDatabase()
- result = db.select("SELECT id, agent_id, agent_name, agent_label, friendly_name, %s FROM notifiers %s"
- % (', '.join(notify_actions), where), args=args)
+ result = db.select(
+ (
+ "SELECT notifiers.id, notifiers.agent_id, notifiers.agent_name, notifiers.agent_label, notifiers.friendly_name, %s, "
+ "MAX(notify_log.timestamp) AS last_triggered, notify_log.success AS last_success "
+ "FROM notifiers "
+ "LEFT OUTER JOIN notify_log ON notifiers.id = notify_log.notifier_id "
+ "%s "
+ "GROUP BY notifiers.id"
+ ) % (', '.join(notify_actions), where), args=args
+ )
for item in result:
item['active'] = int(any([item.pop(k) for k in list(item.keys()) if k in notify_actions]))
diff --git a/plexpy/plextv.py b/plexpy/plextv.py
index 35a2089a..5914273d 100644
--- a/plexpy/plextv.py
+++ b/plexpy/plextv.py
@@ -87,6 +87,7 @@ def get_server_resources(return_presence=False, return_server=False, return_info
hostname=server['pms_ip'],
port=server['pms_port'])
+ plex_tv.ping()
result = plex_tv.get_server_connections(pms_identifier=server['pms_identifier'],
pms_ip=server['pms_ip'],
pms_port=server['pms_port'],
@@ -354,6 +355,13 @@ class PlexTV(object):
return request
+ def ping_plextv(self, output_format=''):
+ uri = '/api/v2/ping'
+ request = self.request_handler.make_request(uri=uri,
+ request_type='GET',
+ output_format=output_format)
+ return request
+
def get_full_users_list(self):
own_account = self.get_plextv_user_details(output_format='xml')
friends_list = self.get_plextv_friends(output_format='xml')
@@ -960,3 +968,18 @@ class PlexTV(object):
}
return geo_info
+
+ def ping(self):
+ logger.info(u"Tautulli PlexTV :: Pinging Plex.tv to refresh token.")
+
+ pong = self.ping_plextv(output_format='xml')
+
+ try:
+ xml_head = pong.getElementsByTagName('pong')
+ except Exception as e:
+ logger.warn(u"Tautulli PlexTV :: Unable to parse XML for ping: %s." % e)
+ return None
+
+ if xml_head:
+ return helpers.bool_true(xml_head[0].firstChild.nodeValue)
+ return False
diff --git a/plexpy/webserve.py b/plexpy/webserve.py
index be711629..3670a66f 100644
--- a/plexpy/webserve.py
+++ b/plexpy/webserve.py
@@ -2572,6 +2572,44 @@ class WebInterface(object):
logger.warn("Unable to retrieve data for get_plays_by_stream_type.")
return result
+ @cherrypy.expose
+ @cherrypy.tools.json_out()
+ @requireAuth()
+ @addtoapi()
+ def get_concurrent_streams_by_stream_type(self, time_range='30', user_id=None, **kwargs):
+ """ Get graph data for concurrent streams by stream type by date.
+
+ ```
+ Required parameters:
+ None
+
+ Optional parameters:
+ time_range (str): The number of days of data to return
+ user_id (str): Comma separated list of user id to filter the data
+
+ Returns:
+ json:
+ {"categories":
+ ["YYYY-MM-DD", "YYYY-MM-DD", ...]
+ "series":
+ [{"name": "Direct Play", "data": [...]}
+ {"name": "Direct Stream", "data": [...]},
+ {"name": "Transcode", "data": [...]},
+ {"name": "Max. Concurrent Streams", "data": [...]}
+ ]
+ }
+ ```
+ """
+
+ graph = graphs.Graphs()
+ result = graph.get_total_concurrent_streams_per_stream_type(time_range=time_range, user_id=user_id)
+
+ if result:
+ return result
+ else:
+ logger.warn("Unable to retrieve data for get_concurrent_streams_by_stream_type.")
+ return result
+
@cherrypy.expose
@cherrypy.tools.json_out()
@requireAuth()
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