diff --git a/data/interfaces/default/graphs.html b/data/interfaces/default/graphs.html
index 15a96a69..006d7ee9 100644
--- a/data/interfaces/default/graphs.html
+++ b/data/interfaces/default/graphs.html
@@ -301,6 +301,10 @@
return obj;
}, {});
+ if (!("Total" in chart_visibility)) {
+ chart_visibility["Total"] = false;
+ }
+
return data_series.map(function(s) {
var obj = Object.assign({}, s);
obj.visible = (chart_visibility[s.name] !== false);
@@ -327,7 +331,8 @@
'Direct Play': '#E5A00D',
'Direct Stream': '#FFFFFF',
'Transcode': '#F06464',
- 'Max. Concurrent Streams': '#96C83C'
+ 'Max. Concurrent Streams': '#96C83C',
+ 'Total': '#96C83C'
};
var series_colors = [];
$.each(data_series, function(index, series) {
diff --git a/data/interfaces/default/index.html b/data/interfaces/default/index.html
index 582ac93f..ca95be42 100644
--- a/data/interfaces/default/index.html
+++ b/data/interfaces/default/index.html
@@ -298,6 +298,8 @@
$('#currentActivityHeader-bandwidth-tooltip').tooltip({ container: 'body', placement: 'right', delay: 50 });
+ var title = document.title;
+
function getCurrentActivity() {
activity_ready = false;
@@ -368,6 +370,8 @@
$('#currentActivityHeader').show();
+ document.title = stream_count + ' stream' + (stream_count > 1 ? 's' : '') + ' | ' + title;
+
sessions.forEach(function (session) {
var s = (typeof Proxy === "function") ? new Proxy(session, defaultHandler) : session;
var key = s.session_key;
@@ -600,6 +604,8 @@
} else {
$('#currentActivityHeader').hide();
$('#currentActivity').html('
Nothing is currently being played.
');
+
+ document.title = title;
}
activity_ready = true;
diff --git a/data/interfaces/default/stream_data.html b/data/interfaces/default/stream_data.html
index 9e42189b..a40bc54a 100644
--- a/data/interfaces/default/stream_data.html
+++ b/data/interfaces/default/stream_data.html
@@ -68,14 +68,14 @@ DOCUMENTATION :: END
-
- |
-
- Stream Details
- |
+ |
Source Details
|
+ |
+
+ Stream Details
+ |
@@ -85,38 +85,46 @@ DOCUMENTATION :: END
Media
|
+
|
+
|
+
|
Bitrate |
- ${data['stream_bitrate']} ${'kbps' if data['stream_bitrate'] else ''} |
${data['bitrate']} ${'kbps' if data['bitrate'] else ''} |
+ |
+ ${data['stream_bitrate']} ${'kbps' if data['stream_bitrate'] else ''} |
% if data['media_type'] != 'track':
Resolution |
- ${data['stream_video_full_resolution']} |
${data['video_full_resolution']} |
+ |
+ ${data['stream_video_full_resolution']} |
% endif
Quality |
- ${data['quality_profile']} |
- |
+ |
+ ${data['quality_profile']} |
% if data['optimized_version'] == 1:
Optimized Version |
- - |
${data['optimized_version_profile']} (${data['optimized_version_title']}) |
+ |
+ - |
% endif
% if data['synced_version'] == 1:
Synced Version |
- - |
${data['synced_version_profile']} |
+ |
+ - |
% endif
@@ -127,6 +135,8 @@ DOCUMENTATION :: END
Container
|
+
|
+
|
${data['stream_container_decision']}
|
@@ -135,8 +145,9 @@ DOCUMENTATION :: END
Container |
- ${data['stream_container'].upper()} |
${data['container'].upper()} |
+ |
+ ${data['stream_container'].upper()} |
@@ -147,6 +158,8 @@ DOCUMENTATION :: END
Video
|
+
|
+
|
${data['stream_video_decision']}
|
@@ -155,38 +168,45 @@ DOCUMENTATION :: END
Codec |
- ${data['stream_video_codec'].upper()} ${'(HW)' if data['transcode_hw_encoding'] else ''} |
${data['video_codec'].upper()} ${'(HW)' if data['transcode_hw_decoding'] else ''} |
+ |
+ ${data['stream_video_codec'].upper()} ${'(HW)' if data['transcode_hw_encoding'] else ''} |
Bitrate |
- ${data['stream_video_bitrate']} ${'kbps' if data['stream_video_bitrate'] else ''} |
${data['video_bitrate']} ${'kbps' if data['video_bitrate'] else ''} |
+ |
+ ${data['stream_video_bitrate']} ${'kbps' if data['stream_video_bitrate'] else ''} |
Width |
- ${data['stream_video_width']} |
${data['video_width']} |
+ |
+ ${data['stream_video_width']} |
Height |
- ${data['stream_video_height']} |
${data['video_height']} |
+ |
+ ${data['stream_video_height']} |
Framerate |
- ${data['stream_video_framerate']} |
${data['video_framerate']} |
+ |
+ ${data['stream_video_framerate']} |
Dynamic Range |
- ${data['stream_video_dynamic_range']} |
${data['video_dynamic_range']} |
+ |
+ ${data['stream_video_dynamic_range']} |
Aspect Ratio |
- - |
${data['aspect_ratio']} |
+ |
+ - |
@@ -197,6 +217,8 @@ DOCUMENTATION :: END
Audio
|
+
|
+
|
${data['stream_audio_decision']}
|
@@ -205,23 +227,27 @@ DOCUMENTATION :: END
Codec |
- ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} |
${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} |
+ |
+ ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} |
Bitrate |
- ${data['stream_audio_bitrate']} ${'kbps' if data['stream_audio_bitrate'] else ''} |
${data['audio_bitrate']} ${'kbps' if data['audio_bitrate'] else ''} |
+ |
+ ${data['stream_audio_bitrate']} ${'kbps' if data['stream_audio_bitrate'] else ''} |
Channels |
- ${data['stream_audio_channels']} |
${data['audio_channels']} |
+ |
+ ${data['stream_audio_channels']} |
Language |
- - |
${data['audio_language'] or 'Unknown'} |
+ |
+ - |
@@ -233,6 +259,8 @@ DOCUMENTATION :: END
Subtitles
|
+
|
+
|
${'direct play' if data['stream_subtitle_decision'] not in ('transcode', 'copy', 'burn') else data['stream_subtitle_decision']}
|
@@ -241,19 +269,22 @@ DOCUMENTATION :: END
Codec |
- ${data['stream_subtitle_codec'].upper() or '-'} |
${data['subtitle_codec'].upper()} |
+ |
+ ${data['stream_subtitle_codec'].upper() or '-'} |
Language |
- - |
${data['subtitle_language'] or 'Unknown'} |
+ |
+ - |
% if data['subtitle_forced']:
Forced |
- - |
${bool(data['subtitle_forced'])} |
+ |
+ - |
% endif
diff --git a/lib/cherrypy_cors/__init__.py b/lib/cherrypy_cors/__init__.py
new file mode 100644
index 00000000..54003451
--- /dev/null
+++ b/lib/cherrypy_cors/__init__.py
@@ -0,0 +1,255 @@
+import re
+
+import cherrypy
+from cherrypy.lib import set_vary_header
+import httpagentparser
+
+
+CORS_ALLOW_METHODS = 'Access-Control-Allow-Methods'
+CORS_ALLOW_ORIGIN = 'Access-Control-Allow-Origin'
+CORS_ALLOW_CREDENTIALS = 'Access-Control-Allow-Credentials'
+CORS_EXPOSE_HEADERS = 'Access-Control-Expose-Headers'
+CORS_REQUEST_METHOD = 'Access-Control-Request-Method'
+CORS_REQUEST_HEADERS = 'Access-Control-Request-Headers'
+CORS_MAX_AGE = 'Access-Control-Max-Age'
+CORS_ALLOW_HEADERS = 'Access-Control-Allow-Headers'
+PUBLIC_ORIGIN = '*'
+
+
+def expose(allow_credentials=False, expose_headers=None, origins=None):
+ """Adds CORS support to the resource.
+
+ If the resource is allowed to be exposed, the value of the
+ `Access-Control-Allow-Origin`_ header in the response will echo
+ the `Origin`_ request header, and `Origin` will be
+ appended to the `Vary`_ response header.
+
+ :param allow_credentials: Use credentials to make cookies work
+ (see `Access-Control-Allow-Credentials`_).
+ :type allow_credentials: bool
+ :param expose_headers: List of headers clients will be able to access
+ (see `Access-Control-Expose-Headers`_).
+ :type expose_headers: list or None
+ :param origins: List of allowed origins clients must reference.
+ :type origins: list or None
+
+ :returns: Whether the resource is being exposed.
+ :rtype: bool
+
+ - Configuration example:
+
+ .. code-block:: python
+
+ config = {
+ '/static': {
+ 'tools.staticdir.on': True,
+ 'cors.expose.on': True,
+ }
+ }
+ - Decorator example:
+
+ .. code-block:: python
+
+ @cherrypy_cors.tools.expose()
+ def DELETE(self):
+ self._delete()
+
+ """
+ if _get_cors().expose(allow_credentials, expose_headers, origins):
+ _safe_caching_headers()
+ return True
+ return False
+
+
+def expose_public(expose_headers=None):
+ """Adds CORS support to the resource from any origin.
+
+ If the resource is allowed to be exposed, the value of the
+ `Access-Control-Allow-Origin`_ header in the response will be `*`.
+
+ :param expose_headers: List of headers clients will be able to access
+ (see `Access-Control-Expose-Headers`_).
+ :type expose_headers: list or None
+
+ :rtype: None
+ """
+ _get_cors().expose_public(expose_headers)
+
+
+def preflight(
+ allowed_methods,
+ allowed_headers=None,
+ allow_credentials=False,
+ max_age=None,
+ origins=None,
+):
+ """Adds CORS `preflight`_ support to a `HTTP OPTIONS` request.
+
+ :param allowed_methods: List of supported `HTTP` methods
+ (see `Access-Control-Allow-Methods`_).
+ :type allowed_methods: list or None
+ :param allowed_headers: List of supported `HTTP` headers
+ (see `Access-Control-Allow-Headers`_).
+ :type allowed_headers: list or None
+ :param allow_credentials: Use credentials to make cookies work
+ (see `Access-Control-Allow-Credentials`_).
+ :type allow_credentials: bool
+ :param max_age: Seconds to cache the preflight request
+ (see `Access-Control-Max-Age`_).
+ :type max_age: int
+ :param origins: List of allowed origins clients must reference.
+ :type origins: list or None
+
+ :returns: Whether the preflight is allowed.
+ :rtype: bool
+
+ - Used as a decorator with the `Method Dispatcher`_
+
+ .. code-block:: python
+
+ @cherrypy_cors.tools.preflight(
+ allowed_methods=["GET", "DELETE", "PUT"])
+ def OPTIONS(self):
+ pass
+
+ - Function call with the `Object Dispatcher`_
+
+ .. code-block:: python
+
+ @cherrypy.expose
+ @cherrypy.tools.allow(
+ methods=["GET", "DELETE", "PUT", "OPTIONS"])
+ def thing(self):
+ if cherrypy.request.method == "OPTIONS":
+ cherrypy_cors.preflight(
+ allowed_methods=["GET", "DELETE", "PUT"])
+ else:
+ self._do_other_things()
+
+ """
+ if _get_cors().preflight(
+ allowed_methods, allowed_headers, allow_credentials, max_age, origins
+ ):
+ _safe_caching_headers()
+ return True
+ return False
+
+
+def install():
+ """Install the toolbox such that it's available in all applications."""
+ cherrypy._cptree.Application.toolboxes.update(cors=tools)
+
+
+class CORS:
+ """A generic CORS handler."""
+
+ def __init__(self, req_headers, resp_headers):
+ self.req_headers = req_headers
+ self.resp_headers = resp_headers
+
+ def expose(self, allow_credentials, expose_headers, origins):
+ if self._is_valid_origin(origins):
+ self._add_origin_and_credentials_headers(allow_credentials)
+ self._add_expose_headers(expose_headers)
+ return True
+ return False
+
+ def expose_public(self, expose_headers):
+ self._add_public_origin()
+ self._add_expose_headers(expose_headers)
+
+ def preflight(
+ self, allowed_methods, allowed_headers, allow_credentials, max_age, origins
+ ):
+ if self._is_valid_preflight_request(allowed_headers, allowed_methods, origins):
+ self._add_origin_and_credentials_headers(allow_credentials)
+ self._add_prefligt_headers(allowed_methods, max_age)
+ return True
+ return False
+
+ @property
+ def origin(self):
+ return self.req_headers.get('Origin')
+
+ def _is_valid_origin(self, origins):
+ if origins is None:
+ origins = [self.origin]
+ origins = map(self._make_regex, origins)
+ return self.origin is not None and any(
+ origin.match(self.origin) for origin in origins
+ )
+
+ @staticmethod
+ def _make_regex(pattern):
+ if isinstance(pattern, str):
+ pattern = re.compile(re.escape(pattern) + '$')
+ return pattern
+
+ def _add_origin_and_credentials_headers(self, allow_credentials):
+ self.resp_headers[CORS_ALLOW_ORIGIN] = self.origin
+ if allow_credentials:
+ self.resp_headers[CORS_ALLOW_CREDENTIALS] = 'true'
+
+ def _add_public_origin(self):
+ self.resp_headers[CORS_ALLOW_ORIGIN] = PUBLIC_ORIGIN
+
+ def _add_expose_headers(self, expose_headers):
+ if expose_headers:
+ self.resp_headers[CORS_EXPOSE_HEADERS] = expose_headers
+
+ @property
+ def requested_method(self):
+ return self.req_headers.get(CORS_REQUEST_METHOD)
+
+ @property
+ def requested_headers(self):
+ return self.req_headers.get(CORS_REQUEST_HEADERS)
+
+ def _has_valid_method(self, allowed_methods):
+ return self.requested_method and self.requested_method in allowed_methods
+
+ def _valid_headers(self, allowed_headers):
+ if self.requested_headers and allowed_headers:
+ for header in self.requested_headers.split(','):
+ if header.strip() not in allowed_headers:
+ return False
+ return True
+
+ def _is_valid_preflight_request(self, allowed_headers, allowed_methods, origins):
+ return (
+ self._is_valid_origin(origins)
+ and self._has_valid_method(allowed_methods)
+ and self._valid_headers(allowed_headers)
+ )
+
+ def _add_prefligt_headers(self, allowed_methods, max_age):
+ rh = self.resp_headers
+ rh[CORS_ALLOW_METHODS] = ', '.join(allowed_methods)
+ if max_age:
+ rh[CORS_MAX_AGE] = max_age
+ if self.requested_headers:
+ rh[CORS_ALLOW_HEADERS] = self.requested_headers
+
+
+def _get_cors():
+ return CORS(cherrypy.serving.request.headers, cherrypy.serving.response.headers)
+
+
+def _safe_caching_headers():
+ """Adds `Origin`_ to the `Vary`_ header to ensure caching works properly.
+
+ Except in IE because it will disable caching completely. The caching
+ strategy in that case is out of the scope of this library.
+ https://blogs.msdn.microsoft.com/ieinternals/2009/06/17/vary-with-care/
+ """
+ uah = cherrypy.serving.request.headers.get('User-Agent', '')
+ ua = httpagentparser.detect(uah)
+ IE = 'Microsoft Internet Explorer'
+ if ua.get('browser', {}).get('name') != IE:
+ set_vary_header(cherrypy.serving.response, "Origin")
+
+
+tools = cherrypy._cptools.Toolbox("cors")
+tools.expose = cherrypy.Tool('before_handler', expose)
+tools.expose_public = cherrypy.Tool('before_handler', expose_public)
+tools.preflight = cherrypy.Tool('before_handler', preflight)
diff --git a/package/requirements-package.txt b/package/requirements-package.txt
index 141b1a27..5837f631 100644
--- a/package/requirements-package.txt
+++ b/package/requirements-package.txt
@@ -1,9 +1,9 @@
apscheduler==3.10.1
-cryptography==43.0.3
+cryptography==44.0.2
importlib-metadata==8.5.0
importlib-resources==6.4.5
-pyinstaller==6.11.1
-pyopenssl==24.2.1
+pyinstaller==6.10.0
+pyopenssl==25.0.0
pyobjc-core==10.3.1; platform_system == "Darwin"
pyobjc-framework-Cocoa==10.3.1; platform_system == "Darwin"
diff --git a/plexpy/activity_handler.py b/plexpy/activity_handler.py
index 2758133b..8c10b922 100644
--- a/plexpy/activity_handler.py
+++ b/plexpy/activity_handler.py
@@ -314,6 +314,10 @@ class ActivityHandler(object):
if self.metadata:
this_guid = self.metadata['guid']
+ # Check for stream offset notifications
+ self.check_markers()
+ self.check_watched()
+
# Make sure the same item is being played
if (self.rating_key == last_rating_key
or self.rating_key == last_rating_key_websocket
@@ -354,10 +358,6 @@ class ActivityHandler(object):
self.on_stop(force_stop=True)
self.on_start()
- # Check for stream offset notifications
- self.check_markers()
- self.check_watched()
-
def check_markers(self):
# Monitor if the stream has reached the intro or credit marker offsets
self.get_metadata()
diff --git a/plexpy/activity_pinger.py b/plexpy/activity_pinger.py
index 9e8487fb..22688027 100644
--- a/plexpy/activity_pinger.py
+++ b/plexpy/activity_pinger.py
@@ -173,7 +173,7 @@ def check_active_sessions(ws_request=False):
row_id = monitor_process.write_session_history(session=stream)
if row_id:
- # If session is written to the databaase successfully, remove the session from the session table
+ # If session is written to the database successfully, remove the session from the session table
logger.debug("Tautulli Monitor :: Removing sessionKey %s ratingKey %s from session queue"
% (stream['session_key'], stream['rating_key']))
monitor_process.delete_session(row_id=row_id)
diff --git a/plexpy/common.py b/plexpy/common.py
index d55674ea..d057cee9 100644
--- a/plexpy/common.py
+++ b/plexpy/common.py
@@ -476,6 +476,7 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Stream Audio Sample Rate', 'type': 'int', 'value': 'stream_audio_sample_rate', 'description': 'The audio sample rate (in Hz) of the stream.'},
{'name': 'Stream Audio Language', 'type': 'str', 'value': 'stream_audio_language', 'description': 'The audio language of the stream.'},
{'name': 'Stream Audio Language Code', 'type': 'str', 'value': 'stream_audio_language_code', 'description': 'The audio language code of the stream.'},
+ {'name': 'Stream Audio Profile', 'type': 'str', 'value': 'stream_audio_profile', 'description': 'The audio profile of the stream.'},
{'name': 'Stream Subtitle Codec', 'type': 'str', 'value': 'stream_subtitle_codec', 'description': 'The subtitle codec of the stream.'},
{'name': 'Stream Subtitle Container', 'type': 'str', 'value': 'stream_subtitle_container', 'description': 'The subtitle container of the stream.'},
{'name': 'Stream Subtitle Format', 'type': 'str', 'value': 'stream_subtitle_format', 'description': 'The subtitle format of the stream.'},
@@ -606,6 +607,7 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Audio Sample Rate', 'type': 'int', 'value': 'audio_sample_rate', 'description': 'The audio sample rate (in Hz) of the original media.'},
{'name': 'Audio Language', 'type': 'str', 'value': 'audio_language', 'description': 'The audio language of the original media.'},
{'name': 'Audio Language Code', 'type': 'str', 'value': 'audio_language_code', 'description': 'The audio language code of the original media.'},
+ {'name': 'Audio Profile', 'type': 'str', 'value': 'audio_profile', 'description': 'The audio profile of the original media.'},
{'name': 'Subtitle Codec', 'type': 'str', 'value': 'subtitle_codec', 'description': 'The subtitle codec of the original media.'},
{'name': 'Subtitle Container', 'type': 'str', 'value': 'subtitle_container', 'description': 'The subtitle container of the original media.'},
{'name': 'Subtitle Format', 'type': 'str', 'value': 'subtitle_format', 'description': 'The subtitle format of the original media.'},
diff --git a/plexpy/config.py b/plexpy/config.py
index c41e3b59..3e9862ff 100644
--- a/plexpy/config.py
+++ b/plexpy/config.py
@@ -568,7 +568,7 @@ class Config(object):
def _upgrade(self):
"""
- Upgrades config file from previous verisions and bumps up config version
+ Upgrades config file from previous versions and bumps up config version
"""
if self.CONFIG_VERSION == 0:
self.CONFIG_VERSION = 1
diff --git a/plexpy/datatables.py b/plexpy/datatables.py
index 68c76c57..0cc94760 100644
--- a/plexpy/datatables.py
+++ b/plexpy/datatables.py
@@ -283,7 +283,7 @@ def extract_columns(columns=None, match_columns=None):
columns_string = columns_string.rstrip(', ')
# We return a dict of the column params
- # column_string is a comma seperated list of the exact column variables received.
+ # column_string is a comma separated list of the exact column variables received.
# column_literal is the text before the "as" if we have an "as". Usually a function.
# column_named is the text after the "as", if we have an "as". Any table prefix is also stripped off.
# We use this to match with columns received from the Datatables request.
diff --git a/plexpy/graphs.py b/plexpy/graphs.py
index 70122aee..771b4eaf 100644
--- a/plexpy/graphs.py
+++ b/plexpy/graphs.py
@@ -138,6 +138,12 @@ class Graphs(object):
if libraries.has_library_type('live'):
series_output.append(series_4_output)
+ if len(series_output) > 0:
+ series_total = [sum(x) for x in zip(*[x['data'] for x in series_output])]
+ series_total_output = {'name': 'Total',
+ 'data': series_total}
+ series_output.append(series_total_output)
+
output = {'categories': categories,
'series': series_output}
return output
diff --git a/plexpy/mobile_app.py b/plexpy/mobile_app.py
index f3c835c3..8ba075d5 100644
--- a/plexpy/mobile_app.py
+++ b/plexpy/mobile_app.py
@@ -132,7 +132,7 @@ def set_mobile_device_config(mobile_device_id=None, **kwargs):
if str(mobile_device_id).isdigit():
mobile_device_id = int(mobile_device_id)
else:
- logger.error("Tautulli MobileApp :: Unable to set exisiting mobile device: invalid mobile_device_id %s." % mobile_device_id)
+ logger.error("Tautulli MobileApp :: Unable to set existing mobile device: invalid mobile_device_id %s." % mobile_device_id)
return False
keys = {'id': mobile_device_id}
diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py
index 56ac9df6..0e30d6e2 100644
--- a/plexpy/notification_handler.py
+++ b/plexpy/notification_handler.py
@@ -684,23 +684,23 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
thetvdb_media_type = 'movie' if notify_params['media_type'] == 'movie' else 'series'
notify_params['thetvdb_id'] = notify_params['thetvdb_id'] or notify_params['guid'].split('thetvdb://')[1].split('/')[0].split('?')[0]
notify_params['thetvdb_url'] = f'https://thetvdb.com/dereferrer/{thetvdb_media_type}/{notify_params["thetvdb_id"]}'
- notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/' + notify_params['thetvdb_id'] + '?type=show'
+ notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/' + notify_params['thetvdb_id'] + '?id_type=show'
elif 'thetvdbdvdorder://' in notify_params['guid']:
notify_params['thetvdb_id'] = notify_params['guid'].split('thetvdbdvdorder://')[1].split('/')[0].split('?')[0]
notify_params['thetvdb_url'] = f'https://thetvdb.com/dereferrer/series/{notify_params["thetvdb_id"]}'
- notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/' + notify_params['thetvdb_id'] + '?type=show'
+ notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/' + notify_params['thetvdb_id'] + '?id_type=show'
if 'themoviedb://' in notify_params['guid'] or notify_params['themoviedb_id']:
if notify_params['media_type'] == 'movie':
notify_params['themoviedb_id'] = notify_params['themoviedb_id'] or notify_params['guid'].split('themoviedb://')[1].split('?')[0]
notify_params['themoviedb_url'] = 'https://www.themoviedb.org/movie/' + notify_params['themoviedb_id']
- notify_params['trakt_url'] = 'https://trakt.tv/search/tmdb/' + notify_params['themoviedb_id'] + '?type=movie'
+ notify_params['trakt_url'] = 'https://trakt.tv/search/tmdb/' + notify_params['themoviedb_id'] + '?id_type=movie'
elif notify_params['media_type'] in ('show', 'season', 'episode'):
notify_params['themoviedb_id'] = notify_params['themoviedb_id'] or notify_params['guid'].split('themoviedb://')[1].split('/')[0].split('?')[0]
notify_params['themoviedb_url'] = 'https://www.themoviedb.org/tv/' + notify_params['themoviedb_id']
- notify_params['trakt_url'] = 'https://trakt.tv/search/tmdb/' + notify_params['themoviedb_id'] + '?type=show'
+ notify_params['trakt_url'] = 'https://trakt.tv/search/tmdb/' + notify_params['themoviedb_id'] + '?id_type=show'
if 'lastfm://' in notify_params['guid']:
notify_params['lastfm_id'] = '/'.join(notify_params['guid'].split('lastfm://')[1].split('?')[0].split('/')[:2])
@@ -765,7 +765,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
if themoviedb_info.get('imdb_id'):
notify_params['imdb_url'] = 'https://www.imdb.com/title/' + themoviedb_info['imdb_id']
if themoviedb_info.get('themoviedb_id'):
- notify_params['trakt_url'] = 'https://trakt.tv/search/tmdb/{}?type={}'.format(
+ notify_params['trakt_url'] = 'https://trakt.tv/search/tmdb/{}?id_type={}'.format(
notify_params['themoviedb_id'], 'show' if lookup_media_type == 'tv' else 'movie')
# Get TVmaze info (for tv shows only)
@@ -790,7 +790,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
if tvmaze_info.get('thetvdb_id'):
notify_params['thetvdb_url'] = f'https://thetvdb.com/dereferrer/series/{tvmaze_info["thetvdb_id"]}'
- notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/{}' + str(notify_params['thetvdb_id']) + '?type=show'
+ notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/{}' + str(notify_params['thetvdb_id']) + '?id_type=show'
if tvmaze_info.get('imdb_id'):
notify_params['imdb_url'] = 'https://www.imdb.com/title/' + tvmaze_info['imdb_id']
notify_params['trakt_url'] = 'https://trakt.tv/search/imdb/' + notify_params['imdb_id']
@@ -955,7 +955,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
now_iso = now.isocalendar()
available_params = {
- # Global paramaters
+ # Global parameters
'tautulli_version': common.RELEASE,
'tautulli_remote': plexpy.CONFIG.GIT_REMOTE,
'tautulli_branch': plexpy.CONFIG.GIT_BRANCH,
@@ -1082,6 +1082,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'stream_audio_sample_rate': notify_params['stream_audio_sample_rate'],
'stream_audio_language': notify_params['stream_audio_language'],
'stream_audio_language_code': notify_params['stream_audio_language_code'],
+ 'stream_audio_profile': notify_params['stream_audio_profile'],
'stream_subtitle_codec': notify_params['stream_subtitle_codec'],
'stream_subtitle_container': notify_params['stream_subtitle_container'],
'stream_subtitle_format': notify_params['stream_subtitle_format'],
@@ -1215,6 +1216,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'audio_sample_rate': notify_params['audio_sample_rate'],
'audio_language': notify_params['audio_language'],
'audio_language_code': notify_params['audio_language_code'],
+ 'audio_profile': notify_params['audio_profile'],
'subtitle_codec': notify_params['subtitle_codec'],
'subtitle_container': notify_params['subtitle_container'],
'subtitle_format': notify_params['subtitle_format'],
@@ -1267,7 +1269,7 @@ def build_server_notify_params(notify_action=None, **kwargs):
now_iso = now.isocalendar()
available_params = {
- # Global paramaters
+ # Global parameters
'tautulli_version': common.RELEASE,
'tautulli_remote': plexpy.CONFIG.GIT_REMOTE,
'tautulli_branch': plexpy.CONFIG.GIT_BRANCH,
diff --git a/plexpy/notifiers.py b/plexpy/notifiers.py
index 2029aa01..21002dd2 100644
--- a/plexpy/notifiers.py
+++ b/plexpy/notifiers.py
@@ -2667,7 +2667,8 @@ class NTFY(Notifier):
provider_name = pretty_metadata.get_provider_name(provider)
provider_link = pretty_metadata.get_provider_link(provider)
- actions.append(f"view, View on {provider_name}, {provider_link}, clear=true")
+ if provider_link:
+ actions.append(f"view, View on {provider_name}, {provider_link}, clear=true")
if self.config['incl_pmslink']:
plex_url = pretty_metadata.get_plex_url()
@@ -3390,8 +3391,11 @@ class PUSHOVER(Notifier):
image = pretty_metadata.get_image()
if image:
- files = {'attachment': image}
- headers = {}
+ if len(image[1]) <= 5242880: # 5MB max attachment size
+ files = {'attachment': image}
+ headers = {}
+ else:
+ logger.warn("Tautulli Notifiers :: Image size exceeds 5MB limit for {name}.".format(name=self.NAME))
return self.make_request('https://api.pushover.net/1/messages.json', headers=headers, data=data, files=files)
@@ -4055,7 +4059,7 @@ class TAUTULLIREMOTEAPP(Notifier):
}
else:
logger.warn("Tautulli Notifiers :: Cryptography library is missing. "
- "Tautulli Remote app notifications will be sent unecrypted. "
+ "Tautulli Remote app notifications will be sent unencrypted. "
"Install the library to encrypt the notifications.")
payload = {'app_id': mobile_app._ONESIGNAL_APP_ID,
@@ -4460,7 +4464,8 @@ class WEBHOOK(Notifier):
'select_options': {'GET': 'GET',
'POST': 'POST',
'PUT': 'PUT',
- 'DELETE': 'DELETE'}
+ 'DELETE': 'DELETE',
+ 'PATCH': 'PATCH'}
}
]
diff --git a/plexpy/pmsconnect.py b/plexpy/pmsconnect.py
index 46cd143e..ed115897 100644
--- a/plexpy/pmsconnect.py
+++ b/plexpy/pmsconnect.py
@@ -2096,6 +2096,7 @@ class PmsConnect(object):
'stream_audio_channel_layout_': stream_audio_channel_layouts_,
'stream_audio_language': helpers.get_xml_attr(audio_stream_info, 'language'),
'stream_audio_language_code': helpers.get_xml_attr(audio_stream_info, 'languageCode'),
+ 'stream_audio_profile': helpers.get_xml_attr(audio_stream_info, 'profile'),
'stream_audio_decision': helpers.get_xml_attr(audio_stream_info, 'decision') or 'direct play'
}
else:
@@ -2108,6 +2109,7 @@ class PmsConnect(object):
'stream_audio_channel_layout_': '',
'stream_audio_language': '',
'stream_audio_language_code': '',
+ 'stream_audio_profile': '',
'stream_audio_decision': ''
}
diff --git a/plexpy/version.py b/plexpy/version.py
index b08e42da..a2a92f99 100644
--- a/plexpy/version.py
+++ b/plexpy/version.py
@@ -16,4 +16,4 @@
# along with Tautulli. If not, see
.
PLEXPY_BRANCH = "master"
-PLEXPY_RELEASE_VERSION = "v2.15.1"
\ No newline at end of file
+PLEXPY_RELEASE_VERSION = "v2.15.2"
\ No newline at end of file
diff --git a/plexpy/webauth.py b/plexpy/webauth.py
index 05c81a81..67658d15 100644
--- a/plexpy/webauth.py
+++ b/plexpy/webauth.py
@@ -50,7 +50,7 @@ def plex_user_login(token=None, headers=None):
user_token = None
user_id = None
- # Try to login to Plex.tv to check if the user has a vaild account
+ # Try to login to Plex.tv to check if the user has a valid account
if token:
plex_tv = PlexTV(token=token, headers=headers)
plex_user = plex_tv.get_plex_account_details()
@@ -176,7 +176,10 @@ def check_auth(*args, **kwargs):
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
else:
- redirect_uri = cherrypy.request.wsgi_environ['REQUEST_URI']
+ if cherrypy.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
+ raise cherrypy.HTTPError(401)
+
+ redirect_uri = cherrypy.request.path_info
if redirect_uri:
redirect_uri = '?redirect_uri=' + quote(redirect_uri)
diff --git a/plexpy/webserve.py b/plexpy/webserve.py
index 6ea9aa63..3f8890cc 100644
--- a/plexpy/webserve.py
+++ b/plexpy/webserve.py
@@ -608,7 +608,7 @@ class WebInterface(object):
status_message = ''
else:
result = None
- status_message = 'An error occured.'
+ status_message = 'An error occurred.'
return serve_template(template_name="edit_library.html", title="Edit Library",
data=result, server_id=plexpy.CONFIG.PMS_IDENTIFIER, status_message=status_message)
@@ -1347,7 +1347,7 @@ class WebInterface(object):
status_message = ''
else:
result = None
- status_message = 'An error occured.'
+ status_message = 'An error occurred.'
return serve_template(template_name="edit_user.html", title="Edit User", data=result, status_message=status_message)
@@ -1365,7 +1365,7 @@ class WebInterface(object):
keep_history (int): 0 or 1
allow_guest (int): 0 or 1
- Optional paramters:
+ Optional parameters:
None
Returns:
@@ -3031,7 +3031,7 @@ class WebInterface(object):
""" Delete the Tautulli notification logs.
```
- Required paramters:
+ Required parameters:
None
Optional parameters:
@@ -3056,7 +3056,7 @@ class WebInterface(object):
""" Delete the Tautulli newsletter logs.
```
- Required paramters:
+ Required parameters:
None
Optional parameters:
@@ -3081,7 +3081,7 @@ class WebInterface(object):
""" Delete the Tautulli login logs.
```
- Required paramters:
+ Required parameters:
None
Optional parameters:
@@ -5921,6 +5921,7 @@ class WebInterface(object):
"stream_audio_decision": "direct play",
"stream_audio_language": "",
"stream_audio_language_code": "",
+ "stream_audio_profile": "",
"stream_audio_sample_rate": "48000",
"stream_bitrate": "10617",
"stream_container": "mkv",
diff --git a/plexpy/webstart.py b/plexpy/webstart.py
index 1f3fb7ed..323c7e8e 100644
--- a/plexpy/webstart.py
+++ b/plexpy/webstart.py
@@ -21,6 +21,7 @@ import sys
import cheroot.errors
import cherrypy
+import cherrypy_cors
import plexpy
from plexpy import logger
@@ -62,6 +63,7 @@ def restart():
def initialize(options):
+ cherrypy_cors.install()
# HTTPS stuff stolen from sickbeard
enable_https = options['enable_https']
@@ -91,7 +93,8 @@ def initialize(options):
'server.socket_timeout': 60,
'tools.encode.on': True,
'tools.encode.encoding': 'utf-8',
- 'tools.decode.on': True
+ 'tools.decode.on': True,
+ 'cors.expose.on': True,
}
if plexpy.DEV:
diff --git a/requirements.txt b/requirements.txt
index 0bd14208..120b01ac 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,6 +5,7 @@ bleach==6.2.0
certifi==2024.8.30
cheroot==10.0.1
cherrypy==18.10.0
+cherrypy-cors==1.7.0
cloudinary==1.41.0
distro==1.9.0
dnspython==2.7.0