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 # 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) ## v2.15.1 (2025-01-11)
* Activity: * Activity:
* Fix: Detection of HDR transcodes. (Thanks @chrisdecker08) (#2412, #2466) * Fix: Detection of HDR transcodes. (Thanks @cdecker08) (#2412, #2466)
* Newsletters: * Newsletters:
* Fix: Disable basic authentication for /newsletter and /image endpoints. (#2472) * Fix: Disable basic authentication for /newsletter and /image endpoints. (#2472)
* Exporter: * Exporter:
* New: Added logos to season and episode exports. * New: Added logos to season and episode exports.
* Other: * Other:
* Fix Docker container https health check. * Fix: Docker container https health check.
## v2.15.0 (2024-11-24) ## 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. 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 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. https://shop.highsoft.com/faq/non-commercial#non-commercial-redistribution.

View file

@ -129,7 +129,7 @@ def main():
if args.quiet: if args.quiet:
plexpy.QUIET = True 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 # Require verbose for pre-initilization to see critical errors
logger.initLogger(console=not plexpy.QUIET, log_dir=False, verbose=True) 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 { .stream-info tr:nth-child(even) td {
background-color: rgba(255,255,255,0.010); background-color: rgba(255,255,255,0.010);
} }
.stream-info td:nth-child(3),
.stream-info th:nth-child(3) {
width: 25px;
}
.number-input { .number-input {
margin: 0 !important; margin: 0 !important;
width: 55px !important; width: 55px !important;

View file

@ -74,6 +74,7 @@ DOCUMENTATION :: END
parent_href = page('info', data['parent_rating_key']) parent_href = page('info', data['parent_rating_key'])
grandparent_href = page('info', data['grandparent_rating_key']) grandparent_href = page('info', data['grandparent_rating_key'])
user_href = page('user', data['user_id']) if data['user_id'] else '#' 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']) season = short_season(data['parent_title'])
%> %>
<div class="dashboard-activity-instance" id="activity-instance-${sk}" data-key="${sk}" data-id="${data['session_id']}" <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"> <div class="dashboard-activity-metadata-subtitle-container">
% if data['live']: % if data['live']:
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="Live TV"> <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> </div>
% elif data['channel_stream'] == 0: % elif data['channel_stream'] == 0:
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="${data['media_type'].capitalize()}"> <div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="${data['media_type'].capitalize()}">
% if data['media_type'] == 'movie': <a href="${library_href}">
<i class="fa fa-fw fa-film"></i>&nbsp; % if data['media_type'] == 'movie':
% elif data['media_type'] == 'episode': <i class="fa fa-fw fa-film"></i>
<i class="fa fa-fw fa-television"></i>&nbsp; % elif data['media_type'] == 'episode':
% elif data['media_type'] == 'track': <i class="fa fa-fw fa-television"></i>
<i class="fa fa-fw fa-music"></i>&nbsp; % elif data['media_type'] == 'track':
% elif data['media_type'] == 'photo': <i class="fa fa-fw fa-music"></i>
<i class="fa fa-fw fa-picture-o"></i>&nbsp; % elif data['media_type'] == 'photo':
% elif data['media_type'] == 'clip': <i class="fa fa-fw fa-picture-o"></i>
<i class="fa fa-fw fa-video-camera"></i>&nbsp; % elif data['media_type'] == 'clip':
% endif <i class="fa fa-fw fa-video-camera"></i>
% else:
<i class="fa fa-fw fa-question-circle"></i>
% endif
</a>&nbsp;
</div> </div>
% else: % else:
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="Channel"> <div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="Channel">

View file

@ -301,6 +301,10 @@
return obj; return obj;
}, {}); }, {});
if (!("Total" in chart_visibility)) {
chart_visibility["Total"] = false;
}
return data_series.map(function(s) { return data_series.map(function(s) {
var obj = Object.assign({}, s); var obj = Object.assign({}, s);
obj.visible = (chart_visibility[s.name] !== false); obj.visible = (chart_visibility[s.name] !== false);
@ -327,7 +331,8 @@
'Direct Play': '#E5A00D', 'Direct Play': '#E5A00D',
'Direct Stream': '#FFFFFF', 'Direct Stream': '#FFFFFF',
'Transcode': '#F06464', 'Transcode': '#F06464',
'Max. Concurrent Streams': '#96C83C' 'Max. Concurrent Streams': '#96C83C',
'Total': '#96C83C'
}; };
var series_colors = []; var series_colors = [];
$.each(data_series, function(index, series) { $.each(data_series, function(index, series) {

View file

@ -298,6 +298,8 @@
$('#currentActivityHeader-bandwidth-tooltip').tooltip({ container: 'body', placement: 'right', delay: 50 }); $('#currentActivityHeader-bandwidth-tooltip').tooltip({ container: 'body', placement: 'right', delay: 50 });
var title = document.title;
function getCurrentActivity() { function getCurrentActivity() {
activity_ready = false; activity_ready = false;
@ -368,6 +370,8 @@
$('#currentActivityHeader').show(); $('#currentActivityHeader').show();
document.title = stream_count + ' stream' + (stream_count > 1 ? 's' : '') + ' | ' + title;
sessions.forEach(function (session) { sessions.forEach(function (session) {
var s = (typeof Proxy === "function") ? new Proxy(session, defaultHandler) : session; var s = (typeof Proxy === "function") ? new Proxy(session, defaultHandler) : session;
var key = s.session_key; var key = s.session_key;
@ -600,6 +604,8 @@
} else { } else {
$('#currentActivityHeader').hide(); $('#currentActivityHeader').hide();
$('#currentActivity').html('<div id="dashboard-no-activity" class="text-muted">Nothing is currently being played.</div>'); $('#currentActivity').html('<div id="dashboard-no-activity" class="text-muted">Nothing is currently being played.</div>');
document.title = title;
} }
activity_ready = true; activity_ready = true;

View file

@ -68,14 +68,14 @@ DOCUMENTATION :: END
<table class="stream-info" style="margin-top: 0;"> <table class="stream-info" style="margin-top: 0;">
<thead> <thead>
<tr> <tr>
<th> <th></th>
</th>
<th class="heading">
Stream Details
</th>
<th class="heading"> <th class="heading">
Source Details Source Details
</th> </th>
<th><i class="fa fa-long-arrow-right"></i></th>
<th class="heading">
Stream Details
</th>
</tr> </tr>
</thead> </thead>
</table> </table>
@ -85,38 +85,46 @@ DOCUMENTATION :: END
<th> <th>
Media Media
</th> </th>
<th></th>
<th></th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>Bitrate</td> <td>Bitrate</td>
<td>${data['stream_bitrate']} ${'kbps' if data['stream_bitrate'] else ''}</td>
<td>${data['bitrate']} ${'kbps' if data['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> </tr>
% if data['media_type'] != 'track': % if data['media_type'] != 'track':
<tr> <tr>
<td>Resolution</td> <td>Resolution</td>
<td>${data['stream_video_full_resolution']}</td>
<td>${data['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> </tr>
% endif % endif
<tr> <tr>
<td>Quality</td> <td>Quality</td>
<td>${data['quality_profile']}</td>
<td>-</td> <td>-</td>
<td></td>
<td>${data['quality_profile']}</td>
</tr> </tr>
% if data['optimized_version'] == 1: % if data['optimized_version'] == 1:
<tr> <tr>
<td>Optimized Version</td> <td>Optimized Version</td>
<td>-</td>
<td>${data['optimized_version_profile']}<br>(${data['optimized_version_title']})</td> <td>${data['optimized_version_profile']}<br>(${data['optimized_version_title']})</td>
<td></td>
<td>-</td>
</tr> </tr>
% endif % endif
% if data['synced_version'] == 1: % if data['synced_version'] == 1:
<tr> <tr>
<td>Synced Version</td> <td>Synced Version</td>
<td>-</td>
<td>${data['synced_version_profile']}</td> <td>${data['synced_version_profile']}</td>
<td></td>
<td>-</td>
</tr> </tr>
% endif % endif
</tbody> </tbody>
@ -127,6 +135,8 @@ DOCUMENTATION :: END
<th> <th>
Container Container
</th> </th>
<th></th>
<th></th>
<th> <th>
${data['stream_container_decision']} ${data['stream_container_decision']}
</th> </th>
@ -135,8 +145,9 @@ DOCUMENTATION :: END
<tbody> <tbody>
<tr> <tr>
<td>Container</td> <td>Container</td>
<td>${data['stream_container'].upper()}</td>
<td>${data['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> </tr>
</tbody> </tbody>
</table> </table>
@ -147,6 +158,8 @@ DOCUMENTATION :: END
<th> <th>
Video Video
</th> </th>
<th></th>
<th></th>
<th> <th>
${data['stream_video_decision']} ${data['stream_video_decision']}
</th> </th>
@ -155,38 +168,45 @@ DOCUMENTATION :: END
<tbody> <tbody>
<tr> <tr>
<td>Codec</td> <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>${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>
<tr> <tr>
<td>Bitrate</td> <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>${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>
<tr> <tr>
<td>Width</td> <td>Width</td>
<td>${data['stream_video_width']}</td>
<td>${data['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>
<tr> <tr>
<td>Height</td> <td>Height</td>
<td>${data['stream_video_height']}</td>
<td>${data['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>
<tr> <tr>
<td>Framerate</td> <td>Framerate</td>
<td>${data['stream_video_framerate']}</td>
<td>${data['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>
<tr> <tr>
<td>Dynamic Range</td> <td>Dynamic Range</td>
<td>${data['stream_video_dynamic_range']}</td>
<td>${data['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>
<tr> <tr>
<td>Aspect Ratio</td> <td>Aspect Ratio</td>
<td>-</td>
<td>${data['aspect_ratio']}</td> <td>${data['aspect_ratio']}</td>
<td></td>
<td>-</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -197,6 +217,8 @@ DOCUMENTATION :: END
<th> <th>
Audio Audio
</th> </th>
<th></th>
<th></th>
<th> <th>
${data['stream_audio_decision']} ${data['stream_audio_decision']}
</th> </th>
@ -205,23 +227,27 @@ DOCUMENTATION :: END
<tbody> <tbody>
<tr> <tr>
<td>Codec</td> <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>${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>
<tr> <tr>
<td>Bitrate</td> <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>${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>
<tr> <tr>
<td>Channels</td> <td>Channels</td>
<td>${data['stream_audio_channels']}</td>
<td>${data['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>
<tr> <tr>
<td>Language</td> <td>Language</td>
<td>-</td>
<td>${data['audio_language'] or 'Unknown'}</td> <td>${data['audio_language'] or 'Unknown'}</td>
<td></td>
<td>-</td>
</tr> </tr>
</tbody> </tbody>
@ -233,6 +259,8 @@ DOCUMENTATION :: END
<th> <th>
Subtitles Subtitles
</th> </th>
<th></th>
<th></th>
<th> <th>
${'direct play' if data['stream_subtitle_decision'] not in ('transcode', 'copy', 'burn') else data['stream_subtitle_decision']} ${'direct play' if data['stream_subtitle_decision'] not in ('transcode', 'copy', 'burn') else data['stream_subtitle_decision']}
</th> </th>
@ -241,19 +269,22 @@ DOCUMENTATION :: END
<tbody> <tbody>
<tr> <tr>
<td>Codec</td> <td>Codec</td>
<td>${data['stream_subtitle_codec'].upper() or '-'}</td>
<td>${data['subtitle_codec'].upper()}</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>
<tr> <tr>
<td>Language</td> <td>Language</td>
<td>-</td>
<td>${data['subtitle_language'] or 'Unknown'}</td> <td>${data['subtitle_language'] or 'Unknown'}</td>
<td></td>
<td>-</td>
</tr> </tr>
% if data['subtitle_forced']: % if data['subtitle_forced']:
<tr> <tr>
<td>Forced</td> <td>Forced</td>
<td>-</td>
<td>${bool(data['subtitle_forced'])}</td> <td>${bool(data['subtitle_forced'])}</td>
<td></td>
<td>-</td>
</tr> </tr>
% endif % endif
</tbody> </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 apscheduler==3.10.1
cryptography==43.0.3 cryptography==44.0.2
importlib-metadata==8.5.0 importlib-metadata==8.5.0
importlib-resources==6.4.5 importlib-resources==6.4.5
pyinstaller==6.11.1 pyinstaller==6.10.0
pyopenssl==24.2.1 pyopenssl==25.0.0
pyobjc-core==10.3.1; platform_system == "Darwin" pyobjc-core==10.3.1; platform_system == "Darwin"
pyobjc-framework-Cocoa==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: if self.metadata:
this_guid = self.metadata['guid'] this_guid = self.metadata['guid']
# Check for stream offset notifications
self.check_markers()
self.check_watched()
# Make sure the same item is being played # Make sure the same item is being played
if (self.rating_key == last_rating_key if (self.rating_key == last_rating_key
or self.rating_key == last_rating_key_websocket or self.rating_key == last_rating_key_websocket
@ -354,10 +358,6 @@ class ActivityHandler(object):
self.on_stop(force_stop=True) self.on_stop(force_stop=True)
self.on_start() self.on_start()
# Check for stream offset notifications
self.check_markers()
self.check_watched()
def check_markers(self): def check_markers(self):
# Monitor if the stream has reached the intro or credit marker offsets # Monitor if the stream has reached the intro or credit marker offsets
self.get_metadata() 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) row_id = monitor_process.write_session_history(session=stream)
if row_id: 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" logger.debug("Tautulli Monitor :: Removing sessionKey %s ratingKey %s from session queue"
% (stream['session_key'], stream['rating_key'])) % (stream['session_key'], stream['rating_key']))
monitor_process.delete_session(row_id=row_id) 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 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', '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 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 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 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.'}, {'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 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', '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 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 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 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.'}, {'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): 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: if self.CONFIG_VERSION == 0:
self.CONFIG_VERSION = 1 self.CONFIG_VERSION = 1

View file

@ -283,7 +283,7 @@ def extract_columns(columns=None, match_columns=None):
columns_string = columns_string.rstrip(', ') columns_string = columns_string.rstrip(', ')
# We return a dict of the column params # 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_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. # 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. # 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'): if libraries.has_library_type('live'):
series_output.append(series_4_output) 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, output = {'categories': categories,
'series': series_output} 'series': series_output}
return 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(): if str(mobile_device_id).isdigit():
mobile_device_id = int(mobile_device_id) mobile_device_id = int(mobile_device_id)
else: 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 return False
keys = {'id': mobile_device_id} 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' 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_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['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']: elif 'thetvdbdvdorder://' in notify_params['guid']:
notify_params['thetvdb_id'] = notify_params['guid'].split('thetvdbdvdorder://')[1].split('/')[0].split('?')[0] 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['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 'themoviedb://' in notify_params['guid'] or notify_params['themoviedb_id']:
if notify_params['media_type'] == 'movie': 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_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['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'): 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_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['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']: if 'lastfm://' in notify_params['guid']:
notify_params['lastfm_id'] = '/'.join(notify_params['guid'].split('lastfm://')[1].split('?')[0].split('/')[:2]) 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'): if themoviedb_info.get('imdb_id'):
notify_params['imdb_url'] = 'https://www.imdb.com/title/' + themoviedb_info['imdb_id'] notify_params['imdb_url'] = 'https://www.imdb.com/title/' + themoviedb_info['imdb_id']
if themoviedb_info.get('themoviedb_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') notify_params['themoviedb_id'], 'show' if lookup_media_type == 'tv' else 'movie')
# Get TVmaze info (for tv shows only) # 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'): if tvmaze_info.get('thetvdb_id'):
notify_params['thetvdb_url'] = f'https://thetvdb.com/dereferrer/series/{tvmaze_info["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'): if tvmaze_info.get('imdb_id'):
notify_params['imdb_url'] = 'https://www.imdb.com/title/' + tvmaze_info['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'] 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() now_iso = now.isocalendar()
available_params = { available_params = {
# Global paramaters # Global parameters
'tautulli_version': common.RELEASE, 'tautulli_version': common.RELEASE,
'tautulli_remote': plexpy.CONFIG.GIT_REMOTE, 'tautulli_remote': plexpy.CONFIG.GIT_REMOTE,
'tautulli_branch': plexpy.CONFIG.GIT_BRANCH, '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_sample_rate': notify_params['stream_audio_sample_rate'],
'stream_audio_language': notify_params['stream_audio_language'], 'stream_audio_language': notify_params['stream_audio_language'],
'stream_audio_language_code': notify_params['stream_audio_language_code'], '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_codec': notify_params['stream_subtitle_codec'],
'stream_subtitle_container': notify_params['stream_subtitle_container'], 'stream_subtitle_container': notify_params['stream_subtitle_container'],
'stream_subtitle_format': notify_params['stream_subtitle_format'], '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_sample_rate': notify_params['audio_sample_rate'],
'audio_language': notify_params['audio_language'], 'audio_language': notify_params['audio_language'],
'audio_language_code': notify_params['audio_language_code'], 'audio_language_code': notify_params['audio_language_code'],
'audio_profile': notify_params['audio_profile'],
'subtitle_codec': notify_params['subtitle_codec'], 'subtitle_codec': notify_params['subtitle_codec'],
'subtitle_container': notify_params['subtitle_container'], 'subtitle_container': notify_params['subtitle_container'],
'subtitle_format': notify_params['subtitle_format'], 'subtitle_format': notify_params['subtitle_format'],
@ -1267,7 +1269,7 @@ def build_server_notify_params(notify_action=None, **kwargs):
now_iso = now.isocalendar() now_iso = now.isocalendar()
available_params = { available_params = {
# Global paramaters # Global parameters
'tautulli_version': common.RELEASE, 'tautulli_version': common.RELEASE,
'tautulli_remote': plexpy.CONFIG.GIT_REMOTE, 'tautulli_remote': plexpy.CONFIG.GIT_REMOTE,
'tautulli_branch': plexpy.CONFIG.GIT_BRANCH, 'tautulli_branch': plexpy.CONFIG.GIT_BRANCH,

View file

@ -2667,7 +2667,8 @@ class NTFY(Notifier):
provider_name = pretty_metadata.get_provider_name(provider) provider_name = pretty_metadata.get_provider_name(provider)
provider_link = pretty_metadata.get_provider_link(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']: if self.config['incl_pmslink']:
plex_url = pretty_metadata.get_plex_url() plex_url = pretty_metadata.get_plex_url()
@ -3390,8 +3391,11 @@ class PUSHOVER(Notifier):
image = pretty_metadata.get_image() image = pretty_metadata.get_image()
if image: if image:
files = {'attachment': image} if len(image[1]) <= 5242880: # 5MB max attachment size
headers = {} 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) 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: else:
logger.warn("Tautulli Notifiers :: Cryptography library is missing. " 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.") "Install the library to encrypt the notifications.")
payload = {'app_id': mobile_app._ONESIGNAL_APP_ID, payload = {'app_id': mobile_app._ONESIGNAL_APP_ID,
@ -4460,7 +4464,8 @@ class WEBHOOK(Notifier):
'select_options': {'GET': 'GET', 'select_options': {'GET': 'GET',
'POST': 'POST', 'POST': 'POST',
'PUT': 'PUT', '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_channel_layout_': stream_audio_channel_layouts_,
'stream_audio_language': helpers.get_xml_attr(audio_stream_info, 'language'), '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_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' 'stream_audio_decision': helpers.get_xml_attr(audio_stream_info, 'decision') or 'direct play'
} }
else: else:
@ -2108,6 +2109,7 @@ class PmsConnect(object):
'stream_audio_channel_layout_': '', 'stream_audio_channel_layout_': '',
'stream_audio_language': '', 'stream_audio_language': '',
'stream_audio_language_code': '', 'stream_audio_language_code': '',
'stream_audio_profile': '',
'stream_audio_decision': '' 'stream_audio_decision': ''
} }

View file

@ -16,4 +16,4 @@
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>. # along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
PLEXPY_BRANCH = "master" 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_token = None
user_id = 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: if token:
plex_tv = PlexTV(token=token, headers=headers) plex_tv = PlexTV(token=token, headers=headers)
plex_user = plex_tv.get_plex_account_details() plex_user = plex_tv.get_plex_account_details()
@ -176,7 +176,10 @@ def check_auth(*args, **kwargs):
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
else: 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: if redirect_uri:
redirect_uri = '?redirect_uri=' + quote(redirect_uri) redirect_uri = '?redirect_uri=' + quote(redirect_uri)

View file

@ -608,7 +608,7 @@ class WebInterface(object):
status_message = '' status_message = ''
else: else:
result = None result = None
status_message = 'An error occured.' status_message = 'An error occurred.'
return serve_template(template_name="edit_library.html", title="Edit Library", return serve_template(template_name="edit_library.html", title="Edit Library",
data=result, server_id=plexpy.CONFIG.PMS_IDENTIFIER, status_message=status_message) data=result, server_id=plexpy.CONFIG.PMS_IDENTIFIER, status_message=status_message)
@ -1347,7 +1347,7 @@ class WebInterface(object):
status_message = '' status_message = ''
else: else:
result = None 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) 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 keep_history (int): 0 or 1
allow_guest (int): 0 or 1 allow_guest (int): 0 or 1
Optional paramters: Optional parameters:
None None
Returns: Returns:
@ -3031,7 +3031,7 @@ class WebInterface(object):
""" Delete the Tautulli notification logs. """ Delete the Tautulli notification logs.
``` ```
Required paramters: Required parameters:
None None
Optional parameters: Optional parameters:
@ -3056,7 +3056,7 @@ class WebInterface(object):
""" Delete the Tautulli newsletter logs. """ Delete the Tautulli newsletter logs.
``` ```
Required paramters: Required parameters:
None None
Optional parameters: Optional parameters:
@ -3081,7 +3081,7 @@ class WebInterface(object):
""" Delete the Tautulli login logs. """ Delete the Tautulli login logs.
``` ```
Required paramters: Required parameters:
None None
Optional parameters: Optional parameters:
@ -5921,6 +5921,7 @@ class WebInterface(object):
"stream_audio_decision": "direct play", "stream_audio_decision": "direct play",
"stream_audio_language": "", "stream_audio_language": "",
"stream_audio_language_code": "", "stream_audio_language_code": "",
"stream_audio_profile": "",
"stream_audio_sample_rate": "48000", "stream_audio_sample_rate": "48000",
"stream_bitrate": "10617", "stream_bitrate": "10617",
"stream_container": "mkv", "stream_container": "mkv",

View file

@ -21,6 +21,7 @@ import sys
import cheroot.errors import cheroot.errors
import cherrypy import cherrypy
import cherrypy_cors
import plexpy import plexpy
from plexpy import logger from plexpy import logger
@ -62,6 +63,7 @@ def restart():
def initialize(options): def initialize(options):
cherrypy_cors.install()
# HTTPS stuff stolen from sickbeard # HTTPS stuff stolen from sickbeard
enable_https = options['enable_https'] enable_https = options['enable_https']
@ -91,7 +93,8 @@ def initialize(options):
'server.socket_timeout': 60, 'server.socket_timeout': 60,
'tools.encode.on': True, 'tools.encode.on': True,
'tools.encode.encoding': 'utf-8', 'tools.encode.encoding': 'utf-8',
'tools.decode.on': True 'tools.decode.on': True,
'cors.expose.on': True,
} }
if plexpy.DEV: if plexpy.DEV:

View file

@ -5,6 +5,7 @@ bleach==6.2.0
certifi==2024.8.30 certifi==2024.8.30
cheroot==10.0.1 cheroot==10.0.1
cherrypy==18.10.0 cherrypy==18.10.0
cherrypy-cors==1.7.0
cloudinary==1.41.0 cloudinary==1.41.0
distro==1.9.0 distro==1.9.0
dnspython==2.7.0 dnspython==2.7.0