Compare commits

..

19 commits

Author SHA1 Message Date
JonnyWong16
76f6a2da6b
v2.15.2 2025-04-12 16:02:46 -07:00
Tom Niget
d2a14ea6c0
Add hidden-by-default Total curve to the daily stream graph (#2497)
* Add hidden-by-default Total curve to the daily stream graph

* Update curve color

Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>

---------

Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
2025-04-12 15:58:28 -07:00
JonnyWong16
e6c0a12dd5
Add stream count to tab title on homepage
Closes #2517
2025-03-30 20:30:01 -07:00
JonnyWong16
24dd403a72
Activity card only link to library if section_id available 2025-03-29 20:42:53 -07:00
JonnyWong16
a876e006d6
Fix Trakt URL redirect to media page
Fixes #2513
2025-03-29 20:42:44 -07:00
dependabot[bot]
74786f0ed1
Bump cryptography from 43.0.3 to 44.0.2 (#2519)
Bumps [cryptography](https://github.com/pyca/cryptography) from 43.0.3 to 44.0.2.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/43.0.3...44.0.2)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-24 14:05:05 -07:00
dependabot[bot]
99e575383c
Bump pyopenssl from 24.2.1 to 25.0.0 (#2482)
Bumps [pyopenssl](https://github.com/pyca/pyopenssl) from 24.2.1 to 25.0.0.
- [Changelog](https://github.com/pyca/pyopenssl/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/pyopenssl/compare/24.2.1...25.0.0)

---
updated-dependencies:
- dependency-name: pyopenssl
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>

[skip ci]
2025-03-24 14:04:46 -07:00
JonnyWong16
3e784c7495
Check stream watched status before stopped status
Fixes #2506
2025-03-23 20:41:30 -07:00
JonnyWong16
68dc095c83
Do not redirect API requests to login page
Fixes #2490
2025-03-23 20:10:43 -07:00
JonnyWong16
ad2ec0e2bf
Fix CherryPy CORS response headers
Fixes #2279
2025-03-23 19:44:10 -07:00
JonnyWong16
09c28e434d
Check Pushover attachment under 5MB limit
Fixes #2396
2025-03-23 18:12:08 -07:00
JonnyWong16
cfc7b817b3
Downgrade pyinstaller to 6.10.0 2025-03-23 16:19:54 -07:00
JonnyWong16
b3aa29c677
Swap source and stream columns in steam info modal 2025-03-23 16:05:01 -07:00
JonnyWong16
e4d181ba5b
Add PATCH method for webhooks 2025-03-16 12:26:34 -07:00
JonnyWong16
53e5f89725
Add audio profile notification parameters 2025-03-16 12:26:33 -07:00
JonnyWong16
0879b848b9
Add link to library page from activity card media type icon 2025-03-16 12:26:32 -07:00
JonnyWong16
c70381c3ff
Fix ntfy notifications not sending if provider link is blank 2025-03-16 12:26:30 -07:00
JonnyWong16
f23d3eb81c
Fix changelog username 2025-03-16 12:26:28 -07:00
luzpaz
2ed603f288
Fix typos (#2520)
Found via codespell
2025-03-16 12:25:29 -07:00
25 changed files with 432 additions and 76 deletions

View file

@ -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)

View file

@ -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.

View file

@ -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)

View file

@ -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;

View file

@ -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'])
%>
<div class="dashboard-activity-instance" id="activity-instance-${sk}" data-key="${sk}" data-id="${data['session_id']}"
@ -463,21 +464,27 @@ DOCUMENTATION :: END
<div class="dashboard-activity-metadata-subtitle-container">
% if data['live']:
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="Live TV">
<i class="fa fa-fw fa-broadcast-tower"></i>&nbsp;
<a href="${library_href}">
<i class="fa fa-fw fa-broadcast-tower"></i>
</a>&nbsp;
</div>
% elif data['channel_stream'] == 0:
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="${data['media_type'].capitalize()}">
<a href="${library_href}">
% if data['media_type'] == 'movie':
<i class="fa fa-fw fa-film"></i>&nbsp;
<i class="fa fa-fw fa-film"></i>
% elif data['media_type'] == 'episode':
<i class="fa fa-fw fa-television"></i>&nbsp;
<i class="fa fa-fw fa-television"></i>
% elif data['media_type'] == 'track':
<i class="fa fa-fw fa-music"></i>&nbsp;
<i class="fa fa-fw fa-music"></i>
% elif data['media_type'] == 'photo':
<i class="fa fa-fw fa-picture-o"></i>&nbsp;
<i class="fa fa-fw fa-picture-o"></i>
% elif data['media_type'] == 'clip':
<i class="fa fa-fw fa-video-camera"></i>&nbsp;
<i class="fa fa-fw fa-video-camera"></i>
% else:
<i class="fa fa-fw fa-question-circle"></i>
% endif
</a>&nbsp;
</div>
% else:
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="Channel">

View file

@ -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) {

View file

@ -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('<div id="dashboard-no-activity" class="text-muted">Nothing is currently being played.</div>');
document.title = title;
}
activity_ready = true;

View file

@ -68,14 +68,14 @@ DOCUMENTATION :: END
<table class="stream-info" style="margin-top: 0;">
<thead>
<tr>
<th>
</th>
<th class="heading">
Stream Details
</th>
<th></th>
<th class="heading">
Source Details
</th>
<th><i class="fa fa-long-arrow-right"></i></th>
<th class="heading">
Stream Details
</th>
</tr>
</thead>
</table>
@ -85,38 +85,46 @@ DOCUMENTATION :: END
<th>
Media
</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>Bitrate</td>
<td>${data['stream_bitrate']} ${'kbps' if data['stream_bitrate'] else ''}</td>
<td>${data['bitrate']} ${'kbps' if data['bitrate'] else ''}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_bitrate']} ${'kbps' if data['stream_bitrate'] else ''}</td>
</tr>
% if data['media_type'] != 'track':
<tr>
<td>Resolution</td>
<td>${data['stream_video_full_resolution']}</td>
<td>${data['video_full_resolution']}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_video_full_resolution']}</td>
</tr>
% endif
<tr>
<td>Quality</td>
<td>${data['quality_profile']}</td>
<td>-</td>
<td></td>
<td>${data['quality_profile']}</td>
</tr>
% if data['optimized_version'] == 1:
<tr>
<td>Optimized Version</td>
<td>-</td>
<td>${data['optimized_version_profile']}<br>(${data['optimized_version_title']})</td>
<td></td>
<td>-</td>
</tr>
% endif
% if data['synced_version'] == 1:
<tr>
<td>Synced Version</td>
<td>-</td>
<td>${data['synced_version_profile']}</td>
<td></td>
<td>-</td>
</tr>
% endif
</tbody>
@ -127,6 +135,8 @@ DOCUMENTATION :: END
<th>
Container
</th>
<th></th>
<th></th>
<th>
${data['stream_container_decision']}
</th>
@ -135,8 +145,9 @@ DOCUMENTATION :: END
<tbody>
<tr>
<td>Container</td>
<td>${data['stream_container'].upper()}</td>
<td>${data['container'].upper()}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_container'].upper()}</td>
</tr>
</tbody>
</table>
@ -147,6 +158,8 @@ DOCUMENTATION :: END
<th>
Video
</th>
<th></th>
<th></th>
<th>
${data['stream_video_decision']}
</th>
@ -155,38 +168,45 @@ DOCUMENTATION :: END
<tbody>
<tr>
<td>Codec</td>
<td>${data['stream_video_codec'].upper()} ${'(HW)' if data['transcode_hw_encoding'] else ''}</td>
<td>${data['video_codec'].upper()} ${'(HW)' if data['transcode_hw_decoding'] else ''}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_video_codec'].upper()} ${'(HW)' if data['transcode_hw_encoding'] else ''}</td>
</tr>
<tr>
<td>Bitrate</td>
<td>${data['stream_video_bitrate']} ${'kbps' if data['stream_video_bitrate'] else ''}</td>
<td>${data['video_bitrate']} ${'kbps' if data['video_bitrate'] else ''}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_video_bitrate']} ${'kbps' if data['stream_video_bitrate'] else ''}</td>
</tr>
<tr>
<td>Width</td>
<td>${data['stream_video_width']}</td>
<td>${data['video_width']}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_video_width']}</td>
</tr>
<tr>
<td>Height</td>
<td>${data['stream_video_height']}</td>
<td>${data['video_height']}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_video_height']}</td>
</tr>
<tr>
<td>Framerate</td>
<td>${data['stream_video_framerate']}</td>
<td>${data['video_framerate']}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_video_framerate']}</td>
</tr>
<tr>
<td>Dynamic Range</td>
<td>${data['stream_video_dynamic_range']}</td>
<td>${data['video_dynamic_range']}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_video_dynamic_range']}</td>
</tr>
<tr>
<td>Aspect Ratio</td>
<td>-</td>
<td>${data['aspect_ratio']}</td>
<td></td>
<td>-</td>
</tr>
</tbody>
</table>
@ -197,6 +217,8 @@ DOCUMENTATION :: END
<th>
Audio
</th>
<th></th>
<th></th>
<th>
${data['stream_audio_decision']}
</th>
@ -205,23 +227,27 @@ DOCUMENTATION :: END
<tbody>
<tr>
<td>Codec</td>
<td>${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())}</td>
<td>${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())}</td>
</tr>
<tr>
<td>Bitrate</td>
<td>${data['stream_audio_bitrate']} ${'kbps' if data['stream_audio_bitrate'] else ''}</td>
<td>${data['audio_bitrate']} ${'kbps' if data['audio_bitrate'] else ''}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_audio_bitrate']} ${'kbps' if data['stream_audio_bitrate'] else ''}</td>
</tr>
<tr>
<td>Channels</td>
<td>${data['stream_audio_channels']}</td>
<td>${data['audio_channels']}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_audio_channels']}</td>
</tr>
<tr>
<td>Language</td>
<td>-</td>
<td>${data['audio_language'] or 'Unknown'}</td>
<td></td>
<td>-</td>
</tr>
</tbody>
@ -233,6 +259,8 @@ DOCUMENTATION :: END
<th>
Subtitles
</th>
<th></th>
<th></th>
<th>
${'direct play' if data['stream_subtitle_decision'] not in ('transcode', 'copy', 'burn') else data['stream_subtitle_decision']}
</th>
@ -241,19 +269,22 @@ DOCUMENTATION :: END
<tbody>
<tr>
<td>Codec</td>
<td>${data['stream_subtitle_codec'].upper() or '-'}</td>
<td>${data['subtitle_codec'].upper()}</td>
<td><i class="fa fa-long-arrow-right"></i></td>
<td>${data['stream_subtitle_codec'].upper() or '-'}</td>
</tr>
<tr>
<td>Language</td>
<td>-</td>
<td>${data['subtitle_language'] or 'Unknown'}</td>
<td></td>
<td>-</td>
</tr>
% if data['subtitle_forced']:
<tr>
<td>Forced</td>
<td>-</td>
<td>${bool(data['subtitle_forced'])}</td>
<td></td>
<td>-</td>
</tr>
% endif
</tbody>

View file

@ -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)

View file

@ -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"

View file

@ -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()

View file

@ -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)

View file

@ -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.'},

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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}

View file

@ -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,

View file

@ -2667,6 +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")
if self.config['incl_pmslink']:
@ -3390,8 +3391,11 @@ 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))
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'}
}
]

View file

@ -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': ''
}

View file

@ -16,4 +16,4 @@
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
PLEXPY_BRANCH = "master"
PLEXPY_RELEASE_VERSION = "v2.15.1"
PLEXPY_RELEASE_VERSION = "v2.15.2"

View file

@ -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)

View file

@ -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",

View file

@ -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:

View file

@ -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