diff --git a/.github/workflows/publish-installers.yml b/.github/workflows/publish-installers.yml index b4a66960..e3f7fe85 100644 --- a/.github/workflows/publish-installers.yml +++ b/.github/workflows/publish-installers.yml @@ -100,24 +100,6 @@ jobs: name: Tautulli-${{ matrix.os }}-installer path: Tautulli-${{ matrix.os }}-${{ steps.get_version.outputs.RELEASE_VERSION }}-${{ matrix.arch }}.${{ matrix.ext }} - virus-total: - name: VirusTotal Scan - needs: build-installer - if: needs.build-installer.result == 'success' && !contains(github.event.head_commit.message, '[skip ci]') - runs-on: ubuntu-latest - steps: - - name: Download Installers - if: needs.build-installer.result == 'success' - uses: actions/download-artifact@v4 - - - name: Upload to VirusTotal - uses: crazy-max/ghaction-virustotal@v4 - with: - vt_api_key: ${{ secrets.VT_API_KEY }} - files: | - Tautulli-windows-installer/Tautulli-windows-*-x64.exe - Tautulli-macos-installer/Tautulli-macos-*-universal.pkg - release: name: Release Installers needs: build-installer diff --git a/.github/workflows/submit-winget.yml b/.github/workflows/submit-winget.yml index 5385c1c3..aa1c4dec 100644 --- a/.github/workflows/submit-winget.yml +++ b/.github/workflows/submit-winget.yml @@ -23,17 +23,3 @@ jobs: # getting latest wingetcreate file iwr https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe .\wingetcreate.exe update $wingetPackage -s -v $version -u $installerUrl -t $gitToken - - virus-total: - name: VirusTotal Scan - runs-on: ubuntu-latest - steps: - - name: Upload to VirusTotal - uses: crazy-max/ghaction-virustotal@v4 - with: - vt_api_key: ${{ secrets.VT_API_KEY }} - github_token: ${{ secrets.GHACTIONS_TOKEN }} - update_release_body: true - files: | - .exe$ - .pkg$ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cfe9f2c..abd3f8d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,40 +1,5 @@ # 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 @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. - - ## v2.15.0 (2024-11-24) * Notes: diff --git a/Dockerfile b/Dockerfile index 8d8c324b..7a52841f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,4 +25,4 @@ CMD [ "python", "Tautulli.py", "--datadir", "/config" ] ENTRYPOINT [ "./start.sh" ] EXPOSE 8181 -HEALTHCHECK --start-period=90s CMD curl -ILfks https://localhost:8181/status > /dev/null || curl -ILfs http://localhost:8181/status > /dev/null || exit 1 +HEALTHCHECK --start-period=90s CMD curl -ILfSs http://localhost:8181/status > /dev/null || curl -ILfkSs https://localhost:8181/status > /dev/null || exit 1 diff --git a/README.md b/README.md index 37829290..1f24741e 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. Commercial users must licence this software, for more information visit +non-commercial use. Commerical 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 b3cf4736..200cd39d 100755 --- a/Tautulli.py +++ b/Tautulli.py @@ -129,7 +129,7 @@ def main(): if args.quiet: plexpy.QUIET = True - # Do an initial setup of the logger. + # Do an intial 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 5cb5d6bf..2835488d 100644 --- a/data/interfaces/default/css/tautulli.css +++ b/data/interfaces/default/css/tautulli.css @@ -4325,10 +4325,6 @@ 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 64d6f25e..fcc1b592 100644 --- a/data/interfaces/default/current_activity_instance.html +++ b/data/interfaces/default/current_activity_instance.html @@ -74,7 +74,6 @@ 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': - - % else: - - % 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': +   + % endif
% else:
diff --git a/data/interfaces/default/graphs.html b/data/interfaces/default/graphs.html index 006d7ee9..15a96a69 100644 --- a/data/interfaces/default/graphs.html +++ b/data/interfaces/default/graphs.html @@ -301,10 +301,6 @@ 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); @@ -331,8 +327,7 @@ 'Direct Play': '#E5A00D', 'Direct Stream': '#FFFFFF', 'Transcode': '#F06464', - 'Max. Concurrent Streams': '#96C83C', - 'Total': '#96C83C' + 'Max. Concurrent Streams': '#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 ca95be42..582ac93f 100644 --- a/data/interfaces/default/index.html +++ b/data/interfaces/default/index.html @@ -298,8 +298,6 @@ $('#currentActivityHeader-bandwidth-tooltip').tooltip({ container: 'body', placement: 'right', delay: 50 }); - var title = document.title; - function getCurrentActivity() { activity_ready = false; @@ -370,8 +368,6 @@ $('#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; @@ -604,8 +600,6 @@ } 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 a40bc54a..9e42189b 100644 --- a/data/interfaces/default/stream_data.html +++ b/data/interfaces/default/stream_data.html @@ -68,14 +68,14 @@ DOCUMENTATION :: END - - - +
- Source Details + Stream Details + Source Details +
@@ -85,46 +85,38 @@ DOCUMENTATION :: END Media - - - Bitrate - ${data['bitrate']} ${'kbps' if data['bitrate'] else ''} - ${data['stream_bitrate']} ${'kbps' if data['stream_bitrate'] else ''} + ${data['bitrate']} ${'kbps' if data['bitrate'] else ''} % if data['media_type'] != 'track': Resolution - ${data['video_full_resolution']} - ${data['stream_video_full_resolution']} + ${data['video_full_resolution']} % endif Quality - - - ${data['quality_profile']} + - % if data['optimized_version'] == 1: Optimized Version - ${data['optimized_version_profile']}
(${data['optimized_version_title']}) - - + ${data['optimized_version_profile']}
(${data['optimized_version_title']}) % endif % if data['synced_version'] == 1: Synced Version - ${data['synced_version_profile']} - - + ${data['synced_version_profile']} % endif @@ -135,8 +127,6 @@ DOCUMENTATION :: END Container - - ${data['stream_container_decision']} @@ -145,9 +135,8 @@ DOCUMENTATION :: END Container - ${data['container'].upper()} - ${data['stream_container'].upper()} + ${data['container'].upper()} @@ -158,8 +147,6 @@ DOCUMENTATION :: END Video - - ${data['stream_video_decision']} @@ -168,45 +155,38 @@ DOCUMENTATION :: END Codec - ${data['video_codec'].upper()} ${'(HW)' if data['transcode_hw_decoding'] else ''} - ${data['stream_video_codec'].upper()} ${'(HW)' if data['transcode_hw_encoding'] else ''} + ${data['video_codec'].upper()} ${'(HW)' if data['transcode_hw_decoding'] else ''} Bitrate - ${data['video_bitrate']} ${'kbps' if data['video_bitrate'] else ''} - ${data['stream_video_bitrate']} ${'kbps' if data['stream_video_bitrate'] else ''} + ${data['video_bitrate']} ${'kbps' if data['video_bitrate'] else ''} Width - ${data['video_width']} - ${data['stream_video_width']} + ${data['video_width']} Height - ${data['video_height']} - ${data['stream_video_height']} + ${data['video_height']} Framerate - ${data['video_framerate']} - ${data['stream_video_framerate']} + ${data['video_framerate']} Dynamic Range - ${data['video_dynamic_range']} - ${data['stream_video_dynamic_range']} + ${data['video_dynamic_range']} Aspect Ratio - ${data['aspect_ratio']} - - + ${data['aspect_ratio']} @@ -217,8 +197,6 @@ DOCUMENTATION :: END Audio - - ${data['stream_audio_decision']} @@ -227,27 +205,23 @@ DOCUMENTATION :: END Codec - ${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} - ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} + ${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} Bitrate - ${data['audio_bitrate']} ${'kbps' if data['audio_bitrate'] else ''} - ${data['stream_audio_bitrate']} ${'kbps' if data['stream_audio_bitrate'] else ''} + ${data['audio_bitrate']} ${'kbps' if data['audio_bitrate'] else ''} Channels - ${data['audio_channels']} - ${data['stream_audio_channels']} + ${data['audio_channels']} Language - ${data['audio_language'] or 'Unknown'} - - + ${data['audio_language'] or 'Unknown'} @@ -259,8 +233,6 @@ DOCUMENTATION :: END Subtitles - - ${'direct play' if data['stream_subtitle_decision'] not in ('transcode', 'copy', 'burn') else data['stream_subtitle_decision']} @@ -269,22 +241,19 @@ DOCUMENTATION :: END Codec - ${data['subtitle_codec'].upper()} - ${data['stream_subtitle_codec'].upper() or '-'} + ${data['subtitle_codec'].upper()} Language - ${data['subtitle_language'] or 'Unknown'} - - + ${data['subtitle_language'] or 'Unknown'} % if data['subtitle_forced']: Forced - ${bool(data['subtitle_forced'])} - - + ${bool(data['subtitle_forced'])} % endif diff --git a/lib/cherrypy_cors/__init__.py b/lib/cherrypy_cors/__init__.py deleted file mode 100644 index 54003451..00000000 --- a/lib/cherrypy_cors/__init__.py +++ /dev/null @@ -1,255 +0,0 @@ -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/lib/jwt/__init__.py b/lib/jwt/__init__.py index 457a4e35..9d4b6744 100644 --- a/lib/jwt/__init__.py +++ b/lib/jwt/__init__.py @@ -27,7 +27,7 @@ from .exceptions import ( ) from .jwks_client import PyJWKClient -__version__ = "2.10.1" +__version__ = "2.10.0" __title__ = "PyJWT" __description__ = "JSON Web Token implementation in Python" diff --git a/lib/jwt/api_jwt.py b/lib/jwt/api_jwt.py index 3a201436..fa4d5e6f 100644 --- a/lib/jwt/api_jwt.py +++ b/lib/jwt/api_jwt.py @@ -419,11 +419,11 @@ class PyJWT: if "iss" not in payload: raise MissingRequiredClaimError("iss") - if isinstance(issuer, str): - if payload["iss"] != issuer: + if isinstance(issuer, Sequence): + if payload["iss"] not in issuer: raise InvalidIssuerError("Invalid issuer") else: - if payload["iss"] not in issuer: + if payload["iss"] != issuer: raise InvalidIssuerError("Invalid issuer") diff --git a/lib/plexapi/const.py b/lib/plexapi/const.py index bc3e81aa..93f7e034 100644 --- a/lib/plexapi/const.py +++ b/lib/plexapi/const.py @@ -4,6 +4,6 @@ # Library version MAJOR_VERSION = 4 MINOR_VERSION = 16 -PATCH_VERSION = 1 +PATCH_VERSION = 0 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" diff --git a/lib/plexapi/sonos.py b/lib/plexapi/sonos.py index 8f1295f4..14f83d31 100644 --- a/lib/plexapi/sonos.py +++ b/lib/plexapi/sonos.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import requests -from plexapi import CONFIG, X_PLEX_IDENTIFIER, TIMEOUT +from plexapi import CONFIG, X_PLEX_IDENTIFIER from plexapi.client import PlexClient from plexapi.exceptions import BadRequest from plexapi.playqueue import PlayQueue @@ -46,7 +46,7 @@ class PlexSonosClient(PlexClient): _session (obj): Requests session object used to access this client. """ - def __init__(self, account, data, timeout=None): + def __init__(self, account, data): self._data = data self.deviceClass = data.attrib.get("deviceClass") self.machineIdentifier = data.attrib.get("machineIdentifier") @@ -66,7 +66,6 @@ class PlexSonosClient(PlexClient): self._last_call = 0 self._proxyThroughServer = False self._showSecrets = CONFIG.get("log.show_secrets", "").lower() == "true" - self._timeout = timeout or TIMEOUT def playMedia(self, media, offset=0, **params): diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index 9e4201b8..6e811aa4 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -716,7 +716,7 @@ class Show( class Season( Video, AdvancedSettingsMixin, ExtrasMixin, RatingMixin, - ArtMixin, LogoMixin, PosterMixin, ThemeUrlMixin, + ArtMixin, PosterMixin, ThemeUrlMixin, SeasonEditMixins ): """ Represents a single Season. @@ -883,7 +883,7 @@ class Season( class Episode( Video, Playable, ExtrasMixin, RatingMixin, - ArtMixin, LogoMixin, PosterMixin, ThemeUrlMixin, + ArtMixin, PosterMixin, ThemeUrlMixin, EpisodeEditMixins ): """ Represents a single Episode. diff --git a/package/Tautulli.nsi b/package/Tautulli.nsi index 7232c23f..ad09846c 100644 --- a/package/Tautulli.nsi +++ b/package/Tautulli.nsi @@ -7,7 +7,7 @@ !define APP_NAME "Tautulli" !define COMP_NAME "Tautulli" !define WEB_SITE "https://tautulli.com" -!define COPYRIGHT "Tautulli © 2025" +!define COPYRIGHT "Tautulli © 2020" !define DESCRIPTION "Monitor your Plex Media Server" !define APP_ICON "..\dist\Tautulli\data\interfaces\default\images\logo-circle.ico" !define LICENSE_TXT "..\dist\Tautulli\LICENSE" diff --git a/package/requirements-package.txt b/package/requirements-package.txt index 5837f631..141b1a27 100644 --- a/package/requirements-package.txt +++ b/package/requirements-package.txt @@ -1,9 +1,9 @@ apscheduler==3.10.1 -cryptography==44.0.2 +cryptography==43.0.3 importlib-metadata==8.5.0 importlib-resources==6.4.5 -pyinstaller==6.10.0 -pyopenssl==25.0.0 +pyinstaller==6.11.1 +pyopenssl==24.2.1 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 8c10b922..2758133b 100644 --- a/plexpy/activity_handler.py +++ b/plexpy/activity_handler.py @@ -314,10 +314,6 @@ 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 @@ -358,6 +354,10 @@ 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 22688027..9e8487fb 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 database successfully, remove the session from the session table + # If session is written to the databaase 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 d057cee9..d55674ea 100644 --- a/plexpy/common.py +++ b/plexpy/common.py @@ -476,7 +476,6 @@ 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.'}, @@ -607,7 +606,6 @@ 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 3e9862ff..c41e3b59 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -568,7 +568,7 @@ class Config(object): def _upgrade(self): """ - Upgrades config file from previous versions and bumps up config version + Upgrades config file from previous verisions 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 0cc94760..68c76c57 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 separated list of the exact column variables received. + # column_string is a comma seperated 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/exporter.py b/plexpy/exporter.py index 18c20a4e..053dd3bc 100644 --- a/plexpy/exporter.py +++ b/plexpy/exporter.py @@ -40,8 +40,8 @@ class Export(object): MEDIA_TYPES = { 'movie': (True, True, True), 'show': (True, True, True), - 'season': (True, True, True), - 'episode': (False, False, True), + 'season': (True, True, False), + 'episode': (False, False, False), 'artist': (True, True, False), 'album': (True, True, False), 'track': (False, False, False), @@ -533,9 +533,6 @@ class Export(object): 'librarySectionID': None, 'librarySectionKey': None, 'librarySectionTitle': None, - 'logo': lambda o: next((i.url for i in o.images if i.type == 'clearLogo'), None), - 'logoFile': lambda o: self.get_image(o, 'logo'), - 'logoProvider': lambda o: self.get_image_provider(o, 'logo'), 'metadataDirectory': None, 'parentGuid': None, 'parentIndex': None, @@ -629,9 +626,6 @@ class Export(object): 'librarySectionKey': None, 'librarySectionTitle': None, 'locations': None, - 'logo': lambda o: next((i.url for i in o.images if i.type == 'clearLogo'), None), - 'logoFile': lambda o: self.get_image(o, 'logo'), - 'logoProvider': lambda o: self.get_image_provider(o, 'logo'), 'markers': { 'end': None, 'final': None, diff --git a/plexpy/graphs.py b/plexpy/graphs.py index 771b4eaf..70122aee 100644 --- a/plexpy/graphs.py +++ b/plexpy/graphs.py @@ -138,12 +138,6 @@ 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 8ba075d5..f3c835c3 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 existing mobile device: invalid mobile_device_id %s." % mobile_device_id) + logger.error("Tautulli MobileApp :: Unable to set exisiting 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 0e30d6e2..56ac9df6 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'] + '?id_type=show' + notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/' + notify_params['thetvdb_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'] + '?id_type=show' + notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/' + notify_params['thetvdb_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'] + '?id_type=movie' + notify_params['trakt_url'] = 'https://trakt.tv/search/tmdb/' + notify_params['themoviedb_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'] + '?id_type=show' + notify_params['trakt_url'] = 'https://trakt.tv/search/tmdb/' + notify_params['themoviedb_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/{}?id_type={}'.format( + notify_params['trakt_url'] = 'https://trakt.tv/search/tmdb/{}?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']) + '?id_type=show' + notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/{}' + str(notify_params['thetvdb_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 parameters + # Global paramaters 'tautulli_version': common.RELEASE, 'tautulli_remote': plexpy.CONFIG.GIT_REMOTE, 'tautulli_branch': plexpy.CONFIG.GIT_BRANCH, @@ -1082,7 +1082,6 @@ 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'], @@ -1216,7 +1215,6 @@ 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'], @@ -1269,7 +1267,7 @@ def build_server_notify_params(notify_action=None, **kwargs): now_iso = now.isocalendar() available_params = { - # Global parameters + # Global paramaters '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 21002dd2..2029aa01 100644 --- a/plexpy/notifiers.py +++ b/plexpy/notifiers.py @@ -2667,8 +2667,7 @@ class NTFY(Notifier): provider_name = pretty_metadata.get_provider_name(provider) provider_link = pretty_metadata.get_provider_link(provider) - if provider_link: - actions.append(f"view, View on {provider_name}, {provider_link}, clear=true") + actions.append(f"view, View on {provider_name}, {provider_link}, clear=true") if self.config['incl_pmslink']: plex_url = pretty_metadata.get_plex_url() @@ -3391,11 +3390,8 @@ class PUSHOVER(Notifier): image = pretty_metadata.get_image() if image: - 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)) + files = {'attachment': image} + headers = {} return self.make_request('https://api.pushover.net/1/messages.json', headers=headers, data=data, files=files) @@ -4059,7 +4055,7 @@ class TAUTULLIREMOTEAPP(Notifier): } else: logger.warn("Tautulli Notifiers :: Cryptography library is missing. " - "Tautulli Remote app notifications will be sent unencrypted. " + "Tautulli Remote app notifications will be sent unecrypted. " "Install the library to encrypt the notifications.") payload = {'app_id': mobile_app._ONESIGNAL_APP_ID, @@ -4464,8 +4460,7 @@ class WEBHOOK(Notifier): 'select_options': {'GET': 'GET', 'POST': 'POST', 'PUT': 'PUT', - 'DELETE': 'DELETE', - 'PATCH': 'PATCH'} + 'DELETE': 'DELETE'} } ] diff --git a/plexpy/pmsconnect.py b/plexpy/pmsconnect.py index ed115897..2575d2da 100644 --- a/plexpy/pmsconnect.py +++ b/plexpy/pmsconnect.py @@ -2096,7 +2096,6 @@ 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: @@ -2109,7 +2108,6 @@ class PmsConnect(object): 'stream_audio_channel_layout_': '', 'stream_audio_language': '', 'stream_audio_language_code': '', - 'stream_audio_profile': '', 'stream_audio_decision': '' } @@ -3388,10 +3386,10 @@ class PmsConnect(object): def get_dynamic_range(stream): extended_display_title = helpers.get_xml_attr(stream, 'extendedDisplayTitle') bit_depth = helpers.cast_to_int(helpers.get_xml_attr(stream, 'bitDepth')) - color_trc = helpers.get_xml_attr(stream, 'colorTrc') + color_space = helpers.get_xml_attr(stream, 'colorSpace') DOVI_profile = helpers.get_xml_attr(stream, 'DOVIProfile') - HDR = bool(bit_depth > 8 and (color_trc == 'smpte2084' or color_trc == 'arib-std-b67')) + HDR = bool(bit_depth > 8 and 'bt2020' in color_space) DV = bool(DOVI_profile) if not HDR and not DV: diff --git a/plexpy/version.py b/plexpy/version.py index a2a92f99..ae1d3273 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.2" \ No newline at end of file +PLEXPY_RELEASE_VERSION = "v2.15.0" \ No newline at end of file diff --git a/plexpy/webauth.py b/plexpy/webauth.py index 67658d15..05c81a81 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 valid account + # Try to login to Plex.tv to check if the user has a vaild account if token: plex_tv = PlexTV(token=token, headers=headers) plex_user = plex_tv.get_plex_account_details() @@ -176,10 +176,7 @@ def check_auth(*args, **kwargs): raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) else: - if cherrypy.request.headers.get('X-Requested-With') == 'XMLHttpRequest': - raise cherrypy.HTTPError(401) - - redirect_uri = cherrypy.request.path_info + redirect_uri = cherrypy.request.wsgi_environ['REQUEST_URI'] if redirect_uri: redirect_uri = '?redirect_uri=' + quote(redirect_uri) diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 3f8890cc..6ea9aa63 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 occurred.' + status_message = 'An error occured.' 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 occurred.' + status_message = 'An error occured.' 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 parameters: + Optional paramters: None Returns: @@ -3031,7 +3031,7 @@ class WebInterface(object): """ Delete the Tautulli notification logs. ``` - Required parameters: + Required paramters: None Optional parameters: @@ -3056,7 +3056,7 @@ class WebInterface(object): """ Delete the Tautulli newsletter logs. ``` - Required parameters: + Required paramters: None Optional parameters: @@ -3081,7 +3081,7 @@ class WebInterface(object): """ Delete the Tautulli login logs. ``` - Required parameters: + Required paramters: None Optional parameters: @@ -5921,7 +5921,6 @@ 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 323c7e8e..8a6edc6e 100644 --- a/plexpy/webstart.py +++ b/plexpy/webstart.py @@ -21,7 +21,6 @@ import sys import cheroot.errors import cherrypy -import cherrypy_cors import plexpy from plexpy import logger @@ -63,7 +62,6 @@ def restart(): def initialize(options): - cherrypy_cors.install() # HTTPS stuff stolen from sickbeard enable_https = options['enable_https'] @@ -93,8 +91,7 @@ def initialize(options): 'server.socket_timeout': 60, 'tools.encode.on': True, 'tools.encode.encoding': 'utf-8', - 'tools.decode.on': True, - 'cors.expose.on': True, + 'tools.decode.on': True } if plexpy.DEV: @@ -174,12 +171,6 @@ def initialize(options): '/status': { 'tools.auth_basic.on': False }, - '/newsletter': { - 'tools.auth_basic.on': False - }, - '/image': { - 'tools.auth_basic.on': False - }, '/interfaces': { 'tools.staticdir.on': True, 'tools.staticdir.dir': "interfaces", diff --git a/requirements.txt b/requirements.txt index 120b01ac..e6a85bdb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,6 @@ 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 @@ -26,10 +25,10 @@ musicbrainzngs==0.7.1 packaging==24.2 paho-mqtt==2.1.0 platformdirs==4.3.6 -plexapi==4.16.1 +plexapi==4.16.0 portend==3.2.0 profilehooks==1.13.0 -PyJWT==2.10.1 +PyJWT==2.10.0 pyparsing==3.2.0 python-dateutil==2.9.0.post0 python-twitter==3.5