diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5524c4..0cfe9f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,38 @@ # Changelog +## v2.15.2 (2025-04-12) + +* Activity: + * New: Added link to library by clicking media type icon. + * New: Added stream count to tab title on homepage. (#2517) +* History: + * Fix: Check stream watched status before stream stopped status. (#2506) +* Notifications: + * Fix: ntfy notifications failing to send if provider link is blank. + * Fix: Check Pushover notification attachment is under 5MB limit. (#2396) + * Fix: Track URLs redirecting to the correct media page. (#2513) + * New: Added audio profile notification parameters. + * New: Added PATCH method for Webhook notifications. +* Graphs: + * New: Added Total line to daily streams graph. (Thanks @zdimension) (#2497) +* UI: + * Fix: Do not redirect API requests to the login page. (#2490) + * Change: Swap source and stream columns in stream info modal. +* Other: + * Fix: Various typos. (Thanks @luzpaz) (#2520) + * Fix: CherryPy CORS response header not being set correctly. (#2279) + + ## v2.15.1 (2025-01-11) * Activity: - * Fix: Detection of HDR transcodes. (Thanks @chrisdecker08) (#2412, #2466) + * Fix: Detection of HDR transcodes. (Thanks @cdecker08) (#2412, #2466) * Newsletters: * Fix: Disable basic authentication for /newsletter and /image endpoints. (#2472) * Exporter: * New: Added logos to season and episode exports. * Other: - * Fix Docker container https health check. + * Fix: Docker container https health check. ## v2.15.0 (2024-11-24) diff --git a/README.md b/README.md index 1f24741e..37829290 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ This is free software under the GPL v3 open source license. Feel free to do with but any modification must be open sourced. A copy of the license is included. This software includes Highsoft software libraries which you may freely distribute for -non-commercial use. Commerical users must licence this software, for more information visit +non-commercial use. Commercial users must licence this software, for more information visit https://shop.highsoft.com/faq/non-commercial#non-commercial-redistribution. diff --git a/Tautulli.py b/Tautulli.py index 200cd39d..b3cf4736 100755 --- a/Tautulli.py +++ b/Tautulli.py @@ -129,7 +129,7 @@ def main(): if args.quiet: plexpy.QUIET = True - # Do an intial setup of the logger. + # Do an initial setup of the logger. # Require verbose for pre-initilization to see critical errors logger.initLogger(console=not plexpy.QUIET, log_dir=False, verbose=True) diff --git a/data/interfaces/default/css/tautulli.css b/data/interfaces/default/css/tautulli.css index 2835488d..5cb5d6bf 100644 --- a/data/interfaces/default/css/tautulli.css +++ b/data/interfaces/default/css/tautulli.css @@ -4325,6 +4325,10 @@ a:hover .overlay-refresh-image:hover { .stream-info tr:nth-child(even) td { background-color: rgba(255,255,255,0.010); } +.stream-info td:nth-child(3), +.stream-info th:nth-child(3) { + width: 25px; +} .number-input { margin: 0 !important; width: 55px !important; diff --git a/data/interfaces/default/current_activity_instance.html b/data/interfaces/default/current_activity_instance.html index fcc1b592..64d6f25e 100644 --- a/data/interfaces/default/current_activity_instance.html +++ b/data/interfaces/default/current_activity_instance.html @@ -74,6 +74,7 @@ DOCUMENTATION :: END parent_href = page('info', data['parent_rating_key']) grandparent_href = page('info', data['grandparent_rating_key']) user_href = page('user', data['user_id']) if data['user_id'] else '#' + library_href = page('library', data['section_id']) if data['section_id'] else '#' season = short_season(data['parent_title']) %>
% if data['live']:
-   + + +  
% elif data['channel_stream'] == 0:
- % if data['media_type'] == 'movie': -   - % elif data['media_type'] == 'episode': -   - % elif data['media_type'] == 'track': -   - % elif data['media_type'] == 'photo': -   - % elif data['media_type'] == 'clip': -   - % endif + + % if data['media_type'] == 'movie': + + % elif data['media_type'] == 'episode': + + % elif data['media_type'] == 'track': + + % elif data['media_type'] == 'photo': + + % elif data['media_type'] == 'clip': + + % else: + + % endif +  
% else:
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