Compare commits
162 Commits
v2.1.15-be
...
v2.1.23-be
Author | SHA1 | Date | |
---|---|---|---|
![]() |
1bce850765 | ||
![]() |
ebe5c3168f | ||
![]() |
6e4fa3ef63 | ||
![]() |
ec7afcdbc4 | ||
![]() |
0f2e25ba72 | ||
![]() |
115b05ee7f | ||
![]() |
85b4116491 | ||
![]() |
863bb4033c | ||
![]() |
92672ddda8 | ||
![]() |
018356b85e | ||
![]() |
d93390f8ed | ||
![]() |
e36be32b8e | ||
![]() |
0e0fb2e2b8 | ||
![]() |
be0144bbe1 | ||
![]() |
0d30df6853 | ||
![]() |
77460f7617 | ||
![]() |
c70cc535e5 | ||
![]() |
16733bbe04 | ||
![]() |
1686b70c1c | ||
![]() |
1ef4fd294a | ||
![]() |
83a4dfc0de | ||
![]() |
2eb82e8732 | ||
![]() |
67f70fab90 | ||
![]() |
fb2362be24 | ||
![]() |
612bf079de | ||
![]() |
a88047eb9c | ||
![]() |
7bdef05a45 | ||
![]() |
1a46e09928 | ||
![]() |
4302c4bc0d | ||
![]() |
3b0f31c112 | ||
![]() |
a976d65e9c | ||
![]() |
40559471cf | ||
![]() |
6bb6e27378 | ||
![]() |
03751abc0e | ||
![]() |
8ab5d88db5 | ||
![]() |
d80919140b | ||
![]() |
1e3a347782 | ||
![]() |
a6e8372d47 | ||
![]() |
ce59692089 | ||
![]() |
df76a02478 | ||
![]() |
a94207691f | ||
![]() |
dbc53ca710 | ||
![]() |
4c9ddbd8b7 | ||
![]() |
045c69f5d8 | ||
![]() |
71ae314c46 | ||
![]() |
c8575bbc0f | ||
![]() |
af3944734f | ||
![]() |
f1b3a6f7b6 | ||
![]() |
8a94f6d63a | ||
![]() |
9b8fb73a7a | ||
![]() |
67c333e86e | ||
![]() |
cfa0b20419 | ||
![]() |
4b2930c890 | ||
![]() |
d98565ea12 | ||
![]() |
471f7c184a | ||
![]() |
3d4a5e6547 | ||
![]() |
382322d5e7 | ||
![]() |
c0ae25611b | ||
![]() |
f025533582 | ||
![]() |
fd28e5183a | ||
![]() |
185099f183 | ||
![]() |
cd6289046e | ||
![]() |
955dc795ff | ||
![]() |
1b772e60a9 | ||
![]() |
c6f4c17a81 | ||
![]() |
1e68a81fe1 | ||
![]() |
4944ce1ca0 | ||
![]() |
f04873446a | ||
![]() |
505b6b616e | ||
![]() |
87dd43d699 | ||
![]() |
a48ebef9ae | ||
![]() |
e40483525b | ||
![]() |
ebc563fd26 | ||
![]() |
5bb3e189fe | ||
![]() |
ed08df5224 | ||
![]() |
ae2584b6f6 | ||
![]() |
f0e2355231 | ||
![]() |
878c48b491 | ||
![]() |
ecfbb4de9b | ||
![]() |
731af75c54 | ||
![]() |
9817da6012 | ||
![]() |
dd3f75f154 | ||
![]() |
1eee03fa8f | ||
![]() |
02af6c4e6c | ||
![]() |
b8a9c4f5b7 | ||
![]() |
0b227dc69e | ||
![]() |
8228018dd0 | ||
![]() |
5c3086a049 | ||
![]() |
4f397b032e | ||
![]() |
3a05b8ec69 | ||
![]() |
84ef02aa03 | ||
![]() |
5d82ed9415 | ||
![]() |
524183c2cb | ||
![]() |
53b361d410 | ||
![]() |
30c7c6592e | ||
![]() |
88b0b888a1 | ||
![]() |
c2dcd98939 | ||
![]() |
56e9845b2c | ||
![]() |
6b94292c7e | ||
![]() |
13dac9c1ea | ||
![]() |
4f4a66f7e7 | ||
![]() |
1bd7cf4d4c | ||
![]() |
b1ec49341e | ||
![]() |
aeccc2db71 | ||
![]() |
6be5397a2d | ||
![]() |
427201a4ce | ||
![]() |
5736e12bc3 | ||
![]() |
4648e3df5f | ||
![]() |
9dbb681f22 | ||
![]() |
658260f1f6 | ||
![]() |
f93e745f0b | ||
![]() |
0821c14aae | ||
![]() |
1b216a35d4 | ||
![]() |
7b4eadb140 | ||
![]() |
a8a676b794 | ||
![]() |
2f40850100 | ||
![]() |
f16560cb40 | ||
![]() |
ab92e48d2e | ||
![]() |
ce2982d948 | ||
![]() |
89d1a5782a | ||
![]() |
97cf2ebe19 | ||
![]() |
4ef36a464a | ||
![]() |
54ec9ad7da | ||
![]() |
bfdfdaaad1 | ||
![]() |
5bd51b2a17 | ||
![]() |
35778cfe72 | ||
![]() |
f81649c4d3 | ||
![]() |
59162713e7 | ||
![]() |
188b728dd0 | ||
![]() |
3446f5543d | ||
![]() |
ab5384cfdf | ||
![]() |
e567134ee1 | ||
![]() |
98b5cb67ca | ||
![]() |
26db7f1984 | ||
![]() |
e1afbd4eff | ||
![]() |
f6090bcdf0 | ||
![]() |
0950ff7ecf | ||
![]() |
e766cb6093 | ||
![]() |
8982ae83ac | ||
![]() |
2b395a7ad9 | ||
![]() |
a9b5c91f84 | ||
![]() |
c0b960bccf | ||
![]() |
8514cf1975 | ||
![]() |
c5e37badd8 | ||
![]() |
c0f1079b4e | ||
![]() |
1da4b8ecb4 | ||
![]() |
c2ba2b4e98 | ||
![]() |
d9ea781462 | ||
![]() |
5ee5ca7dbf | ||
![]() |
176392d837 | ||
![]() |
b65e6a39a0 | ||
![]() |
5c7a3a12e9 | ||
![]() |
e7072edbd1 | ||
![]() |
2711597ffb | ||
![]() |
24458bd23c | ||
![]() |
3fd0708d21 | ||
![]() |
434cb89ba8 | ||
![]() |
745d398527 | ||
![]() |
16bfcade8c | ||
![]() |
f69f5a79d9 | ||
![]() |
3bd1b03faf | ||
![]() |
634e003bb7 |
37
API.md
37
API.md
@@ -1,9 +1,15 @@
|
||||
# API Reference
|
||||
|
||||
The API is still pretty new and needs some serious cleaning up on the backend but should be reasonably functional. There are no error codes yet.
|
||||
|
||||
## General structure
|
||||
The API endpoint is `http://ip:port + HTTP_ROOT + /api/v2?apikey=$apikey&cmd=$command`
|
||||
The API endpoint is
|
||||
```
|
||||
http://IP_ADDRESS:PORT + [/HTTP_ROOT] + /api/v2?apikey=$apikey&cmd=$command
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
http://localhost:8181/api/v2?apikey=66198313a092496b8a725867d2223b5f&cmd=get_metadata&rating_key=153037
|
||||
```
|
||||
|
||||
Response example (default `json`)
|
||||
```
|
||||
@@ -354,7 +360,8 @@ Required parameters:
|
||||
None
|
||||
|
||||
Optional parameters:
|
||||
None
|
||||
session_key (int): Session key for the session info to return, OR
|
||||
session_id (str): Session ID for the session info to return
|
||||
|
||||
Returns:
|
||||
json:
|
||||
@@ -373,6 +380,7 @@ Returns:
|
||||
"art": "/library/metadata/1219/art/1503306930",
|
||||
"aspect_ratio": "1.78",
|
||||
"audience_rating": "",
|
||||
"audience_rating_image": "rottentomatoes://image.rating.upright",
|
||||
"audio_bitrate": "384",
|
||||
"audio_bitrate_mode": "",
|
||||
"audio_channel_layout": "5.1(side)",
|
||||
@@ -449,6 +457,7 @@ Returns:
|
||||
"progress_percent": "0",
|
||||
"quality_profile": "Original",
|
||||
"rating": "7.8",
|
||||
"rating_image": "rottentomatoes://image.rating.ripe",
|
||||
"rating_key": "153037",
|
||||
"relay": 0,
|
||||
"section_id": "2",
|
||||
@@ -724,7 +733,7 @@ Required parameters:
|
||||
Optional parameters:
|
||||
grouping (int): 0 or 1
|
||||
time_range (str): The time range to calculate statistics, '30'
|
||||
stats_type (int): 0 for plays, 1 for duration
|
||||
stats_type (str): plays or duration
|
||||
stats_count (str): The number of top items to list, '5'
|
||||
|
||||
Returns:
|
||||
@@ -1084,6 +1093,7 @@ Returns:
|
||||
"added_at": "1461572396",
|
||||
"art": "/library/metadata/1219/art/1462175063",
|
||||
"audience_rating": "8",
|
||||
"audience_rating_image": "rottentomatoes://image.rating.upright",
|
||||
"banner": "/library/metadata/1219/banner/1462175063",
|
||||
"collections": [],
|
||||
"content_rating": "TV-MA",
|
||||
@@ -1137,7 +1147,8 @@ Returns:
|
||||
"video_language_code": "",
|
||||
"video_profile": "high",
|
||||
"video_ref_frames": "4",
|
||||
"video_width": "1920"
|
||||
"video_width": "1920",
|
||||
"selected": 0
|
||||
},
|
||||
{
|
||||
"audio_bitrate": "384",
|
||||
@@ -1150,7 +1161,8 @@ Returns:
|
||||
"audio_profile": "",
|
||||
"audio_sample_rate": "48000",
|
||||
"id": "511664",
|
||||
"type": "2"
|
||||
"type": "2",
|
||||
"selected": 1
|
||||
},
|
||||
{
|
||||
"id": "511953",
|
||||
@@ -1161,7 +1173,8 @@ Returns:
|
||||
"subtitle_language": "English",
|
||||
"subtitle_language_code": "eng",
|
||||
"subtitle_location": "external",
|
||||
"type": "3"
|
||||
"type": "3",
|
||||
"selected": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1181,6 +1194,7 @@ Returns:
|
||||
"parent_thumb": "/library/metadata/153036/thumb/1462175062",
|
||||
"parent_title": "",
|
||||
"rating": "7.8",
|
||||
"rating_image": "rottentomatoes://image.rating.ripe",
|
||||
"rating_key": "153037",
|
||||
"section_id": "2",
|
||||
"sort_title": "Game of Thrones",
|
||||
@@ -1761,7 +1775,7 @@ Returns:
|
||||
|
||||
|
||||
### get_recently_added
|
||||
Get all items that where recelty added to plex.
|
||||
Get all items that where recently added to plex.
|
||||
|
||||
```
|
||||
Required parameters:
|
||||
@@ -1769,7 +1783,7 @@ Required parameters:
|
||||
|
||||
Optional parameters:
|
||||
start (str): The item number to start at
|
||||
type (str): The media type: movie, show, artist
|
||||
media_type (str): The media type: movie, show, artist
|
||||
section_id (str): The id of the Plex library section
|
||||
|
||||
Returns:
|
||||
@@ -2431,7 +2445,7 @@ Required parameters:
|
||||
body (str): The body of the message
|
||||
|
||||
Optional parameters:
|
||||
None
|
||||
script_args (str): The arguments for script notifications
|
||||
|
||||
Returns:
|
||||
None
|
||||
@@ -2492,6 +2506,7 @@ Optional parameters:
|
||||
img_format (str): png
|
||||
fallback (str): "poster", "cover", "art"
|
||||
refresh (bool): True or False whether to refresh the image cache
|
||||
return_hash (bool): True or False to return the self-hosted image hash instead of the image
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
132
CHANGELOG.md
132
CHANGELOG.md
@@ -1,5 +1,137 @@
|
||||
# Changelog
|
||||
|
||||
## v2.1.23-beta (2018-10-14)
|
||||
|
||||
* Monitoring:
|
||||
* Fix: Buffer events not being triggered properly.
|
||||
* Fix: Watched progress sometimes not saved correctly. (Thanks @Sheigutn)
|
||||
* Notifications:
|
||||
* New: Added notification trigger for transcode decision change.
|
||||
* Fix: Multiple buffer notifications being triggered within the same second.
|
||||
* Change: Default buffer notification threshold changed to 10 for buffer thresholds less than 10.
|
||||
* Newsletter:
|
||||
* New: Added Other Video libraries to the newsletter.
|
||||
* Homepage:
|
||||
* New: Added Other Video type to recently added on the homepage.
|
||||
* Change: Save homepage recently added media type toggle state.
|
||||
* Change: Save homepage stats config to local storage instead of the server.
|
||||
* History:
|
||||
* Change: Save history table media type toggle state.
|
||||
* Graphs:
|
||||
* Change: Save series visibility state when toggling the legend.
|
||||
* Change: Save graph config to local storage instead of the server.
|
||||
* UI:
|
||||
* New: Show the remote app device token and id in the edit device modal.
|
||||
* Change: Lock certain settings if using the Tautulli docker container.
|
||||
* API:
|
||||
* Fix: download_config, download_database, download_log, and download_plex_log API commands not working.
|
||||
* Change: get_recently_added command 'type' parameter renamed to 'media_type'. Backwards compatibility is maintained.
|
||||
* Change: get_home_stats command 'stats_type' parameter change to string 'plays' or 'duration'. Backwards compatibility is maintained.
|
||||
|
||||
|
||||
## v2.1.22 (2018-10-05)
|
||||
|
||||
* Notifications:
|
||||
* Fix: Notification agent settings not loading when failed to retrieve some data.
|
||||
* UI:
|
||||
* Fix: Incorrectly showing localhost server in the setup wizard.
|
||||
* Other:
|
||||
* Fix: Incorrect redirect to HTTP when HTTPS proxy header is present.
|
||||
* Fix: Websocket not connecting automatically after the setup wizard.
|
||||
|
||||
|
||||
## v2.1.21 (2018-09-21)
|
||||
|
||||
* Notifications:
|
||||
* Fix: Content Rating notification condition always evaluating to True. (Thanks @Arcanemagus)
|
||||
* Fix: Script arguments not showing substituted values in the notification logs.
|
||||
* UI:
|
||||
* New: Unsupported browser warning when using IE or Edge.
|
||||
* Fix: Misaligned refresh image icon in album search results. (Thanks @Sheigutn)
|
||||
* Fix: Music history showing as pre-Tautulli in stream info modal.
|
||||
* Other:
|
||||
* Fix: Typo in Systemd init script group value. (Thanks @ldumont)
|
||||
* Fix: Execute permissions in Fedora/CentOS and Systemd init scripts. (Thanks @wilmardo)
|
||||
* Fix: Systemd init script instructions per Linux distro. (Thanks @samwiseg00)
|
||||
* Change: Fallback to Tautulli data directory if logs/backup/cache/newsletter directories are not writable.
|
||||
* Change: Check for alternative reverse proxy headers if X-Forwarded-Host is missing.
|
||||
|
||||
|
||||
## v2.1.20 (2018-09-05)
|
||||
* No changes.
|
||||
|
||||
|
||||
## v2.1.20-beta (2018-09-02)
|
||||
|
||||
* Monitoring:
|
||||
* Fix: Fetch messing season info when "Hide Seasons" is enabled for a show.
|
||||
* Fix: Video and Audio details sometimes missing on activity cards.
|
||||
* Notifications:
|
||||
* New: Added UTC timestamp to notification parameters. (Thanks @samwiseg00)
|
||||
* New: Added TAUTULLI_PUBLIC_URL to script environment variables. (Thanks @samwiseg00)
|
||||
* UI:
|
||||
* Change: Automatically redirect '/' to HTTP root if enabled.
|
||||
* API:
|
||||
* New: Added return_hash parameter to pms_image_proxy command.
|
||||
* New: Added session_id parameter to get_activity command.
|
||||
* Other:
|
||||
* Change: Linux systemd startup script to use the "tautulli" group permission. (Thanks @samwiseg00)
|
||||
|
||||
|
||||
## v2.1.19-beta (2018-08-19)
|
||||
|
||||
* Notifications:
|
||||
* New: Added Webhook notification agent.
|
||||
* Fix: Scripts failing due to unicode characters in substituted script arguments.
|
||||
* Change: Ability to override PYTHONPATH for scripts.
|
||||
* Remove: Notify My Android notification agent.
|
||||
* Newsletters:
|
||||
* New: Added option for threaded newsletter emails.
|
||||
* Fix: Missing space in newsletter format.
|
||||
* UI:
|
||||
* New: Added Windows system tray icon.
|
||||
* Fix: Plex OAuth not working with Plex remote access disabled. (Thanks @samwiseg00)
|
||||
* API:
|
||||
* Fix: SQL command creating a database backup every time. (Thanks @samwiseg00)
|
||||
|
||||
|
||||
## v2.1.18 (2018-07-27)
|
||||
|
||||
* Monitoring:
|
||||
* Fix: Progress bar on activity cards showing incorrect 100% when starting a stream.
|
||||
* Notifications:
|
||||
* Fix: Notification text boxes scrolling to top when inputting text.
|
||||
* Change: Skip formatting invalid notification parameters instead of returning default text.
|
||||
* UI:
|
||||
* Fix: Padding around search bar causing the navigation bar to break on smaller screens.
|
||||
|
||||
|
||||
## v2.1.17-beta (2018-07-22)
|
||||
|
||||
* Notifications:
|
||||
* Change: Use default selected stream for media info in notifications.
|
||||
* UI:
|
||||
* New: Automatically discover localhost Plex servers in server selection dropdown.
|
||||
* Change: Save Datatables state indefinitely.
|
||||
|
||||
|
||||
## v2.1.16-beta (2018-07-06)
|
||||
|
||||
* Monitoring:
|
||||
* Fix: Plex server not detected as down during sudden network loss.
|
||||
* Notifications:
|
||||
* Fix: Incorrect rounding of percentages in some cases.
|
||||
* Fix: Incorrect stream duration value for playback start notifications.
|
||||
* New: Added critic rating parameter for Rotten Tomatoes ratings.
|
||||
* Newsletters:
|
||||
* Fix: Typo in "seasons" when there is only one additional season.
|
||||
* UI:
|
||||
* New: Added ability to use Plex OAuth to login to Tautulli.
|
||||
* API:
|
||||
* Fix: Unicode characters causing get_logs command to return bad data.
|
||||
* New: Added rating_image and audience_rating_image to get_activity and get_metadata commands.
|
||||
|
||||
|
||||
## v2.1.15-beta (2018-07-01)
|
||||
|
||||
* Monitoring:
|
||||
|
19
Tautulli.py
19
Tautulli.py
@@ -106,6 +106,9 @@ def main():
|
||||
logger.initLogger(console=not plexpy.QUIET, log_dir=False,
|
||||
verbose=plexpy.VERBOSE)
|
||||
|
||||
if os.getenv('TAUTULLI_DOCKER', False) == 'True':
|
||||
plexpy.DOCKER = True
|
||||
|
||||
if args.dev:
|
||||
plexpy.DEV = True
|
||||
logger.debug(u"Tautulli is running in the dev environment.")
|
||||
@@ -204,10 +207,10 @@ def main():
|
||||
|
||||
# Force the http port if neccessary
|
||||
if args.port:
|
||||
http_port = args.port
|
||||
logger.info('Using forced web server port: %i', http_port)
|
||||
plexpy.HTTP_PORT = args.port
|
||||
logger.info('Using forced web server port: %i', plexpy.HTTP_PORT)
|
||||
else:
|
||||
http_port = int(plexpy.CONFIG.HTTP_PORT)
|
||||
plexpy.HTTP_PORT = int(plexpy.CONFIG.HTTP_PORT)
|
||||
|
||||
# Check if pyOpenSSL is installed. It is required for certificate generation
|
||||
# and for CherryPy.
|
||||
@@ -221,7 +224,7 @@ def main():
|
||||
|
||||
# Try to start the server. Will exit here is address is already in use.
|
||||
web_config = {
|
||||
'http_port': http_port,
|
||||
'http_port': plexpy.HTTP_PORT,
|
||||
'http_host': plexpy.CONFIG.HTTP_HOST,
|
||||
'http_root': plexpy.CONFIG.HTTP_ROOT,
|
||||
'http_environment': plexpy.CONFIG.HTTP_ENVIRONMENT,
|
||||
@@ -238,8 +241,12 @@ def main():
|
||||
|
||||
# Open webbrowser
|
||||
if plexpy.CONFIG.LAUNCH_BROWSER and not args.nolaunch and not plexpy.DEV:
|
||||
plexpy.launch_browser(plexpy.CONFIG.HTTP_HOST, http_port,
|
||||
plexpy.CONFIG.HTTP_ROOT)
|
||||
plexpy.launch_browser(plexpy.CONFIG.HTTP_HOST, plexpy.HTTP_PORT,
|
||||
plexpy.HTTP_ROOT)
|
||||
|
||||
# Windows system tray icon
|
||||
if os.name == 'nt' and plexpy.CONFIG.WIN_SYS_TRAY:
|
||||
plexpy.win_system_tray()
|
||||
|
||||
# Wait endlessy for a signal to happen
|
||||
while True:
|
||||
|
@@ -43,18 +43,18 @@
|
||||
<div class="container">
|
||||
<div id="ajaxMsg" class="ajaxMsg"></div>
|
||||
% if _session['user_group'] == 'admin':
|
||||
% if plexpy.CONFIG.CHECK_GITHUB and not plexpy.CURRENT_VERSION:
|
||||
% if plexpy.CONFIG.CHECK_GITHUB and plexpy.UPDATE_AVAILABLE is None:
|
||||
<div id="updatebar" style="display: none;">
|
||||
You are running an unknown version of Tautulli.<br />
|
||||
<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>
|
||||
</div>
|
||||
% elif plexpy.CONFIG.CHECK_GITHUB and plexpy.COMMITS_BEHIND > 0 and plexpy.common.BRANCH in ('master', 'beta') and plexpy.common.RELEASE != plexpy.LATEST_RELEASE:
|
||||
% elif plexpy.CONFIG.CHECK_GITHUB and plexpy.UPDATE_AVAILABLE == 'release':
|
||||
<div id="updatebar" style="display: none;">
|
||||
A <a href="${anon_url('https://github.com/%s/%s/releases/tag/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.LATEST_RELEASE))}" target="_blank">
|
||||
new release (${plexpy.LATEST_RELEASE})</a> of Tautulli is available!<br />
|
||||
<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>
|
||||
</div>
|
||||
% elif plexpy.CONFIG.CHECK_GITHUB and plexpy.COMMITS_BEHIND > 0 and plexpy.CURRENT_VERSION != plexpy.LATEST_VERSION and plexpy.INSTALL_TYPE != 'win':
|
||||
% elif plexpy.CONFIG.CHECK_GITHUB and plexpy.UPDATE_AVAILABLE == 'commit':
|
||||
<div id="updatebar" style="display: none;">
|
||||
A <a href="${anon_url('https://github.com/%s/%s/compare/%s...%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CURRENT_VERSION, plexpy.LATEST_VERSION))}" target="_blank">
|
||||
newer version</a> of Tautulli is available!<br />
|
||||
@@ -75,7 +75,7 @@
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="home" title="Tautulli">
|
||||
<img src="${http_root}images/logo-tautulli-45.png" height="45" alt="PlexPy">
|
||||
<img src="${http_root}images/logo-tautulli-45.png" height="45" alt="Tautulli">
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse navbar-collapse navbar-right" id="navbar-collapse-1">
|
||||
@@ -140,7 +140,7 @@
|
||||
<li><a href="#" data-target="#donate-modal" data-toggle="modal"><i class="fa fa-fw fa-heart"></i> Donate</a></li>
|
||||
<li role="separator" class="divider"></li>
|
||||
% if plexpy.CONFIG.CHECK_GITHUB:
|
||||
<li><a href="#" id="nav-update"><i class="fa fa-fw fa-arrow-circle-up"></i> Check for Updates</a></li>
|
||||
<li><a href="#" id="nav-update"><i class="fa fa-fw fa-arrow-alt-circle-up"></i> Check for Updates</a></li>
|
||||
% endif
|
||||
<li><a href="#" id="nav-restart"><i class="fa fa-fw fa-refresh"></i> Restart</a></li>
|
||||
<li><a href="#" id="nav-shutdown"><i class="fa fa-fw fa-power-off"></i> Shutdown</a></li>
|
||||
@@ -291,6 +291,7 @@ ${next.modalIncludes()}
|
||||
<script src="${http_root}js/bootstrap.min.js"></script>
|
||||
<script src="${http_root}js/bootstrap-hover-dropdown.min.js"></script>
|
||||
<script src="${http_root}js/pnotify.custom.min.js"></script>
|
||||
<script src="${http_root}js/platform.min.js"></script>
|
||||
<script src="${http_root}js/script.js${cache_param}"></script>
|
||||
<script src="${http_root}js/jquery.qrcode.min.js"></script>
|
||||
<script src="${http_root}js/jquery.tripleclick.min.js"></script>
|
||||
@@ -361,7 +362,7 @@ ${next.modalIncludes()}
|
||||
|
||||
$('#nav-update').click(function () {
|
||||
$(this).html('<i class="fa fa-fw fa-spin fa-refresh"></i> Checking');
|
||||
checkUpdate(function () { $('#nav-update').html('<i class="fa fa-fw fa-arrow-circle-up"></i> Check for Updates'); });
|
||||
checkUpdate(function () { $('#nav-update').html('<i class="fa fa-fw fa-arrow-alt-circle-up"></i> Check for Updates'); });
|
||||
});
|
||||
|
||||
$('#donation_type a.crypto-donation').on('shown.bs.tab', function () {
|
||||
|
@@ -22,11 +22,11 @@ DOCUMENTATION :: END
|
||||
% if plexpy.CURRENT_VERSION:
|
||||
<tr>
|
||||
<td>Git Branch:</td>
|
||||
<td><a class="no-highlight" href="${anon_url('https://github.com/%s/%s/tree/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CONFIG.GIT_BRANCH))}">${plexpy.CONFIG.GIT_BRANCH}</a></td>
|
||||
<td><a class="no-highlight" href="${anon_url('https://github.com/%s/%s/tree/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CONFIG.GIT_BRANCH))}" target="_blank">${plexpy.CONFIG.GIT_BRANCH}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Git Commit Hash:</td>
|
||||
<td><a class="no-highlight" href="${anon_url('https://github.com/%s/%s/commit/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CURRENT_VERSION))}">${plexpy.CURRENT_VERSION}</a></td>
|
||||
<td><a class="no-highlight" href="${anon_url('https://github.com/%s/%s/commit/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CURRENT_VERSION))}" target="_blank">${plexpy.CURRENT_VERSION}</a></td>
|
||||
</tr>
|
||||
% endif
|
||||
<tr>
|
||||
|
@@ -101,6 +101,9 @@ select.form-control {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wizard-input-section p.welcome-message {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.wizard-input-section .selectize-control.form-control.selectize-pms-ip .selectize-input > div {
|
||||
max-width: 360px;
|
||||
overflow: hidden;
|
||||
@@ -673,7 +676,9 @@ textarea.form-control:focus {
|
||||
color: #F9AA03;
|
||||
margin: 5px 40px 5px 0;
|
||||
}
|
||||
.form-control[readonly] {
|
||||
.form-control[disabled],
|
||||
.form-control[readonly],
|
||||
fieldset[disabled] .form-control {
|
||||
background-color: #555;
|
||||
}
|
||||
.form-control[readonly]:focus {
|
||||
@@ -2148,6 +2153,10 @@ div.advanced-setting {
|
||||
li.advanced-setting {
|
||||
border-left: 1px solid #cc7b19;
|
||||
}
|
||||
.docker-setting {
|
||||
color: #cc7b19;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.user-info-wrapper {
|
||||
}
|
||||
.user-info-poster-face {
|
||||
@@ -2970,10 +2979,13 @@ a .home-platforms-list-cover-face:hover
|
||||
-o-transition: all 0.3s ease;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.accordion li .link:hover {
|
||||
color: #fff;
|
||||
.accordion li .link:hover,
|
||||
.accordion li .link:hover i.fa {
|
||||
background: #2f2f2f;
|
||||
}
|
||||
.accordion li .link i.fa {
|
||||
color: #999;
|
||||
}
|
||||
.accordion li .link span.toggle-right {
|
||||
float: right;
|
||||
padding-left: 10px;
|
||||
@@ -2987,7 +2999,8 @@ a .home-platforms-list-cover-face:hover
|
||||
-o-transition: all 0.3s ease;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.accordion li.open .link {
|
||||
.accordion li.open .link,
|
||||
.accordion li.open .link i.fa {
|
||||
color: #f9be03;
|
||||
}
|
||||
.accordion li.open .fa-chevron-down {
|
||||
@@ -3281,7 +3294,7 @@ pre::-webkit-scrollbar-thumb {
|
||||
}
|
||||
}
|
||||
#search_form {
|
||||
width: 300px;
|
||||
width: 270px;
|
||||
padding: 8px 15px;
|
||||
}
|
||||
#search_form span.input-textbox {
|
||||
@@ -3470,6 +3483,9 @@ a.no-highlight:hover {
|
||||
max-width: 1170px;
|
||||
}
|
||||
}
|
||||
.login-body-container {
|
||||
margin: 50px 0;
|
||||
}
|
||||
.login-container {
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
@@ -3483,6 +3499,11 @@ a.no-highlight:hover {
|
||||
margin: 0 auto 50px auto;
|
||||
text-align: center;
|
||||
}
|
||||
.login-container .login-method-header {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.login-container .form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -3503,8 +3524,9 @@ a.no-highlight:hover {
|
||||
text-shadow: 0 -1px 1px rgba(0,0,0,.4),0 0 15px rgba(0,0,0,.2);
|
||||
}
|
||||
.login-container .remember-group {
|
||||
float: left;
|
||||
color: #999;
|
||||
display: inline-block;
|
||||
margin-top: 7.5px;
|
||||
}
|
||||
.login-container .remember-group .control-label {
|
||||
display: inline;
|
||||
@@ -3512,6 +3534,33 @@ a.no-highlight:hover {
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-divider {
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #555;
|
||||
line-height: 0.1em;
|
||||
margin: 50px auto;
|
||||
max-width: 400px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.login-divider span {
|
||||
background: #1f1f1f;
|
||||
padding: 0 15px;
|
||||
color: #999;
|
||||
}
|
||||
.login-button-plex {
|
||||
text-align: center;
|
||||
}
|
||||
.login-button-plex .remember-group {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.login-button-plex button#sign-in-plex {
|
||||
float: none;
|
||||
}
|
||||
.login-alert {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
display: none;
|
||||
}
|
||||
#admin-login-modal .form-group label {
|
||||
font-weight: 400;
|
||||
color: #999;
|
||||
@@ -4119,4 +4168,16 @@ a[data-tab-destination] {
|
||||
}
|
||||
.fa-blank {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
#browser-warning {
|
||||
height: 25px;
|
||||
width: 100%;
|
||||
background: #cc7b19;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
padding-top: 2px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
@@ -21,137 +21,109 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="btn-group" style="margin-right: 2px;" data-toggle="buttons" id="yaxis-selection">
|
||||
% if config['graph_type'] == 'duration':
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" name="yaxis-options" id="yaxis-count" value="plays" autocomplete="off"> Play Count
|
||||
</label>
|
||||
<label class="btn btn-dark active">
|
||||
<input type="radio" name="yaxis-options" id="yaxis-duration" value="duration" autocomplete="off" checked> Play Duration
|
||||
</label>
|
||||
% else:
|
||||
<label class="btn btn-dark active">
|
||||
<input type="radio" name="yaxis-options" id="yaxis-count" value="plays" autocomplete="off" checked> Play Count
|
||||
<input type="radio" name="yaxis-options" id="yaxis-plays" value="plays" autocomplete="off"> Play Count
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" name="yaxis-options" id="yaxis-duration" value="duration" autocomplete="off"> Play Duration
|
||||
</label>
|
||||
% endif
|
||||
</div>
|
||||
<div class="input-group pull-right" style="width: 1px;" id="days-selection">
|
||||
<span class="input-group-addon btn-dark inactive">Last</span>
|
||||
<input type="number" class="form-control number-input" name="graph-days" id="graph-days" value="${config['graph_days']}" min="1" data-default="7" data-toggle="tooltip" title="Min: 1 day" />
|
||||
<input type="number" class="form-control number-input" name="graph-days" id="graph-days" value="30" min="1" data-default="7" data-toggle="tooltip" title="Min: 1 day" />
|
||||
<span class="input-group-addon btn-dark inactive">days</span>
|
||||
</div>
|
||||
<div class="input-group pull-right" style="width: 1px;" id="months-selection">
|
||||
<span class="input-group-addon btn-dark inactive">Last</span>
|
||||
<input type="number" class="form-control number-input" name="graph-months" id="graph-months" value="${config['graph_months']}" min="1" data-default="12" data-toggle="tooltip" title="Min: 1 month" />
|
||||
<input type="number" class="form-control number-input" name="graph-months" id="graph-months" value="12" min="1" data-default="12" data-toggle="tooltip" title="Min: 1 month" />
|
||||
<span class="input-group-addon btn-dark inactive">months</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='table-card-back'>
|
||||
<ul class="nav nav-pills" role="tablist" id="graph-tabs">
|
||||
% if config['graph_tab'] == 'tabs-3':
|
||||
<li role="presentation"><a href="#tabs-1" aria-controls="tabs-1" data-toggle="tab" role="tab">Plays by period</a></li>
|
||||
<li role="presentation"><a href="#tabs-2" aria-controls="tabs-2" data-toggle="tab" role="tab">Stream Info</a></li>
|
||||
<li role="presentation" class="active"><a href="#tabs-3" aria-controls="tabs-3" data-toggle="tab" role="tab">Play Totals</a></li>
|
||||
% elif config['graph_tab'] == 'tabs-2':
|
||||
<li role="presentation"><a href="#tabs-1" aria-controls="tabs-1" data-toggle="tab" role="tab">Plays by period</a></li>
|
||||
<li role="presentation" class="active"><a href="#tabs-2" aria-controls="tabs-2" data-toggle="tab" role="tab">Stream Info</a></li>
|
||||
<li role="presentation"><a href="#tabs-3" aria-controls="tabs-3" data-toggle="tab" role="tab">Play Totals</a></li>
|
||||
% else:
|
||||
<li role="presentation" class="active"><a href="#tabs-1" aria-controls="tabs-1" data-toggle="tab" role="tab">Plays by period</a></li>
|
||||
<li role="presentation"><a href="#tabs-1" aria-controls="tabs-1" data-toggle="tab" role="tab">Plays by Period</a></li>
|
||||
<li role="presentation"><a href="#tabs-2" aria-controls="tabs-2" data-toggle="tab" role="tab">Stream Info</a></li>
|
||||
<li role="presentation"><a href="#tabs-3" aria-controls="tabs-3" data-toggle="tab" role="tab">Play Totals</a></li>
|
||||
% endif
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
% if config['graph_tab'] != 'tabs-2' and config['graph_tab'] != 'tabs-3':
|
||||
<div role="tabpanel" class="tab-pane active" id="tabs-1">
|
||||
% else:
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-1">
|
||||
% endif
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h4><i class="fa fa-history"></i> Daily <span class="yaxis-text">Play count</span> <small>Last <span class="days">30</span> days</small></h4>
|
||||
<p class="help-block">
|
||||
The total play count or duration of tv, movies, and music played per day. Click a graph point to open up a list of items played for that specific date.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="chart_div_plays_by_day">
|
||||
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...</div>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h4><i class="fa fa-calendar"></i> <span class="yaxis-text">Play count</span> by day of week <small>Last <span class="days">30</span> days</small></h4>
|
||||
<p class="help-block">
|
||||
The combined total of tv, movies, and music played per day of the week.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="chart_div_plays_by_dayofweek" style="float: left;">
|
||||
<div class="graphs-load">
|
||||
<i class="fa fa-refresh fa-spin"></i> Loading chart...
|
||||
</div>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h4><i class="fa fa-clock-o"></i> <span class="yaxis-text">Play count</span> by hour of day <small>Last <span class="days">30</span> days</small></h4>
|
||||
<p class="help-block">
|
||||
The combined total of tv, movies, and music played per hour of the day.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="chart_div_plays_by_hourofday">
|
||||
<div class="graphs-load">
|
||||
<i class="fa fa-refresh fa-spin"></i> Loading chart...
|
||||
</div>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h4><i class="fa fa-television"></i> <span class="yaxis-text">Play count</span> by top 10 platforms <small>Last <span class="days">30</span> days</small></h4>
|
||||
<p class="help-block">
|
||||
The combined total of tv, movies, and music played by top 10 most active platforms.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="chart_div_plays_by_platform" style="float: left;">
|
||||
<div class="graphs-load">
|
||||
<i class="fa fa-refresh fa-spin"></i> Loading chart...
|
||||
</div>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h4><i class="fa fa-user"></i> <span class="yaxis-text">Play count</span> by top 10 users <small>Last <span class="days">30</span> days</small></h4>
|
||||
<p class="help-block">
|
||||
The combined total of tv, movies, and music played by top 10 most active users.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="chart_div_plays_by_user">
|
||||
<div class="graphs-load">
|
||||
<i class="fa fa-refresh fa-spin"></i> Loading chart...
|
||||
</div>
|
||||
<br>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h4><i class="fa fa-history"></i> Daily <span class="yaxis-text">Play count</span> <small>Last <span class="days">30</span> days</small></h4>
|
||||
<p class="help-block">
|
||||
The total play count or duration of tv, movies, and music played per day. Click a graph point to open up a list of items played for that specific date.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="graph_plays_by_day">
|
||||
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...</div>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
% if config['graph_tab'] == 'tabs-2':
|
||||
<div role="tabpanel" class="tab-pane active" id="tabs-2">
|
||||
% else:
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h4><i class="fa fa-calendar"></i> <span class="yaxis-text">Play count</span> by day of week <small>Last <span class="days">30</span> days</small></h4>
|
||||
<p class="help-block">
|
||||
The combined total of tv, movies, and music played per day of the week.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="graph_plays_by_dayofweek" style="float: left;">
|
||||
<div class="graphs-load">
|
||||
<i class="fa fa-refresh fa-spin"></i> Loading chart...
|
||||
</div>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h4><i class="fa fa-clock-o"></i> <span class="yaxis-text">Play count</span> by hour of day <small>Last <span class="days">30</span> days</small></h4>
|
||||
<p class="help-block">
|
||||
The combined total of tv, movies, and music played per hour of the day.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="graph_plays_by_hourofday">
|
||||
<div class="graphs-load">
|
||||
<i class="fa fa-refresh fa-spin"></i> Loading chart...
|
||||
</div>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h4><i class="fa fa-television"></i> <span class="yaxis-text">Play count</span> by top 10 platforms <small>Last <span class="days">30</span> days</small></h4>
|
||||
<p class="help-block">
|
||||
The combined total of tv, movies, and music played by top 10 most active platforms.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="graph_plays_by_platform" style="float: left;">
|
||||
<div class="graphs-load">
|
||||
<i class="fa fa-refresh fa-spin"></i> Loading chart...
|
||||
</div>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h4><i class="fa fa-user"></i> <span class="yaxis-text">Play count</span> by top 10 users <small>Last <span class="days">30</span> days</small></h4>
|
||||
<p class="help-block">
|
||||
The combined total of tv, movies, and music played by top 10 most active users.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="graph_plays_by_user">
|
||||
<div class="graphs-load">
|
||||
<i class="fa fa-refresh fa-spin"></i> Loading chart...
|
||||
</div>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-2">
|
||||
% endif
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h4><i class="fa fa-video-camera"></i> Daily Stream type breakdown <small>Last <span class="days">30</span> days</small></h4>
|
||||
@@ -159,7 +131,7 @@
|
||||
The total play count or duration of tv, movies, and music by the transcode decision. Click a graph point to open up a list of items played for that specific date.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="chart_div_plays_by_stream_type">
|
||||
<div class="watch-chart" id="graph_plays_by_stream_type">
|
||||
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...</div>
|
||||
<br>
|
||||
</div>
|
||||
@@ -173,7 +145,7 @@
|
||||
The combined total of tv and movies by their original resolution (pre-transcoding).
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="chart_div_plays_by_source_resolution" style="float: left;">
|
||||
<div class="watch-chart" id="graph_plays_by_source_resolution" style="float: left;">
|
||||
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...
|
||||
</div>
|
||||
<br>
|
||||
@@ -186,7 +158,7 @@
|
||||
The combined total of tv and movies by their streamed resolution (post-transcoding).
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="chart_div_plays_by_stream_resolution">
|
||||
<div class="watch-chart" id="graph_plays_by_stream_resolution">
|
||||
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...
|
||||
</div>
|
||||
<br>
|
||||
@@ -201,7 +173,7 @@
|
||||
The combined total of tv, movies, and music by platform and stream type.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="chart_div_plays_by_platform_by_stream_type" style="float: left;">
|
||||
<div class="watch-chart" id="graph_plays_by_platform_by_stream_type" style="float: left;">
|
||||
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...
|
||||
</div>
|
||||
<br>
|
||||
@@ -214,7 +186,7 @@
|
||||
The combined total of tv, movies, and music by user and stream type.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="chart_div_plays_by_user_by_stream_type" style="float: left;">
|
||||
<div class="watch-chart" id="graph_plays_by_user_by_stream_type" style="float: left;">
|
||||
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...
|
||||
</div>
|
||||
<br>
|
||||
@@ -223,12 +195,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
% if config['graph_tab'] == 'tabs-3':
|
||||
<div role="tabpanel" class="tab-pane active" id="tabs-3">
|
||||
% else:
|
||||
<div role="tabpanel" class="tab-pane" id="tabs-3">
|
||||
% endif
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h4><i class="fa fa-calendar"></i> Plays by month <small>Last <span class="months">12</span> months</small></h4>
|
||||
@@ -236,7 +203,7 @@
|
||||
The combined total of tv, movies, and music by month.
|
||||
</p>
|
||||
<div class="graphs-instance">
|
||||
<div class="watch-chart" id="chart_div_plays_by_month">
|
||||
<div class="watch-chart" id="graph_plays_by_month">
|
||||
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...
|
||||
</div>
|
||||
<br>
|
||||
@@ -266,7 +233,7 @@
|
||||
<script src="${http_root}js/dataTables.bootstrap.pagination.js"></script>
|
||||
|
||||
<script>
|
||||
var selected_user_id = null
|
||||
var selected_user_id = null;
|
||||
|
||||
// Modal popup dialog
|
||||
function selectHandler(selectedDate, selectedSeries) {
|
||||
@@ -311,6 +278,32 @@
|
||||
console.log("Failed to retrieve history modal data.");
|
||||
}
|
||||
}
|
||||
|
||||
function getGraphVisibility(chart_name, data_series) {
|
||||
var chart_key = 'HighCharts_' + chart_name;
|
||||
var chart_visibility = JSON.parse(getLocalStorage(chart_key, null)) || [];
|
||||
|
||||
chart_visibility = chart_visibility.reduce(function(obj, s) {
|
||||
obj[s.name] = s.visible;
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
return data_series.map(function(s) {
|
||||
var obj = Object.assign({}, s);
|
||||
obj.visible = (chart_visibility[s.name] !== false);
|
||||
return obj
|
||||
});
|
||||
}
|
||||
|
||||
function setGraphVisibility(chart_name, data_series, series_name) {
|
||||
var chart_key = 'HighCharts_' + chart_name;
|
||||
|
||||
var chart_visibility = data_series.map(function(s) {
|
||||
return {name: s.name, visible: (s.name === series_name) ? !s.visible : s.visible}
|
||||
});
|
||||
|
||||
setLocalStorage(chart_key, JSON.stringify(chart_visibility));
|
||||
}
|
||||
</script>
|
||||
<script src="${http_root}js/graphs/plays_by_day.js"></script>
|
||||
<script src="${http_root}js/graphs/plays_by_dayofweek.js"></script>
|
||||
@@ -326,12 +319,20 @@
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
|
||||
// Initial values for graph from config
|
||||
var yaxis = "${config['graph_type']}";
|
||||
var current_day_range = ${config['graph_days']};
|
||||
var current_month_range = ${config['graph_months']};
|
||||
var current_tab = "${'#' + config['graph_tab']}";
|
||||
|
||||
// Initial values for graph from local storage
|
||||
var yaxis = getLocalStorage('graph_type', 'plays');
|
||||
var current_day_range = getLocalStorage('graph_days', 30);
|
||||
var current_month_range = getLocalStorage('graph_months', 12);
|
||||
var current_tab = '#' + getLocalStorage('graph_tab', 'tabs-1');
|
||||
|
||||
$('#yaxis-' + yaxis).prop('checked', true);
|
||||
$('#yaxis-' + yaxis).closest('label').addClass('active');
|
||||
$('#graph-days').val(current_day_range);
|
||||
$('#graph-months').val(current_month_range);
|
||||
$('#graph-tabs a[href="' + current_tab + '"]').closest('li').addClass('active');
|
||||
$(current_tab).addClass('active');
|
||||
|
||||
|
||||
$('.days').html(current_day_range);
|
||||
$('.months').html(current_month_range);
|
||||
|
||||
@@ -352,9 +353,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
var music_visible = (${config['music_logging_enable']} == 1 ? true : false);
|
||||
|
||||
function dataSecondsToHours(data) {
|
||||
$.each(data.series, function (i, series) {
|
||||
series.data = $.map(series.data, function (value) {
|
||||
@@ -379,8 +377,8 @@
|
||||
$.each(data.categories, function (i, day) {
|
||||
dateArray.push(moment(day, 'YYYY-MM-DD').valueOf());
|
||||
// Highlight the weekend
|
||||
if ((moment(day, 'YYYY-MM-DD').format('ddd') == 'Sat') ||
|
||||
(moment(day, 'YYYY-MM-DD').format('ddd') == 'Sun')) {
|
||||
if ((moment(day, 'YYYY-MM-DD').format('ddd') === 'Sat') ||
|
||||
(moment(day, 'YYYY-MM-DD').format('ddd') === 'Sun')) {
|
||||
hc_plays_by_day_options.xAxis.plotBands.push({
|
||||
from: i-0.5,
|
||||
to: i+0.5,
|
||||
@@ -391,8 +389,7 @@
|
||||
if (yaxis === 'duration') { dataSecondsToHours(data); }
|
||||
hc_plays_by_day_options.yAxis.min = 0;
|
||||
hc_plays_by_day_options.xAxis.categories = dateArray;
|
||||
hc_plays_by_day_options.series = data.series;
|
||||
hc_plays_by_day_options.series[2].visible = music_visible;
|
||||
hc_plays_by_day_options.series = getGraphVisibility(hc_plays_by_day_options.chart.renderTo, data.series);
|
||||
var hc_plays_by_day = new Highcharts.Chart(hc_plays_by_day_options);
|
||||
}
|
||||
});
|
||||
@@ -405,8 +402,7 @@
|
||||
success: function(data) {
|
||||
if (yaxis === 'duration') { dataSecondsToHours(data); }
|
||||
hc_plays_by_dayofweek_options.xAxis.categories = data.categories;
|
||||
hc_plays_by_dayofweek_options.series = data.series;
|
||||
hc_plays_by_dayofweek_options.series[2].visible = music_visible;
|
||||
hc_plays_by_dayofweek_options.series = getGraphVisibility(hc_plays_by_dayofweek_options.chart.renderTo, data.series);
|
||||
var hc_plays_by_dayofweek = new Highcharts.Chart(hc_plays_by_dayofweek_options);
|
||||
}
|
||||
});
|
||||
@@ -419,8 +415,7 @@
|
||||
success: function(data) {
|
||||
if (yaxis === 'duration') { dataSecondsToHours(data); }
|
||||
hc_plays_by_hourofday_options.xAxis.categories = data.categories;
|
||||
hc_plays_by_hourofday_options.series = data.series;
|
||||
hc_plays_by_hourofday_options.series[2].visible = music_visible;
|
||||
hc_plays_by_hourofday_options.series = getGraphVisibility(hc_plays_by_hourofday_options.chart.renderTo, data.series);
|
||||
var hc_plays_by_hourofday = new Highcharts.Chart(hc_plays_by_hourofday_options);
|
||||
}
|
||||
});
|
||||
@@ -433,8 +428,7 @@
|
||||
success: function(data) {
|
||||
if (yaxis === 'duration') { dataSecondsToHours(data); }
|
||||
hc_plays_by_platform_options.xAxis.categories = data.categories;
|
||||
hc_plays_by_platform_options.series = data.series;
|
||||
hc_plays_by_platform_options.series[2].visible = music_visible;
|
||||
hc_plays_by_platform_options.series = getGraphVisibility(hc_plays_by_platform_options.chart.renderTo, data.series);
|
||||
var hc_plays_by_platform = new Highcharts.Chart(hc_plays_by_platform_options);
|
||||
}
|
||||
});
|
||||
@@ -447,11 +441,12 @@
|
||||
success: function(data) {
|
||||
if (yaxis === 'duration') { dataSecondsToHours(data); }
|
||||
hc_plays_by_user_options.xAxis.categories = data.categories;
|
||||
hc_plays_by_user_options.series = data.series;
|
||||
hc_plays_by_user_options.series[2].visible = music_visible;
|
||||
hc_plays_by_user_options.series = getGraphVisibility(hc_plays_by_user_options.chart.renderTo, data.series);
|
||||
var hc_plays_by_user = new Highcharts.Chart(hc_plays_by_user_options);
|
||||
}
|
||||
});
|
||||
|
||||
$('#graph-tabs a[href="#tabs-1"]').tab('show')
|
||||
}
|
||||
|
||||
function loadGraphsTab2(time_range, yaxis) {
|
||||
@@ -482,7 +477,7 @@
|
||||
if (yaxis === 'duration') { dataSecondsToHours(data); }
|
||||
hc_plays_by_stream_type_options.yAxis.min = 0;
|
||||
hc_plays_by_stream_type_options.xAxis.categories = dateArray;
|
||||
hc_plays_by_stream_type_options.series = data.series;
|
||||
hc_plays_by_stream_type_options.series = getGraphVisibility(hc_plays_by_stream_type_options.chart.renderTo, data.series);
|
||||
var hc_plays_by_stream_type = new Highcharts.Chart(hc_plays_by_stream_type_options);
|
||||
}
|
||||
});
|
||||
@@ -495,7 +490,7 @@
|
||||
success: function(data) {
|
||||
if (yaxis === 'duration') { dataSecondsToHours(data); }
|
||||
hc_plays_by_source_resolution_options.xAxis.categories = data.categories;
|
||||
hc_plays_by_source_resolution_options.series = data.series;
|
||||
hc_plays_by_source_resolution_options.series = getGraphVisibility(hc_plays_by_source_resolution_options.chart.renderTo, data.series);
|
||||
var hc_plays_by_source_resolution = new Highcharts.Chart(hc_plays_by_source_resolution_options);
|
||||
}
|
||||
});
|
||||
@@ -508,7 +503,7 @@
|
||||
success: function(data) {
|
||||
if (yaxis === 'duration') { dataSecondsToHours(data); }
|
||||
hc_plays_by_stream_resolution_options.xAxis.categories = data.categories;
|
||||
hc_plays_by_stream_resolution_options.series = data.series;
|
||||
hc_plays_by_stream_resolution_options.series = getGraphVisibility(hc_plays_by_stream_resolution_options.chart.renderTo, data.series);
|
||||
var hc_plays_by_stream_resolution = new Highcharts.Chart(hc_plays_by_stream_resolution_options);
|
||||
}
|
||||
});
|
||||
@@ -521,7 +516,7 @@
|
||||
success: function(data) {
|
||||
if (yaxis === 'duration') { dataSecondsToHours(data); }
|
||||
hc_plays_by_platform_by_stream_type_options.xAxis.categories = data.categories;
|
||||
hc_plays_by_platform_by_stream_type_options.series = data.series;
|
||||
hc_plays_by_platform_by_stream_type_options.series = getGraphVisibility(hc_plays_by_platform_by_stream_type_options.chart.renderTo, data.series);
|
||||
var hc_plays_by_platform_by_stream_type = new Highcharts.Chart(hc_plays_by_platform_by_stream_type_options);
|
||||
}
|
||||
});
|
||||
@@ -534,10 +529,12 @@
|
||||
success: function(data) {
|
||||
if (yaxis === 'duration') { dataSecondsToHours(data); }
|
||||
hc_plays_by_user_by_stream_type_options.xAxis.categories = data.categories;
|
||||
hc_plays_by_user_by_stream_type_options.series = data.series;
|
||||
hc_plays_by_user_by_stream_type_options.series = getGraphVisibility(hc_plays_by_user_by_stream_type_options.chart.renderTo, data.series);
|
||||
var hc_plays_by_user_by_stream_type = new Highcharts.Chart(hc_plays_by_user_by_stream_type_options);
|
||||
}
|
||||
});
|
||||
|
||||
$('#graph-tabs a[href="#tabs-2"]').tab('show')
|
||||
}
|
||||
|
||||
function loadGraphsTab3(time_range, yaxis) {
|
||||
@@ -555,51 +552,52 @@
|
||||
if (yaxis === 'duration') { dataSecondsToHours(data); }
|
||||
hc_plays_by_month_options.yAxis.min = 0;
|
||||
hc_plays_by_month_options.xAxis.categories = data.categories;
|
||||
hc_plays_by_month_options.series = data.series;
|
||||
hc_plays_by_month_options.series[2].visible = music_visible;
|
||||
hc_plays_by_month_options.series = getGraphVisibility(hc_plays_by_month_options.chart.renderTo, data.series);
|
||||
var hc_plays_by_month = new Highcharts.Chart(hc_plays_by_month_options);
|
||||
}
|
||||
});
|
||||
|
||||
$('#graph-tabs a[href="#tabs-3"]').tab('show')
|
||||
}
|
||||
|
||||
// Set initial state
|
||||
if (current_tab == '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
|
||||
if (current_tab == '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
|
||||
if (current_tab == '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
|
||||
if (current_tab === '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
|
||||
if (current_tab === '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
|
||||
if (current_tab === '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
|
||||
|
||||
// Tab1 opened
|
||||
$('#graph-tabs a[href="#tabs-1"]').on('shown.bs.tab', function (e) {
|
||||
e.preventDefault();
|
||||
current_tab = $(this).attr('href');
|
||||
setLocalStorage('graph_tab', current_tab.replace('#',''));
|
||||
loadGraphsTab1(current_day_range, yaxis);
|
||||
$.post('set_graph_config', { graph_tab: current_tab.replace('#','') });
|
||||
})
|
||||
});
|
||||
|
||||
// Tab2 opened
|
||||
$('#graph-tabs a[href="#tabs-2"]').on('shown.bs.tab', function (e) {
|
||||
e.preventDefault();
|
||||
current_tab = $(this).attr('href');
|
||||
setLocalStorage('graph_tab', current_tab.replace('#',''));
|
||||
loadGraphsTab2(current_day_range, yaxis);
|
||||
$.post('set_graph_config', { graph_tab: current_tab.replace('#','') });
|
||||
})
|
||||
});
|
||||
|
||||
// Tab3 opened
|
||||
$('#graph-tabs a[href="#tabs-3"]').on('shown.bs.tab', function (e) {
|
||||
e.preventDefault();
|
||||
current_tab = $(this).attr('href');
|
||||
setLocalStorage('graph_tab', current_tab.replace('#',''));
|
||||
loadGraphsTab3(current_month_range, yaxis);
|
||||
$.post('set_graph_config', { graph_tab: current_tab.replace('#','') });
|
||||
})
|
||||
});
|
||||
|
||||
// Date range changed
|
||||
$('#graph-days').tooltip({ container: 'body', placement: 'top', html: true });
|
||||
$('#graph-days').on('change', function() {
|
||||
forceMinMax($(this));
|
||||
current_day_range = $(this).val();
|
||||
if (current_tab == '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
|
||||
if (current_tab == '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
|
||||
setLocalStorage('graph_days', current_day_range);
|
||||
if (current_tab === '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
|
||||
if (current_tab === '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
|
||||
$('.days').html(current_day_range);
|
||||
$.post('set_graph_config', { graph_days: current_day_range });
|
||||
});
|
||||
|
||||
// Month range changed
|
||||
@@ -607,26 +605,26 @@
|
||||
$('#graph-months').on('change', function() {
|
||||
forceMinMax($(this));
|
||||
current_month_range = $(this).val();
|
||||
if (current_tab == '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
|
||||
setLocalStorage('graph_months', current_month_range);
|
||||
if (current_tab === '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
|
||||
$('.months').html(current_month_range);
|
||||
$.post('set_graph_config', { graph_months: current_month_range });
|
||||
});
|
||||
|
||||
// User changed
|
||||
$('#graph-user').on('change', function() {
|
||||
selected_user_id = $(this).val() || null;
|
||||
if (current_tab == '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
|
||||
if (current_tab == '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
|
||||
if (current_tab == '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
|
||||
if (current_tab === '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
|
||||
if (current_tab === '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
|
||||
if (current_tab === '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
|
||||
});
|
||||
|
||||
// Y-axis changed
|
||||
$('#yaxis-selection').on('change', function() {
|
||||
yaxis = $('input[name=yaxis-options]:checked', '#yaxis-selection').val();
|
||||
if (current_tab == '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
|
||||
if (current_tab == '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
|
||||
if (current_tab == '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
|
||||
$.post('set_graph_config', { graph_type: yaxis });
|
||||
setLocalStorage('graph_type', yaxis);
|
||||
if (current_tab === '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
|
||||
if (current_tab === '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
|
||||
if (current_tab === '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
|
||||
});
|
||||
|
||||
function setGraphFormat(type) {
|
||||
|
@@ -32,17 +32,17 @@
|
||||
</div>
|
||||
% endif
|
||||
<div class="btn-group" data-toggle="buttons" id="media_type-selection">
|
||||
<label class="btn btn-dark active">
|
||||
<input type="radio" name="media_type-filter" id="history-all" value="" autocomplete="off"> All
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" name="media_type-filter" id="history-all" value="all" autocomplete="off"> All
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" name="media_type-filter" id="history-movies" value="movie" autocomplete="off"> Movies
|
||||
<input type="radio" name="media_type-filter" id="history-movie" value="movie" autocomplete="off"> Movies
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" name="media_type-filter" id="history-tv_shows" value="episode" autocomplete="off"> TV Shows
|
||||
<input type="radio" name="media_type-filter" id="history-episode" value="episode" autocomplete="off"> TV Shows
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" name="media_type-filter" id="history-music" value="track" autocomplete="off"> Music
|
||||
<input type="radio" name="media_type-filter" id="history-track" value="track" autocomplete="off"> Music
|
||||
</label>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
@@ -154,6 +154,7 @@
|
||||
selected_filter = $('input[name=media_type-filter]:checked', '#media_type-selection');
|
||||
$(selected_filter).closest('label').addClass('active');
|
||||
media_type = $(selected_filter).val();
|
||||
setLocalStorage('history_media_type', media_type);
|
||||
history_table.draw();
|
||||
});
|
||||
|
||||
@@ -163,8 +164,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
var media_type = null;
|
||||
var media_type = getLocalStorage('history_media_type', 'all');
|
||||
var selected_user_id = "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}";
|
||||
|
||||
$('#history-' + media_type).prop('checked', true);
|
||||
$('#history-' + media_type).closest('label').addClass('active');
|
||||
|
||||
loadHistoryTable(media_type, selected_user_id);
|
||||
|
||||
% if _session['user_group'] == 'admin':
|
||||
|
BIN
data/interfaces/default/images/logo_tray-update.ico
Normal file
BIN
data/interfaces/default/images/logo_tray-update.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 112 KiB |
BIN
data/interfaces/default/images/logo_tray.ico
Normal file
BIN
data/interfaces/default/images/logo_tray.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 107 KiB |
@@ -44,25 +44,16 @@
|
||||
<h3 class="pull-left">Watch Statistics</h3>
|
||||
<div class="button-bar">
|
||||
<div class="btn-group pull-left" data-toggle="buttons" id="watch-stats-toggles" style="margin-right: 3px">
|
||||
% if config['home_stats_type'] == 0:
|
||||
<label class="btn btn-dark active">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-plays" value="0" autocomplete="off" checked> Play Count
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-plays" value="plays" autocomplete="off"> Play Count
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-duration" value="1" autocomplete="off"> Play Duration
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-duration" value="duration" autocomplete="off"> Play Duration
|
||||
</label>
|
||||
% else:
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-plays" value="0" autocomplete="off"> Play Count
|
||||
</label>
|
||||
<label class="btn btn-dark active">
|
||||
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-duration" value="1" autocomplete="off" checked> Play Duration
|
||||
</label>
|
||||
% endif
|
||||
</div>
|
||||
<div class="input-group pull-left" style="width: 1px; margin-right: 3px" id="watched-stats-days-selection">
|
||||
<span class="input-group-addon btn-dark inactive">Last</span>
|
||||
<input type="number" class="form-control number-input" name="watched-stats-days" id="watched-stats-days" value="${config['home_stats_length']}" min="1" data-default="30" data-toggle="tooltip" title="Min: 1 day" />
|
||||
<input type="number" class="form-control number-input" name="watched-stats-days" id="watched-stats-days" value="30" min="1" data-default="30" data-toggle="tooltip" title="Min: 1 day" />
|
||||
<span class="input-group-addon btn-dark inactive">days</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,7 +91,7 @@
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="home-padded-header padded-header">
|
||||
<h3 class="pull-left">Recently Added</h3>
|
||||
<h3 class="pull-left"><span id="recently-added-xml">Recently Added</span></h3>
|
||||
<ul class="nav nav-header nav-dashboard pull-right" style="margin-top: -3px;">
|
||||
<li>
|
||||
<a href="#" id="recently-added-page-left" class="paginate btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
|
||||
@@ -111,8 +102,8 @@
|
||||
</ul>
|
||||
<div class="button-bar">
|
||||
<div class="btn-group pull-left" data-toggle="buttons" id="recently-added-toggles" style="margin-right: 3px">
|
||||
<label class="btn btn-dark active" id="recently-added-label-all">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-all" value="" autocomplete="off"> All
|
||||
<label class="btn btn-dark" id="recently-added-label-all">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-all" value="all" autocomplete="off"> All
|
||||
</label>
|
||||
<label class="btn btn-dark" id="recently-added-label-movies">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-movie" value="movie" autocomplete="off"> Movies
|
||||
@@ -121,11 +112,14 @@
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-show" value="show" autocomplete="off"> TV Shows
|
||||
</label>
|
||||
<label class="btn btn-dark" id="recently-added-label-music">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-music" value="artist" autocomplete="off"> Music
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-artist" value="artist" autocomplete="off"> Music
|
||||
</label>
|
||||
<label class="btn btn-dark" id="recently-added-label-other_video">
|
||||
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-other_video" value="other_video" autocomplete="off"> Videos
|
||||
</label>
|
||||
</div>
|
||||
<div class="input-group pull-left" style="width: 1px;" id="recently-added-count-selection">
|
||||
<input type="number" class="form-control number-input" name="recently-added-count" id="recently-added-count" value="${config['home_stats_recently_added_count']}" min="1" max="50" data-default="50" data-toggle="tooltip" title="Min: 1 item<br>Max: 50 items" />
|
||||
<input type="number" class="form-control number-input" name="recently-added-count" id="recently-added-count" value="50" min="1" max="50" data-default="50" data-toggle="tooltip" title="Min: 1 item<br>Max: 50 items" />
|
||||
<span class="input-group-addon btn-dark inactive">items</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,6 +163,7 @@
|
||||
<div class="modal-body">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger" data-target="#donate-modal" data-toggle="modal" style="float: left;"><i class="fa fa-fw fa-heart"></i> Donate</button>
|
||||
<input type="button" class="btn btn-bright" data-dismiss="modal" value="Close">
|
||||
</div>
|
||||
</div>
|
||||
@@ -438,7 +433,7 @@
|
||||
$('#transcode_container-' + key).html(transcode_container);
|
||||
|
||||
var video_decision = '';
|
||||
if (['movie', 'episode', 'clip'].indexOf(s.media_type) > -1 && s.video_decision !== '') {
|
||||
if (['movie', 'episode', 'clip'].indexOf(s.media_type) > -1 && s.stream_video_decision) {
|
||||
var v_res= '';
|
||||
switch (s.video_resolution.toLowerCase()) {
|
||||
case 'sd':
|
||||
@@ -476,7 +471,7 @@
|
||||
$('#video_decision-' + key).html(video_decision);
|
||||
|
||||
var audio_decision = '';
|
||||
if (['movie', 'episode', 'clip', 'track'].indexOf(s.media_type) > -1 && s.audio_decision) {
|
||||
if (['movie', 'episode', 'clip', 'track'].indexOf(s.media_type) > -1 && s.stream_audio_decision) {
|
||||
var a_codec = (s.audio_codec === 'truehd') ? 'TrueHD' : s.audio_codec.toUpperCase();
|
||||
var sa_codec = (s.stream_audio_codec === 'truehd') ? 'TrueHD' : s.stream_audio_codec.toUpperCase();
|
||||
if (s.stream_audio_decision === 'transcode') {
|
||||
@@ -617,7 +612,8 @@
|
||||
if ($(this).data('state') === 'playing' && $(this).data('view_offset') >= 0) {
|
||||
var view_offset = parseInt($(this).data('view_offset'));
|
||||
var stream_duration = parseInt($(this).data('stream_duration'));
|
||||
var progress_percent = Math.min(Math.floor(view_offset / stream_duration * 100) || 100, 100);
|
||||
var progress_percent = Math.floor(view_offset / stream_duration * 100);
|
||||
progress_percent = (progress_percent >= 0) ? Math.min(progress_percent, 100) : 100;
|
||||
$(this).width(progress_percent - 3 + '%').html(progress_percent + '%')
|
||||
.attr('data-original-title', 'Stream Progress ' + progress_percent + '%')
|
||||
.data('view_offset', Math.min(view_offset + 1000, stream_duration));
|
||||
@@ -722,20 +718,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
var time_range = $('#watched-stats-days').val();
|
||||
var stats_type = $('input[name=watched-stats-type]:checked', '#watch-stats-toggles').val();
|
||||
var stats_type = getLocalStorage('home_stats_type', 'plays');
|
||||
var time_range = getLocalStorage('home_stats_days', 30);
|
||||
|
||||
$('#watched-stats-' + stats_type).prop('checked', true);
|
||||
$('#watched-stats-' + stats_type).closest('label').addClass('active');
|
||||
$('#watched-stats-days').val(time_range);
|
||||
|
||||
getHomeStats(time_range, stats_type);
|
||||
|
||||
$('input[name=watched-stats-type]').change(function () {
|
||||
stats_type = $(this).filter(':checked').val();
|
||||
setLocalStorage('home_stats_type', stats_type);
|
||||
getHomeStats(time_range, stats_type);
|
||||
$.post('set_home_stats_config', { stats_type: stats_type });
|
||||
});
|
||||
$('#watched-stats-days').change(function () {
|
||||
forceMinMax($(this));
|
||||
time_range = $(this).val();
|
||||
setLocalStorage('home_stats_days', time_range);
|
||||
getHomeStats(time_range, stats_type);
|
||||
$.post('set_home_stats_config', { time_range: time_range });
|
||||
});
|
||||
|
||||
$('#watched-stats-days').tooltip({ container: 'body', placement: 'top', html: true });
|
||||
@@ -769,7 +770,7 @@
|
||||
async: true,
|
||||
data: {
|
||||
count: recently_added_count,
|
||||
type: recently_added_type
|
||||
media_type: recently_added_type
|
||||
},
|
||||
complete: function (xhr, status) {
|
||||
$("#recentlyAdded").html(xhr.responseText);
|
||||
@@ -778,8 +779,14 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
var recently_added_count = $('#recently-added-count').val();
|
||||
var recently_added_type = '';
|
||||
|
||||
var recently_added_count = getLocalStorage('home_stats_recently_added_count', 50);
|
||||
var recently_added_type = getLocalStorage('home_stats_recently_added_type', 'all');;
|
||||
|
||||
$('#recently-added-toggle-' + recently_added_type).prop('checked', true);
|
||||
$('#recently-added-toggle-' + recently_added_type).closest('label').addClass('active');
|
||||
$('#recently-added-count').val(recently_added_count);
|
||||
|
||||
recentlyAdded(recently_added_count, recently_added_type);
|
||||
|
||||
function highlightAddedScrollerButton() {
|
||||
@@ -833,6 +840,7 @@
|
||||
$(selected_filter).closest('label').addClass('active');
|
||||
recently_added_type = $(selected_filter).val();
|
||||
resetScroller();
|
||||
setLocalStorage('home_stats_recently_added_type', recently_added_type);
|
||||
recentlyAdded(recently_added_count, recently_added_type);
|
||||
});
|
||||
|
||||
@@ -840,11 +848,15 @@
|
||||
forceMinMax($(this));
|
||||
recently_added_count = $(this).val();
|
||||
resetScroller();
|
||||
setLocalStorage('home_stats_recently_added_count', recently_added_count);
|
||||
recentlyAdded(recently_added_count, recently_added_type);
|
||||
$.post('set_home_stats_config', { recently_added_count: recently_added_count });
|
||||
});
|
||||
|
||||
$('#recently-added-count').tooltip({ container: 'body', placement: 'top', html: true });
|
||||
|
||||
$('#recently-added-xml').on('tripleclick', function () {
|
||||
openPlexXML('/library/recentlyAdded', false, {'X-Plex-Container-Start': 0, 'X-Plex-Container-Size': recently_added_count});
|
||||
});
|
||||
</script>
|
||||
% endif
|
||||
% if _session['user_group'] == 'admin' and config['update_show_changelog']:
|
||||
|
@@ -190,12 +190,12 @@ DOCUMENTATION :: END
|
||||
<li>
|
||||
<a href="info?rating_key=${child['rating_key']}" id="${child['rating_key']}">
|
||||
<div class="item-children-poster">
|
||||
<div class="item-children-poster-face cover-item style="background-image: url(pms_image_proxy?img=${child['thumb']}&width=300&height=300&fallback=cover);"></div>
|
||||
<div class="item-children-poster-face cover-item" style="background-image: url(pms_image_proxy?img=${child['thumb']}&width=300&height=300&fallback=cover);"></div>
|
||||
% if _session['user_group'] == 'admin':
|
||||
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
|
||||
% endif
|
||||
</div>
|
||||
<div class="item-children-instance-text-wrapper album-item">
|
||||
<div class="item-children-instance-text-wrapper cover-item">
|
||||
<h3 title="${child['title']}">${child['title']}</h3>
|
||||
</div>
|
||||
</a>
|
||||
@@ -219,7 +219,7 @@ DOCUMENTATION :: END
|
||||
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
|
||||
% endif
|
||||
</div>
|
||||
<div class="item-children-instance-text-wrapper album-item">
|
||||
<div class="item-children-instance-text-wrapper cover-item">
|
||||
<h3 title="${child['parent_title']}">${child['parent_title']}</h3>
|
||||
<h3 title="${child['title']}">${child['title']}</h3>
|
||||
</div>
|
||||
@@ -246,11 +246,11 @@ DOCUMENTATION :: END
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
% if _session['user_group'] == 'admin':
|
||||
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
|
||||
% endif
|
||||
</div>
|
||||
% if _session['user_group'] == 'admin':
|
||||
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
|
||||
% endif
|
||||
<div class="item-children-instance-text-wrapper album-item">
|
||||
<div class="item-children-instance-text-wrapper cover-item">
|
||||
<h3 title="${child['original_title'] or child['grandparent_title']}">${child['original_title'] or child['grandparent_title']}</h3>
|
||||
<h3 title="${child['title']}">${child['title']}</h3>
|
||||
<h3 title="${child['parent_title']}" class="text-muted">${child['parent_title']}</h3>
|
||||
|
@@ -2,7 +2,7 @@ var hc_plays_by_day_options = {
|
||||
chart: {
|
||||
type: 'line',
|
||||
backgroundColor: 'rgba(0,0,0,0)',
|
||||
renderTo: 'chart_div_plays_by_day'
|
||||
renderTo: 'graph_plays_by_day'
|
||||
},
|
||||
title: {
|
||||
text: ''
|
||||
@@ -32,6 +32,11 @@ var hc_plays_by_day_options = {
|
||||
selectHandler(this.category, this.series.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
events: {
|
||||
legendItemClick: function() {
|
||||
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -2,17 +2,11 @@ var hc_plays_by_dayofweek_options = {
|
||||
chart: {
|
||||
type: 'column',
|
||||
backgroundColor: 'rgba(0,0,0,0)',
|
||||
renderTo: 'chart_div_plays_by_dayofweek'
|
||||
renderTo: 'graph_plays_by_dayofweek'
|
||||
},
|
||||
title: {
|
||||
text: ''
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
pointPadding: 0.2,
|
||||
borderWidth: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
enabled: true,
|
||||
itemStyle: {
|
||||
@@ -56,14 +50,21 @@ var hc_plays_by_dayofweek_options = {
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
borderWidth: 0,
|
||||
stacking: 'normal',
|
||||
borderWidth: '0',
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
style: {
|
||||
color: '#000'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: {
|
||||
events: {
|
||||
legendItemClick: function () {
|
||||
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
|
@@ -2,17 +2,11 @@ var hc_plays_by_hourofday_options = {
|
||||
chart: {
|
||||
type: 'column',
|
||||
backgroundColor: 'rgba(0,0,0,0)',
|
||||
renderTo: 'chart_div_plays_by_hourofday'
|
||||
renderTo: 'graph_plays_by_hourofday'
|
||||
},
|
||||
title: {
|
||||
text: ''
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
pointPadding: 0.2,
|
||||
borderWidth: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
enabled: true,
|
||||
itemStyle: {
|
||||
@@ -56,14 +50,21 @@ var hc_plays_by_hourofday_options = {
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
borderWidth: 0,
|
||||
stacking: 'normal',
|
||||
borderWidth: '0',
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
style: {
|
||||
color: '#000'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: {
|
||||
events: {
|
||||
legendItemClick: function () {
|
||||
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
|
@@ -2,7 +2,7 @@ var hc_plays_by_month_options = {
|
||||
chart: {
|
||||
type: 'column',
|
||||
backgroundColor: 'rgba(0,0,0,0)',
|
||||
renderTo: 'chart_div_plays_by_month'
|
||||
renderTo: 'graph_plays_by_month'
|
||||
},
|
||||
title: {
|
||||
text: ''
|
||||
@@ -50,14 +50,21 @@ var hc_plays_by_month_options = {
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
borderWidth: 0,
|
||||
stacking: 'normal',
|
||||
borderWidth: '0',
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
style: {
|
||||
color: '#000'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: {
|
||||
events: {
|
||||
legendItemClick: function () {
|
||||
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
|
@@ -2,17 +2,11 @@ var hc_plays_by_platform_options = {
|
||||
chart: {
|
||||
type: 'column',
|
||||
backgroundColor: 'rgba(0,0,0,0)',
|
||||
renderTo: 'chart_div_plays_by_platform'
|
||||
renderTo: 'graph_plays_by_platform'
|
||||
},
|
||||
title: {
|
||||
text: ''
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
pointPadding: 0.2,
|
||||
borderWidth: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
enabled: true,
|
||||
itemStyle: {
|
||||
@@ -56,14 +50,21 @@ var hc_plays_by_platform_options = {
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
borderWidth: 0,
|
||||
stacking: 'normal',
|
||||
borderWidth: '0',
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
style: {
|
||||
color: '#000'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: {
|
||||
events: {
|
||||
legendItemClick: function () {
|
||||
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
|
@@ -2,17 +2,11 @@ var hc_plays_by_platform_by_stream_type_options = {
|
||||
chart: {
|
||||
type: 'column',
|
||||
backgroundColor: 'rgba(0,0,0,0)',
|
||||
renderTo: 'chart_div_plays_by_platform_by_stream_type'
|
||||
renderTo: 'graph_plays_by_platform_by_stream_type'
|
||||
},
|
||||
title: {
|
||||
text: ''
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
pointPadding: 0.2,
|
||||
borderWidth: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
enabled: true,
|
||||
itemStyle: {
|
||||
@@ -56,14 +50,21 @@ var hc_plays_by_platform_by_stream_type_options = {
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
borderWidth: 0,
|
||||
stacking: 'normal',
|
||||
borderWidth: '0',
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
style: {
|
||||
color: '#000'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: {
|
||||
events: {
|
||||
legendItemClick: function () {
|
||||
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
|
@@ -2,17 +2,11 @@ var hc_plays_by_source_resolution_options = {
|
||||
chart: {
|
||||
type: 'column',
|
||||
backgroundColor: 'rgba(0,0,0,0)',
|
||||
renderTo: 'chart_div_plays_by_source_resolution'
|
||||
renderTo: 'graph_plays_by_source_resolution'
|
||||
},
|
||||
title: {
|
||||
text: ''
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
pointPadding: 0.2,
|
||||
borderWidth: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
enabled: true,
|
||||
itemStyle: {
|
||||
@@ -56,14 +50,21 @@ var hc_plays_by_source_resolution_options = {
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
borderWidth: 0,
|
||||
stacking: 'normal',
|
||||
borderWidth: '0',
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
style: {
|
||||
color: '#000'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: {
|
||||
events: {
|
||||
legendItemClick: function () {
|
||||
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
|
@@ -2,17 +2,11 @@ var hc_plays_by_stream_resolution_options = {
|
||||
chart: {
|
||||
type: 'column',
|
||||
backgroundColor: 'rgba(0,0,0,0)',
|
||||
renderTo: 'chart_div_plays_by_stream_resolution'
|
||||
renderTo: 'graph_plays_by_stream_resolution'
|
||||
},
|
||||
title: {
|
||||
text: ''
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
pointPadding: 0.2,
|
||||
borderWidth: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
enabled: true,
|
||||
itemStyle: {
|
||||
@@ -56,14 +50,21 @@ var hc_plays_by_stream_resolution_options = {
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
borderWidth: 0,
|
||||
stacking: 'normal',
|
||||
borderWidth: '0',
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
style: {
|
||||
color: '#000'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: {
|
||||
events: {
|
||||
legendItemClick: function () {
|
||||
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
|
@@ -2,7 +2,7 @@ var hc_plays_by_stream_type_options = {
|
||||
chart: {
|
||||
type: 'line',
|
||||
backgroundColor: 'rgba(0,0,0,0)',
|
||||
renderTo: 'chart_div_plays_by_stream_type'
|
||||
renderTo: 'graph_plays_by_stream_type'
|
||||
},
|
||||
title: {
|
||||
text: ''
|
||||
@@ -32,6 +32,11 @@ var hc_plays_by_stream_type_options = {
|
||||
selectHandler(this.category, this.series.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
events: {
|
||||
legendItemClick: function() {
|
||||
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -2,17 +2,11 @@ var hc_plays_by_user_options = {
|
||||
chart: {
|
||||
type: 'column',
|
||||
backgroundColor: 'rgba(0,0,0,0)',
|
||||
renderTo: 'chart_div_plays_by_user'
|
||||
renderTo: 'graph_plays_by_user'
|
||||
},
|
||||
title: {
|
||||
text: ''
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
pointPadding: 0.2,
|
||||
borderWidth: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
enabled: true,
|
||||
itemStyle: {
|
||||
@@ -56,14 +50,21 @@ var hc_plays_by_user_options = {
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
borderWidth: 0,
|
||||
stacking: 'normal',
|
||||
borderWidth: '0',
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
style: {
|
||||
color: '#000'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: {
|
||||
events: {
|
||||
legendItemClick: function () {
|
||||
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
|
@@ -2,17 +2,11 @@ var hc_plays_by_user_by_stream_type_options = {
|
||||
chart: {
|
||||
type: 'column',
|
||||
backgroundColor: 'rgba(0,0,0,0)',
|
||||
renderTo: 'chart_div_plays_by_user_by_stream_type'
|
||||
renderTo: 'graph_plays_by_user_by_stream_type'
|
||||
},
|
||||
title: {
|
||||
text: ''
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
pointPadding: 0.2,
|
||||
borderWidth: 0
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
enabled: true,
|
||||
itemStyle: {
|
||||
@@ -56,14 +50,21 @@ var hc_plays_by_user_by_stream_type_options = {
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
borderWidth: 0,
|
||||
stacking: 'normal',
|
||||
borderWidth: '0',
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
style: {
|
||||
color: '#000'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: {
|
||||
events: {
|
||||
legendItemClick: function () {
|
||||
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
|
7
data/interfaces/default/js/platform.min.js
vendored
Normal file
7
data/interfaces/default/js/platform.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,3 +1,29 @@
|
||||
var p = {
|
||||
name: 'Unknown',
|
||||
version: 'Unknown',
|
||||
os: 'Unknown'
|
||||
};
|
||||
if (typeof platform !== 'undefined') {
|
||||
p.name = platform.name;
|
||||
p.version = platform.version;
|
||||
p.os = platform.os.toString();
|
||||
}
|
||||
|
||||
if (['IE', 'Microsoft Edge', 'IE Mobile'].indexOf(p.name) > -1) {
|
||||
$('body').prepend('<div id="browser-warning"><i class="fa fa-exclamation-circle"></i> ' +
|
||||
'Tautulli does not support Internet Explorer or Microsoft Edge! ' +
|
||||
'Please use a different browser such as Chrome or Firefox.</div>');
|
||||
var offset = $('#browser-warning').height();
|
||||
var navbar = $('.navbar-fixed-top');
|
||||
if (navbar.length) {
|
||||
navbar.offset({top: navbar.offset().top + offset});
|
||||
}
|
||||
var container = $('.body-container');
|
||||
if (container.length) {
|
||||
container.offset({top: container.offset().top + offset});
|
||||
}
|
||||
}
|
||||
|
||||
function initConfigCheckbox(elem, toggleElem, reverse) {
|
||||
toggleElem = (toggleElem === undefined) ? null : toggleElem;
|
||||
reverse = (reverse === undefined) ? false : reverse;
|
||||
@@ -37,7 +63,7 @@ function showMsg(msg, loader, timeout, ms, error) {
|
||||
}
|
||||
var message = $("<div class='msg'>" + msg + "</div>");
|
||||
if (loader) {
|
||||
message = $("<div class='msg'><i class='fa fa-refresh fa-spin'></i> " + msg + "</div>");
|
||||
message = $("<div class='msg'><i class='fa fa-refresh fa-spin'></i> " + msg + "</div>");
|
||||
feedback.css("padding", "14px 10px");
|
||||
}
|
||||
if (error) {
|
||||
@@ -73,9 +99,9 @@ function confirmAjaxCall(url, msg, data, loader_msg, callback) {
|
||||
var result = $.parseJSON(xhr.responseText);
|
||||
var msg = result.message;
|
||||
if (result.result == 'success') {
|
||||
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
|
||||
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
|
||||
} else {
|
||||
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true);
|
||||
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true);
|
||||
}
|
||||
if (typeof callback === "function") {
|
||||
callback(result);
|
||||
@@ -103,7 +129,7 @@ function doAjaxCall(url, elem, reload, form, showMsg, callback) {
|
||||
dataString = $(formID).serialize();
|
||||
}
|
||||
// Loader Image
|
||||
var loader = $("<i class='fa fa-refresh fa-spin ajaxLoader-" + url +"></i>");
|
||||
var loader = $("<div class='msg ajaxLoader-" + url +"'><i class='fa fa-refresh fa-spin'></i> Saving...</div>");
|
||||
// Data Success Message
|
||||
var dataSucces = $(elem).data('success');
|
||||
if (typeof dataSucces === "undefined") {
|
||||
@@ -117,8 +143,8 @@ function doAjaxCall(url, elem, reload, form, showMsg, callback) {
|
||||
dataError = "There was an error";
|
||||
}
|
||||
// Get Success & Error message from inline data, else use standard message
|
||||
var succesMsg = $("<div class='msg'><i class='fa fa-check'></i> " + dataSucces + "</div>");
|
||||
var errorMsg = $("<div class='msg'><i class='fa fa-exclamation-triangle'></i> " + dataError + "</div>");
|
||||
var succesMsg = $("<div class='msg'><i class='fa fa-check'></i> " + dataSucces + "</div>");
|
||||
var errorMsg = $("<div class='msg'><i class='fa fa-exclamation-triangle'></i> " + dataError + "</div>");
|
||||
// Check if checkbox is selected
|
||||
if (form) {
|
||||
if ($('td#select input[type=checkbox]').length > 0 && !$('td#select input[type=checkbox]').is(':checked') ||
|
||||
@@ -141,7 +167,7 @@ function doAjaxCall(url, elem, reload, form, showMsg, callback) {
|
||||
$.ajax({
|
||||
url: url,
|
||||
data: dataString,
|
||||
type: 'post',
|
||||
type: 'POST',
|
||||
beforeSend: function (jqXHR, settings) {
|
||||
// Start loader etc.
|
||||
feedback.prepend(loader);
|
||||
@@ -187,7 +213,7 @@ function doAjaxCall(url, elem, reload, form, showMsg, callback) {
|
||||
},
|
||||
complete: function (jqXHR, textStatus) {
|
||||
// Remove loaders and stuff, ajax request is complete!
|
||||
feedback.remove('.ajaxLoader-' + url);
|
||||
$('.ajaxLoader-' + url).remove();
|
||||
if (typeof callback === "function") {
|
||||
callback(jqXHR);
|
||||
}
|
||||
@@ -351,21 +377,26 @@ function getCookie(cname) {
|
||||
}
|
||||
return "";
|
||||
}
|
||||
var Accordion = function (el, multiple) {
|
||||
var Accordion = function (el, multiple, close) {
|
||||
this.el = el || {};
|
||||
this.multiple = multiple || false;
|
||||
this.close = (close === undefined) ? true : close;
|
||||
// Variables privadas
|
||||
var links = this.el.find('.link');
|
||||
// Evento
|
||||
links.on('click', {
|
||||
el: this.el,
|
||||
multiple: this.multiple
|
||||
multiple: this.multiple,
|
||||
close: this.close
|
||||
}, this.dropdown);
|
||||
};
|
||||
Accordion.prototype.dropdown = function (e) {
|
||||
var $el = e.data.el;
|
||||
$this = $(this);
|
||||
$next = $this.next();
|
||||
if (!e.data.close && $this.parent().hasClass('open')) {
|
||||
return
|
||||
}
|
||||
$next.slideToggle();
|
||||
$this.parent().toggleClass('open');
|
||||
if (!e.data.multiple) {
|
||||
@@ -465,3 +496,178 @@ function openPlexXML(endpoint, plextv, params) {
|
||||
window.open(xml_url, '_blank');
|
||||
});
|
||||
}
|
||||
|
||||
function PopupCenter(url, title, w, h) {
|
||||
// Fixes dual-screen position Most browsers Firefox
|
||||
var dualScreenLeft = window.screenLeft != undefined ? window.screenLeft : window.screenX;
|
||||
var dualScreenTop = window.screenTop != undefined ? window.screenTop : window.screenY;
|
||||
|
||||
var width = window.innerWidth ? window.innerWidth : document.documentElement.clientWidth ? document.documentElement.clientWidth : screen.width;
|
||||
var height = window.innerHeight ? window.innerHeight : document.documentElement.clientHeight ? document.documentElement.clientHeight : screen.height;
|
||||
|
||||
var left = ((width / 2) - (w / 2)) + dualScreenLeft;
|
||||
var top = ((height / 2) - (h / 2)) + dualScreenTop;
|
||||
var newWindow = window.open(url, title, 'scrollbars=yes, width=' + w + ', height=' + h + ', top=' + top + ', left=' + left);
|
||||
|
||||
// Puts focus on the newWindow
|
||||
if (window.focus) {
|
||||
newWindow.focus();
|
||||
}
|
||||
|
||||
return newWindow;
|
||||
}
|
||||
|
||||
function setLocalStorage(key, value) {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
function getLocalStorage(key, default_value) {
|
||||
var value = localStorage.getItem(key);
|
||||
if (value !== null) {
|
||||
return value
|
||||
} else if (default_value !== undefined) {
|
||||
setLocalStorage(key, default_value);
|
||||
return default_value
|
||||
}
|
||||
}
|
||||
|
||||
if (!getLocalStorage('Tautulli_ClientId')) {
|
||||
setLocalStorage('Tautulli_ClientId', uuidv4());
|
||||
}
|
||||
|
||||
function uuidv4() {
|
||||
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, function(c) {
|
||||
var cryptoObj = window.crypto || window.msCrypto; // for IE 11
|
||||
return (c ^ cryptoObj.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
||||
});
|
||||
}
|
||||
|
||||
var x_plex_headers = {
|
||||
'Accept': 'application/json',
|
||||
'X-Plex-Product': 'Tautulli',
|
||||
'X-Plex-Version': 'Plex OAuth',
|
||||
'X-Plex-Client-Identifier': localStorage.getItem('Tautulli_ClientId'),
|
||||
'X-Plex-Platform': p.name,
|
||||
'X-Plex-Platform-Version': p.version,
|
||||
'X-Plex-Device': p.os,
|
||||
'X-Plex-Device-Name': p.name
|
||||
};
|
||||
|
||||
var plex_oauth_window = null;
|
||||
const plex_oauth_loader = '<style>' +
|
||||
'.login-loader-container {' +
|
||||
'font-family: "Open Sans", Arial, sans-serif;' +
|
||||
'position: absolute;' +
|
||||
'top: 0;' +
|
||||
'right: 0;' +
|
||||
'bottom: 0;' +
|
||||
'left: 0;' +
|
||||
'}' +
|
||||
'.login-loader-message {' +
|
||||
'color: #282A2D;' +
|
||||
'text-align: center;' +
|
||||
'position: absolute;' +
|
||||
'left: 50%;' +
|
||||
'top: 25%;' +
|
||||
'transform: translate(-50%, -50%);' +
|
||||
'}' +
|
||||
'.login-loader {' +
|
||||
'border: 5px solid #ccc;' +
|
||||
'-webkit-animation: spin 1s linear infinite;' +
|
||||
'animation: spin 1s linear infinite;' +
|
||||
'border-top: 5px solid #282A2D;' +
|
||||
'border-radius: 50%;' +
|
||||
'width: 50px;' +
|
||||
'height: 50px;' +
|
||||
'position: relative;' +
|
||||
'left: calc(50% - 25px);' +
|
||||
'}' +
|
||||
'@keyframes spin {' +
|
||||
'0% { transform: rotate(0deg); }' +
|
||||
'100% { transform: rotate(360deg); }' +
|
||||
'}' +
|
||||
'</style>' +
|
||||
'<div class="login-loader-container">' +
|
||||
'<div class="login-loader-message">' +
|
||||
'<div class="login-loader"></div>' +
|
||||
'<br>' +
|
||||
'Redirecting to the Plex login page...' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
function closePlexOAuthWindow() {
|
||||
if (plex_oauth_window) {
|
||||
plex_oauth_window.close();
|
||||
}
|
||||
}
|
||||
|
||||
getPlexOAuthPin = function () {
|
||||
var deferred = $.Deferred();
|
||||
|
||||
$.ajax({
|
||||
url: 'https://plex.tv/api/v2/pins?strong=true',
|
||||
type: 'POST',
|
||||
headers: x_plex_headers,
|
||||
success: function(data) {
|
||||
deferred.resolve({pin: data.id, code: data.code});
|
||||
},
|
||||
error: function() {
|
||||
closePlexOAuthWindow();
|
||||
deferred.reject();
|
||||
}
|
||||
});
|
||||
return deferred;
|
||||
};
|
||||
|
||||
var polling = null;
|
||||
|
||||
function PlexOAuth(success, error, pre) {
|
||||
if (typeof pre === "function") {
|
||||
pre()
|
||||
}
|
||||
closePlexOAuthWindow();
|
||||
plex_oauth_window = PopupCenter('', 'Plex-OAuth', 600, 700);
|
||||
$(plex_oauth_window.document.body).html(plex_oauth_loader);
|
||||
|
||||
getPlexOAuthPin().then(function (data) {
|
||||
const pin = data.pin;
|
||||
const code = data.code;
|
||||
|
||||
plex_oauth_window.location = 'https://app.plex.tv/auth/#!?clientID=' + x_plex_headers['X-Plex-Client-Identifier'] + '&code=' + code;
|
||||
polling = pin;
|
||||
|
||||
(function poll() {
|
||||
$.ajax({
|
||||
url: 'https://plex.tv/api/v2/pins/' + pin,
|
||||
type: 'GET',
|
||||
headers: x_plex_headers,
|
||||
success: function (data) {
|
||||
if (data.authToken){
|
||||
closePlexOAuthWindow();
|
||||
if (typeof success === "function") {
|
||||
success(data.authToken)
|
||||
}
|
||||
}
|
||||
},
|
||||
error: function (jqXHR, textStatus, errorThrown) {
|
||||
if (textStatus !== "timeout") {
|
||||
closePlexOAuthWindow();
|
||||
if (typeof error === "function") {
|
||||
error()
|
||||
}
|
||||
}
|
||||
},
|
||||
complete: function () {
|
||||
if (!plex_oauth_window.closed && polling === pin){
|
||||
setTimeout(function() {poll()}, 1000);
|
||||
}
|
||||
},
|
||||
timeout: 10000
|
||||
});
|
||||
})();
|
||||
}, function () {
|
||||
closePlexOAuthWindow();
|
||||
if (typeof error === "function") {
|
||||
error()
|
||||
}
|
||||
});
|
||||
}
|
@@ -24,6 +24,7 @@ history_table_options = {
|
||||
},
|
||||
"pagingType": "full_numbers",
|
||||
"stateSave": true,
|
||||
"stateDuration": 0,
|
||||
"processing": false,
|
||||
"serverSide": true,
|
||||
"pageLength": 25,
|
||||
@@ -289,7 +290,7 @@ history_table_options = {
|
||||
' (filtered from ' + settings.json.total_duration + ' total)</span>');
|
||||
},
|
||||
"preDrawCallback": function(settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0);
|
||||
$('[data-toggle="tooltip"]').tooltip('destroy');
|
||||
$('[data-toggle="popover"]').popover('destroy');
|
||||
|
@@ -148,7 +148,7 @@ history_table_modal_options = {
|
||||
});
|
||||
},
|
||||
"preDrawCallback": function(settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
}
|
||||
}
|
||||
|
@@ -17,6 +17,7 @@ libraries_list_table_options = {
|
||||
"pageLength": 25,
|
||||
"order": [ 2, 'asc'],
|
||||
"stateSave": true,
|
||||
"stateDuration": 0,
|
||||
"pagingType": "full_numbers",
|
||||
"autoWidth": false,
|
||||
"scrollX": true,
|
||||
@@ -238,7 +239,7 @@ libraries_list_table_options = {
|
||||
}
|
||||
},
|
||||
"preDrawCallback": function(settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
},
|
||||
"rowCallback": function (row, rowData) {
|
||||
|
@@ -10,6 +10,7 @@ login_log_table_options = {
|
||||
"loadingRecords": '<i class="fa fa-refresh fa-spin"></i> Loading items...</div>'
|
||||
},
|
||||
"stateSave": true,
|
||||
"stateDuration": 0,
|
||||
"pagingType": "full_numbers",
|
||||
"processing": false,
|
||||
"serverSide": true,
|
||||
@@ -110,7 +111,7 @@ login_log_table_options = {
|
||||
|
||||
},
|
||||
"preDrawCallback": function (settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
}
|
||||
};
|
||||
|
@@ -6,6 +6,7 @@ var log_table_options = {
|
||||
"order": [ 0, 'desc'],
|
||||
"pageLength": 50,
|
||||
"stateSave": true,
|
||||
"stateDuration": 0,
|
||||
"language": {
|
||||
"search": "Search: ",
|
||||
"lengthMenu": "Show _MENU_ lines per page",
|
||||
@@ -39,7 +40,7 @@ var log_table_options = {
|
||||
$('#ajaxMsg').fadeOut();
|
||||
},
|
||||
"preDrawCallback": function(settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
}
|
||||
}
|
||||
|
@@ -25,6 +25,7 @@ media_info_table_options = {
|
||||
},
|
||||
"pagingType": "full_numbers",
|
||||
"stateSave": true,
|
||||
"stateDuration": 0,
|
||||
"processing": false,
|
||||
"serverSide": true,
|
||||
"pageLength": 25,
|
||||
@@ -299,7 +300,7 @@ media_info_table_options = {
|
||||
' (filtered from ' + humanFileSize(settings.json.total_file_size) + ')</span>');
|
||||
},
|
||||
"preDrawCallback": function (settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
},
|
||||
"rowCallback": function (row, rowData, rowIndex) {
|
||||
|
@@ -6,6 +6,7 @@ newsletter_log_table_options = {
|
||||
"order": [ 0, 'desc'],
|
||||
"pageLength": 50,
|
||||
"stateSave": true,
|
||||
"stateDuration": 0,
|
||||
"language": {
|
||||
"search":"Search: ",
|
||||
"lengthMenu": "Show _MENU_ lines per page",
|
||||
@@ -140,7 +141,7 @@ newsletter_log_table_options = {
|
||||
});
|
||||
},
|
||||
"preDrawCallback": function(settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
}
|
||||
};
|
||||
|
@@ -6,6 +6,7 @@ notification_log_table_options = {
|
||||
"order": [ 0, 'desc'],
|
||||
"pageLength": 50,
|
||||
"stateSave": true,
|
||||
"stateDuration": 0,
|
||||
"language": {
|
||||
"search":"Search: ",
|
||||
"lengthMenu": "Show _MENU_ lines per page",
|
||||
@@ -110,7 +111,7 @@ notification_log_table_options = {
|
||||
});
|
||||
},
|
||||
"preDrawCallback": function(settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
}
|
||||
};
|
||||
|
@@ -6,6 +6,7 @@ var plex_log_table_options = {
|
||||
"order": [ 0, 'desc'],
|
||||
"pageLength": 50,
|
||||
"stateSave": true,
|
||||
"stateDuration": 0,
|
||||
"language": {
|
||||
"search": "Search: ",
|
||||
"lengthMenu": "Show _MENU_ lines per page",
|
||||
@@ -39,7 +40,7 @@ var plex_log_table_options = {
|
||||
$('#ajaxMsg').fadeOut();
|
||||
},
|
||||
"preDrawCallback": function(settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
}
|
||||
}
|
||||
|
@@ -7,6 +7,7 @@ sync_table_options = {
|
||||
"order": [ [ 0, 'desc'], [ 1, 'asc'], [2, 'asc'] ],
|
||||
"pageLength": 25,
|
||||
"stateSave": true,
|
||||
"stateDuration": 0,
|
||||
"language": {
|
||||
"search": "Search: ",
|
||||
"lengthMenu": "Show _MENU_ lines per page",
|
||||
@@ -147,7 +148,7 @@ sync_table_options = {
|
||||
|
||||
},
|
||||
"preDrawCallback": function (settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
},
|
||||
"rowCallback": function (row, rowData, rowIndex) {
|
||||
|
@@ -10,6 +10,7 @@ user_ip_table_options = {
|
||||
"loadingRecords": '<i class="fa fa-refresh fa-spin"></i> Loading items...</div>'
|
||||
},
|
||||
"stateSave": true,
|
||||
"stateDuration": 0,
|
||||
"pagingType": "full_numbers",
|
||||
"processing": false,
|
||||
"serverSide": true,
|
||||
@@ -141,7 +142,7 @@ user_ip_table_options = {
|
||||
|
||||
},
|
||||
"preDrawCallback": function(settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
}
|
||||
}
|
||||
|
@@ -34,6 +34,7 @@ users_list_table_options = {
|
||||
"pageLength": 25,
|
||||
"order": [ 2, 'asc'],
|
||||
"stateSave": true,
|
||||
"stateDuration": 0,
|
||||
"pagingType": "full_numbers",
|
||||
"autoWidth": false,
|
||||
"scrollX": true,
|
||||
@@ -240,7 +241,7 @@ users_list_table_options = {
|
||||
}
|
||||
},
|
||||
"preDrawCallback": function(settings) {
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
var msg = "<i class='fa fa-refresh fa-spin'></i> Fetching rows...";
|
||||
showMsg(msg, false, false, 0)
|
||||
},
|
||||
"rowCallback": function (row, rowData) {
|
||||
|
@@ -1,4 +1,8 @@
|
||||
<!doctype html>
|
||||
<%
|
||||
import plexpy
|
||||
plex_login = plexpy.CONFIG.HTTP_PLEX_ADMIN or plexpy.CONFIG.ALLOW_GUEST_ACCESS
|
||||
%>
|
||||
<!doctype html>
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -8,7 +12,6 @@
|
||||
<meta name="description" content="">
|
||||
<meta name="author" content="">
|
||||
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet">
|
||||
<link href="${http_root}css/pnotify.custom.min.css" rel="stylesheet" />
|
||||
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
|
||||
<link href="${http_root}css/opensans.min.css" rel="stylesheet">
|
||||
<link href="${http_root}css/font-awesome.all.min.css" rel="stylesheet">
|
||||
@@ -32,41 +35,73 @@
|
||||
<meta name="msapplication-config" content="${http_root}images/favicon/browserconfig.xml?v=2.0.5">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="body-container">
|
||||
<body style="margin: 0; overflow: auto;">
|
||||
<div class="login-body-container">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="login-container">
|
||||
<div class="login-logo">
|
||||
<img src="${http_root}images/logo-tautulli-100.png" height="100" alt="PlexPy">
|
||||
<img src="${http_root}images/logo-tautulli-100.png" height="100" alt="Tautulli">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-6 col-sm-offset-3">
|
||||
<form id="login-form">
|
||||
<div id="incorrect-login" class="alert alert-danger" style="text-align: center; padding: 8px; display: none;">
|
||||
Incorrect username or password.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="username" class="control-label">
|
||||
Username
|
||||
</label>
|
||||
<input type="text" id="username" name="username" class="form-control" autocorrect="off" autocapitalize="off" autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password" class="control-label">
|
||||
Password
|
||||
</label>
|
||||
<input type="password" id="password" name="password" class="form-control">
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<div class="remember-group">
|
||||
<label class="control-label">
|
||||
<input type="checkbox" id="remember_me" name="remember_me" title="for 30 days" value="1" checked="checked" /> Remember me
|
||||
</label>
|
||||
<div id="sign-in-alert" class="alert alert-danger login-alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-6 col-sm-offset-3">
|
||||
<ul id="login-methods" class="accordion list-unstyled">
|
||||
% if plex_login:
|
||||
<li class="open">
|
||||
<div class="link login-method-header">
|
||||
Sign In with Plex
|
||||
</div>
|
||||
<button id="sign-in" type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i> Sign In</button>
|
||||
</div>
|
||||
</form>
|
||||
<ul class="submenu login-button-plex" style="display: block;">
|
||||
<li>
|
||||
<div>
|
||||
<button id="sign-in-plex" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i> Sign In</button>
|
||||
</div>
|
||||
<div class="remember-group">
|
||||
<label class="control-label">
|
||||
<input type="checkbox" id="remember_me_plex" name="remember_me_plex" title="for 30 days" value="1" checked="checked" /> Remember me
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
% endif
|
||||
<li class="${'open' if not plex_login else ''}">
|
||||
<div class="link login-method-header">
|
||||
Sign In with Tautulli
|
||||
</div>
|
||||
<ul class="submenu" style="${'display: block;' if not plex_login else ''}">
|
||||
<li>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username" class="control-label">
|
||||
Username
|
||||
</label>
|
||||
<input type="text" id="username" name="username" class="form-control" autocorrect="off" autocapitalize="off" autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password" class="control-label">
|
||||
Password
|
||||
</label>
|
||||
<input type="password" id="password" name="password" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="remember-group">
|
||||
<label class="control-label">
|
||||
<input type="checkbox" id="remember_me" name="remember_me" title="for 30 days" value="1" checked="checked" /> Remember me
|
||||
</label>
|
||||
</span>
|
||||
<button id="sign-in" type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i> Sign In</button>
|
||||
</div>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,29 +110,76 @@
|
||||
</div>
|
||||
|
||||
<script src="${http_root}js/jquery-2.1.4.min.js"></script>
|
||||
<script src="${http_root}js/platform.min.js"></script>
|
||||
<script src="${http_root}js/script.js${cache_param}"></script>
|
||||
<script>
|
||||
var login_accordion = new Accordion($('#login-methods'), false, false);
|
||||
|
||||
function OAuthSuccessCallback(authToken) {
|
||||
signIn(true, authToken);
|
||||
}
|
||||
function OAuthErrorCallback() {
|
||||
$('#sign-in-alert').text('Error communicating with Plex.tv.').show();
|
||||
}
|
||||
|
||||
$('#sign-in-plex').click(function() {
|
||||
PlexOAuth(OAuthSuccessCallback, OAuthErrorCallback);
|
||||
});
|
||||
|
||||
$('#login-form').submit(function(event) {
|
||||
event.preventDefault();
|
||||
$('#sign-in').prop('disabled', true).html('<i class="fa fa-refresh fa-spin"></i> Sign In');
|
||||
signIn(false);
|
||||
});
|
||||
|
||||
function signIn(plex, token) {
|
||||
$('.login-container button').prop('disabled', true);
|
||||
if (plex) {
|
||||
$('#sign-in-plex').html('<i class="fa fa-refresh fa-spin"></i> Sign In');
|
||||
} else {
|
||||
$('#sign-in').html('<i class="fa fa-refresh fa-spin"></i> Sign In');
|
||||
}
|
||||
|
||||
const username = plex ? null : $('#username').val();
|
||||
const password = plex ? null : $('#password').val();
|
||||
const remember_me = plex ? ($('#remember_me_plex').is(':checked') ? '1' : '0')
|
||||
: ($('#remember_me').is(':checked') ? '1' : '0');
|
||||
|
||||
var data = {
|
||||
username: username,
|
||||
password: password,
|
||||
token: token,
|
||||
remember_me: remember_me
|
||||
};
|
||||
data = $.extend(data, x_plex_headers);
|
||||
|
||||
$.ajax({
|
||||
url: '${http_root}auth/signin',
|
||||
type: 'POST',
|
||||
data: $(this).serialize(),
|
||||
data: data,
|
||||
dataType: 'json',
|
||||
statusCode: {
|
||||
200: function() {
|
||||
window.location = "${redirect_uri or http_root}";
|
||||
},
|
||||
401: function() {
|
||||
$('#incorrect-login').show();
|
||||
$('#username').focus();
|
||||
if (plex) {
|
||||
$('#sign-in-alert').text('Invalid Plex Login.').show();
|
||||
} else {
|
||||
$('#sign-in-alert').text('Incorrect username or password.').show();
|
||||
$('#username').focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
complete: function() {
|
||||
$('#sign-in').prop('disabled', false).html('<i class="fa fa-sign-in"></i> Sign In');
|
||||
$('.login-container button').prop('disabled', false);
|
||||
if (plex) {
|
||||
$('#sign-in-plex').html('<i class="fa fa-sign-in"></i> Sign In');
|
||||
} else {
|
||||
$('#sign-in').html('<i class="fa fa-sign-in"></i> Sign In');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@@ -20,6 +20,24 @@
|
||||
</div>
|
||||
<p class="help-block">Optional: Enter a friendly name for this device. Leave blank for default.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="friendly_name">Device Token</label>
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<input type="text" class="form-control" id="device_token" value="${device['device_token']}" size="30" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Your app device token.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="friendly_name">OneSignal Device ID</label>
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<input type="text" class="form-control" id="device_id" value="${device['device_id']}" size="30" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Your OneSignal device ID for notifications.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -21,7 +21,7 @@
|
||||
<div class="row">
|
||||
<div class="login-container">
|
||||
<div class="newsletter-logo">
|
||||
<img src="${http_root}images/newsletter/newsletter-header.png" height="100" alt="PlexPy">
|
||||
<img src="${http_root}images/newsletter/newsletter-header.png" height="100" alt="Tautulli">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-6 col-sm-offset-3">
|
||||
|
@@ -173,7 +173,11 @@
|
||||
<input type="text" class="form-control" id="id_name" name="id_name" value="${newsletter['id_name']}" size="30">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Optional: Enter a unique ID name to create a static URL to the last sent scheduled newsletter at <span class="inline-pre">${http_root}newsletter/id/<id_name></span>. Only letters (a-z), numbers (0-9), underscores (_) and hyphens (-) are allowed. Leave blank to disable.</p>
|
||||
<p class="help-block">
|
||||
Optional: Enter a unique ID name to create a static URL to the <em>last sent scheduled newsletter</em> at <span class="inline-pre">${http_root}newsletter/id/<id_name></span>.
|
||||
Only letters (a-z), numbers (0-9), underscores (_) and hyphens (-) are allowed. Leave blank to disable.<br>
|
||||
Note: Test newsletters are not considered as scheduled newsletters.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="friendly_name">Description</label>
|
||||
@@ -218,6 +222,13 @@
|
||||
<input type="hidden" id="newsletter_config_formatted" name="newsletter_config_formatted" value="${newsletter['config']['formatted']}">
|
||||
</div>
|
||||
<div class="form-group" id="email_notifier_select">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="newsletter_config_threaded_checkbox" data-id="newsletter_config_threaded" class="checkboxes" value="1" ${checked(newsletter['config']['threaded'])}> Enable Grouped Email Thread
|
||||
</label>
|
||||
<p class="help-block">Enable to group this newsletter together in a single Email thread. Disable to send a new Email for each newsletter.</p>
|
||||
<input type="hidden" id="newsletter_config_threaded" name="newsletter_config_threaded" value="${newsletter['config']['threaded']}">
|
||||
</div>
|
||||
<label for="newsletter_email_notifier_id">Email Notification Agent</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
@@ -766,9 +777,12 @@
|
||||
|
||||
// auto resizing textarea for custom notification message body
|
||||
$('textarea[data-autoresize]').each(function () {
|
||||
var modal_body = $(this).closest('.modal-body');
|
||||
var offset = this.offsetHeight - this.clientHeight;
|
||||
var resizeTextarea = function (el) {
|
||||
var modal_offset = modal_body.scrollTop();
|
||||
$(el).css('height', 'auto').css('height', el.scrollHeight + offset);
|
||||
modal_body.scrollTop(modal_offset);
|
||||
};
|
||||
$(this).on('focus keyup input', function () { resizeTextarea(this); }).removeAttr('data-autoresize');
|
||||
});
|
||||
|
@@ -21,7 +21,13 @@
|
||||
<li role="presentation" class="active"><a href="#tabs-notifier_config" aria-controls="tabs-notifier_config" role="tab" data-toggle="tab">Configuration</a></li>
|
||||
<li role="presentation"><a href="#tabs-notify_triggers" aria-controls="tabs-notify_triggers" role="tab" data-toggle="tab">Triggers</a></li>
|
||||
<li role="presentation"><a href="#tabs-notify_conditions" aria-controls="tabs-notify_conditions" role="tab" data-toggle="tab">Conditions</a></li>
|
||||
<li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">${'Arguments' if notifier['agent_name'] == 'scripts' else 'Text'}</a></li>
|
||||
% if notifier['agent_name'] == 'scripts':
|
||||
<li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">Arguments</a></li>
|
||||
% elif notifier['agent_name'] == 'webhook':
|
||||
<li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">Data</a></li>
|
||||
% else:
|
||||
<li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">Text</a></li>
|
||||
% endif
|
||||
<li role="presentation"><a href="#tabs-test_notifications" aria-controls="tabs-test_notifications" role="tab" data-toggle="tab">Test Notifications</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -184,6 +190,8 @@
|
||||
<p class="help-block">
|
||||
% if notifier['agent_name'] == 'scripts':
|
||||
Set the custom arguments passed to the script for each type of notification.
|
||||
% elif notifier['agent_name'] == 'webhook':
|
||||
Set the custom JSON data sent to the webhook for each type of notification.
|
||||
% else:
|
||||
Set the custom formatted text for each type of notification.
|
||||
% endif
|
||||
@@ -225,6 +233,32 @@
|
||||
</ul>
|
||||
</li>
|
||||
% endfor
|
||||
% elif notifier['agent_name'] == 'webhook':
|
||||
% for action in available_notification_actions:
|
||||
<li>
|
||||
<div class="link">
|
||||
<span class="toggle-left"><i class="fa ${action['icon']} fa-fw"></i></span>
|
||||
${action['label']}
|
||||
<span class="toggle-right"><i class="fa fa-chevron-down"></i></span>
|
||||
</div>
|
||||
<ul class="submenu">
|
||||
<li>
|
||||
<div class="form-group">
|
||||
<label for="${action['name']}_body">JSON Data</label>
|
||||
<textarea class="form-control" id="${action['name']}_body" name="${action['name']}_body" data-parsley-trigger="change" data-autoresize required>${notifier['notify_text'][action['name']]['body']}</textarea>
|
||||
<p class="help-block">Set custom JSON data.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input type="button" class="btn btn-bright notifier-text-preview" data-action="${action['name']}" value="Preview JSON Data">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
% endfor
|
||||
% else:
|
||||
% for action in available_notification_actions:
|
||||
<li>
|
||||
@@ -291,6 +325,16 @@
|
||||
</div>
|
||||
<p class="help-block">Set custom arguments passed to the script.</p>
|
||||
</div>
|
||||
% elif notifier['agent_name'] == 'webhook':
|
||||
<div class="form-group">
|
||||
<label for="test_body">JSON Data</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<textarea class="form-control" id="test_body" name="test_body" data-autoresize></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Set custom JSON data sent to the webhook.</p>
|
||||
</div>
|
||||
% else:
|
||||
<div class="form-group">
|
||||
<label for="test_subject">Subject Line</label>
|
||||
@@ -305,7 +349,7 @@
|
||||
<label for="test_body">Message Body</label>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<input class="form-control" type="text" id="test_body" name="test_body" value="Test notification">
|
||||
<textarea class="form-control" id="test_body" name="test_body" data-autoresize>Test Notification</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Set a custom body.</p>
|
||||
@@ -735,6 +779,7 @@
|
||||
|
||||
$.ajax({
|
||||
url: 'get_notify_text_preview',
|
||||
type: 'POST',
|
||||
data: {
|
||||
notify_action: action,
|
||||
subject: subject,
|
||||
@@ -811,9 +856,12 @@
|
||||
|
||||
// auto resizing textarea for custom notification message body
|
||||
$('textarea[data-autoresize]').each(function () {
|
||||
var modal_body = $(this).closest('.modal-body');
|
||||
var offset = this.offsetHeight - this.clientHeight;
|
||||
var resizeTextarea = function (el) {
|
||||
var modal_offset = modal_body.scrollTop();
|
||||
$(el).css('height', 'auto').css('height', el.scrollHeight + offset);
|
||||
modal_body.scrollTop(modal_offset);
|
||||
};
|
||||
$(this).on('focus keyup input', function () { resizeTextarea(this); }).removeAttr('data-autoresize');
|
||||
});
|
||||
|
@@ -9,7 +9,9 @@
|
||||
% for item in text:
|
||||
<div style="padding-bottom: 10px;">
|
||||
<h4>${item['media_type'].capitalize()}</h4>
|
||||
% if agent != 'webhook':
|
||||
<pre>${item['subject']}</pre>
|
||||
% endif
|
||||
% if agent != 'scripts':
|
||||
<pre>${item['body']}</pre>
|
||||
% endif
|
||||
|
@@ -7,6 +7,9 @@
|
||||
from plexpy import common, notifiers, newsletters
|
||||
from plexpy.helpers import anon_url, checked
|
||||
|
||||
docker_setting = 'disabled' if plexpy.DOCKER else ''
|
||||
docker_msg = '<span class="docker-setting small">(Controlled by Docker Container)</span>' if plexpy.DOCKER else ''
|
||||
|
||||
available_notification_agents = sorted(notifiers.available_notification_agents(), key=lambda k: k['label'].lower())
|
||||
available_newsletter_agents = sorted(newsletters.available_newsletter_agents(), key=lambda k: k['label'].lower())
|
||||
%>
|
||||
@@ -33,7 +36,7 @@
|
||||
<button id="menu_link_show_advanced_settings" class="btn btn-dark"><i class="fa fa-wrench"></i> Show Advanced</button>
|
||||
% endif
|
||||
% if config['check_github']:
|
||||
<button id="menu_link_update_check" class="btn btn-dark"><i class="fa fa-arrow-circle-up"></i> Check for Updates</button>
|
||||
<button id="menu_link_update_check" class="btn btn-dark"><i class="fa fa-arrow-alt-circle-up"></i> Check for Updates</button>
|
||||
% endif
|
||||
<button id="menu_link_restart" class="btn btn-dark"><i class="fa fa-refresh"></i> Restart</button>
|
||||
<button id="menu_link_shutdown" class="btn btn-dark"><i class="fa fa-power-off"></i> Shutdown</button>
|
||||
@@ -230,12 +233,12 @@
|
||||
|
||||
% if plexpy.INSTALL_TYPE == 'git':
|
||||
<div class="form-group advanced-setting">
|
||||
<label for="git_branch">Git Remote / Branch</label>
|
||||
<label for="git_branch">Git Remote / Branch</label> ${docker_msg | n}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group git-group">
|
||||
<input type="text" class="form-control" id="git_remote" name="git_remote" value="${config['git_remote']}" data-parsley-trigger="change">
|
||||
<select class="form-control" id="git_branch" name="git_branch">
|
||||
<input type="text" class="form-control" id="git_remote" name="git_remote" value="${config['git_remote']}" data-parsley-trigger="change" ${docker_setting}>
|
||||
<select class="form-control" id="git_branch" name="git_branch" ${docker_setting}>
|
||||
<% branches = ('master', 'beta', 'nightly') %>
|
||||
% for branch in branches:
|
||||
<option value="${branch}" ${'selected' if config['git_branch'] == branch else ''}>${branch}</option>
|
||||
@@ -245,7 +248,7 @@
|
||||
% endif
|
||||
</select>
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-form" type="button" id="switch_git_branch">Checkout Branch</button>
|
||||
<button class="btn btn-form" type="button" id="switch_git_branch" ${docker_setting}>Checkout Branch</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,10 +256,10 @@
|
||||
<p class="help-block">The git tracking remote and branch (default "origin/master"). Select to switch the git branch (requires restart).</p>
|
||||
</div>
|
||||
<div class="form-group advanced-setting">
|
||||
<label for="git_path">Git Path</label>
|
||||
<label for="git_path">Git Path</label> ${docker_msg | n}
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control" id="git_path" name="git_path" value="${config['git_path']}" size="30">
|
||||
<input type="text" class="form-control" id="git_path" name="git_path" value="${config['git_path']}" size="30" ${docker_setting}>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Optional: The path to your git environment variable. Leave blank for default.</p>
|
||||
@@ -430,6 +433,14 @@
|
||||
</div>
|
||||
|
||||
<p class="help-block">Note: Web interface changes require a restart.</p>
|
||||
% if os.name == 'nt':
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" class="http-settings" name="win_sys_tray" id="win_sys_tray" value="1" ${config['win_sys_tray']}> Enable System Tray Icon
|
||||
</label>
|
||||
<p class="help-block">Show Tautulli shortcut in the system tray.</p>
|
||||
</div>
|
||||
% endif
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="launch_browser" id="launch_browser" value="1" ${config['launch_browser']}> Launch Browser on Startup
|
||||
@@ -437,19 +448,19 @@
|
||||
<p class="help-block">Launch browser pointed to Tautulli on startup.</p>
|
||||
</div>
|
||||
<div class="form-group advanced-setting">
|
||||
<label for="http_host">HTTP Host</label>
|
||||
<label for="http_host">HTTP Host</label> ${docker_msg | n}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control http-settings" id="http_host" name="http_host" value="${config['http_host']}" data-parsley-trigger="change" required>
|
||||
<input type="text" class="form-control http-settings" id="http_host" name="http_host" value="${config['http_host']}" data-parsley-trigger="change" required ${docker_setting}>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">localhost or an IP address to bind the web server to. Default 0.0.0.0 to bind to all interfaces.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="http_port">HTTP Port</label>
|
||||
<label for="http_port">HTTP Port</label> ${docker_msg | n}
|
||||
<div class="row">
|
||||
<div class="col-md-2">
|
||||
<input type="text" class="form-control http-settings" data-parsley-type="integer" id="http_port" name="http_port" value="${config['http_port']}" data-parsley-trigger="change" data-parsley-errors-container="#http_port_error" required>
|
||||
<input type="text" class="form-control http-settings" data-parsley-type="integer" id="http_port" name="http_port" value="${config['http_port']}" data-parsley-trigger="change" data-parsley-errors-container="#http_port_error" required ${docker_setting}>
|
||||
</div>
|
||||
<div id="http_port_error" class="alert alert-danger settings-alert" role="alert"></div>
|
||||
</div>
|
||||
@@ -650,12 +661,20 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group has-feedback" id="pms_ip_group">
|
||||
<label for="pms_ip">Plex IP Address or Hostname</label>
|
||||
<label for="pms_ip_selectize">Plex IP Address or Hostname</label>
|
||||
<div class="row">
|
||||
<div class="col-md-9" id="selectize-pms-ip-container">
|
||||
<div class="input-group">
|
||||
<select class="form-control pms-settings selectize-pms-ip" id="pms_ip" name="pms_ip" data-parsley-trigger="change" aria-describedby="server-verified" data-parsley-errors-container="#pms_ip_error" required>
|
||||
<option value="${config['pms_ip']}" selected>${config['pms_ip']}</option>
|
||||
<select class="form-control pms-settings selectize-pms-ip" id="pms_ip_selectize" data-parsley-trigger="change" aria-describedby="server-verified" data-parsley-errors-container="#pms_ip_error" required>
|
||||
<option value="${config['pms_ip']}:${config['pms_port']}"
|
||||
data-identifier="${config['pms_identifier']}"
|
||||
data-ip="${config['pms_ip']}"
|
||||
data-port="${config['pms_port']}"
|
||||
data-local="${int(not int(config['pms_is_remote']))}"
|
||||
data-ssl="${config['pms_ssl']}"
|
||||
data-is_cloud="${config['pms_is_cloud']}"
|
||||
data-label="${config['pms_name'] or 'Local'}"
|
||||
selected>${config['pms_ip']}</option>
|
||||
</select>
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-form" type="button" id="verify_server_button">Verify Server</button>
|
||||
@@ -738,6 +757,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="pms_ip" name="pms_ip" value="${config['pms_ip']}">
|
||||
<input type="hidden" id="pms_is_cloud" name="pms_is_cloud" value="${config['pms_is_cloud']}">
|
||||
<input type="checkbox" name="server_changed" id="server_changed" value="1" style="display: none;">
|
||||
|
||||
@@ -842,21 +862,23 @@
|
||||
<h3>Plex.tv Authentication</h3>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group has-feedback">
|
||||
<label for="pms_token">Plex.tv Account Token</label>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="pms_token" name="pms_token" value="${config['pms_token']}" data-parsley-trigger="change" data-parsley-errors-container="#pms_token_error" required>
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-form" type="button" data-toggle="modal" data-target="#pms-auth-modal">Fetch Token</button>
|
||||
<button id="sign-in-plex" class="btn btn-form" type="button">Fetch Token</button>
|
||||
</span>
|
||||
</div>
|
||||
<span class="form-control-feedback" id="token_verify" aria-hidden="true" style="right: 80px;"></span>
|
||||
</div>
|
||||
<div id="pms_token_error" class="alert alert-danger settings-alert" role="alert"></div>
|
||||
</div>
|
||||
<p class="help-block">Token for Plex.tv authentication.</p>
|
||||
</div>
|
||||
<input type="hidden" id="pms_uuid" name="pms_uuid" value="${config['pms_uuid']}">
|
||||
|
||||
<p><input type="button" class="btn btn-bright save-button" value="Save" data-success="Changes saved successfully"></p>
|
||||
|
||||
@@ -868,7 +890,6 @@
|
||||
<h3>Current Activity Notifications</h3>
|
||||
</div>
|
||||
|
||||
<p class="help-block">Note: Buffer warnings only work on certain Plex clients. Android and Plex Web do not report buffer events accurately or at all.</p>
|
||||
<div class="form-group">
|
||||
<label for="buffer_threshold">Buffer Threshold</label>
|
||||
<div class="row">
|
||||
@@ -877,7 +898,13 @@
|
||||
</div>
|
||||
<div id="buffer_threshold_error" class="alert alert-danger settings-alert" role="alert"></div>
|
||||
</div>
|
||||
<p class="help-block">How many buffer events should we wait before triggering the first warning. Buffer events increment on each monitor ping if play state is buffering. 0 to disable buffer warnings.</p>
|
||||
<p class="help-block">
|
||||
The number of buffer events required before triggering the first notification.
|
||||
Buffer events increment on each incoming websocket message if the play state is buffering.
|
||||
<br>
|
||||
Note: Buffer warnings only work on certain Plex clients. Some clients can send excessive buffer messages or no messages at all.
|
||||
This notification may be unreliable and not indicative of a real problem.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group advanced-setting">
|
||||
<label for="buffer_wait">Buffer Wait</label>
|
||||
@@ -1015,10 +1042,10 @@
|
||||
<p class="help-block">Optional: Enter the full path to your custom newsletter templates folder. Leave blank for default.</p>
|
||||
</div>
|
||||
<div class="form-group advanced-setting">
|
||||
<label for="newsletter_dir">Newsletter Output Directory</label>
|
||||
<label for="newsletter_dir">Newsletter Output Directory</label> ${docker_msg | n}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="newsletter_dir" name="newsletter_dir" value="${config['newsletter_dir']}">
|
||||
<input type="text" class="form-control" id="newsletter_dir" name="newsletter_dir" value="${config['newsletter_dir']}" ${docker_setting}>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Enter the full path to where newsletter files will be saved.</p>
|
||||
@@ -1214,10 +1241,10 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="log_dir">Log Directory</label>
|
||||
<label for="log_dir">Log Directory</label> ${docker_msg | n}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control directory-settings" id="log_dir" name="log_dir" value="${config['log_dir']}">
|
||||
<input type="text" class="form-control directory-settings" id="log_dir" name="log_dir" value="${config['log_dir']}" ${docker_setting}>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-form" type="button" id="clear_logs">Clear Logs</button>
|
||||
</div>
|
||||
@@ -1225,10 +1252,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="backup_dir">Backup Directory</label>
|
||||
<label for="backup_dir">Backup Directory</label> ${docker_msg | n}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control directory-settings" id="backup_dir" name="backup_dir" value="${config['backup_dir']}">
|
||||
<input type="text" class="form-control directory-settings" id="backup_dir" name="backup_dir" value="${config['backup_dir']}" ${docker_setting}>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-form" type="button" id="backup_config">Backup Config</button>
|
||||
<button class="btn btn-form" type="button" id="backup_database">Backup Database</button>
|
||||
@@ -1237,10 +1264,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cache_dir">Cache Directory</label>
|
||||
<label for="cache_dir">Cache Directory</label> ${docker_msg | n}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control directory-settings" id="cache_dir" name="cache_dir" value="${config['cache_dir']}">
|
||||
<input type="text" class="form-control directory-settings" id="cache_dir" name="cache_dir" value="${config['cache_dir']}" ${docker_setting}>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-form" type="button" id="clear_cache">Clear All Cache</button>
|
||||
<button class="btn btn-form" type="button" id="clear_image_cache">Clear Image Cache</button>
|
||||
@@ -1366,49 +1393,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pms-auth-modal" class="modal fade" tabindex="-1" role="dialog"
|
||||
aria-labelledby="ip-info-modal">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
|
||||
<h4 class="modal-title">Fetch Plex.tv Token</h4>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-text">
|
||||
<div>
|
||||
<p class="help-block">
|
||||
This will attempt to fetch a new Plex.tv token for you. Tautulli does not store your username and password.
|
||||
Note: This will not work on Internet Explorer 9 or lower.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="pms_username">Plex.tv Username</label>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="pms_username" name="pms_username" size="30">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Username for Plex.tv authentication.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pms_password">Plex.tv Password</label>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<input type="password" class="form-control" id="pms_password" name="pms_password" size="30">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Password for Plex.tv authentication.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div style="float: left;">
|
||||
<strong><span id="pms-token-status"></span></strong>
|
||||
</div>
|
||||
<input type="button" id="get-pms-auth-token" class="btn btn-bright" value="Fetch Token">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app-import-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="app-import-modal"></div>
|
||||
<div id="add-notifier-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="add-notifier-modal">
|
||||
<div class="modal-dialog" role="document">
|
||||
@@ -1961,7 +1945,7 @@ $(document).ready(function() {
|
||||
$('#menu_link_update_check').click(function() {
|
||||
$(this).html('<i class="fa fa-spin fa-refresh"></i> Checking').prop('disabled', true);
|
||||
checkUpdate(function () {
|
||||
$('#menu_link_update_check').html('<i class="fa fa-arrow-circle-up"></i> Check for Updates')
|
||||
$('#menu_link_update_check').html('<i class="fa fa-arrow-alt-circle-up"></i> Check for Updates')
|
||||
.prop('disabled', false);
|
||||
});
|
||||
});
|
||||
@@ -2094,7 +2078,7 @@ $(document).ready(function() {
|
||||
}
|
||||
});
|
||||
|
||||
var $select_pms = $('#pms_ip').selectize({
|
||||
var $select_pms = $('#pms_ip_selectize').selectize({
|
||||
createOnBlur: true,
|
||||
openOnFocus: true,
|
||||
maxItems: 1,
|
||||
@@ -2105,13 +2089,19 @@ $(document).ready(function() {
|
||||
dropdownParent: '#selectize-pms-ip-container',
|
||||
render: {
|
||||
item: function (item, escape) {
|
||||
if (!item.label) {
|
||||
$.extend(item,
|
||||
$(this.revertSettings.$children)
|
||||
.filter('[value="' + item.value + '"]').data()
|
||||
);
|
||||
}
|
||||
var label = item.label || item.value;
|
||||
var caption = item.label ? item.value : null;
|
||||
return '<div data-ssl="' + item.httpsRequired +
|
||||
'" data-local="' + item.local +
|
||||
'" data-identifier="' + item.clientIdentifier +
|
||||
var caption = item.label ? item.ip : null;
|
||||
return '<div data-identifier="' + item.clientIdentifier +
|
||||
'" data-ip="' + item.ip +
|
||||
'" data-port="' + item.port +
|
||||
'" data-local="' + item.local +
|
||||
'" data-ssl="' + item.httpsRequired +
|
||||
'" data-is_cloud="' + item.is_cloud +
|
||||
'" data-label="' + item.label + '">' +
|
||||
'<span class="item-text">' + escape(label) + '</span>' +
|
||||
@@ -2121,11 +2111,11 @@ $(document).ready(function() {
|
||||
option: function (item, escape) {
|
||||
var label = item.label || item.value;
|
||||
var caption = item.label ? item.value : null;
|
||||
return '<div data-ssl="' + item.httpsRequired +
|
||||
'" data-local="' + item.local +
|
||||
'" data-identifier="' + item.clientIdentifier +
|
||||
return '<div data-identifier="' + item.clientIdentifier +
|
||||
'" data-ip="' + item.ip +
|
||||
'" data-port="' + item.port +
|
||||
'" data-local="' + item.local +
|
||||
'" data-ssl="' + item.httpsRequired +
|
||||
'" data-is_cloud="' + item.is_cloud +
|
||||
'" data-label="' + item.label + '">' +
|
||||
escape(label) +
|
||||
@@ -2136,15 +2126,24 @@ $(document).ready(function() {
|
||||
create: function(input) {
|
||||
return {label: '', value: input};
|
||||
},
|
||||
onInitialize: function () {
|
||||
var s = this;
|
||||
this.revertSettings.$children.each(function () {
|
||||
$.extend(s.options[this.value], $(this).data());
|
||||
});
|
||||
},
|
||||
onChange: function (item) {
|
||||
var pms_ip_selected = this.getItem(item)[0];
|
||||
var identifier = $(pms_ip_selected).data('identifier');
|
||||
var ip = $(pms_ip_selected).data('ip');
|
||||
var port = $(pms_ip_selected).data('port');
|
||||
var local = $(pms_ip_selected).data('local');
|
||||
var ssl = $(pms_ip_selected).data('ssl');
|
||||
var is_cloud = $(pms_ip_selected).data('is_cloud');
|
||||
var value = $(pms_ip_selected).data('value');
|
||||
|
||||
$("#pms_identifier").val(identifier !== 'undefined' ? identifier : '');
|
||||
$('#pms_ip').val(ip !== 'undefined' ? ip : value);
|
||||
$('#pms_port').val(port !== 'undefined' ? port : 32400);
|
||||
$('#pms_is_remote_checkbox').prop('checked', (local !== 'undefined' && local === 0));
|
||||
$('#pms_is_remote').val(local !== 'undefined' && local === 0 ? 1 : 0);
|
||||
@@ -2169,9 +2168,10 @@ $(document).ready(function() {
|
||||
},
|
||||
success: function (result) {
|
||||
if (result) {
|
||||
var existing_value = $('#pms_ip').val();
|
||||
var existing_ip = $('#pms_ip').val();
|
||||
var existing_port = $('#pms_port').val();
|
||||
result.forEach(function (item) {
|
||||
if (item.value === existing_value) {
|
||||
if (item.ip === existing_ip && item.port === existing_port) {
|
||||
select_pms.updateOption(item.value, item);
|
||||
} else {
|
||||
select_pms.addOption(item);
|
||||
@@ -2296,40 +2296,21 @@ $(document).ready(function() {
|
||||
window.open(pms_web_url, '_blank');
|
||||
});
|
||||
|
||||
// Plex.tv auth token fetch
|
||||
$("#get-pms-auth-token").click(function() {
|
||||
$("#pms-token-status").html('<i class="fa fa-refresh fa-spin"></i> Fetching token...');
|
||||
var pms_username = $.trim($("#pms_username").val());
|
||||
var pms_password = $.trim($("#pms_password").val());
|
||||
if ((pms_username !== '') && (pms_password !== '')) {
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: 'get_plexpy_pms_token',
|
||||
data: {
|
||||
username: pms_username,
|
||||
password: pms_password,
|
||||
force: true
|
||||
},
|
||||
cache: false,
|
||||
async: true,
|
||||
complete: function(xhr, status) {
|
||||
var result = $.parseJSON(xhr.responseText);
|
||||
var msg = result.message;
|
||||
if (result.result == 'success') {
|
||||
var authToken = result.token;
|
||||
$("#pms-token-status").html('<i class="fa fa-check"></i> ' + msg);
|
||||
$("#pms_token").val(authToken);
|
||||
$('#pms-auth-modal').modal('hide');
|
||||
getServerOptions(authToken);
|
||||
} else {
|
||||
$("#pms-token-status").html('<i class="fa fa-exclamation-circle"></i> ' + msg);
|
||||
}
|
||||
loadUpdateDistros();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$("#pms-token-status").html('<i class="fa fa-exclamation-circle"></i> Username and password required.');
|
||||
}
|
||||
function OAuthPreFunction() {
|
||||
$("#token_verify").html('<i class="fa fa-refresh fa-spin"></i>').fadeIn('fast');
|
||||
}
|
||||
function OAuthSuccessCallback(authToken) {
|
||||
$("#pms_token").val(authToken);
|
||||
$("#pms_uuid").val(x_plex_headers['X-Plex-Client-Identifier']);
|
||||
$("#token_verify").html('<i class="fa fa-check"></i>').fadeIn('fast');
|
||||
getServerOptions(authToken);
|
||||
}
|
||||
function OAuthErrorCallback() {
|
||||
$("#token_verify").html('<i class="fa fa-close"></i>').fadeIn('fast');
|
||||
}
|
||||
|
||||
$('#sign-in-plex').click(function() {
|
||||
PlexOAuth(OAuthSuccessCallback, OAuthErrorCallback, OAuthPreFunction);
|
||||
});
|
||||
|
||||
// Load database import modal
|
||||
|
@@ -60,7 +60,7 @@
|
||||
|
||||
$('#popout-iframe-button').click(function () {
|
||||
var iframe = $('#support-iframe');
|
||||
popout_chat = window.open(iframe.data('src'), 'Tautulli-Discord-Support', 'width=1280,height=720');
|
||||
popout_chat = PopupCenter(iframe.data('src'), 'Tautulli-Discord-Support', 1280, 720);
|
||||
iframe.attr('src', '').fadeOut();
|
||||
$('.iframe-overlay').fadeIn();
|
||||
});
|
||||
|
@@ -203,8 +203,8 @@ DOCUMENTATION :: END
|
||||
$('#confirm-modal-update').modal();
|
||||
$('#confirm-modal-update').one('click', '#confirm-update', function () {
|
||||
$(this).prop('disabled', true);
|
||||
var msg = '<i class="fa fa-refresh fa-spin"></i> Updating database...'
|
||||
showMsg(msg, false, false, 0)
|
||||
var msg = '<i class="fa fa-refresh fa-spin"></i> Updating database...';
|
||||
showMsg(msg, false, false, 0);
|
||||
|
||||
$.ajax({
|
||||
url: 'update_metadata_details',
|
||||
|
@@ -156,17 +156,17 @@ DOCUMENTATION :: END
|
||||
</div>
|
||||
% endif
|
||||
<div class="btn-group" data-toggle="buttons" id="media_type-selection">
|
||||
<label class="btn btn-dark active">
|
||||
<input type="radio" name="media_type-filter" id="history-all" value="" autocomplete="off"> All
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" name="media_type-filter" id="history-all" value="all" autocomplete="off"> All
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" name="media_type-filter" id="history-movies" value="movie" autocomplete="off"> Movies
|
||||
<input type="radio" name="media_type-filter" id="history-movie" value="movie" autocomplete="off"> Movies
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" name="media_type-filter" id="history-tv_shows" value="episode" autocomplete="off"> TV Shows
|
||||
<input type="radio" name="media_type-filter" id="history-episode" value="episode" autocomplete="off"> TV Shows
|
||||
</label>
|
||||
<label class="btn btn-dark">
|
||||
<input type="radio" name="media_type-filter" id="history-music" value="track" autocomplete="off"> Music
|
||||
<input type="radio" name="media_type-filter" id="history-track" value="track" autocomplete="off"> Music
|
||||
</label>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
@@ -435,6 +435,7 @@ DOCUMENTATION :: END
|
||||
selected_filter = $('input[name=media_type-filter]:checked', '#media_type-selection');
|
||||
$(selected_filter).closest('label').addClass('active');
|
||||
media_type = $(selected_filter).val();
|
||||
setLocalStorage('user_' + user_id + '-history_media_type', media_type);
|
||||
history_table.draw();
|
||||
});
|
||||
}
|
||||
@@ -494,7 +495,9 @@ DOCUMENTATION :: END
|
||||
|
||||
$('a[href="#tabs-history"]').on('shown.bs.tab', function() {
|
||||
if (typeof(history_table) === 'undefined') {
|
||||
var media_type = null;
|
||||
var media_type = getLocalStorage('user_' + user_id + '-history_media_type', 'all');
|
||||
$('#history-' + media_type).prop('checked', true);
|
||||
$('#history-' + media_type).closest('label').addClass('active');
|
||||
loadHistoryTable(media_type);
|
||||
}
|
||||
});
|
||||
|
@@ -52,51 +52,55 @@
|
||||
<form>
|
||||
<div class="wizard-card" data-cardname="card1">
|
||||
<div style="float: right;">
|
||||
<img src="${http_root}images/logo-tautulli-45.png" height="45" alt="PlexPy">
|
||||
<img src="${http_root}images/logo-tautulli-45.png" height="45" alt="Tautulli">
|
||||
</div>
|
||||
<h3 style="line-height: 50px;">Welcome!</h3>
|
||||
<br />
|
||||
<div>
|
||||
Thanks for taking the time to try out Tautulli. Hope you find it useful.
|
||||
<br /><br />
|
||||
Tautulli requires a permanent internet connection to ensure a reliable experience.
|
||||
<br /><br />
|
||||
This wizard will help you get set up, to continue press Next.
|
||||
<div class="wizard-input-section">
|
||||
<p class="welcome-message">
|
||||
Thanks for taking the time to try out Tautulli. Hope you find it useful.
|
||||
</p>
|
||||
<p class="welcome-message">
|
||||
Tautulli requires a permanent internet connection to ensure a reliable experience.
|
||||
</p>
|
||||
<p class="welcome-message">
|
||||
This wizard will help you get set up, to continue press Next.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wizard-card" data-cardname="card2">
|
||||
<h3>Plex Authentication</h3>
|
||||
<p class="help-block">Enter your Plex.tv username and password. Tautulli does not store your username or password.</p>
|
||||
<div class="wizard-input-section">
|
||||
<label for="pms_username">Plex.tv Username</label>
|
||||
<div class="row">
|
||||
<div class="col-xs-8">
|
||||
<input type="text" class="form-control pms-auth" id="pms_username" placeholder="" required>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
Tautulli requires a Plex.tv account. Click the button below to sign in on Plex.tv. You may need to allow popups in your browser.
|
||||
</p>
|
||||
</div>
|
||||
<div class="wizard-input-section">
|
||||
<label for="pms_password">Plex.tv Password</label>
|
||||
<div class="row">
|
||||
<div class="col-xs-8">
|
||||
<input type="password" class="form-control pms-auth" id="pms_password" placeholder="" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" class="form-control pms-auth" name="pms_token" id="pms_token" value="" data-validate="validatePMStoken">
|
||||
<a class="btn btn-dark" id="pms-authenticate" href="#" role="button">Authenticate</a><span style="margin-left: 10px; display: none;" id="pms-token-status"></span>
|
||||
<input type="hidden" class="form-control" name="pms_token" id="pms_token" value="" data-validate="validatePMStoken">
|
||||
<a class="btn btn-dark" id="sign-in-plex" href="#" role="button">Sign In with Plex</a>
|
||||
<span style="margin-left: 10px; display: none;" id="pms-token-status"></span>
|
||||
</div>
|
||||
<div class="wizard-card" data-cardname="card3">
|
||||
<h3>Plex Media Server</h3>
|
||||
<p class="help-block">
|
||||
Select your Plex Media Server from the dropdown menu or enter an IP address or hostname.
|
||||
</p>
|
||||
<div class="wizard-input-section">
|
||||
<label for="pms_ip">Plex IP or Hostname</label>
|
||||
<p class="help-block">
|
||||
Select your Plex Media Server from the dropdown menu or enter an IP address or hostname.
|
||||
</p>
|
||||
</div>
|
||||
<div class="wizard-input-section">
|
||||
<label for="pms_ip_selectize">Plex IP Address or Hostname</label>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<select class="form-control pms-settings selectize-pms-ip" id="pms_ip" name="pms_ip">
|
||||
<option value="${config['pms_ip']}" selected>${config['pms_ip']}</option>
|
||||
<select class="form-control pms-settings selectize-pms-ip" id="pms_ip_selectize">
|
||||
% if config['pms_identifier']:
|
||||
<option value="${config['pms_ip']}:${config['pms_port']}"
|
||||
data-identifier="${config['pms_identifier']}"
|
||||
data-ip="${config['pms_ip']}"
|
||||
data-port="${config['pms_port']}"
|
||||
data-local="${int(not int(config['pms_is_remote']))}"
|
||||
data-ssl="${config['pms_ssl']}"
|
||||
data-is_cloud="${config['pms_is_cloud']}"
|
||||
data-label="${config['pms_name'] or 'Local'}"
|
||||
selected>${config['pms_ip']}</option>
|
||||
% endif
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,15 +130,20 @@
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="pms_valid" data-validate="validatePMSip" value="">
|
||||
<input type="hidden" id="pms_ip" name="pms_ip" value="${config['pms_ip']}">
|
||||
<input type="hidden" id="pms_is_cloud" name="pms_is_cloud" value="${config['pms_is_cloud']}">
|
||||
<input type="hidden" id="pms_identifier" name="pms_identifier" value="${config['pms_identifier']}">
|
||||
<a class="btn btn-dark" id="verify-plex-server" href="#" role="button">Verify</a><span style="margin-left: 10px; display: none;" id="pms-verify-status"></span>
|
||||
<a class="btn btn-dark" id="verify-plex-server" href="#" role="button">Verify</a>
|
||||
<span style="margin-left: 10px; display: none;" id="pms-verify-status"></span>
|
||||
</div>
|
||||
|
||||
<div class="wizard-card" data-cardname="card4">
|
||||
<h3>Activity Logging</h3>
|
||||
<p class="help-block">Tautulli will keep a history of all streaming activity on your Plex server.</p>
|
||||
|
||||
<div class="wizard-input-section">
|
||||
<p class="help-block">
|
||||
Tautulli will keep a history of all streaming activity on your Plex server.
|
||||
</p>
|
||||
</div>
|
||||
<div class="wizard-input-section">
|
||||
<label for="logging_ignore_interval">Ignore Interval</label>
|
||||
<div class="row">
|
||||
@@ -145,29 +154,38 @@
|
||||
</div>
|
||||
<p class="help-block">The interval (in seconds) an item must be in a playing state before logging it. 0 to disable.</p>
|
||||
</div>
|
||||
|
||||
<p class="help-block">
|
||||
Additional options to disable history logging for certain libraries or users can be found by editing them
|
||||
on the <strong>Libraries</strong> or <strong>Users</strong> pages.
|
||||
</p>
|
||||
<div class="wizard-input-section">
|
||||
<p class="help-block">
|
||||
Additional options to disable history logging for certain libraries or users can be found by editing them
|
||||
on the <strong>Libraries</strong> or <strong>Users</strong> pages.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-card" data-cardname="card4">
|
||||
<h3>Notifications</h3>
|
||||
<p class="help-block">Tautulli can send a wide variety of notifications to alert you of activity on your Plex server.</p>
|
||||
<p class="help-block">
|
||||
To set up a notification agent, navigate to the <strong>Settings</strong> page
|
||||
and to the <strong>Notification Agents</strong> tab after you have completed this setup wizard.
|
||||
</p>
|
||||
<div class="wizard-input-section">
|
||||
<p class="help-block">
|
||||
Tautulli can send a wide variety of notifications to alert you of activity on your Plex server.
|
||||
</p>
|
||||
<p class="help-block">
|
||||
To set up a notification agent, navigate to the <strong>Settings</strong> page
|
||||
and to the <strong>Notification Agents</strong> tab after you have completed this setup wizard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-card" data-cardname="card5">
|
||||
<h3>Database Import</h3>
|
||||
<p class="help-block">If you have an existing PlexWatch/Plexivity database, you can import the data into Tautulli.</p>
|
||||
<p class="help-block">
|
||||
To import a database, navigate to the <strong>Settings</strong> page
|
||||
and to the <strong>Import & Backups</strong> tab after you have completed this setup wizard.
|
||||
</p>
|
||||
<div class="wizard-input-section">
|
||||
<p class="help-block">
|
||||
If you have an existing PlexWatch/Plexivity database, you can import the data into Tautulli.
|
||||
</p>
|
||||
<p class="help-block">
|
||||
To import a database, navigate to the <strong>Settings</strong> page
|
||||
and to the <strong>Import & Backups</strong> tab after you have completed this setup wizard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Required fields but hidden -->
|
||||
<div style="display: none;">
|
||||
@@ -205,6 +223,7 @@
|
||||
<script src="${http_root}js/jquery-2.1.4.min.js"></script>
|
||||
<script src="${http_root}js/bootstrap.min.js"></script>
|
||||
<script src="${http_root}js/selectize.min.js"></script>
|
||||
<script src="${http_root}js/platform.min.js"></script>
|
||||
<script src="${http_root}js/script.js${cache_param}"></script>
|
||||
<script src="${http_root}js/bootstrap-wizard.min.js"></script>
|
||||
<script>
|
||||
@@ -306,7 +325,7 @@ $(document).ready(function() {
|
||||
}
|
||||
});
|
||||
|
||||
var $select_pms = $('#pms_ip').selectize({
|
||||
var $select_pms = $('#pms_ip_selectize').selectize({
|
||||
createOnBlur: true,
|
||||
openOnFocus: true,
|
||||
maxItems: 1,
|
||||
@@ -316,13 +335,19 @@ $(document).ready(function() {
|
||||
inputClass: 'form-control selectize-input',
|
||||
render: {
|
||||
item: function (item, escape) {
|
||||
if (!item.label) {
|
||||
$.extend(item,
|
||||
$(this.revertSettings.$children)
|
||||
.filter('[value="' + item.value + '"]').data()
|
||||
);
|
||||
}
|
||||
var label = item.label || item.value;
|
||||
var caption = item.label ? item.value : null;
|
||||
return '<div data-ssl="' + item.httpsRequired +
|
||||
'" data-local="' + item.local +
|
||||
'" data-identifier="' + item.clientIdentifier +
|
||||
var caption = item.label ? item.ip : null;
|
||||
return '<div data-identifier="' + item.clientIdentifier +
|
||||
'" data-ip="' + item.ip +
|
||||
'" data-port="' + item.port +
|
||||
'" data-local="' + item.local +
|
||||
'" data-ssl="' + item.httpsRequired +
|
||||
'" data-is_cloud="' + item.is_cloud +
|
||||
'" data-label="' + item.label + '">' +
|
||||
'<span class="item-text">' + escape(label) + '</span>' +
|
||||
@@ -332,11 +357,11 @@ $(document).ready(function() {
|
||||
option: function (item, escape) {
|
||||
var label = item.label || item.value;
|
||||
var caption = item.label ? item.value : null;
|
||||
return '<div data-ssl="' + item.httpsRequired +
|
||||
'" data-local="' + item.local +
|
||||
'" data-identifier="' + item.clientIdentifier +
|
||||
return '<div data-identifier="' + item.clientIdentifier +
|
||||
'" data-ip="' + item.ip +
|
||||
'" data-port="' + item.port +
|
||||
'" data-local="' + item.local +
|
||||
'" data-ssl="' + item.httpsRequired +
|
||||
'" data-is_cloud="' + item.is_cloud +
|
||||
'" data-label="' + item.label + '">' +
|
||||
escape(label) +
|
||||
@@ -347,18 +372,27 @@ $(document).ready(function() {
|
||||
create: function(input) {
|
||||
return {label: '', value: input};
|
||||
},
|
||||
onInitialize: function () {
|
||||
var s = this;
|
||||
this.revertSettings.$children.each(function () {
|
||||
$.extend(s.options[this.value], $(this).data());
|
||||
});
|
||||
},
|
||||
onChange: function (item) {
|
||||
var pms_ip_selected = this.getItem(item)[0];
|
||||
var identifier = $(pms_ip_selected).data('identifier');
|
||||
var ip = $(pms_ip_selected).data('ip');
|
||||
var port = $(pms_ip_selected).data('port');
|
||||
var local = $(pms_ip_selected).data('local');
|
||||
var ssl = $(pms_ip_selected).data('ssl');
|
||||
var is_cloud = $(pms_ip_selected).data('is_cloud');
|
||||
var value = $(pms_ip_selected).data('value');
|
||||
|
||||
$("#pms_valid").val(identifier !== 'undefined' ? 'valid' : '');
|
||||
$("#pms-verify-status").html(identifier !== 'undefined' ? '<i class="fa fa-check"></i> Server found!' : '').fadeIn('fast');
|
||||
$("#pms-verify-status").html(identifier !== 'undefined' ? '<i class="fa fa-check"></i> Server found!' : '').fadeIn('fast');
|
||||
|
||||
$("#pms_identifier").val(identifier !== 'undefined' ? identifier : '');
|
||||
$('#pms_ip').val(ip !== 'undefined' ? ip : value);
|
||||
$('#pms_port').val(port !== 'undefined' ? port : 32400);
|
||||
$('#pms_is_remote_checkbox').prop('checked', (local !== 'undefined' && local === 0));
|
||||
$('#pms_is_remote').val(local !== 'undefined' && local === 0 ? 1 : 0);
|
||||
@@ -391,9 +425,10 @@ $(document).ready(function() {
|
||||
},
|
||||
success: function (result) {
|
||||
if (result) {
|
||||
var existing_value = $('#pms_ip').val();
|
||||
var existing_ip = $('#pms_ip').val();
|
||||
var existing_port = $('#pms_port').val();
|
||||
result.forEach(function (item) {
|
||||
if (item.value === existing_value) {
|
||||
if (item.ip === existing_ip && item.port === existing_port) {
|
||||
select_pms.updateOption(item.value, item);
|
||||
} else {
|
||||
select_pms.addOption(item);
|
||||
@@ -414,7 +449,7 @@ $(document).ready(function() {
|
||||
var pms_ssl = $("#pms_ssl").val();
|
||||
var pms_is_remote = $("#pms_is_remote").val();
|
||||
if ((pms_ip !== '') || (pms_port !== '')) {
|
||||
$("#pms-verify-status").html('<i class="fa fa-refresh fa-spin"></i> Validating server...');
|
||||
$("#pms-verify-status").html('<i class="fa fa-refresh fa-spin"></i> Validating server...');
|
||||
$('#pms-verify-status').fadeIn('fast');
|
||||
$.ajax({
|
||||
url: 'get_server_id',
|
||||
@@ -429,7 +464,7 @@ $(document).ready(function() {
|
||||
async: true,
|
||||
timeout: 5000,
|
||||
error: function (jqXHR, textStatus, errorThrown) {
|
||||
$("#pms-verify-status").html('<i class="fa fa-exclamation-circle"></i> This is not a Plex Server!');
|
||||
$("#pms-verify-status").html('<i class="fa fa-exclamation-circle"></i> This is not a Plex Server!');
|
||||
$('#pms-verify-status').fadeIn('fast');
|
||||
},
|
||||
success: function(xhr, status) {
|
||||
@@ -437,18 +472,18 @@ $(document).ready(function() {
|
||||
var identifier = result.identifier;
|
||||
if (identifier) {
|
||||
$("#pms_identifier").val(identifier);
|
||||
$("#pms-verify-status").html('<i class="fa fa-check"></i> Server found!');
|
||||
$("#pms-verify-status").html('<i class="fa fa-check"></i> Server found!');
|
||||
$('#pms-verify-status').fadeIn('fast');
|
||||
pms_verified = true;
|
||||
$("#pms_valid").val("valid");
|
||||
} else {
|
||||
$("#pms-verify-status").html('<i class="fa fa-exclamation-circle"></i> This is not a Plex Server!');
|
||||
$("#pms-verify-status").html('<i class="fa fa-exclamation-circle"></i> This is not a Plex Server!');
|
||||
$('#pms-verify-status').fadeIn('fast');
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$("#pms-verify-status").html('<i class="fa fa-exclamation-circle"></i> Please enter both fields.');
|
||||
$("#pms-verify-status").html('<i class="fa fa-exclamation-circle"></i> Please enter both fields.');
|
||||
$('#pms-verify-status').fadeIn('fast');
|
||||
}
|
||||
}
|
||||
@@ -460,47 +495,22 @@ $(document).ready(function() {
|
||||
$("#pms-verify-status").html("");
|
||||
});
|
||||
|
||||
$( ".pms-auth" ).change(function() {
|
||||
authenticated = false;
|
||||
$("#pms_token").val("");
|
||||
$("#pms-token-status").html("");
|
||||
});
|
||||
function OAuthPreFunction() {
|
||||
$("#pms_token").val('');
|
||||
$("#pms-token-status").html('<i class="fa fa-refresh fa-spin"></i> Waiting for authentication...').fadeIn('fast');
|
||||
}
|
||||
function OAuthSuccessCallback(authToken) {
|
||||
$("#pms_token").val(authToken);
|
||||
$("#pms-token-status").html('<i class="fa fa-check"></i> Authentication successful.').fadeIn('fast');
|
||||
authenticated = true;
|
||||
getServerOptions(authToken);
|
||||
}
|
||||
function OAuthErrorCallback() {
|
||||
$("#pms-token-status").html('<i class="fa fa-exclamation-circle"></i> Error communicating with Plex.tv.').fadeIn('fast');
|
||||
}
|
||||
|
||||
// Plex.tv auth token fetch
|
||||
$("#pms-authenticate").click(function() {
|
||||
$("#pms-token-status").html('<i class="fa fa-refresh fa-spin"></i> Fetching token...');
|
||||
$('#pms-token-status').fadeIn('fast');
|
||||
var pms_username = $("#pms_username").val().trim();
|
||||
var pms_password = $("#pms_password").val().trim();
|
||||
if ((pms_username !== '') && (pms_password !== '')) {
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: 'get_plexpy_pms_token',
|
||||
data: {
|
||||
username: pms_username,
|
||||
password: pms_password
|
||||
},
|
||||
cache: false,
|
||||
async: true,
|
||||
complete: function (xhr, status) {
|
||||
var result = $.parseJSON(xhr.responseText);
|
||||
var msg = result.message;
|
||||
if (result.result == 'success') {
|
||||
var authToken = result.token;
|
||||
$("#pms-token-status").html('<i class="fa fa-check"></i> ' + msg);
|
||||
$('#pms-token-status').fadeIn('fast');
|
||||
$("#pms_token").val(authToken);
|
||||
authenticated = true;
|
||||
getServerOptions(authToken)
|
||||
} else {
|
||||
$("#pms-token-status").html('<i class="fa fa-exclamation-circle"></i> ' + msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$("#pms-token-status").html('<i class="fa fa-exclamation-circle"></i> Username and password required.');
|
||||
$('#pms-token-status').fadeIn('fast');
|
||||
}
|
||||
$('#sign-in-plex').click(function() {
|
||||
PlexOAuth(OAuthSuccessCallback, OAuthErrorCallback, OAuthPreFunction);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
@@ -368,6 +368,7 @@
|
||||
line-height: 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 5px;
|
||||
max-width: 320px;
|
||||
}
|
||||
.card-info-title a {
|
||||
text-decoration: none;
|
||||
@@ -691,7 +692,7 @@
|
||||
<img src="${(base_url_image + 'images/libraries/show.png') if base_url_image else 'https://tautulli.com/images/libraries/show.png'}" class="sub-header-icon" width="30" height="30" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;height: 30px;width: 30px;vertical-align: middle;margin-right: 5px;margin-bottom: 5px;"> Recently Added TV Shows
|
||||
</div>
|
||||
<div class="sub-header-count" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;">
|
||||
<span class="count" style="color: #E5A00D;">${len(recently_added['show'])}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">show${'s' if len(recently_added['show']) > 1 else ''}</span> /
|
||||
<span class="count" style="color: #E5A00D;">${len(recently_added['show'])}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">show${'s' if len(recently_added['show']) > 1 else ''}</span> /
|
||||
<% total_episodes = sum(season['episode_count'] for show in recently_added['show'] for season in show['season']) %>
|
||||
<span class="count" style="color: #E5A00D;">${total_episodes}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">episode${'s' if total > 1 else ''}</span>
|
||||
</div>
|
||||
@@ -744,7 +745,7 @@
|
||||
<td class="card-info-body" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.75rem;vertical-align: top;padding: 5px;height: 100%;">
|
||||
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;color: #ffffff;">
|
||||
% if show['season_count'] > 1:
|
||||
<em>${show['season_count']} seasons /</em>
|
||||
<em>${show['season_count']} seasons / </em>
|
||||
% endif
|
||||
<% total_show_episodes = sum(s['episode_count'] for s in show['season']) %>
|
||||
<em>${total_show_episodes} episode${'s' if total_show_episodes > 1 else ''}</em>
|
||||
@@ -759,7 +760,8 @@
|
||||
% if i < min(show['season_count'], 7):
|
||||
<br>
|
||||
% elif i == 7 and show['season_count'] > 8:
|
||||
...plus ${show['season_count'] - 8} more seasons!
|
||||
<% remaining_seasons = show['season_count'] - 8 %>
|
||||
...plus ${remaining_seasons} more season${'s' if remaining_seasons > 1 else ''}!
|
||||
% endif
|
||||
% endfor
|
||||
</p>
|
||||
@@ -951,6 +953,124 @@
|
||||
</td>
|
||||
</tr>
|
||||
% endif
|
||||
% if recently_added.get('other_video'):
|
||||
<tr>
|
||||
<td class="wrapper" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;box-sizing: border-box;padding: 5px;overflow: auto;">
|
||||
<div class="sub-header-bar" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;width: 200px;border-top: 1px solid #E5A00D;margin-top: 15px;margin-bottom: 25px;"></div>
|
||||
<div class="sub-header-title" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;font-weight: lighter;">
|
||||
<img src="${(base_url_image + 'images/libraries/video.png') if base_url_image else 'https://tautulli.com/images/libraries/video.png'}" class="sub-header-icon" width="30" height="30" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;height: 30px;width: 30px;vertical-align: middle;margin-right: 5px;margin-bottom: 5px;"> Recently Added Videos
|
||||
</div>
|
||||
<div class="sub-header-count" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;">
|
||||
<span class="count" style="color: #E5A00D;">${len(recently_added['other_video'])}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">video${'s' if len(recently_added['other_video']) > 1 else ''}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
|
||||
% for video_a, video_b in grouper(recently_added['other_video'], 2):
|
||||
<tr>
|
||||
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
|
||||
<tr>
|
||||
% for video in (video_a, video_b):
|
||||
% if video:
|
||||
% if not video_b:
|
||||
<td align="center" valign="top" class="card-instance pad" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 0 !important;width: 251px !important;min-width: 251px !important;max-width: 251px !important;"></td>
|
||||
% endif
|
||||
<td align="center" valign="top" class="card-instance movie" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 3px;width: 502px;min-width: 502px;max-width: 502px;height: 233px;">
|
||||
<table border="0" cellpadding="0" cellspacing="3" width="100%" class="card-background" style="background-image: url(${(base_url_image + video['art_hash']) if base_url_image else video['art_url']});border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background-color: #282828;background-position: center;background-size: cover;background-repeat: no-repeat;background-clip: padding-box;border: 1px solid rgba(255,255,255,.1);">
|
||||
<tr>
|
||||
<td class="card-poster-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;width: 152px;min-width: 152px;height: 227px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + video['thumb_hash']) if base_url_image else video['thumb_url']});border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background-color: #3F4245;background-position: center;background-size: cover;background-repeat: no-repeat;background-clip: padding-box;border: 1px solid rgba(255,255,255,.1);">
|
||||
<tr>
|
||||
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
|
||||
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank" style="text-decoration: underline;">
|
||||
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-poster.png' if base_url_image else 'https://tautulli.com/images/newsletter/view-on-plex-poster.png'}" width="150" height="225" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;display: block;">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td class="card-info-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;padding-left: 4px;text-align: left;height: 227px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;height: 100%;">
|
||||
<tr>
|
||||
<td class="card-info-title nowrap" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.9rem;vertical-align: top;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;border-bottom: 1px solid rgba(255, 255, 255, .1);line-height: 1.2rem;padding: 5px;max-width: 320px;">
|
||||
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank" style="text-decoration: none;color: #ffffff;">${video['title']}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="card-info-body" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.75rem;vertical-align: top;padding: 5px;height: 100%;">
|
||||
% if video['tagline']:
|
||||
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;color: #ffffff;">
|
||||
<em>${video['tagline']}</em>
|
||||
</p>
|
||||
% endif
|
||||
<p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;color: #ffffff;">
|
||||
${video['summary'][:450] + (video['summary'][450:] and '...')}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="card-info-footer nowrap" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.6rem;vertical-align: top;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;padding-top: 0px;padding-right: 5px;padding-bottom: 5px;padding-left: 5px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
|
||||
<tr>
|
||||
<td class="badge-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;max-width: 260px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
|
||||
<tr>
|
||||
% if video['year']:
|
||||
<td class="badge" title="${video['year']}" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 11px;vertical-align: middle;display: inline-block;min-width: 10px;margin-right: 4px;padding: 3px 7px;line-height: 1;text-align: center;white-space: nowrap;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${video['year']}</td>
|
||||
% endif
|
||||
% if video['duration']:
|
||||
<% duration = int(int(video['duration'])/60000) %>
|
||||
<td class="badge" title="${duration} mins" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 11px;vertical-align: middle;display: inline-block;min-width: 10px;margin-right: 4px;padding: 3px 7px;line-height: 1;text-align: center;white-space: nowrap;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${duration} mins</td>
|
||||
% endif
|
||||
% if video['genres']:
|
||||
% for genre in video['genres'][:]:
|
||||
<td class="badge" title="${genre}" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 11px;vertical-align: middle;display: inline-block;min-width: 10px;margin-right: 4px;padding: 3px 7px;line-height: 1;text-align: center;white-space: nowrap;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${genre}</td>
|
||||
% endfor
|
||||
% endif
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
% if video['rating']:
|
||||
<% rating = int(round(float(video['rating']) / 2)) %>
|
||||
<td class="star-rating-container" title="${int(float(video['rating'])/0.1)}%" align="right" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;width: 65px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
|
||||
<tr>
|
||||
% for _ in range(rating):
|
||||
<td class="star-rating full" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.8rem;vertical-align: bottom;margin-left: 4px;line-height: 1rem;width: 0.5rem;display: inline-block;color: #E5A00D;">★</td>
|
||||
% endfor
|
||||
% for _ in range(5-rating):
|
||||
<td class="star-rating empty" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.8rem;vertical-align: bottom;margin-left: 4px;line-height: 1rem;width: 0.5rem;display: inline-block;color: #aaaaaa;">☆</td>
|
||||
% endfor
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
% endif
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
% if not video_b:
|
||||
<td align="center" valign="top" class="card-instance pad" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 0 !important;width: 251px !important;min-width: 251px !important;max-width: 251px !important;"></td>
|
||||
% endif
|
||||
% endif
|
||||
% endfor
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
% endfor
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
% endif
|
||||
<tr>
|
||||
<td class="footer" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;clear: both;margin-top: 10px;text-align: center;width: 100%;">
|
||||
<div class="footer-bar" style="margin-left: auto;margin-right: auto;width: 200px;border-top: 1px solid #E5A00D;margin-top: 25px;"></div>
|
||||
|
@@ -692,7 +692,7 @@
|
||||
<img src="${(base_url_image + 'images/libraries/show.png') if base_url_image else 'https://tautulli.com/images/libraries/show.png'}" class="sub-header-icon" width="30" height="30"/> Recently Added TV Shows
|
||||
</div>
|
||||
<div class="sub-header-count">
|
||||
<span class="count">${len(recently_added['show'])}</span> <span class="count-units">show${'s' if len(recently_added['show']) > 1 else ''}</span> /
|
||||
<span class="count">${len(recently_added['show'])}</span> <span class="count-units">show${'s' if len(recently_added['show']) > 1 else ''}</span> /
|
||||
<% total_episodes = sum(season['episode_count'] for show in recently_added['show'] for season in show['season']) %>
|
||||
<span class="count">${total_episodes}</span> <span class="count-units">episode${'s' if total > 1 else ''}</span>
|
||||
</div>
|
||||
@@ -745,7 +745,7 @@
|
||||
<td class="card-info-body">
|
||||
<p class="nowrap mb5">
|
||||
% if show['season_count'] > 1:
|
||||
<em>${show['season_count']} seasons /</em>
|
||||
<em>${show['season_count']} seasons / </em>
|
||||
% endif
|
||||
<% total_show_episodes = sum(s['episode_count'] for s in show['season']) %>
|
||||
<em>${total_show_episodes} episode${'s' if total_show_episodes > 1 else ''}</em>
|
||||
@@ -760,7 +760,8 @@
|
||||
% if i < min(show['season_count'], 7):
|
||||
<br>
|
||||
% elif i == 7 and show['season_count'] > 8:
|
||||
...plus ${show['season_count'] - 8} more seasons!
|
||||
<% remaining_seasons = show['season_count'] - 8 %>
|
||||
...plus ${remaining_seasons} more season${'s' if remaining_seasons > 1 else ''}!
|
||||
% endif
|
||||
% endfor
|
||||
</p>
|
||||
@@ -952,6 +953,124 @@
|
||||
</td>
|
||||
</tr>
|
||||
% endif
|
||||
% if recently_added.get('other_video'):
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<div class="sub-header-bar"></div>
|
||||
<div class="sub-header-title">
|
||||
<img src="${(base_url_image + 'images/libraries/video.png') if base_url_image else 'https://tautulli.com/images/libraries/video.png'}" class="sub-header-icon" width="30" height="30"/> Recently Added Videos
|
||||
</div>
|
||||
<div class="sub-header-count">
|
||||
<span class="count">${len(recently_added['other_video'])}</span> <span class="count-units">video${'s' if len(recently_added['other_video']) > 1 else ''}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
% for video_a, video_b in grouper(recently_added['other_video'], 2):
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
% for video in (video_a, video_b):
|
||||
% if video:
|
||||
% if not video_b:
|
||||
<td align="center" valign="top" class="card-instance pad"></td>
|
||||
% endif
|
||||
<td align="center" valign="top" class="card-instance movie">
|
||||
<table border="0" cellpadding="0" cellspacing="3" width="100%" class="card-background" style="background-image: url(${(base_url_image + video['art_hash']) if base_url_image else video['art_url']});">
|
||||
<tr>
|
||||
<td class="card-poster-container">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + video['thumb_hash']) if base_url_image else video['thumb_url']})">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank">
|
||||
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-poster.png' if base_url_image else 'https://tautulli.com/images/newsletter/view-on-plex-poster.png'}" width="150" height="225">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td class="card-info-container">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
|
||||
<tr>
|
||||
<td class="card-info-title nowrap">
|
||||
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank">${video['title']}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="card-info-body">
|
||||
% if video['tagline']:
|
||||
<p class="nowrap mb5">
|
||||
<em>${video['tagline']}</em>
|
||||
</p>
|
||||
% endif
|
||||
<p>
|
||||
${video['summary'][:450] + (video['summary'][450:] and '...')}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="card-info-footer nowrap">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td class="badge-container">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
% if video['year']:
|
||||
<td class="badge" title="${video['year']}">${video['year']}</td>
|
||||
% endif
|
||||
% if video['duration']:
|
||||
<% duration = int(int(video['duration'])/60000) %>
|
||||
<td class="badge" title="${duration} mins">${duration} mins</td>
|
||||
% endif
|
||||
% if video['genres']:
|
||||
% for genre in video['genres'][:]:
|
||||
<td class="badge" title="${genre}">${genre}</td>
|
||||
% endfor
|
||||
% endif
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
% if video['rating']:
|
||||
<% rating = int(round(float(video['rating']) / 2)) %>
|
||||
<td class="star-rating-container" title="${int(float(video['rating'])/0.1)}%" align="right">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
% for _ in range(rating):
|
||||
<td class="star-rating full">★</td>
|
||||
% endfor
|
||||
% for _ in range(5-rating):
|
||||
<td class="star-rating empty">☆</td>
|
||||
% endfor
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
% endif
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
% if not video_b:
|
||||
<td align="center" valign="top" class="card-instance pad"></td>
|
||||
% endif
|
||||
% endif
|
||||
% endfor
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
% endfor
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
% endif
|
||||
<tr>
|
||||
<td class="footer">
|
||||
<div class="footer-bar"></div>
|
||||
|
0
init-scripts/init.fedora.centos.service
Normal file → Executable file
0
init-scripts/init.fedora.centos.service
Normal file → Executable file
42
init-scripts/init.systemd
Normal file → Executable file
42
init-scripts/init.systemd
Normal file → Executable file
@@ -4,51 +4,55 @@
|
||||
#
|
||||
# INSTALLATION NOTES
|
||||
#
|
||||
# 1. Rename this file as you want, ensuring that it ends in .service
|
||||
# e.g. 'tautulli.service'
|
||||
# 1. Copy this file into your systemd service unit directory (often '/lib/systemd/system')
|
||||
# and name it 'tautulli.service' with the following command:
|
||||
# cp /opt/Tautulli/init-scripts/init.systemd /lib/systemd/system/tautulli.service
|
||||
#
|
||||
# 2. Adjust configuration settings as required. More details in the
|
||||
# "CONFIGURATION NOTES" section shown below.
|
||||
# 2. Edit the new tautulli.service file with configuration settings as required.
|
||||
# More details in the "CONFIGURATION NOTES" section shown below.
|
||||
#
|
||||
# 3. Copy this file into your systemd service unit directory, which is
|
||||
# often '/lib/systemd/system'.
|
||||
#
|
||||
# 4. Enable boot-time autostart with the following commands:
|
||||
# 3. Enable boot-time autostart with the following commands:
|
||||
# systemctl daemon-reload
|
||||
# systemctl enable tautulli.service
|
||||
#
|
||||
# 5. Start now with the following command:
|
||||
# 4. Start now with the following command:
|
||||
# systemctl start tautulli.service
|
||||
#
|
||||
# CONFIGURATION NOTES
|
||||
#
|
||||
# - The example settings in this file assume that you will run Tautulli as user: tautulli
|
||||
# - To create this user and give it ownership of the tautulli directory:
|
||||
# sudo adduser --system --no-create-home tautulli
|
||||
# sudo chown tautulli:nogroup -R /opt/Tautulli
|
||||
#
|
||||
# - Option names (e.g. ExecStart=, Type=) appear to be case-sensitive)
|
||||
# - The example settings in this file assume that Tautulli is installed to: /opt/Tautulli
|
||||
#
|
||||
# - To create this user and give it ownership of the Tautulli directory:
|
||||
# Ubuntu/Debian: sudo addgroup tautulli && sudo adduser --system --no-create-home tautulli --ingroup tautulli
|
||||
# CentOS/Fedora: sudo adduser --system --no-create-home tautulli
|
||||
# sudo chown tautulli:tautulli -R /opt/Tautulli
|
||||
#
|
||||
# - Adjust ExecStart= to point to:
|
||||
# 1. Your Tautulli executable,
|
||||
# 1. Your Tautulli executable
|
||||
# - Default: /opt/Tautulli/Tautulli.py
|
||||
# 2. Your config file (recommended is to put it somewhere in /etc)
|
||||
# - Default: --config /opt/Tautulli/config.ini
|
||||
# 3. Your datadir (recommended is to NOT put it in your Tautulli exec dir)
|
||||
# - Default: --datadir /opt/Tautulli
|
||||
#
|
||||
# - Adjust User= and Group= to the user/group you want Tautulli to run as.
|
||||
#
|
||||
# - WantedBy= specifies which target (i.e. runlevel) to start Tautulli for.
|
||||
# multi-user.target equates to runlevel 3 (multi-user text mode)
|
||||
# graphical.target equates to runlevel 5 (multi-user X11 graphical mode)
|
||||
# multi-user.target equates to runlevel 3 (multi-user text mode)
|
||||
# graphical.target equates to runlevel 5 (multi-user X11 graphical mode)
|
||||
|
||||
[Unit]
|
||||
Description=Tautulli - Stats for Plex Media Server usage
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/opt/Tautulli/Tautulli.py --quiet --daemon --nolaunch --config /opt/Tautulli/config.ini --datadir /opt/Tautulli
|
||||
ExecStart=/opt/Tautulli/Tautulli.py --config /opt/Tautulli/config.ini --datadir /opt/Tautulli --quiet --daemon --nolaunch
|
||||
GuessMainPID=no
|
||||
Type=forking
|
||||
User=tautulli
|
||||
Group=nogroup
|
||||
Group=tautulli
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
@@ -1,127 +0,0 @@
|
||||
# Copyright (c) 2009 Raymond Hettinger
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person
|
||||
# obtaining a copy of this software and associated documentation files
|
||||
# (the "Software"), to deal in the Software without restriction,
|
||||
# including without limitation the rights to use, copy, modify, merge,
|
||||
# publish, distribute, sublicense, and/or sell copies of the Software,
|
||||
# and to permit persons to whom the Software is furnished to do so,
|
||||
# subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
# OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
from UserDict import DictMixin
|
||||
|
||||
class OrderedDict(dict, DictMixin):
|
||||
|
||||
def __init__(self, *args, **kwds):
|
||||
if len(args) > 1:
|
||||
raise TypeError('expected at most 1 arguments, got %d' % len(args))
|
||||
try:
|
||||
self.__end
|
||||
except AttributeError:
|
||||
self.clear()
|
||||
self.update(*args, **kwds)
|
||||
|
||||
def clear(self):
|
||||
self.__end = end = []
|
||||
end += [None, end, end] # sentinel node for doubly linked list
|
||||
self.__map = {} # key --> [key, prev, next]
|
||||
dict.clear(self)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if key not in self:
|
||||
end = self.__end
|
||||
curr = end[1]
|
||||
curr[2] = end[1] = self.__map[key] = [key, curr, end]
|
||||
dict.__setitem__(self, key, value)
|
||||
|
||||
def __delitem__(self, key):
|
||||
dict.__delitem__(self, key)
|
||||
key, prev, next = self.__map.pop(key)
|
||||
prev[2] = next
|
||||
next[1] = prev
|
||||
|
||||
def __iter__(self):
|
||||
end = self.__end
|
||||
curr = end[2]
|
||||
while curr is not end:
|
||||
yield curr[0]
|
||||
curr = curr[2]
|
||||
|
||||
def __reversed__(self):
|
||||
end = self.__end
|
||||
curr = end[1]
|
||||
while curr is not end:
|
||||
yield curr[0]
|
||||
curr = curr[1]
|
||||
|
||||
def popitem(self, last=True):
|
||||
if not self:
|
||||
raise KeyError('dictionary is empty')
|
||||
if last:
|
||||
key = reversed(self).next()
|
||||
else:
|
||||
key = iter(self).next()
|
||||
value = self.pop(key)
|
||||
return key, value
|
||||
|
||||
def __reduce__(self):
|
||||
items = [[k, self[k]] for k in self]
|
||||
tmp = self.__map, self.__end
|
||||
del self.__map, self.__end
|
||||
inst_dict = vars(self).copy()
|
||||
self.__map, self.__end = tmp
|
||||
if inst_dict:
|
||||
return (self.__class__, (items,), inst_dict)
|
||||
return self.__class__, (items,)
|
||||
|
||||
def keys(self):
|
||||
return list(self)
|
||||
|
||||
setdefault = DictMixin.setdefault
|
||||
update = DictMixin.update
|
||||
pop = DictMixin.pop
|
||||
values = DictMixin.values
|
||||
items = DictMixin.items
|
||||
iterkeys = DictMixin.iterkeys
|
||||
itervalues = DictMixin.itervalues
|
||||
iteritems = DictMixin.iteritems
|
||||
|
||||
def __repr__(self):
|
||||
if not self:
|
||||
return '%s()' % (self.__class__.__name__,)
|
||||
return '%s(%r)' % (self.__class__.__name__, self.items())
|
||||
|
||||
def copy(self):
|
||||
return self.__class__(self)
|
||||
|
||||
@classmethod
|
||||
def fromkeys(cls, iterable, value=None):
|
||||
d = cls()
|
||||
for key in iterable:
|
||||
d[key] = value
|
||||
return d
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, OrderedDict):
|
||||
if len(self) != len(other):
|
||||
return False
|
||||
for p, q in zip(self.items(), other.items()):
|
||||
if p != q:
|
||||
return False
|
||||
return True
|
||||
return dict.__eq__(self, other)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self == other
|
2895
lib/pkg_resources.py
2895
lib/pkg_resources.py
File diff suppressed because it is too large
Load Diff
2
lib/systray/__init__.py
Normal file
2
lib/systray/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
__import__("pkg_resources").declare_namespace(__name__)
|
||||
from .traybar import SysTrayIcon
|
314
lib/systray/traybar.py
Normal file
314
lib/systray/traybar.py
Normal file
@@ -0,0 +1,314 @@
|
||||
import os
|
||||
from .win32_adapter import *
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
class SysTrayIcon(object):
|
||||
"""
|
||||
menu_options: tuple of tuples (menu text, menu icon path or None, function name)
|
||||
|
||||
menu text and tray hover text should be Unicode
|
||||
hover_text length is limited to 128; longer text will be truncated
|
||||
|
||||
Can be used as context manager to enable automatic termination of tray
|
||||
if parent thread is closed:
|
||||
|
||||
with SysTrayIcon(icon, hover_text) as systray:
|
||||
for item in ['item1', 'item2', 'item3']:
|
||||
systray.update(hover_text=item)
|
||||
do_something(item)
|
||||
|
||||
"""
|
||||
QUIT = 'QUIT'
|
||||
SPECIAL_ACTIONS = [QUIT]
|
||||
|
||||
FIRST_ID = 1023
|
||||
|
||||
def __init__(self,
|
||||
icon,
|
||||
hover_text,
|
||||
menu_options=None,
|
||||
on_quit=None,
|
||||
default_menu_index=None,
|
||||
window_class_name=None):
|
||||
|
||||
self._icon = icon
|
||||
self._icon_shared = False
|
||||
self._hover_text = hover_text
|
||||
self._on_quit = on_quit
|
||||
|
||||
menu_options = menu_options or ()
|
||||
menu_options = menu_options + (('Quit', None, SysTrayIcon.QUIT, None),)
|
||||
self._next_action_id = SysTrayIcon.FIRST_ID
|
||||
self._menu_actions_by_id = set()
|
||||
self._menu_options = self._add_ids_to_menu_options(list(menu_options))
|
||||
self._menu_actions_by_id = dict(self._menu_actions_by_id)
|
||||
|
||||
window_class_name = window_class_name or ("SysTrayIconPy-%s" % (str(uuid.uuid4())))
|
||||
|
||||
self._default_menu_index = (default_menu_index or 0)
|
||||
self._window_class_name = encode_for_locale(window_class_name)
|
||||
self._message_dict = {RegisterWindowMessage("TaskbarCreated"): self._restart,
|
||||
WM_DESTROY: self._destroy,
|
||||
WM_CLOSE: self._destroy,
|
||||
WM_COMMAND: self._command,
|
||||
WM_USER+20: self._notify}
|
||||
self._notify_id = None
|
||||
self._message_loop_thread = None
|
||||
self._hwnd = None
|
||||
self._hicon = 0
|
||||
self._hinst = None
|
||||
self._window_class = None
|
||||
self._menu = None
|
||||
self._register_class()
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager so SysTray can automatically close"""
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
"""Context manager so SysTray can automatically close"""
|
||||
self.shutdown()
|
||||
|
||||
def WndProc(self, hwnd, msg, wparam, lparam):
|
||||
hwnd = HANDLE(hwnd)
|
||||
wparam = WPARAM(wparam)
|
||||
lparam = LPARAM(lparam)
|
||||
if msg in self._message_dict:
|
||||
self._message_dict[msg](hwnd, msg, wparam.value, lparam.value)
|
||||
return DefWindowProc(hwnd, msg, wparam, lparam)
|
||||
|
||||
def _register_class(self):
|
||||
# Register the Window class.
|
||||
self._window_class = WNDCLASS()
|
||||
self._hinst = self._window_class.hInstance = GetModuleHandle(None)
|
||||
self._window_class.lpszClassName = self._window_class_name
|
||||
self._window_class.style = CS_VREDRAW | CS_HREDRAW
|
||||
self._window_class.hCursor = LoadCursor(0, IDC_ARROW)
|
||||
self._window_class.hbrBackground = COLOR_WINDOW
|
||||
self._window_class.lpfnWndProc = LPFN_WNDPROC(self.WndProc)
|
||||
RegisterClass(ctypes.byref(self._window_class))
|
||||
|
||||
def _create_window(self):
|
||||
style = WS_OVERLAPPED | WS_SYSMENU
|
||||
self._hwnd = CreateWindowEx(0, self._window_class_name,
|
||||
self._window_class_name,
|
||||
style,
|
||||
0,
|
||||
0,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
0,
|
||||
0,
|
||||
self._hinst,
|
||||
None)
|
||||
UpdateWindow(self._hwnd)
|
||||
self._refresh_icon()
|
||||
|
||||
def _message_loop_func(self):
|
||||
self._create_window()
|
||||
PumpMessages()
|
||||
|
||||
def start(self):
|
||||
if self._hwnd:
|
||||
return # already started
|
||||
self._message_loop_thread = threading.Thread(target=self._message_loop_func)
|
||||
self._message_loop_thread.start()
|
||||
|
||||
def shutdown(self):
|
||||
if not self._hwnd:
|
||||
return # not started
|
||||
PostMessage(self._hwnd, WM_CLOSE, 0, 0)
|
||||
self._message_loop_thread.join()
|
||||
|
||||
def update(self, icon=None, hover_text=None):
|
||||
""" update icon image and/or hover text """
|
||||
if icon:
|
||||
self._icon = icon
|
||||
self._load_icon()
|
||||
if hover_text:
|
||||
self._hover_text = hover_text
|
||||
self._refresh_icon()
|
||||
|
||||
def _add_ids_to_menu_options(self, menu_options):
|
||||
result = []
|
||||
for menu_option in menu_options:
|
||||
option_text, option_icon, option_action, option_state = menu_option
|
||||
if callable(option_action) or option_action in SysTrayIcon.SPECIAL_ACTIONS:
|
||||
self._menu_actions_by_id.add((self._next_action_id, option_action))
|
||||
result.append(menu_option + (self._next_action_id,))
|
||||
elif option_action == 'separator':
|
||||
result.append((option_text,
|
||||
option_icon,
|
||||
option_action,
|
||||
option_state,
|
||||
self._next_action_id))
|
||||
elif non_string_iterable(option_action):
|
||||
result.append((option_text,
|
||||
option_icon,
|
||||
option_state,
|
||||
self._add_ids_to_menu_options(option_action),
|
||||
self._next_action_id))
|
||||
else:
|
||||
raise Exception('Unknown item', option_text, option_icon, option_action)
|
||||
self._next_action_id += 1
|
||||
return result
|
||||
|
||||
def _load_icon(self):
|
||||
# release previous icon, if a custom one was loaded
|
||||
# note: it's important *not* to release the icon if we loaded the default system icon (with
|
||||
# the LoadIcon function) - this is why we assign self._hicon only if it was loaded using LoadImage
|
||||
if not self._icon_shared and self._hicon != 0:
|
||||
DestroyIcon(self._hicon)
|
||||
self._hicon = 0
|
||||
|
||||
# Try and find a custom icon
|
||||
hicon = 0
|
||||
if self._icon is not None and os.path.isfile(self._icon):
|
||||
icon_flags = LR_LOADFROMFILE | LR_DEFAULTSIZE
|
||||
icon = encode_for_locale(self._icon)
|
||||
hicon = self._hicon = LoadImage(0, icon, IMAGE_ICON, 0, 0, icon_flags)
|
||||
self._icon_shared = False
|
||||
|
||||
# Can't find icon file - using default shared icon
|
||||
if hicon == 0:
|
||||
self._hicon = LoadIcon(0, IDI_APPLICATION)
|
||||
self._icon_shared = True
|
||||
self._icon = None
|
||||
|
||||
def _refresh_icon(self):
|
||||
if self._hwnd is None:
|
||||
return
|
||||
if self._hicon == 0:
|
||||
self._load_icon()
|
||||
if self._notify_id:
|
||||
message = NIM_MODIFY
|
||||
else:
|
||||
message = NIM_ADD
|
||||
self._notify_id = NotifyData(self._hwnd,
|
||||
0,
|
||||
NIF_ICON | NIF_MESSAGE | NIF_TIP,
|
||||
WM_USER+20,
|
||||
self._hicon,
|
||||
self._hover_text)
|
||||
Shell_NotifyIcon(message, ctypes.byref(self._notify_id))
|
||||
|
||||
def _restart(self, hwnd, msg, wparam, lparam):
|
||||
self._refresh_icon()
|
||||
|
||||
def _destroy(self, hwnd, msg, wparam, lparam):
|
||||
if self._on_quit:
|
||||
self._on_quit(self)
|
||||
nid = NotifyData(self._hwnd, 0)
|
||||
Shell_NotifyIcon(NIM_DELETE, ctypes.byref(nid))
|
||||
PostQuitMessage(0) # Terminate the app.
|
||||
# TODO * release self._menu with DestroyMenu and reset the memeber
|
||||
# * release self._hicon with DestoryIcon and reset the member
|
||||
# * release loaded menu icons (loaded in _load_menu_icon) with DeleteObject
|
||||
# (we don't keep those objects anywhere now)
|
||||
self._hwnd = None
|
||||
self._notify_id = None
|
||||
|
||||
def _notify(self, hwnd, msg, wparam, lparam):
|
||||
if lparam == WM_LBUTTONDBLCLK:
|
||||
self._execute_menu_option(self._default_menu_index + SysTrayIcon.FIRST_ID)
|
||||
elif lparam == WM_RBUTTONUP:
|
||||
self._show_menu()
|
||||
elif lparam == WM_LBUTTONUP:
|
||||
pass
|
||||
return True
|
||||
|
||||
def _show_menu(self):
|
||||
if self._menu is None:
|
||||
self._menu = CreatePopupMenu()
|
||||
self._create_menu(self._menu, self._menu_options)
|
||||
#SetMenuDefaultItem(self._menu, 1000, 0)
|
||||
|
||||
pos = POINT()
|
||||
GetCursorPos(ctypes.byref(pos))
|
||||
# See http://msdn.microsoft.com/library/default.asp?url=/library/en-us/winui/menus_0hdi.asp
|
||||
SetForegroundWindow(self._hwnd)
|
||||
TrackPopupMenu(self._menu,
|
||||
TPM_LEFTALIGN,
|
||||
pos.x,
|
||||
pos.y,
|
||||
0,
|
||||
self._hwnd,
|
||||
None)
|
||||
PostMessage(self._hwnd, WM_NULL, 0, 0)
|
||||
|
||||
def _create_menu(self, menu, menu_options):
|
||||
for option_text, option_icon, option_action, option_state, option_id in menu_options[::-1]:
|
||||
if option_icon:
|
||||
option_icon = self._prep_menu_icon(option_icon)
|
||||
|
||||
mi_fstate = 0
|
||||
mi_ftype = 0
|
||||
|
||||
if option_state == 'default':
|
||||
mi_fstate = mi_fstate | MFS_DEFAULT
|
||||
if option_state == 'highlight':
|
||||
mi_fstate = mi_fstate | MFS_HILITE
|
||||
if option_state == 'disabled':
|
||||
mi_fstate = mi_fstate | MFS_DISABLED
|
||||
if option_action == 'separator':
|
||||
mi_ftype = mi_ftype | MFT_SEPARATOR
|
||||
|
||||
if isinstance(option_action, tuple):
|
||||
submenu = CreatePopupMenu()
|
||||
self._create_menu(submenu, option_action)
|
||||
item = PackMENUITEMINFO(text=option_text,
|
||||
hbmpItem=option_icon,
|
||||
hSubMenu=submenu)
|
||||
InsertMenuItem(menu, 0, 1, ctypes.byref(item))
|
||||
else:
|
||||
item = PackMENUITEMINFO(text=option_text,
|
||||
hbmpItem=option_icon,
|
||||
wID=option_id,
|
||||
fState=mi_fstate,
|
||||
fType=mi_ftype)
|
||||
InsertMenuItem(menu, 0, 1, ctypes.byref(item))
|
||||
|
||||
def _prep_menu_icon(self, icon):
|
||||
icon = encode_for_locale(icon)
|
||||
# First load the icon.
|
||||
ico_x = GetSystemMetrics(SM_CXSMICON)
|
||||
ico_y = GetSystemMetrics(SM_CYSMICON)
|
||||
hicon = LoadImage(0, icon, IMAGE_ICON, ico_x, ico_y, LR_LOADFROMFILE)
|
||||
|
||||
hdcBitmap = CreateCompatibleDC(None)
|
||||
hdcScreen = GetDC(None)
|
||||
hbm = CreateCompatibleBitmap(hdcScreen, ico_x, ico_y)
|
||||
hbmOld = SelectObject(hdcBitmap, hbm)
|
||||
# Fill the background.
|
||||
brush = GetSysColorBrush(COLOR_MENU)
|
||||
FillRect(hdcBitmap, ctypes.byref(RECT(0, 0, 16, 16)), brush)
|
||||
# draw the icon
|
||||
DrawIconEx(hdcBitmap, 0, 0, hicon, ico_x, ico_y, 0, 0, DI_NORMAL)
|
||||
SelectObject(hdcBitmap, hbmOld)
|
||||
|
||||
# No need to free the brush
|
||||
DeleteDC(hdcBitmap)
|
||||
DestroyIcon(hicon)
|
||||
|
||||
return hbm
|
||||
|
||||
def _command(self, hwnd, msg, wparam, lparam):
|
||||
id = LOWORD(wparam)
|
||||
self._execute_menu_option(id)
|
||||
|
||||
def _execute_menu_option(self, id):
|
||||
menu_action = self._menu_actions_by_id[id]
|
||||
if menu_action == SysTrayIcon.QUIT:
|
||||
DestroyWindow(self._hwnd)
|
||||
else:
|
||||
menu_action(self)
|
||||
|
||||
def non_string_iterable(obj):
|
||||
try:
|
||||
iter(obj)
|
||||
except TypeError:
|
||||
return False
|
||||
else:
|
||||
return not isinstance(obj, str)
|
199
lib/systray/win32_adapter.py
Normal file
199
lib/systray/win32_adapter.py
Normal file
@@ -0,0 +1,199 @@
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
import locale
|
||||
import sys
|
||||
|
||||
RegisterWindowMessage = ctypes.windll.user32.RegisterWindowMessageA
|
||||
LoadCursor = ctypes.windll.user32.LoadCursorA
|
||||
LoadIcon = ctypes.windll.user32.LoadIconA
|
||||
LoadImage = ctypes.windll.user32.LoadImageA
|
||||
RegisterClass = ctypes.windll.user32.RegisterClassA
|
||||
CreateWindowEx = ctypes.windll.user32.CreateWindowExA
|
||||
UpdateWindow = ctypes.windll.user32.UpdateWindow
|
||||
DefWindowProc = ctypes.windll.user32.DefWindowProcA
|
||||
GetSystemMetrics = ctypes.windll.user32.GetSystemMetrics
|
||||
InsertMenuItem = ctypes.windll.user32.InsertMenuItemA
|
||||
PostMessage = ctypes.windll.user32.PostMessageA
|
||||
PostQuitMessage = ctypes.windll.user32.PostQuitMessage
|
||||
SetMenuDefaultItem = ctypes.windll.user32.SetMenuDefaultItem
|
||||
GetCursorPos = ctypes.windll.user32.GetCursorPos
|
||||
SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow
|
||||
TrackPopupMenu = ctypes.windll.user32.TrackPopupMenu
|
||||
CreatePopupMenu = ctypes.windll.user32.CreatePopupMenu
|
||||
CreateCompatibleDC = ctypes.windll.gdi32.CreateCompatibleDC
|
||||
GetDC = ctypes.windll.user32.GetDC
|
||||
CreateCompatibleBitmap = ctypes.windll.gdi32.CreateCompatibleBitmap
|
||||
GetSysColorBrush = ctypes.windll.user32.GetSysColorBrush
|
||||
FillRect = ctypes.windll.user32.FillRect
|
||||
DrawIconEx = ctypes.windll.user32.DrawIconEx
|
||||
SelectObject = ctypes.windll.gdi32.SelectObject
|
||||
DeleteDC = ctypes.windll.gdi32.DeleteDC
|
||||
DestroyWindow = ctypes.windll.user32.DestroyWindow
|
||||
GetModuleHandle = ctypes.windll.kernel32.GetModuleHandleA
|
||||
GetMessage = ctypes.windll.user32.GetMessageA
|
||||
TranslateMessage = ctypes.windll.user32.TranslateMessage
|
||||
DispatchMessage = ctypes.windll.user32.DispatchMessageA
|
||||
Shell_NotifyIcon = ctypes.windll.shell32.Shell_NotifyIcon
|
||||
DestroyIcon = ctypes.windll.user32.DestroyIcon
|
||||
|
||||
NIM_ADD = 0
|
||||
NIM_MODIFY = 1
|
||||
NIM_DELETE = 2
|
||||
NIF_ICON = 2
|
||||
NIF_MESSAGE = 1
|
||||
NIF_TIP = 4
|
||||
MIIM_STATE = 1
|
||||
MIIM_ID = 2
|
||||
MIIM_SUBMENU = 4
|
||||
MIIM_STRING = 64
|
||||
MIIM_BITMAP = 128
|
||||
MIIM_FTYPE = 256
|
||||
WM_DESTROY = 2
|
||||
WM_CLOSE = 16
|
||||
WM_COMMAND = 273
|
||||
WM_USER = 1024
|
||||
WM_LBUTTONDBLCLK = 515
|
||||
WM_RBUTTONUP = 517
|
||||
WM_LBUTTONUP = 514
|
||||
WM_NULL = 0
|
||||
CS_VREDRAW = 1
|
||||
CS_HREDRAW = 2
|
||||
IDC_ARROW = 32512
|
||||
COLOR_WINDOW = 5
|
||||
WS_OVERLAPPED = 0
|
||||
WS_SYSMENU = 524288
|
||||
CW_USEDEFAULT = -2147483648
|
||||
LR_LOADFROMFILE = 16
|
||||
LR_DEFAULTSIZE = 64
|
||||
IMAGE_ICON = 1
|
||||
IDI_APPLICATION = 32512
|
||||
TPM_LEFTALIGN = 0
|
||||
SM_CXSMICON = 49
|
||||
SM_CYSMICON = 50
|
||||
COLOR_MENU = 4
|
||||
DI_NORMAL = 3
|
||||
MFS_DISABLED = 3
|
||||
MFS_DEFAULT = 4096
|
||||
MFS_HILITE = 128
|
||||
MFT_SEPARATOR = 2048
|
||||
|
||||
WPARAM = ctypes.wintypes.WPARAM
|
||||
LPARAM = ctypes.wintypes.LPARAM
|
||||
HANDLE = ctypes.wintypes.HANDLE
|
||||
if ctypes.sizeof(ctypes.c_long) == ctypes.sizeof(ctypes.c_void_p):
|
||||
LRESULT = ctypes.c_long
|
||||
elif ctypes.sizeof(ctypes.c_longlong) == ctypes.sizeof(ctypes.c_void_p):
|
||||
LRESULT = ctypes.c_longlong
|
||||
|
||||
SZTIP_MAX_LENGTH = 128
|
||||
LOCALE_ENCODING = locale.getpreferredencoding()
|
||||
|
||||
|
||||
def encode_for_locale(s):
|
||||
"""
|
||||
Encode text items for system locale. If encoding fails, fall back to ASCII.
|
||||
"""
|
||||
try:
|
||||
return s.encode(LOCALE_ENCODING, 'ignore')
|
||||
except (AttributeError, UnicodeDecodeError):
|
||||
return s.decode('ascii', 'ignore').encode(LOCALE_ENCODING)
|
||||
|
||||
POINT = ctypes.wintypes.POINT
|
||||
RECT = ctypes.wintypes.RECT
|
||||
MSG = ctypes.wintypes.MSG
|
||||
|
||||
LPFN_WNDPROC = ctypes.CFUNCTYPE(LRESULT, HANDLE, ctypes.c_uint, WPARAM, LPARAM)
|
||||
class WNDCLASS(ctypes.Structure):
|
||||
_fields_ = [("style", ctypes.c_uint),
|
||||
("lpfnWndProc", LPFN_WNDPROC),
|
||||
("cbClsExtra", ctypes.c_int),
|
||||
("cbWndExtra", ctypes.c_int),
|
||||
("hInstance", HANDLE),
|
||||
("hIcon", HANDLE),
|
||||
("hCursor", HANDLE),
|
||||
("hbrBackground", HANDLE),
|
||||
("lpszMenuName", ctypes.c_char_p),
|
||||
("lpszClassName", ctypes.c_char_p),
|
||||
]
|
||||
|
||||
class MENUITEMINFO(ctypes.Structure):
|
||||
_fields_ = [("cbSize", ctypes.c_uint),
|
||||
("fMask", ctypes.c_uint),
|
||||
("fType", ctypes.c_uint),
|
||||
("fState", ctypes.c_uint),
|
||||
("wID", ctypes.c_uint),
|
||||
("hSubMenu", HANDLE),
|
||||
("hbmpChecked", HANDLE),
|
||||
("hbmpUnchecked", HANDLE),
|
||||
("dwItemData", ctypes.c_void_p),
|
||||
("dwTypeData", ctypes.c_char_p),
|
||||
("cch", ctypes.c_uint),
|
||||
("hbmpItem", HANDLE),
|
||||
]
|
||||
|
||||
class NOTIFYICONDATA(ctypes.Structure):
|
||||
_fields_ = [("cbSize", ctypes.c_uint),
|
||||
("hWnd", HANDLE),
|
||||
("uID", ctypes.c_uint),
|
||||
("uFlags", ctypes.c_uint),
|
||||
("uCallbackMessage", ctypes.c_uint),
|
||||
("hIcon", HANDLE),
|
||||
("szTip", ctypes.c_char * SZTIP_MAX_LENGTH),
|
||||
("dwState", ctypes.c_uint),
|
||||
("dwStateMask", ctypes.c_uint),
|
||||
("szInfo", ctypes.c_char * 256),
|
||||
("uTimeout", ctypes.c_uint),
|
||||
("szInfoTitle", ctypes.c_char * 64),
|
||||
("dwInfoFlags", ctypes.c_uint),
|
||||
("guidItem", ctypes.c_char * 16),
|
||||
]
|
||||
if sys.getwindowsversion().major >= 5:
|
||||
_fields_.append(("hBalloonIcon", HANDLE))
|
||||
|
||||
|
||||
def PackMENUITEMINFO(text=None, hbmpItem=None, wID=None, hSubMenu=None,
|
||||
fType=None, fState=None):
|
||||
res = MENUITEMINFO()
|
||||
res.cbSize = ctypes.sizeof(res)
|
||||
res.fMask = 0
|
||||
if hbmpItem is not None:
|
||||
res.fMask |= MIIM_BITMAP
|
||||
res.hbmpItem = hbmpItem
|
||||
if wID is not None:
|
||||
res.fMask |= MIIM_ID
|
||||
res.wID = wID
|
||||
if text is not None:
|
||||
text = encode_for_locale(text)
|
||||
res.fMask |= MIIM_STRING
|
||||
res.dwTypeData = text
|
||||
if hSubMenu is not None:
|
||||
res.fMask |= MIIM_SUBMENU
|
||||
res.hSubMenu = hSubMenu
|
||||
if fType is not None:
|
||||
res.fMask |= MIIM_FTYPE
|
||||
res.fType = fType
|
||||
if fState is not None:
|
||||
res.fMask |= MIIM_STATE
|
||||
res.fState = fState
|
||||
return res
|
||||
|
||||
def LOWORD(w):
|
||||
return w & 0xFFFF
|
||||
|
||||
def PumpMessages():
|
||||
msg = MSG()
|
||||
while GetMessage(ctypes.byref(msg), None, 0, 0) > 0:
|
||||
TranslateMessage(ctypes.byref(msg))
|
||||
DispatchMessage(ctypes.byref(msg))
|
||||
|
||||
def NotifyData(hWnd=0, uID=0, uFlags=0, uCallbackMessage=0, hIcon=0, szTip=""):
|
||||
szTip = encode_for_locale(szTip)[:SZTIP_MAX_LENGTH]
|
||||
res = NOTIFYICONDATA()
|
||||
res.cbSize = ctypes.sizeof(res)
|
||||
res.hWnd = hWnd
|
||||
res.uID = uID
|
||||
res.uFlags = uFlags
|
||||
res.uCallbackMessage = uCallbackMessage
|
||||
res.hIcon = hIcon
|
||||
res.szTip = szTip
|
||||
return res
|
@@ -68,6 +68,7 @@ DAEMON = False
|
||||
CREATEPID = False
|
||||
PIDFILE = None
|
||||
NOFORK = False
|
||||
DOCKER = False
|
||||
|
||||
SCHED = BackgroundScheduler()
|
||||
SCHED_LOCK = threading.Lock()
|
||||
@@ -92,9 +93,11 @@ LATEST_VERSION = None
|
||||
COMMITS_BEHIND = None
|
||||
PREV_RELEASE = None
|
||||
LATEST_RELEASE = None
|
||||
UPDATE_AVAILABLE = False
|
||||
|
||||
UMASK = None
|
||||
|
||||
HTTP_PORT = None
|
||||
HTTP_ROOT = None
|
||||
|
||||
DEV = False
|
||||
@@ -105,6 +108,8 @@ PLEX_SERVER_UP = None
|
||||
|
||||
TRACKER = None
|
||||
|
||||
WIN_SYS_TRAY_ICON = None
|
||||
|
||||
|
||||
def initialize(config_file):
|
||||
with INIT_LOCK:
|
||||
@@ -136,21 +141,13 @@ def initialize(config_file):
|
||||
if not CONFIG.HTTPS_KEY:
|
||||
CONFIG.HTTPS_KEY = os.path.join(DATA_DIR, 'server.key')
|
||||
|
||||
if not CONFIG.LOG_DIR:
|
||||
CONFIG.LOG_DIR = os.path.join(DATA_DIR, 'logs')
|
||||
|
||||
if not os.path.exists(CONFIG.LOG_DIR):
|
||||
try:
|
||||
os.makedirs(CONFIG.LOG_DIR)
|
||||
except OSError:
|
||||
CONFIG.LOG_DIR = None
|
||||
|
||||
if not QUIET:
|
||||
sys.stderr.write("Unable to create the log directory. " \
|
||||
"Logging to screen only.\n")
|
||||
CONFIG.LOG_DIR, log_writable = check_folder_writable(
|
||||
CONFIG.LOG_DIR, os.path.join(DATA_DIR, 'logs'), 'logs')
|
||||
if not log_writable and not QUIET:
|
||||
sys.stderr.write("Unable to create the log directory. Logging to screen only.\n")
|
||||
|
||||
# Start the logger, disable console if needed
|
||||
logger.initLogger(console=not QUIET, log_dir=CONFIG.LOG_DIR,
|
||||
logger.initLogger(console=not QUIET, log_dir=CONFIG.LOG_DIR if log_writable else None,
|
||||
verbose=VERBOSE)
|
||||
|
||||
logger.info(u"Starting Tautulli {}".format(
|
||||
@@ -163,30 +160,22 @@ def initialize(config_file):
|
||||
logger.info(u"Python {}".format(
|
||||
sys.version
|
||||
))
|
||||
logger.info(u"Program Dir: {}".format(
|
||||
PROG_DIR
|
||||
))
|
||||
logger.info(u"Config File: {}".format(
|
||||
CONFIG_FILE
|
||||
))
|
||||
logger.info(u"Database File: {}".format(
|
||||
DB_FILE
|
||||
))
|
||||
|
||||
if not CONFIG.BACKUP_DIR:
|
||||
CONFIG.BACKUP_DIR = os.path.join(DATA_DIR, 'backups')
|
||||
if not os.path.exists(CONFIG.BACKUP_DIR):
|
||||
try:
|
||||
os.makedirs(CONFIG.BACKUP_DIR)
|
||||
except OSError as e:
|
||||
logger.error(u"Could not create backup dir '%s': %s" % (CONFIG.BACKUP_DIR, e))
|
||||
|
||||
if not CONFIG.CACHE_DIR:
|
||||
CONFIG.CACHE_DIR = os.path.join(DATA_DIR, 'cache')
|
||||
if not os.path.exists(CONFIG.CACHE_DIR):
|
||||
try:
|
||||
os.makedirs(CONFIG.CACHE_DIR)
|
||||
except OSError as e:
|
||||
logger.error(u"Could not create cache dir '%s': %s" % (CONFIG.CACHE_DIR, e))
|
||||
|
||||
if not CONFIG.NEWSLETTER_DIR:
|
||||
CONFIG.NEWSLETTER_DIR = os.path.join(DATA_DIR, 'newsletters')
|
||||
if not os.path.exists(CONFIG.NEWSLETTER_DIR):
|
||||
try:
|
||||
os.makedirs(CONFIG.NEWSLETTER_DIR)
|
||||
except OSError as e:
|
||||
logger.error(u"Could not create newsletter dir '%s': %s" % (CONFIG.NEWSLETTER_DIR, e))
|
||||
CONFIG.BACKUP_DIR, _ = check_folder_writable(
|
||||
CONFIG.BACKUP_DIR, os.path.join(DATA_DIR, 'backups'), 'backups')
|
||||
CONFIG.CACHE_DIR, _ = check_folder_writable(
|
||||
CONFIG.CACHE_DIR, os.path.join(DATA_DIR, 'cache'), 'cache')
|
||||
CONFIG.NEWSLETTER_DIR, _ = check_folder_writable(
|
||||
CONFIG.NEWSLETTER_DIR, os.path.join(DATA_DIR, 'newsletters'), 'newsletters')
|
||||
|
||||
# Initialize the database
|
||||
logger.info(u"Checking if the database upgrades are required...")
|
||||
@@ -256,7 +245,7 @@ def initialize(config_file):
|
||||
# Check for new versions
|
||||
if CONFIG.CHECK_GITHUB_ON_STARTUP and CONFIG.CHECK_GITHUB:
|
||||
try:
|
||||
LATEST_VERSION = versioncheck.check_github()
|
||||
LATEST_VERSION = versioncheck.check_update()
|
||||
except:
|
||||
logger.exception(u"Unhandled exception")
|
||||
LATEST_VERSION = CURRENT_VERSION
|
||||
@@ -378,6 +367,51 @@ def launch_browser(host, port, root):
|
||||
logger.error(u"Could not launch browser: %s" % e)
|
||||
|
||||
|
||||
def win_system_tray():
|
||||
from systray import SysTrayIcon
|
||||
|
||||
def tray_open(sysTrayIcon):
|
||||
launch_browser(plexpy.CONFIG.HTTP_HOST, plexpy.HTTP_PORT, plexpy.HTTP_ROOT)
|
||||
|
||||
def tray_check_update(sysTrayIcon):
|
||||
versioncheck.check_update()
|
||||
|
||||
def tray_update(sysTrayIcon):
|
||||
if plexpy.UPDATE_AVAILABLE:
|
||||
plexpy.SIGNAL = 'update'
|
||||
else:
|
||||
hover_text = common.PRODUCT + ' - No Update Available'
|
||||
plexpy.WIN_SYS_TRAY_ICON.update(hover_text=hover_text)
|
||||
|
||||
def tray_restart(sysTrayIcon):
|
||||
plexpy.SIGNAL = 'restart'
|
||||
|
||||
def tray_quit(sysTrayIcon):
|
||||
plexpy.SIGNAL = 'shutdown'
|
||||
|
||||
if plexpy.UPDATE_AVAILABLE:
|
||||
icon = os.path.join(plexpy.PROG_DIR, 'data/interfaces/', plexpy.CONFIG.INTERFACE, 'images/logo_tray-update.ico')
|
||||
hover_text = common.PRODUCT + ' - Update Available!'
|
||||
else:
|
||||
icon = os.path.join(plexpy.PROG_DIR, 'data/interfaces/', plexpy.CONFIG.INTERFACE, 'images/logo_tray.ico')
|
||||
hover_text = common.PRODUCT
|
||||
|
||||
menu_options = (('Open Tautulli', None, tray_open, 'default'),
|
||||
('', None, 'separator', None),
|
||||
('Check for Updates', None, tray_check_update, None),
|
||||
('Update', None, tray_update, None),
|
||||
('Restart', None, tray_restart, None))
|
||||
|
||||
logger.info(u"Launching system tray icon.")
|
||||
|
||||
try:
|
||||
plexpy.WIN_SYS_TRAY_ICON = SysTrayIcon(icon, hover_text, menu_options, on_quit=tray_quit)
|
||||
plexpy.WIN_SYS_TRAY_ICON.start()
|
||||
except Exception as e:
|
||||
logger.error(u"Unable to launch system tray icon: %s." % e)
|
||||
plexpy.WIN_SYS_TRAY_ICON = None
|
||||
|
||||
|
||||
def initialize_scheduler():
|
||||
"""
|
||||
Start the scheduled background tasks. Re-schedule if interval settings changed.
|
||||
@@ -391,7 +425,7 @@ def initialize_scheduler():
|
||||
# Update check
|
||||
github_minutes = CONFIG.CHECK_GITHUB_INTERVAL if CONFIG.CHECK_GITHUB_INTERVAL and CONFIG.CHECK_GITHUB else 0
|
||||
|
||||
schedule_job(versioncheck.check_github, 'Check GitHub for updates',
|
||||
schedule_job(versioncheck.check_update, 'Check GitHub for updates',
|
||||
hours=0, minutes=github_minutes, seconds=0, args=(bool(CONFIG.PLEXPY_AUTO_UPDATE), True))
|
||||
|
||||
backup_hours = CONFIG.BACKUP_INTERVAL if 1 <= CONFIG.BACKUP_INTERVAL <= 24 else 6
|
||||
@@ -421,6 +455,8 @@ def initialize_scheduler():
|
||||
|
||||
schedule_job(activity_pinger.connect_server, 'Check for server response',
|
||||
hours=0, minutes=0, seconds=0)
|
||||
schedule_job(web_socket.send_ping, 'Websocket ping',
|
||||
hours=0, minutes=0, seconds=10 * bool(CONFIG.WEBSOCKET_MONITOR_PING_PONG))
|
||||
|
||||
else:
|
||||
# Cancel all jobs
|
||||
@@ -440,6 +476,8 @@ def initialize_scheduler():
|
||||
# Schedule job to reconnect server
|
||||
schedule_job(activity_pinger.connect_server, 'Check for server response',
|
||||
hours=0, minutes=0, seconds=60, args=(False,))
|
||||
schedule_job(web_socket.send_ping, 'Websocket ping',
|
||||
hours=0, minutes=0, seconds=0)
|
||||
|
||||
# Start scheduler
|
||||
if start_jobs and len(SCHED.get_jobs()):
|
||||
@@ -607,7 +645,7 @@ def dbcheck():
|
||||
# library_sections table :: This table keeps record of the servers library sections
|
||||
c_db.execute(
|
||||
'CREATE TABLE IF NOT EXISTS library_sections (id INTEGER PRIMARY KEY AUTOINCREMENT, '
|
||||
'server_id TEXT, section_id INTEGER, section_name TEXT, section_type TEXT, '
|
||||
'server_id TEXT, section_id INTEGER, section_name TEXT, section_type TEXT, agent TEXT, '
|
||||
'thumb TEXT, custom_thumb_url TEXT, art TEXT, count INTEGER, parent_count INTEGER, child_count INTEGER, '
|
||||
'do_notify INTEGER DEFAULT 1, do_notify_created INTEGER DEFAULT 1, keep_history INTEGER DEFAULT 1, '
|
||||
'deleted_section INTEGER DEFAULT 0, UNIQUE(server_id, section_id))'
|
||||
@@ -625,17 +663,17 @@ def dbcheck():
|
||||
'CREATE TABLE IF NOT EXISTS notifiers (id INTEGER PRIMARY KEY AUTOINCREMENT, '
|
||||
'agent_id INTEGER, agent_name TEXT, agent_label TEXT, friendly_name TEXT, notifier_config TEXT, '
|
||||
'on_play INTEGER DEFAULT 0, on_stop INTEGER DEFAULT 0, on_pause INTEGER DEFAULT 0, '
|
||||
'on_resume INTEGER DEFAULT 0, on_buffer INTEGER DEFAULT 0, on_watched INTEGER DEFAULT 0, '
|
||||
'on_resume INTEGER DEFAULT 0, on_change INTEGER DEFAULT 0, on_buffer INTEGER DEFAULT 0, on_watched INTEGER DEFAULT 0, '
|
||||
'on_created INTEGER DEFAULT 0, on_extdown INTEGER DEFAULT 0, on_intdown INTEGER DEFAULT 0, '
|
||||
'on_extup INTEGER DEFAULT 0, on_intup INTEGER DEFAULT 0, on_pmsupdate INTEGER DEFAULT 0, '
|
||||
'on_concurrent INTEGER DEFAULT 0, on_newdevice INTEGER DEFAULT 0, on_plexpyupdate INTEGER DEFAULT 0, '
|
||||
'on_play_subject TEXT, on_stop_subject TEXT, on_pause_subject TEXT, '
|
||||
'on_resume_subject TEXT, on_buffer_subject TEXT, on_watched_subject TEXT, '
|
||||
'on_resume_subject TEXT, on_change_subject TEXT, on_buffer_subject TEXT, on_watched_subject TEXT, '
|
||||
'on_created_subject TEXT, on_extdown_subject TEXT, on_intdown_subject TEXT, '
|
||||
'on_extup_subject TEXT, on_intup_subject TEXT, on_pmsupdate_subject TEXT, '
|
||||
'on_concurrent_subject TEXT, on_newdevice_subject TEXT, on_plexpyupdate_subject TEXT, '
|
||||
'on_play_body TEXT, on_stop_body TEXT, on_pause_body TEXT, '
|
||||
'on_resume_body TEXT, on_buffer_body TEXT, on_watched_body TEXT, '
|
||||
'on_resume_body TEXT, on_change_body TEXT, on_buffer_body TEXT, on_watched_body TEXT, '
|
||||
'on_created_body TEXT, on_extdown_body TEXT, on_intdown_body TEXT, '
|
||||
'on_extup_body TEXT, on_intup_body TEXT, on_pmsupdate_body TEXT, '
|
||||
'on_concurrent_body TEXT, on_newdevice_body TEXT, on_plexpyupdate_body TEXT, '
|
||||
@@ -664,7 +702,8 @@ def dbcheck():
|
||||
'CREATE TABLE IF NOT EXISTS newsletter_log (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER, '
|
||||
'newsletter_id INTEGER, agent_id INTEGER, agent_name TEXT, notify_action TEXT, '
|
||||
'subject_text TEXT, body_text TEXT, message_text TEXT, start_date TEXT, end_date TEXT, '
|
||||
'start_time INTEGER, end_time INTEGER, uuid TEXT UNIQUE, filename TEXT, success INTEGER DEFAULT 0)'
|
||||
'start_time INTEGER, end_time INTEGER, uuid TEXT UNIQUE, filename TEXT, email_msg_id TEXT, '
|
||||
'success INTEGER DEFAULT 0)'
|
||||
)
|
||||
|
||||
# recently_added table :: This table keeps record of recently added items
|
||||
@@ -1559,6 +1598,15 @@ def dbcheck():
|
||||
'ALTER TABLE newsletter_log ADD COLUMN filename TEXT'
|
||||
)
|
||||
|
||||
# Upgrade newsletter_log table from earlier versions
|
||||
try:
|
||||
c_db.execute('SELECT email_msg_id FROM newsletter_log')
|
||||
except sqlite3.OperationalError:
|
||||
logger.debug(u"Altering database. Updating database table newsletter_log.")
|
||||
c_db.execute(
|
||||
'ALTER TABLE newsletter_log ADD COLUMN email_msg_id TEXT'
|
||||
)
|
||||
|
||||
# Upgrade newsletters table from earlier versions
|
||||
try:
|
||||
c_db.execute('SELECT id_name FROM newsletters')
|
||||
@@ -1615,6 +1663,15 @@ def dbcheck():
|
||||
except sqlite3.OperationalError:
|
||||
logger.warn(u"Unable to remove duplicate libraries from library_sections table.")
|
||||
|
||||
# Upgrade library_sections table from earlier versions
|
||||
try:
|
||||
c_db.execute('SELECT agent FROM library_sections')
|
||||
except sqlite3.OperationalError:
|
||||
logger.debug(u"Altering database. Updating database table library_sections.")
|
||||
c_db.execute(
|
||||
'ALTER TABLE library_sections ADD COLUMN agent TEXT'
|
||||
)
|
||||
|
||||
# Upgrade users table from earlier versions (remove UNIQUE constraint on username)
|
||||
try:
|
||||
result = c_db.execute('SELECT SQL FROM sqlite_master WHERE type="table" AND name="users"').fetchone()
|
||||
@@ -1686,6 +1743,21 @@ def dbcheck():
|
||||
'ALTER TABLE notifiers ADD COLUMN custom_conditions_logic TEXT'
|
||||
)
|
||||
|
||||
# Upgrade notifiers table from earlier versions
|
||||
try:
|
||||
c_db.execute('SELECT on_change FROM notifiers')
|
||||
except sqlite3.OperationalError:
|
||||
logger.debug(u"Altering database. Updating database table notifiers.")
|
||||
c_db.execute(
|
||||
'ALTER TABLE notifiers ADD COLUMN on_change INTEGER DEFAULT 0'
|
||||
)
|
||||
c_db.execute(
|
||||
'ALTER TABLE notifiers ADD COLUMN on_change_subject TEXT'
|
||||
)
|
||||
c_db.execute(
|
||||
'ALTER TABLE notifiers ADD COLUMN on_change_body TEXT'
|
||||
)
|
||||
|
||||
# Upgrade tvmaze_lookup table from earlier versions
|
||||
try:
|
||||
c_db.execute('SELECT rating_key FROM tvmaze_lookup')
|
||||
@@ -1784,6 +1856,7 @@ def upgrade():
|
||||
|
||||
|
||||
def shutdown(restart=False, update=False, checkout=False):
|
||||
logger.info(u"Stopping Tautulli web server...")
|
||||
cherrypy.engine.exit()
|
||||
|
||||
# Shutdown the websocket connection
|
||||
@@ -1822,6 +1895,9 @@ def shutdown(restart=False, update=False, checkout=False):
|
||||
logger.info(u"Removing pidfile %s", PIDFILE)
|
||||
os.remove(PIDFILE)
|
||||
|
||||
if WIN_SYS_TRAY_ICON:
|
||||
WIN_SYS_TRAY_ICON.shutdown()
|
||||
|
||||
if restart:
|
||||
logger.info(u"Tautulli is restarting...")
|
||||
|
||||
@@ -1863,7 +1939,7 @@ def generate_uuid():
|
||||
def initialize_tracker():
|
||||
data = {
|
||||
'dataSource': 'server',
|
||||
'appName': 'Tautulli',
|
||||
'appName': common.PRODUCT,
|
||||
'appVersion': common.RELEASE,
|
||||
'appId': plexpy.INSTALL_TYPE,
|
||||
'appInstallerId': plexpy.CONFIG.GIT_BRANCH,
|
||||
@@ -1898,3 +1974,29 @@ def analytics_event(category, action, label=None, value=None, **kwargs):
|
||||
TRACKER.send('event', data)
|
||||
except Exception as e:
|
||||
logger.warn(u"Failed to send analytics event for category '%s', action '%s': %s" % (category, action, e))
|
||||
|
||||
|
||||
def check_folder_writable(folder, fallback, name):
|
||||
if not folder:
|
||||
folder = fallback
|
||||
|
||||
if not os.path.exists(folder):
|
||||
try:
|
||||
os.makedirs(folder)
|
||||
except OSError as e:
|
||||
logger.error(u"Could not create %s dir '%s': %s" % (name, folder, e))
|
||||
if folder != fallback:
|
||||
logger.warn(u"Falling back to %s dir '%s'" % (name, fallback))
|
||||
return check_folder_writable(None, fallback, name)
|
||||
else:
|
||||
return folder, None
|
||||
|
||||
if not os.access(folder, os.W_OK):
|
||||
logger.error(u"Cannot write to %s dir '%s'" % (name, folder))
|
||||
if folder != fallback:
|
||||
logger.warn(u"Falling back to %s dir '%s'" % (name, fallback))
|
||||
return check_folder_writable(None, fallback, name)
|
||||
else:
|
||||
return folder, False
|
||||
|
||||
return folder, True
|
||||
|
@@ -184,6 +184,19 @@ class ActivityHandler(object):
|
||||
|
||||
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session.copy(), 'notify_action': 'on_resume'})
|
||||
|
||||
def on_change(self):
|
||||
if self.is_valid_session():
|
||||
logger.debug(u"Tautulli ActivityHandler :: Session %s has changed transcode decision." % str(self.get_session_key()))
|
||||
|
||||
# Update the session state and viewOffset
|
||||
self.update_db_session()
|
||||
|
||||
# Retrieve the session data from our temp table
|
||||
ap = activity_processor.ActivityProcessor()
|
||||
db_session = ap.get_session_by_key(session_key=self.get_session_key())
|
||||
|
||||
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session.copy(), 'notify_action': 'on_change'})
|
||||
|
||||
def on_buffer(self):
|
||||
if self.is_valid_session():
|
||||
logger.debug(u"Tautulli ActivityHandler :: Session %s is buffering." % self.get_session_key())
|
||||
@@ -204,14 +217,14 @@ class ActivityHandler(object):
|
||||
# Update the session state and viewOffset
|
||||
self.update_db_session()
|
||||
|
||||
time_since_last_trigger = 0
|
||||
time_since_last_trigger = None
|
||||
if buffer_last_triggered:
|
||||
logger.debug(u"Tautulli ActivityHandler :: Session %s buffer last triggered at %s." %
|
||||
(self.get_session_key(), buffer_last_triggered))
|
||||
time_since_last_trigger = int(time.time()) - int(buffer_last_triggered)
|
||||
|
||||
if plexpy.CONFIG.BUFFER_THRESHOLD > 0 and (current_buffer_count >= plexpy.CONFIG.BUFFER_THRESHOLD and \
|
||||
time_since_last_trigger == 0 or time_since_last_trigger >= plexpy.CONFIG.BUFFER_WAIT):
|
||||
if current_buffer_count >= plexpy.CONFIG.BUFFER_THRESHOLD and time_since_last_trigger is None or \
|
||||
time_since_last_trigger >= plexpy.CONFIG.BUFFER_WAIT:
|
||||
ap.set_session_buffer_trigger_time(session_key=self.get_session_key())
|
||||
|
||||
# Retrieve the session data from our temp table
|
||||
@@ -228,6 +241,7 @@ class ActivityHandler(object):
|
||||
this_state = self.timeline['state']
|
||||
this_rating_key = str(self.timeline['ratingKey'])
|
||||
this_key = self.timeline['key']
|
||||
this_transcode_key = self.timeline.get('transcodeSession', '')
|
||||
|
||||
# Get the live tv session uuid
|
||||
this_live_uuid = this_key.split('/')[-1] if this_key.startswith('/livetv/sessions') else None
|
||||
@@ -241,13 +255,14 @@ class ActivityHandler(object):
|
||||
last_state = db_session['state']
|
||||
last_rating_key = str(db_session['rating_key'])
|
||||
last_live_uuid = db_session['live_uuid']
|
||||
last_transcode_key = db_session['transcode_key'].split('/')[-1]
|
||||
|
||||
# Make sure the same item is being played
|
||||
if this_rating_key == last_rating_key or this_live_uuid == last_live_uuid:
|
||||
# Update the session state and viewOffset
|
||||
if this_state == 'playing':
|
||||
# Update the session in our temp session table
|
||||
# if the last set temporary stopped time exceeds 15 seconds
|
||||
# if the last set temporary stopped time exceeds 60 seconds
|
||||
if int(time.time()) - db_session['stopped'] > 60:
|
||||
self.update_db_session()
|
||||
|
||||
@@ -260,13 +275,16 @@ class ActivityHandler(object):
|
||||
elif this_state == 'stopped':
|
||||
self.on_stop()
|
||||
|
||||
elif this_state == 'buffering':
|
||||
self.on_buffer()
|
||||
|
||||
elif this_state == 'paused':
|
||||
# Update the session last_paused timestamp
|
||||
self.on_pause(still_paused=True)
|
||||
|
||||
if this_state == 'buffering':
|
||||
self.on_buffer()
|
||||
|
||||
if this_transcode_key != last_transcode_key:
|
||||
self.on_change()
|
||||
|
||||
# If a client doesn't register stop events (I'm looking at you PHT!) check if the ratingKey has changed
|
||||
else:
|
||||
# Manually stop and start
|
||||
@@ -372,7 +390,7 @@ class TimelineHandler(object):
|
||||
if metadata:
|
||||
grandparent_rating_key = int(metadata['grandparent_rating_key'])
|
||||
parent_rating_key = int(metadata['parent_rating_key'])
|
||||
|
||||
|
||||
grandparent_set = RECENTLY_ADDED_QUEUE.get(grandparent_rating_key, set())
|
||||
grandparent_set.add(parent_rating_key)
|
||||
RECENTLY_ADDED_QUEUE[grandparent_rating_key] = grandparent_set
|
||||
@@ -421,7 +439,7 @@ class TimelineHandler(object):
|
||||
elif media_type in ('movie', 'show', 'artist') and section_id > 0 and \
|
||||
state_type == 5 and metadata_state is None and queue_size is None and \
|
||||
rating_key in RECENTLY_ADDED_QUEUE:
|
||||
|
||||
|
||||
logger.debug(u"Tautulli TimelineHandler :: Library item '%s' (%s) done processing metadata."
|
||||
% (title, str(rating_key)))
|
||||
|
||||
@@ -456,7 +474,7 @@ def schedule_callback(id, func=None, remove_job=False, args=None, **kwargs):
|
||||
ACTIVITY_SCHED.add_job(
|
||||
func, args=args, id=id, trigger=DateTrigger(
|
||||
run_date=datetime.datetime.now() + datetime.timedelta(**kwargs)))
|
||||
|
||||
|
||||
|
||||
def force_stop_stream(session_key):
|
||||
ap = activity_processor.ActivityProcessor()
|
||||
@@ -503,7 +521,7 @@ def clear_recently_added_queue(rating_key):
|
||||
elif child_keys:
|
||||
for child_key in child_keys:
|
||||
grandchild_keys = RECENTLY_ADDED_QUEUE.get(child_key, [])
|
||||
|
||||
|
||||
if plexpy.CONFIG.NOTIFY_GROUP_RECENTLY_ADDED_PARENT and len(grandchild_keys) > 1:
|
||||
on_created(child_key, child_keys=grandchild_keys)
|
||||
|
||||
@@ -550,7 +568,7 @@ def on_created(rating_key, **kwargs):
|
||||
all_keys = [rating_key]
|
||||
if 'child_keys' in kwargs:
|
||||
all_keys.extend(kwargs['child_keys'])
|
||||
|
||||
|
||||
for key in all_keys:
|
||||
data_factory.set_recently_added_item(key)
|
||||
|
||||
|
@@ -156,10 +156,11 @@ class ActivityProcessor(object):
|
||||
# Reload json from raw stream info
|
||||
if session.get('raw_stream_info'):
|
||||
raw_stream_info = json.loads(session['raw_stream_info'])
|
||||
# Don't overwrite id, session_key, stopped
|
||||
# Don't overwrite id, session_key, stopped, view_offset
|
||||
raw_stream_info.pop('id', None)
|
||||
raw_stream_info.pop('session_key', None)
|
||||
raw_stream_info.pop('stopped', None)
|
||||
raw_stream_info.pop('view_offset', None)
|
||||
session.update(raw_stream_info)
|
||||
|
||||
session = defaultdict(str, session)
|
||||
|
@@ -32,6 +32,7 @@ import xmltodict
|
||||
import plexpy
|
||||
import config
|
||||
import database
|
||||
import helpers
|
||||
import libraries
|
||||
import logger
|
||||
import mobile_app
|
||||
@@ -121,7 +122,7 @@ class API2:
|
||||
|
||||
else:
|
||||
self._api_msg = 'Invalid apikey'
|
||||
|
||||
|
||||
if self._api_authenticated and self._api_cmd in self._api_valid_methods:
|
||||
self._api_msg = None
|
||||
self._api_kwargs = kwargs
|
||||
@@ -173,47 +174,51 @@ class API2:
|
||||
end = int(end)
|
||||
|
||||
if regex:
|
||||
logger.api_debug(u'Tautulli APIv2 :: Filtering log using regex %s' % regex)
|
||||
reg = re.compile('u' + regex, flags=re.I)
|
||||
logger.api_debug(u"Tautulli APIv2 :: Filtering log using regex '%s'" % regex)
|
||||
reg = re.compile(regex, flags=re.I)
|
||||
|
||||
for line in open(logfile, 'r').readlines():
|
||||
temp_loglevel_and_time = None
|
||||
with open(logfile, 'r') as f:
|
||||
for line in f.readlines():
|
||||
temp_loglevel_and_time = None
|
||||
|
||||
try:
|
||||
temp_loglevel_and_time = line.split('- ')
|
||||
loglvl = temp_loglevel_and_time[1].split(' :')[0].strip()
|
||||
tl_tread = line.split(' :: ')
|
||||
if loglvl is None:
|
||||
msg = line.replace('\n', '')
|
||||
else:
|
||||
msg = line.split(' : ')[1].replace('\n', '')
|
||||
thread = tl_tread[1].split(' : ')[0]
|
||||
except IndexError:
|
||||
# We assume this is a traceback
|
||||
tl = (len(templog) - 1)
|
||||
templog[tl]['msg'] += line.replace('\n', '')
|
||||
continue
|
||||
try:
|
||||
temp_loglevel_and_time = line.split('- ')
|
||||
loglvl = temp_loglevel_and_time[1].split(' :')[0].strip()
|
||||
tl_tread = line.split(' :: ')
|
||||
if loglvl is None:
|
||||
msg = line.replace('\n', '')
|
||||
else:
|
||||
msg = line.split(' : ')[1].replace('\n', '')
|
||||
thread = tl_tread[1].split(' : ')[0]
|
||||
except IndexError:
|
||||
# We assume this is a traceback
|
||||
tl = (len(templog) - 1)
|
||||
templog[tl]['msg'] += helpers.sanitize(unicode(line.replace('\n', ''), 'utf-8'))
|
||||
continue
|
||||
|
||||
if len(line) > 1 and temp_loglevel_and_time is not None and loglvl in line:
|
||||
if len(line) > 1 and temp_loglevel_and_time is not None and loglvl in line:
|
||||
|
||||
d = {
|
||||
'time': temp_loglevel_and_time[0],
|
||||
'loglevel': loglvl,
|
||||
'msg': msg.replace('\n', ''),
|
||||
'thread': thread
|
||||
}
|
||||
templog.append(d)
|
||||
d = {
|
||||
'time': temp_loglevel_and_time[0],
|
||||
'loglevel': loglvl,
|
||||
'msg': helpers.sanitize(unicode(msg.replace('\n', ''), 'utf-8')),
|
||||
'thread': thread
|
||||
}
|
||||
templog.append(d)
|
||||
|
||||
if order == 'desc':
|
||||
templog = templog[::-1]
|
||||
|
||||
if end > 0 or start > 0:
|
||||
logger.api_debug(u'Tautulli APIv2 :: Slicing the log from %s to %s' % (start, end))
|
||||
templog = templog[start:end]
|
||||
logger.api_debug(u"Tautulli APIv2 :: Slicing the log from %s to %s" % (start, end))
|
||||
templog = templog[start:end]
|
||||
|
||||
if sort:
|
||||
logger.api_debug(u'Tautulli APIv2 :: Sorting log based on %s' % sort)
|
||||
logger.api_debug(u"Tautulli APIv2 :: Sorting log based on '%s'" % sort)
|
||||
templog = sorted(templog, key=lambda k: k[sort])
|
||||
|
||||
if search:
|
||||
logger.api_debug(u'Tautulli APIv2 :: Searching log values for %s' % search)
|
||||
logger.api_debug(u"Tautulli APIv2 :: Searching log values for '%s'" % search)
|
||||
tt = [d for d in templog for k, v in d.items() if search.lower() in v.lower()]
|
||||
|
||||
if len(tt):
|
||||
@@ -222,16 +227,13 @@ class API2:
|
||||
if regex:
|
||||
tt = []
|
||||
for l in templog:
|
||||
stringdict = ' '.join('{}{}'.format(k, v) for k, v in l.items())
|
||||
stringdict = ' '.join(u'{}{}'.format(k, v) for k, v in l.items())
|
||||
if reg.search(stringdict):
|
||||
tt.append(l)
|
||||
|
||||
if len(tt):
|
||||
templog = tt
|
||||
|
||||
if order == 'desc':
|
||||
templog = templog[::-1]
|
||||
|
||||
return templog
|
||||
|
||||
def get_settings(self, key=''):
|
||||
@@ -309,8 +311,8 @@ class API2:
|
||||
self.backup_db()
|
||||
else:
|
||||
# If the backup is less then 24 h old lets make a backup
|
||||
if any([os.path.getctime(os.path.join(plexpy.CONFIG.BACKUP_DIR, file_)) < (time.time() - 86400)
|
||||
and file_.endswith('.db') for file_ in os.listdir(plexpy.CONFIG.BACKUP_DIR)]):
|
||||
if not any(os.path.getctime(os.path.join(plexpy.CONFIG.BACKUP_DIR, file_)) > (time.time() - 86400)
|
||||
and file_.endswith('.db') for file_ in os.listdir(plexpy.CONFIG.BACKUP_DIR)):
|
||||
self.backup_db()
|
||||
|
||||
db = database.MonitorDatabase()
|
||||
@@ -411,7 +413,7 @@ class API2:
|
||||
body (str): The body of the message
|
||||
|
||||
Optional parameters:
|
||||
None
|
||||
script_args (str): The arguments for script notifications
|
||||
|
||||
Returns:
|
||||
None
|
||||
@@ -494,10 +496,16 @@ class API2:
|
||||
""" Tries to make a API.md to simplify the api docs. """
|
||||
|
||||
head = '''# API Reference\n
|
||||
The API is still pretty new and needs some serious cleaning up on the backend but should be reasonably functional. There are no error codes yet.
|
||||
|
||||
## General structure
|
||||
The API endpoint is `http://ip:port + HTTP_ROOT + /api/v2?apikey=$apikey&cmd=$command`
|
||||
The API endpoint is
|
||||
```
|
||||
http://IP_ADDRESS:PORT + [/HTTP_ROOT] + /api/v2?apikey=$apikey&cmd=$command
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
http://localhost:8181/api/v2?apikey=66198313a092496b8a725867d2223b5f&cmd=get_metadata&rating_key=153037
|
||||
```
|
||||
|
||||
Response example (default `json`)
|
||||
```
|
||||
@@ -590,12 +598,13 @@ General optional parameters:
|
||||
if self._api_cmd == 'docs_md':
|
||||
return out['response']['data']
|
||||
|
||||
elif self._api_cmd == 'download_log':
|
||||
elif self._api_cmd.startswith('download_'):
|
||||
return
|
||||
|
||||
elif self._api_cmd == 'pms_image_proxy':
|
||||
cherrypy.response.headers['Content-Type'] = 'image/jpeg'
|
||||
return out['response']['data']
|
||||
if 'return_hash' not in self._api_kwargs:
|
||||
cherrypy.response.headers['Content-Type'] = 'image/jpeg'
|
||||
return out['response']['data']
|
||||
|
||||
if self._api_out_type == 'json':
|
||||
cherrypy.response.headers['Content-Type'] = 'application/json;charset=UTF-8'
|
||||
|
@@ -19,6 +19,7 @@ from collections import OrderedDict
|
||||
import version
|
||||
|
||||
# Identify Our Application
|
||||
PRODUCT = 'Tautulli'
|
||||
PLATFORM = platform.system()
|
||||
PLATFORM_RELEASE = platform.release()
|
||||
PLATFORM_VERSION = platform.version()
|
||||
@@ -27,7 +28,7 @@ PLATFORM_DEVICE_NAME = platform.node()
|
||||
BRANCH = version.PLEXPY_BRANCH
|
||||
RELEASE = version.PLEXPY_RELEASE_VERSION
|
||||
|
||||
USER_AGENT = 'Tautulli/{} ({} {})'.format(RELEASE, PLATFORM, PLATFORM_RELEASE)
|
||||
USER_AGENT = '{}/{} ({} {})'.format(PRODUCT, RELEASE, PLATFORM, PLATFORM_RELEASE)
|
||||
|
||||
DEFAULT_USER_THUMB = "interfaces/default/images/gravatar-default-80x80.png"
|
||||
DEFAULT_POSTER_THUMB = "interfaces/default/images/poster.png"
|
||||
@@ -176,7 +177,8 @@ HW_ENCODERS = [
|
||||
'videotoolbox',
|
||||
'mediacodecndk',
|
||||
'vaapi',
|
||||
'nvenc'
|
||||
'nvenc',
|
||||
'x264'
|
||||
]
|
||||
|
||||
EXTRA_TYPES = {
|
||||
@@ -319,6 +321,7 @@ NOTIFICATION_PARAMETERS = [
|
||||
{'name': 'Datestamp', 'type': 'str', 'value': 'datestamp', 'description': 'The date (in date format) when the notification is triggered.'},
|
||||
{'name': 'Timestamp', 'type': 'str', 'value': 'timestamp', 'description': 'The time (in time format) when the notification is triggered.'},
|
||||
{'name': 'Unix Time', 'type': 'int', 'value': 'unixtime', 'description': 'The unix timestamp when the notification is triggered.'},
|
||||
{'name': 'UTC Time', 'type': 'int', 'value': 'utctime', 'description': 'The UTC timestamp in ISO format when the notification is triggered.'},
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -430,7 +433,7 @@ NOTIFICATION_PARAMETERS = [
|
||||
{'name': 'Updated Date', 'type': 'str', 'value': 'updated_date', 'description': 'The date (in date format) the item was updated on Plex.'},
|
||||
{'name': 'Last Viewed Date', 'type': 'str', 'value': 'last_viewed_date', 'description': 'The date (in date format) the item was last viewed on Plex.'},
|
||||
{'name': 'Studio', 'type': 'str', 'value': 'studio', 'description': 'The studio for the item.'},
|
||||
{'name': 'Content Rating', 'type': 'int', 'value': 'content_rating', 'description': 'The content rating for the item.', 'example': 'e.g. TV-MA, TV-PG, etc.'},
|
||||
{'name': 'Content Rating', 'type': 'str', 'value': 'content_rating', 'description': 'The content rating for the item.', 'example': 'e.g. TV-MA, TV-PG, etc.'},
|
||||
{'name': 'Directors', 'type': 'str', 'value': 'directors', 'description': 'A list of directors for the item.'},
|
||||
{'name': 'Writers', 'type': 'str', 'value': 'writers', 'description': 'A list of writers for the item.'},
|
||||
{'name': 'Actors', 'type': 'str', 'value': 'actors', 'description': 'A list of actors for the item.'},
|
||||
@@ -440,7 +443,8 @@ NOTIFICATION_PARAMETERS = [
|
||||
{'name': 'Summary', 'type': 'str', 'value': 'summary', 'description': 'A short plot summary for the item.'},
|
||||
{'name': 'Tagline', 'type': 'str', 'value': 'tagline', 'description': 'A tagline for the media item.'},
|
||||
{'name': 'Rating', 'type': 'float', 'value': 'rating', 'description': 'The rating (out of 10) for the item.'},
|
||||
{'name': 'Audience Rating', 'type': 'float', 'value': 'audience_rating', 'description': 'The audience rating (%) for the item.', 'help_text': 'Ratings source must be Rotten Tomatoes for the Plex Movie agent'},
|
||||
{'name': 'Critic Rating', 'type': 'int', 'value': 'critic_rating', 'description': 'The critic rating (%) for the item.', 'help_text': 'Ratings source must be Rotten Tomatoes for the Plex Movie agent'},
|
||||
{'name': 'Audience Rating', 'type': 'int', 'value': 'audience_rating', 'description': 'The audience rating (%) for the item.', 'help_text': 'Ratings source must be Rotten Tomatoes for the Plex Movie agent'},
|
||||
{'name': 'Duration', 'type': 'int', 'value': 'duration', 'description': 'The duration (in minutes) for the item.'},
|
||||
{'name': 'Poster URL', 'type': 'str', 'value': 'poster_url', 'description': 'A URL for the movie, TV show, or album poster.'},
|
||||
{'name': 'Plex URL', 'type': 'str', 'value': 'plex_url', 'description': 'The Plex URL to your server for the item.'},
|
||||
|
@@ -104,7 +104,7 @@ _CONFIG_DEFINITIONS = {
|
||||
'BROWSER_ON_PMSUPDATE': (int, 'Browser', 0),
|
||||
'BROWSER_ON_CONCURRENT': (int, 'Browser', 0),
|
||||
'BROWSER_ON_NEWDEVICE': (int, 'Browser', 0),
|
||||
'BUFFER_THRESHOLD': (int, 'Monitoring', 3),
|
||||
'BUFFER_THRESHOLD': (int, 'Monitoring', 10),
|
||||
'BUFFER_WAIT': (int, 'Monitoring', 900),
|
||||
'BACKUP_DAYS': (int, 'General', 3),
|
||||
'BACKUP_DIR': (str, 'General', ''),
|
||||
@@ -182,10 +182,6 @@ _CONFIG_DEFINITIONS = {
|
||||
'GIT_TOKEN': (str, 'General', ''),
|
||||
'GIT_USER': (str, 'General', 'Tautulli'),
|
||||
'GIT_REPO': (str, 'General', 'Tautulli'),
|
||||
'GRAPH_TYPE': (str, 'General', 'plays'),
|
||||
'GRAPH_DAYS': (int, 'General', 30),
|
||||
'GRAPH_MONTHS': (int, 'General', 12),
|
||||
'GRAPH_TAB': (str, 'General', 'tabs-1'),
|
||||
'GROUP_HISTORY_TABLES': (int, 'General', 1),
|
||||
'GROWL_ENABLED': (int, 'Growl', 0),
|
||||
'GROWL_HOST': (str, 'Growl', ''),
|
||||
@@ -207,12 +203,8 @@ _CONFIG_DEFINITIONS = {
|
||||
'HISTORY_TABLE_ACTIVITY': (int, 'General', 1),
|
||||
'HOME_SECTIONS': (list, 'General', ['current_activity','watch_stats','library_stats','recently_added']),
|
||||
'HOME_LIBRARY_CARDS': (list, 'General', ['first_run']),
|
||||
'HOME_STATS_LENGTH': (int, 'General', 30),
|
||||
'HOME_STATS_TYPE': (int, 'General', 0),
|
||||
'HOME_STATS_COUNT': (int, 'General', 5),
|
||||
'HOME_STATS_CARDS': (list, 'General', ['top_movies', 'popular_movies', 'top_tv', 'popular_tv', 'top_music', \
|
||||
'popular_music', 'last_watched', 'top_users', 'top_platforms', 'most_concurrent']),
|
||||
'HOME_STATS_RECENTLY_ADDED_COUNT': (int, 'General', 50),
|
||||
'HOME_REFRESH_INTERVAL': (int, 'General', 10),
|
||||
'HTTPS_CREATE_CERT': (int, 'General', 1),
|
||||
'HTTPS_CERT': (str, 'General', ''),
|
||||
@@ -607,6 +599,7 @@ _CONFIG_DEFINITIONS = {
|
||||
'UPDATE_NOTIFIERS_DB': (int, 'General', 1),
|
||||
'VERIFY_SSL_CERT': (bool_int, 'Advanced', 1),
|
||||
'VIDEO_LOGGING_ENABLE': (int, 'Monitoring', 1),
|
||||
'WEBSOCKET_MONITOR_PING_PONG': (int, 'Advanced', 0),
|
||||
'WEBSOCKET_CONNECTION_ATTEMPTS': (int, 'Advanced', 5),
|
||||
'WEBSOCKET_CONNECTION_TIMEOUT': (int, 'Advanced', 5),
|
||||
'WEEK_START_MONDAY': (int, 'General', 0),
|
||||
@@ -629,7 +622,8 @@ _CONFIG_DEFINITIONS = {
|
||||
'XBMC_ON_CONCURRENT': (int, 'XBMC', 0),
|
||||
'XBMC_ON_NEWDEVICE': (int, 'XBMC', 0),
|
||||
'JWT_SECRET': (str, 'Advanced', ''),
|
||||
'SYSTEM_ANALYTICS': (int, 'Advanced', 1)
|
||||
'SYSTEM_ANALYTICS': (int, 'Advanced', 1),
|
||||
'WIN_SYS_TRAY': (int, 'General', 1)
|
||||
}
|
||||
|
||||
_BLACKLIST_KEYS = ['_APITOKEN', '_TOKEN', '_KEY', '_SECRET', '_PASSWORD', '_APIKEY', '_ID', '_HOOK']
|
||||
@@ -802,6 +796,7 @@ class Config(object):
|
||||
if self.VIDEO_LOGGING_ENABLE == 0:
|
||||
self.MOVIE_LOGGING_ENABLE = 0
|
||||
self.TV_LOGGING_ENABLE = 0
|
||||
|
||||
self.CONFIG_VERSION = 1
|
||||
|
||||
if self.CONFIG_VERSION == 1:
|
||||
@@ -817,11 +812,12 @@ class Config(object):
|
||||
if 'library_statistics' in home_library_cards:
|
||||
home_library_cards.remove('library_statistics')
|
||||
self.HOME_LIBRARY_CARDS = home_library_cards
|
||||
|
||||
self.CONFIG_VERSION = 2
|
||||
|
||||
if self.CONFIG_VERSION == 2:
|
||||
def rep(s):
|
||||
return s.replace('{progress}','{progress_duration}')
|
||||
return s.replace('{progress}', '{progress_duration}')
|
||||
|
||||
self.NOTIFY_ON_START_SUBJECT_TEXT = rep(self.NOTIFY_ON_START_SUBJECT_TEXT)
|
||||
self.NOTIFY_ON_START_BODY_TEXT = rep(self.NOTIFY_ON_START_BODY_TEXT)
|
||||
@@ -836,10 +832,13 @@ class Config(object):
|
||||
self.NOTIFY_ON_WATCHED_SUBJECT_TEXT = rep(self.NOTIFY_ON_WATCHED_SUBJECT_TEXT)
|
||||
self.NOTIFY_ON_WATCHED_BODY_TEXT = rep(self.NOTIFY_ON_WATCHED_BODY_TEXT)
|
||||
self.NOTIFY_SCRIPTS_ARGS_TEXT = rep(self.NOTIFY_SCRIPTS_ARGS_TEXT)
|
||||
|
||||
self.CONFIG_VERSION = 3
|
||||
|
||||
if self.CONFIG_VERSION == 3:
|
||||
if self.HTTP_ROOT == '/': self.HTTP_ROOT = ''
|
||||
if self.HTTP_ROOT == '/':
|
||||
self.HTTP_ROOT = ''
|
||||
|
||||
self.CONFIG_VERSION = 4
|
||||
|
||||
if self.CONFIG_VERSION == 4:
|
||||
@@ -851,20 +850,26 @@ class Config(object):
|
||||
home_sections = self.HOME_SECTIONS
|
||||
home_sections.remove('library_stats')
|
||||
self.HOME_SECTIONS = home_sections
|
||||
|
||||
self.CONFIG_VERSION = 5
|
||||
|
||||
if self.CONFIG_VERSION == 5:
|
||||
self.MONITOR_PMS_UPDATES = 0
|
||||
|
||||
self.CONFIG_VERSION = 6
|
||||
|
||||
if self.CONFIG_VERSION == 6:
|
||||
if self.GIT_USER.lower() == 'drzoidberg33':
|
||||
self.GIT_USER = 'JonnyWong16'
|
||||
|
||||
self.CONFIG_VERSION = 7
|
||||
|
||||
if self.CONFIG_VERSION == 7:
|
||||
def rep(s):
|
||||
return s.replace('<tv>','<episode>').replace('</tv>','</episode>').replace('<music>','<track>').replace('</music>','</track>')
|
||||
return s.replace('<tv>', '<episode>') \
|
||||
.replace('</tv>', '</episode>') \
|
||||
.replace('<music>', '<track>') \
|
||||
.replace('</music>', '</track>')
|
||||
|
||||
self.NOTIFY_ON_START_SUBJECT_TEXT = rep(self.NOTIFY_ON_START_SUBJECT_TEXT)
|
||||
self.NOTIFY_ON_START_BODY_TEXT = rep(self.NOTIFY_ON_START_BODY_TEXT)
|
||||
@@ -904,3 +909,13 @@ class Config(object):
|
||||
self.GIT_REPO = 'Tautulli'
|
||||
|
||||
self.CONFIG_VERSION = 11
|
||||
|
||||
if self.CONFIG_VERSION == 11:
|
||||
self.ANON_REDIRECT = self.ANON_REDIRECT.replace('http://www.nullrefer.com/?',
|
||||
'https://www.nullrefer.com/?')
|
||||
self.CONFIG_VERSION = 12
|
||||
|
||||
if self.CONFIG_VERSION == 12:
|
||||
self.BUFFER_THRESHOLD = max(self.BUFFER_THRESHOLD, 10)
|
||||
|
||||
self.CONFIG_VERSION = 13
|
||||
|
@@ -261,17 +261,11 @@ class DataFactory(object):
|
||||
|
||||
return dict
|
||||
|
||||
def get_home_stats(self, grouping=None, time_range=None, stats_type=None, stats_count=None, stats_cards=None):
|
||||
def get_home_stats(self, grouping=None, time_range=30, stats_type='plays', stats_count=10, stats_cards=None):
|
||||
monitor_db = database.MonitorDatabase()
|
||||
|
||||
if grouping is None:
|
||||
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
|
||||
if time_range is None:
|
||||
time_range = plexpy.CONFIG.HOME_STATS_LENGTH
|
||||
if stats_type is None:
|
||||
stats_type = plexpy.CONFIG.HOME_STATS_TYPE
|
||||
if stats_count is None:
|
||||
stats_count = plexpy.CONFIG.HOME_STATS_COUNT
|
||||
if stats_cards is None:
|
||||
stats_cards = plexpy.CONFIG.HOME_STATS_CARDS
|
||||
|
||||
@@ -280,7 +274,7 @@ class DataFactory(object):
|
||||
music_watched_percent = plexpy.CONFIG.MUSIC_WATCHED_PERCENT
|
||||
|
||||
group_by = 'session_history.reference_id' if grouping else 'session_history.id'
|
||||
sort_type = 'total_duration' if helpers.cast_to_int(stats_type) == 1 else 'total_plays'
|
||||
sort_type = 'total_duration' if stats_type == 'duration' else 'total_plays'
|
||||
|
||||
home_stats = []
|
||||
|
||||
@@ -926,7 +920,7 @@ class DataFactory(object):
|
||||
pre_tautulli = 0
|
||||
|
||||
# For backwards compatibility. Pick one new Tautulli key to check and override with old values.
|
||||
if not item['stream_video_resolution']:
|
||||
if not item['stream_container']:
|
||||
item['stream_video_resolution'] = item['video_resolution']
|
||||
item['stream_container'] = item['transcode_container'] or item['container']
|
||||
item['stream_video_decision'] = item['video_decision']
|
||||
@@ -1449,7 +1443,8 @@ class DataFactory(object):
|
||||
'media_index, parent_media_index ' \
|
||||
'FROM session_history_metadata ' \
|
||||
'WHERE {0} = ? ' \
|
||||
'GROUP BY {1} '
|
||||
'GROUP BY {1} ' \
|
||||
'ORDER BY {1} DESC '
|
||||
|
||||
# get grandparent_rating_keys
|
||||
grandparents = {}
|
||||
|
@@ -33,6 +33,7 @@ import maxminddb
|
||||
from operator import itemgetter
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
@@ -202,17 +203,22 @@ def convert_seconds_to_minutes(s):
|
||||
def today():
|
||||
today = datetime.date.today()
|
||||
yyyymmdd = datetime.date.isoformat(today)
|
||||
|
||||
return yyyymmdd
|
||||
|
||||
|
||||
def now():
|
||||
now = datetime.datetime.now()
|
||||
|
||||
return now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def utc_now_iso():
|
||||
utcnow = datetime.datetime.utcnow()
|
||||
|
||||
return utcnow.isoformat()
|
||||
|
||||
|
||||
def human_duration(s, sig='dhms'):
|
||||
|
||||
hd = ''
|
||||
@@ -466,7 +472,7 @@ def get_percent(value1, value2):
|
||||
else:
|
||||
percent = 0
|
||||
|
||||
return math.trunc(percent)
|
||||
return math.trunc(round(percent, 0))
|
||||
|
||||
|
||||
def hex_to_int(hex):
|
||||
@@ -1115,3 +1121,29 @@ def grouper(iterable, n, fillvalue=None):
|
||||
# grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx
|
||||
args = [iter(iterable)] * n
|
||||
return izip_longest(fillvalue=fillvalue, *args)
|
||||
|
||||
|
||||
def traverse_map(obj, func):
|
||||
if isinstance(obj, list):
|
||||
new_obj = []
|
||||
for i in obj:
|
||||
new_obj.append(traverse_map(i, func))
|
||||
|
||||
elif isinstance(obj, dict):
|
||||
new_obj = {}
|
||||
for k, v in obj.iteritems():
|
||||
new_obj[traverse_map(k, func)] = traverse_map(v, func)
|
||||
|
||||
else:
|
||||
new_obj = func(obj)
|
||||
|
||||
return new_obj
|
||||
|
||||
|
||||
def split_args(args=None):
|
||||
if isinstance(args, list):
|
||||
return args
|
||||
elif isinstance(args, basestring):
|
||||
return [arg.decode(plexpy.SYS_ENCODING, 'ignore')
|
||||
for arg in shlex.split(args.encode(plexpy.SYS_ENCODING, 'ignore'))]
|
||||
return []
|
||||
|
@@ -33,20 +33,26 @@ class HTTPHandler(object):
|
||||
Retrieve data from Plex Server
|
||||
"""
|
||||
|
||||
def __init__(self, urls, token=None, timeout=10, ssl_verify=True):
|
||||
def __init__(self, urls, headers=None, token=None, timeout=10, ssl_verify=True, silent=False):
|
||||
self._silent = silent
|
||||
|
||||
if isinstance(urls, basestring):
|
||||
self.urls = urls.split() or urls.split(',')
|
||||
else:
|
||||
self.urls = urls
|
||||
|
||||
self.headers = {'X-Plex-Product': 'Tautulli',
|
||||
'X-Plex-Version': plexpy.common.RELEASE,
|
||||
'X-Plex-Client-Identifier': plexpy.CONFIG.PMS_UUID,
|
||||
'X-Plex-Platform': plexpy.common.PLATFORM,
|
||||
'X-Plex-Platform-Version': plexpy.common.PLATFORM_RELEASE,
|
||||
'X-Plex-Device': 'Web',
|
||||
'X-Plex-Device-Name': plexpy.common.PLATFORM_DEVICE_NAME
|
||||
}
|
||||
if headers:
|
||||
self.headers = headers
|
||||
else:
|
||||
self.headers = {'X-Plex-Product': plexpy.common.PRODUCT,
|
||||
'X-Plex-Version': plexpy.common.RELEASE,
|
||||
'X-Plex-Client-Identifier': plexpy.CONFIG.PMS_UUID,
|
||||
'X-Plex-Platform': plexpy.common.PLATFORM,
|
||||
'X-Plex-Platform-Version': plexpy.common.PLATFORM_RELEASE,
|
||||
'X-Plex-Device': '{} {}'.format(plexpy.common.PLATFORM,
|
||||
plexpy.common.PLATFORM_RELEASE),
|
||||
'X-Plex-Device-Name': plexpy.common.PLATFORM_DEVICE_NAME
|
||||
}
|
||||
|
||||
self.token = token
|
||||
if self.token:
|
||||
@@ -127,7 +133,8 @@ class HTTPHandler(object):
|
||||
for work in pool.imap_unordered(part, urls, chunk):
|
||||
yield work
|
||||
except Exception as e:
|
||||
logger.error(u"Failed to yield request: %s" % e)
|
||||
if not self._silent:
|
||||
logger.error(u"Failed to yield request: %s" % e)
|
||||
finally:
|
||||
pool.close()
|
||||
pool.join()
|
||||
@@ -137,13 +144,16 @@ class HTTPHandler(object):
|
||||
try:
|
||||
r = session.request(self.request_type, url, headers=self.headers, timeout=self.timeout)
|
||||
except IOError as e:
|
||||
logger.warn(u"Failed to access uri endpoint %s with error %s" % (self.uri, e))
|
||||
if not self._silent:
|
||||
logger.warn(u"Failed to access uri endpoint %s with error %s" % (self.uri, e))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warn(u"Failed to access uri endpoint %s. Is your server maybe accepting SSL connections only? %s" % (self.uri, e))
|
||||
if not self._silent:
|
||||
logger.warn(u"Failed to access uri endpoint %s. Is your server maybe accepting SSL connections only? %s" % (self.uri, e))
|
||||
return None
|
||||
except:
|
||||
logger.warn(u"Failed to access uri endpoint %s with Uncaught exception." % self.uri)
|
||||
if not self._silent:
|
||||
logger.warn(u"Failed to access uri endpoint %s with Uncaught exception." % self.uri)
|
||||
return None
|
||||
|
||||
response_status = r.status
|
||||
@@ -153,7 +163,8 @@ class HTTPHandler(object):
|
||||
if response_status in (200, 201):
|
||||
return self._http_format_output(response_content, response_headers)
|
||||
else:
|
||||
logger.warn(u"Failed to access uri endpoint %s. Status code %r" % (self.uri, response_status))
|
||||
if not self._silent:
|
||||
logger.warn(u"Failed to access uri endpoint %s. Status code %r" % (self.uri, response_status))
|
||||
return None
|
||||
|
||||
def _http_format_output(self, response_content, response_headers):
|
||||
@@ -179,5 +190,6 @@ class HTTPHandler(object):
|
||||
return output
|
||||
|
||||
except Exception as e:
|
||||
logger.warn(u"Failed format response from uri %s to %s error %s" % (self.uri, self.output_format, e))
|
||||
if not self._silent:
|
||||
logger.warn(u"Failed format response from uri %s to %s error %s" % (self.uri, self.output_format, e))
|
||||
return None
|
||||
|
@@ -50,6 +50,7 @@ def refresh_libraries():
|
||||
'section_id': section['section_id'],
|
||||
'section_name': section['section_name'],
|
||||
'section_type': section['section_type'],
|
||||
'agent': section['agent'],
|
||||
'thumb': section['thumb'],
|
||||
'art': section['art'],
|
||||
'count': section['count'],
|
||||
@@ -633,7 +634,8 @@ class Libraries(object):
|
||||
if 'media_info' in child_metadata and len(child_metadata['media_info']) > 0:
|
||||
media_info = child_metadata['media_info'][0]
|
||||
if 'parts' in media_info and len (media_info['parts']) > 0:
|
||||
media_part_info = media_info['parts'][0]
|
||||
media_part_info = next((p for p in media_info['parts'] if p['selected']),
|
||||
media_info['parts'][0])
|
||||
|
||||
file_size += helpers.cast_to_int(media_part_info.get('file_size', 0))
|
||||
|
||||
@@ -922,7 +924,7 @@ class Libraries(object):
|
||||
monitor_db = database.MonitorDatabase()
|
||||
|
||||
try:
|
||||
query = 'SELECT section_id, section_name, section_type FROM library_sections WHERE deleted_section = 0'
|
||||
query = 'SELECT section_id, section_name, section_type, agent FROM library_sections WHERE deleted_section = 0'
|
||||
result = monitor_db.select(query=query)
|
||||
except Exception as e:
|
||||
logger.warn(u"Tautulli Libraries :: Unable to execute database query for get_sections: %s." % e)
|
||||
@@ -932,7 +934,8 @@ class Libraries(object):
|
||||
for item in result:
|
||||
library = {'section_id': item['section_id'],
|
||||
'section_name': item['section_name'],
|
||||
'section_type': item['section_type']
|
||||
'section_type': item['section_type'],
|
||||
'agent': item['agent']
|
||||
}
|
||||
libraries.append(library)
|
||||
|
||||
|
@@ -130,6 +130,32 @@ class PublicIPFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
|
||||
class PlexTokenFilter(logging.Filter):
|
||||
"""
|
||||
Log filter for X-Plex-Token
|
||||
"""
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def filter(self, record):
|
||||
try:
|
||||
tokens = re.findall(r'X-Plex-Token(?:=|%3D)([a-zA-Z0-9]+)', record.msg)
|
||||
for token in tokens:
|
||||
record.msg = record.msg.replace(token, 8 * '*' + token[-2:])
|
||||
|
||||
args = []
|
||||
for arg in record.args:
|
||||
tokens = re.findall(r'X-Plex-Token(?:=|%3D)([a-zA-Z0-9]+)', arg) if isinstance(arg, basestring) else []
|
||||
for token in tokens:
|
||||
arg = arg.replace(token, 8 * '*' + token[-2:])
|
||||
args.append(arg)
|
||||
record.args = tuple(args)
|
||||
except:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def listener():
|
||||
"""
|
||||
@@ -268,6 +294,7 @@ def initLogger(console=False, log_dir=False, verbose=False):
|
||||
for handler in logger.handlers + logger_api.handlers + logger_plex_websocket.handlers:
|
||||
handler.addFilter(BlacklistFilter())
|
||||
handler.addFilter(PublicIPFilter())
|
||||
handler.addFilter(PlexTokenFilter())
|
||||
|
||||
# Install exception hooks
|
||||
initHooks()
|
||||
|
@@ -18,6 +18,7 @@ import time
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
import email.utils
|
||||
|
||||
import plexpy
|
||||
import database
|
||||
@@ -86,6 +87,9 @@ def notify(newsletter_id=None, notify_action=None, **kwargs):
|
||||
body = newsletter_config['body']
|
||||
message = newsletter_config['message']
|
||||
|
||||
email_msg_id = email.utils.make_msgid()
|
||||
email_reply_msg_id = get_last_newsletter_email_msg_id(newsletter_id=newsletter_id, notify_action=notify_action)
|
||||
|
||||
newsletter_agent = newsletters.get_agent_class(newsletter_id=newsletter_id,
|
||||
newsletter_id_name=newsletter_config['id_name'],
|
||||
agent_id=newsletter_config['agent_id'],
|
||||
@@ -93,7 +97,9 @@ def notify(newsletter_id=None, notify_action=None, **kwargs):
|
||||
email_config=newsletter_config['email_config'],
|
||||
subject=subject,
|
||||
body=body,
|
||||
message=message
|
||||
message=message,
|
||||
email_msg_id=email_msg_id,
|
||||
email_reply_msg_id=email_reply_msg_id
|
||||
)
|
||||
|
||||
# Set the newsletter state in the db
|
||||
@@ -107,7 +113,8 @@ def notify(newsletter_id=None, notify_action=None, **kwargs):
|
||||
end_date=newsletter_agent.end_date.format('YYYY-MM-DD'),
|
||||
start_time=newsletter_agent.start_time,
|
||||
end_time=newsletter_agent.end_time,
|
||||
newsletter_uuid=newsletter_agent.uuid)
|
||||
newsletter_uuid=newsletter_agent.uuid,
|
||||
email_msg_id=email_msg_id)
|
||||
|
||||
# Send the notification
|
||||
success = newsletter_agent.send()
|
||||
@@ -118,7 +125,7 @@ def notify(newsletter_id=None, notify_action=None, **kwargs):
|
||||
|
||||
|
||||
def set_notify_state(newsletter, notify_action, subject, body, message, filename,
|
||||
start_date, end_date, start_time, end_time, newsletter_uuid):
|
||||
start_date, end_date, start_time, end_time, newsletter_uuid, email_msg_id):
|
||||
|
||||
if newsletter and notify_action:
|
||||
db = database.MonitorDatabase()
|
||||
@@ -137,6 +144,7 @@ def set_notify_state(newsletter, notify_action, subject, body, message, filename
|
||||
'end_date': end_date,
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
'email_msg_id': email_msg_id,
|
||||
'filename': filename}
|
||||
|
||||
db.upsert(table_name='newsletter_log', key_dict=keys, value_dict=values)
|
||||
@@ -153,6 +161,17 @@ def set_notify_success(newsletter_log_id):
|
||||
db.upsert(table_name='newsletter_log', key_dict=keys, value_dict=values)
|
||||
|
||||
|
||||
def get_last_newsletter_email_msg_id(newsletter_id, notify_action):
|
||||
db = database.MonitorDatabase()
|
||||
|
||||
result = db.select_single('SELECT email_msg_id FROM newsletter_log '
|
||||
'WHERE newsletter_id = ? AND notify_action = ? AND success = 1 '
|
||||
'ORDER BY timestamp DESC LIMIT 1', [newsletter_id, notify_action])
|
||||
|
||||
if result:
|
||||
return result['email_msg_id']
|
||||
|
||||
|
||||
def get_newsletter(newsletter_uuid=None, newsletter_id_name=None):
|
||||
db = database.MonitorDatabase()
|
||||
|
||||
|
@@ -14,6 +14,7 @@
|
||||
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import arrow
|
||||
from collections import OrderedDict
|
||||
import json
|
||||
from itertools import groupby
|
||||
from mako.lookup import TemplateLookup
|
||||
@@ -65,7 +66,8 @@ def available_notification_actions():
|
||||
|
||||
|
||||
def get_agent_class(newsletter_id=None, newsletter_id_name=None, agent_id=None, config=None, email_config=None,
|
||||
start_date=None, end_date=None, subject=None, body=None, message=None):
|
||||
start_date=None, end_date=None, subject=None, body=None, message=None,
|
||||
email_msg_id=None, email_reply_msg_id=None):
|
||||
if str(agent_id).isdigit():
|
||||
agent_id = int(agent_id)
|
||||
|
||||
@@ -77,7 +79,9 @@ def get_agent_class(newsletter_id=None, newsletter_id_name=None, agent_id=None,
|
||||
'end_date': end_date,
|
||||
'subject': subject,
|
||||
'body': body,
|
||||
'message': message}
|
||||
'message': message,
|
||||
'email_msg_id': email_msg_id,
|
||||
'email_reply_msg_id': email_reply_msg_id}
|
||||
|
||||
if agent_id == 0:
|
||||
return RecentlyAdded(**kwargs)
|
||||
@@ -326,6 +330,7 @@ class Newsletter(object):
|
||||
'time_frame': 7,
|
||||
'time_frame_units': 'days',
|
||||
'formatted': 1,
|
||||
'threaded': 0,
|
||||
'notifier_id': 0,
|
||||
'filename': '',
|
||||
'save_only': 0}
|
||||
@@ -339,11 +344,15 @@ class Newsletter(object):
|
||||
_TEMPLATE = ''
|
||||
|
||||
def __init__(self, newsletter_id=None, newsletter_id_name=None, config=None, email_config=None,
|
||||
start_date=None, end_date=None, subject=None, body=None, message=None):
|
||||
start_date=None, end_date=None, subject=None, body=None, message=None,
|
||||
email_msg_id=None, email_reply_msg_id=None):
|
||||
self.config = self.set_config(config=config, default=self._DEFAULT_CONFIG)
|
||||
self.email_config = self.set_config(config=email_config, default=self._DEFAULT_EMAIL_CONFIG)
|
||||
self.uuid = generate_newsletter_uuid()
|
||||
|
||||
self.email_msg_id = email_msg_id
|
||||
self.email_reply_msg_id = email_reply_msg_id
|
||||
|
||||
self.newsletter_id = newsletter_id
|
||||
self.newsletter_id_name = newsletter_id_name or ''
|
||||
self.start_date = None
|
||||
@@ -516,12 +525,16 @@ class Newsletter(object):
|
||||
if plexpy.CONFIG.NEWSLETTER_SELF_HOSTED and plexpy.CONFIG.HTTP_BASE_URL:
|
||||
plaintext += self._DEFAULT_BODY.format(**self.parameters)
|
||||
|
||||
email_reply_msg_id = self.email_reply_msg_id if self.config['threaded'] else None
|
||||
|
||||
if self.email_config['notifier_id']:
|
||||
return send_notification(
|
||||
notifier_id=self.email_config['notifier_id'],
|
||||
subject=self.subject_formatted,
|
||||
body=newsletter_stripped,
|
||||
plaintext=plaintext
|
||||
plaintext=plaintext,
|
||||
msg_id=self.email_msg_id,
|
||||
reply_msg_id=email_reply_msg_id
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -529,7 +542,9 @@ class Newsletter(object):
|
||||
return email.notify(
|
||||
subject=self.subject_formatted,
|
||||
body=newsletter_stripped,
|
||||
plaintext=plaintext
|
||||
plaintext=plaintext,
|
||||
msg_id=self.email_msg_id,
|
||||
reply_msg_id=email_reply_msg_id
|
||||
)
|
||||
elif self.config['notifier_id']:
|
||||
return send_notification(
|
||||
@@ -669,7 +684,7 @@ class RecentlyAdded(Newsletter):
|
||||
start = 0
|
||||
|
||||
while not done:
|
||||
recent_items = pms_connect.get_recently_added_details(start=str(start), count='10', type=media_type)
|
||||
recent_items = pms_connect.get_recently_added_details(start=str(start), count='10', media_type=media_type)
|
||||
filtered_items = [i for i in recent_items['recently_added']
|
||||
if self.start_time < helpers.cast_to_int(i['added_at']) < self.end_time]
|
||||
if len(filtered_items) < 10:
|
||||
@@ -679,7 +694,7 @@ class RecentlyAdded(Newsletter):
|
||||
|
||||
recently_added.extend(filtered_items)
|
||||
|
||||
if media_type == 'movie':
|
||||
if media_type in ('movie', 'other_video'):
|
||||
movie_list = []
|
||||
for item in recently_added:
|
||||
# Filter included libraries
|
||||
@@ -781,8 +796,13 @@ class RecentlyAdded(Newsletter):
|
||||
if not self.config['incl_libraries']:
|
||||
logger.warn(u"Tautulli Newsletters :: Failed to retrieve %s newsletter data: no libraries selected." % self.NAME)
|
||||
|
||||
media_types = {s['section_type'] for s in self._get_sections()
|
||||
if str(s['section_id']) in self.config['incl_libraries']}
|
||||
media_types = set()
|
||||
for s in self._get_sections():
|
||||
if str(s['section_id']) in self.config['incl_libraries']:
|
||||
if s['section_type'] == 'movie' and s['agent'] == 'com.plexapp.agents.none':
|
||||
media_types.add('other_video')
|
||||
else:
|
||||
media_types.add(s['section_type'])
|
||||
|
||||
recently_added = {}
|
||||
for media_type in media_types:
|
||||
@@ -793,9 +813,10 @@ class RecentlyAdded(Newsletter):
|
||||
shows = recently_added.get('show', [])
|
||||
artists = recently_added.get('artist', [])
|
||||
albums = [a for artist in artists for a in artist['album']]
|
||||
other_video = recently_added.get('other_video', [])
|
||||
|
||||
if self.is_preview or helpers.get_img_service(include_self=True) == 'self-hosted':
|
||||
for item in movies + shows + albums:
|
||||
for item in movies + shows + albums + other_video:
|
||||
if item['media_type'] == 'album':
|
||||
height = 150
|
||||
fallback = 'cover'
|
||||
@@ -819,7 +840,7 @@ class RecentlyAdded(Newsletter):
|
||||
|
||||
elif helpers.get_img_service():
|
||||
# Upload posters and art to image hosting service
|
||||
for item in movies + shows + albums:
|
||||
for item in movies + shows + albums + other_video:
|
||||
if item['media_type'] == 'album':
|
||||
height = 150
|
||||
fallback = 'cover'
|
||||
@@ -844,7 +865,7 @@ class RecentlyAdded(Newsletter):
|
||||
item['poster_url'] = item['thumb_url'] # Keep for backwards compatibility
|
||||
|
||||
else:
|
||||
for item in movies + shows + albums:
|
||||
for item in movies + shows + albums + other_video:
|
||||
item['thumb_hash'] = ''
|
||||
item['art_hash'] = ''
|
||||
item['thumb_url'] = ''
|
||||
@@ -857,10 +878,11 @@ class RecentlyAdded(Newsletter):
|
||||
|
||||
def _has_data(self):
|
||||
recently_added = self.data.get('recently_added')
|
||||
if recently_added and \
|
||||
recently_added.get('movie') or \
|
||||
recently_added.get('show') or \
|
||||
recently_added.get('artist'):
|
||||
if recently_added and (
|
||||
recently_added.get('movie') or
|
||||
recently_added.get('show') or
|
||||
recently_added.get('artist') or
|
||||
recently_added.get('other_video')):
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -869,18 +891,26 @@ class RecentlyAdded(Newsletter):
|
||||
return libraries.Libraries().get_sections()
|
||||
|
||||
def _get_sections_options(self):
|
||||
library_types = {'movie': 'Movie Libraries',
|
||||
'show': 'TV Show Libraries',
|
||||
'artist': 'Music Libraries'}
|
||||
sections = {}
|
||||
for s in self._get_sections():
|
||||
if s['section_type'] != 'photo':
|
||||
library_type = library_types[s['section_type']]
|
||||
if s['section_type'] == 'movie' and s['agent'] == 'com.plexapp.agents.none':
|
||||
library_type = 'other_video'
|
||||
else:
|
||||
library_type = s['section_type']
|
||||
group = sections.get(library_type, [])
|
||||
group.append({'value': s['section_id'],
|
||||
'text': s['section_name']})
|
||||
sections[library_type] = group
|
||||
return sections
|
||||
|
||||
groups = OrderedDict([(k, v) for k, v in [
|
||||
('Movie Libraries', sections.get('movie')),
|
||||
('TV Show Libraries', sections.get('show')),
|
||||
('Music Libraries', sections.get('artist')),
|
||||
('Other Video Libraries', sections.get('other_video'))
|
||||
] if v is not None])
|
||||
|
||||
return groups
|
||||
|
||||
def build_params(self):
|
||||
parameters = self._build_params()
|
||||
|
@@ -23,7 +23,6 @@ import json
|
||||
from operator import itemgetter
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from string import Formatter
|
||||
import threading
|
||||
import time
|
||||
@@ -337,12 +336,7 @@ def notify(notifier_id=None, notify_action=None, stream_data=None, timeline_data
|
||||
if notify_action in ('test', 'api'):
|
||||
subject = kwargs.pop('subject', 'Tautulli')
|
||||
body = kwargs.pop('body', 'Test Notification')
|
||||
script_args = kwargs.pop('script_args', [])
|
||||
|
||||
if script_args and isinstance(script_args, basestring):
|
||||
# Attemps to format test script args for the user
|
||||
script_args = [arg.decode(plexpy.SYS_ENCODING, 'ignore')
|
||||
for arg in shlex.split(script_args.encode(plexpy.SYS_ENCODING, 'ignore'))]
|
||||
script_args = helpers.split_args(kwargs.pop('script_args', []))
|
||||
|
||||
else:
|
||||
# Get the subject and body strings
|
||||
@@ -486,20 +480,24 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
|
||||
if 'media_info' in notify_params and len(notify_params['media_info']) > 0:
|
||||
media_info = notify_params['media_info'][0]
|
||||
if 'parts' in media_info and len(media_info['parts']) > 0:
|
||||
media_part_info = media_info.pop('parts')[0]
|
||||
parts = media_info.pop('parts')
|
||||
media_part_info = next((p for p in parts if p['selected']), parts[0])
|
||||
|
||||
stream_video = stream_audio = stream_subtitle = False
|
||||
if 'streams' in media_part_info:
|
||||
for stream in media_part_info.pop('streams'):
|
||||
if not stream_video and stream['type'] == '1':
|
||||
media_part_info.update(stream)
|
||||
stream_video = True
|
||||
if not stream_audio and stream['type'] == '2':
|
||||
media_part_info.update(stream)
|
||||
stream_audio = True
|
||||
if not stream_subtitle and stream['type'] == '3':
|
||||
media_part_info.update(stream)
|
||||
stream_subtitle = True
|
||||
streams = media_part_info.pop('streams')
|
||||
video_streams = [s for s in streams if s['type'] == '1']
|
||||
audio_streams = [s for s in streams if s['type'] == '2']
|
||||
subtitle_streams = [s for s in streams if s['type'] == '3']
|
||||
|
||||
if video_streams:
|
||||
video_stream = next((s for s in video_streams if s['selected']), video_streams[0])
|
||||
media_part_info.update(video_stream)
|
||||
if audio_streams:
|
||||
audio_stream = next((s for s in audio_streams if s['selected']), audio_streams[0])
|
||||
media_part_info.update(audio_stream)
|
||||
if subtitle_streams:
|
||||
subtitle_stream = next((s for s in subtitle_streams if s['selected']), subtitle_streams[0])
|
||||
media_part_info.update(subtitle_stream)
|
||||
|
||||
notify_params.update(media_info)
|
||||
notify_params.update(media_part_info)
|
||||
@@ -526,8 +524,8 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
|
||||
transcode_decision = 'Direct Stream'
|
||||
else:
|
||||
transcode_decision = 'Direct Play'
|
||||
|
||||
if notify_action != 'play':
|
||||
|
||||
if notify_action != 'on_play':
|
||||
stream_duration = int((time.time() -
|
||||
helpers.cast_to_int(session.get('started', 0)) -
|
||||
helpers.cast_to_int(session.get('paused_counter', 0))) / 60)
|
||||
@@ -708,6 +706,14 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
|
||||
child_count = 1
|
||||
grandchild_count = 1
|
||||
|
||||
critic_rating = ''
|
||||
if notify_params['rating_image'].startswith('rottentomatoes://') and notify_params['rating']:
|
||||
critic_rating = helpers.get_percent(notify_params['rating'], 10)
|
||||
|
||||
audience_rating = ''
|
||||
if notify_params['audience_rating']:
|
||||
audience_rating = helpers.get_percent(notify_params['audience_rating'], 10)
|
||||
|
||||
now = arrow.now()
|
||||
now_iso = now.isocalendar()
|
||||
|
||||
@@ -737,6 +743,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
|
||||
'datestamp': now.format(date_format),
|
||||
'timestamp': now.format(time_format),
|
||||
'unixtime': int(time.time()),
|
||||
'utctime': helpers.utc_now_iso(),
|
||||
# Stream parameters
|
||||
'streams': stream_count,
|
||||
'user_streams': user_stream_count,
|
||||
@@ -856,7 +863,8 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
|
||||
'summary': notify_params['summary'],
|
||||
'tagline': notify_params['tagline'],
|
||||
'rating': notify_params['rating'],
|
||||
'audience_rating': helpers.get_percent(notify_params['audience_rating'], 10) or '',
|
||||
'critic_rating': critic_rating,
|
||||
'audience_rating': audience_rating,
|
||||
'duration': duration,
|
||||
'poster_title': notify_params['poster_title'],
|
||||
'poster_url': notify_params['poster_url'],
|
||||
@@ -956,6 +964,7 @@ def build_server_notify_params(notify_action=None, **kwargs):
|
||||
'datestamp': now.format(date_format),
|
||||
'timestamp': now.format(time_format),
|
||||
'unixtime': int(time.time()),
|
||||
'utctime': helpers.utc_now_iso(),
|
||||
# Plex Media Server update parameters
|
||||
'update_version': pms_download_info['version'],
|
||||
'update_url': pms_download_info['download_url'],
|
||||
@@ -1026,6 +1035,7 @@ def build_notify_text(subject='', body='', notify_action=None, parameters=None,
|
||||
# Remove the unwanted tags and strip any unmatch tags too.
|
||||
subject = strip_tag(re.sub(pattern, '', subject), agent_id).strip(' \t\n\r')
|
||||
body = strip_tag(re.sub(pattern, '', body), agent_id).strip(' \t\n\r')
|
||||
script_args = []
|
||||
|
||||
if test:
|
||||
return subject, body
|
||||
@@ -1034,16 +1044,13 @@ def build_notify_text(subject='', body='', notify_action=None, parameters=None,
|
||||
|
||||
if agent_id == 15:
|
||||
try:
|
||||
script_args = [custom_formatter.format(arg, **parameters).decode(plexpy.SYS_ENCODING, 'ignore')
|
||||
for arg in shlex.split(subject.encode(plexpy.SYS_ENCODING, 'ignore'))]
|
||||
script_args = [custom_formatter.format(arg, **parameters) for arg in helpers.split_args(subject)]
|
||||
except LookupError as e:
|
||||
logger.error(u"Tautulli NotificationHandler :: Unable to parse parameter %s in script argument. Using fallback." % e)
|
||||
script_args = []
|
||||
except Exception as e:
|
||||
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom script arguments: %s. Using fallback." % e)
|
||||
script_args = []
|
||||
else:
|
||||
script_args = []
|
||||
|
||||
try:
|
||||
subject = custom_formatter.format(unicode(subject), **parameters)
|
||||
@@ -1054,14 +1061,38 @@ def build_notify_text(subject='', body='', notify_action=None, parameters=None,
|
||||
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom notification subject: %s. Using fallback." % e)
|
||||
subject = unicode(default_subject).format(**parameters)
|
||||
|
||||
try:
|
||||
body = custom_formatter.format(unicode(body), **parameters)
|
||||
except LookupError as e:
|
||||
logger.error(u"Tautulli NotificationHandler :: Unable to parse parameter %s in notification body. Using fallback." % e)
|
||||
body = unicode(default_body).format(**parameters)
|
||||
except Exception as e:
|
||||
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom notification body: %s. Using fallback." % e)
|
||||
body = unicode(default_body).format(**parameters)
|
||||
if agent_id == 25:
|
||||
if body:
|
||||
try:
|
||||
body = json.loads(body)
|
||||
except ValueError as e:
|
||||
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom webhook json data: %s. Using fallback." % e)
|
||||
body = ''
|
||||
|
||||
if body:
|
||||
def str_format(s):
|
||||
if isinstance(s, basestring):
|
||||
return custom_formatter.format(unicode(s), **parameters)
|
||||
return s
|
||||
|
||||
try:
|
||||
body = json.dumps(helpers.traverse_map(body, str_format))
|
||||
except LookupError as e:
|
||||
logger.error(u"Tautulli NotificationHandler :: Unable to parse parameter %s in webhook data. Using fallback." % e)
|
||||
body = ''
|
||||
except Exception as e:
|
||||
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom webhook data: %s. Using fallback." % e)
|
||||
body = ''
|
||||
|
||||
else:
|
||||
try:
|
||||
body = custom_formatter.format(unicode(body), **parameters)
|
||||
except LookupError as e:
|
||||
logger.error(u"Tautulli NotificationHandler :: Unable to parse parameter %s in notification body. Using fallback." % e)
|
||||
body = unicode(default_body).format(**parameters)
|
||||
except Exception as e:
|
||||
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom notification body: %s. Using fallback." % e)
|
||||
body = unicode(default_body).format(**parameters)
|
||||
|
||||
return subject, body, script_args
|
||||
|
||||
@@ -1218,7 +1249,8 @@ def get_img_info(img=None, rating_key=None, title='', width=1000, height=1500,
|
||||
|
||||
|
||||
def set_hash_image_info(img=None, rating_key=None, width=750, height=1000,
|
||||
opacity=100, background='000000', blur=0, fallback=None):
|
||||
opacity=100, background='000000', blur=0, fallback=None,
|
||||
add_to_db=True):
|
||||
if not rating_key and not img:
|
||||
return fallback
|
||||
|
||||
@@ -1236,18 +1268,19 @@ def set_hash_image_info(img=None, rating_key=None, width=750, height=1000,
|
||||
plexpy.CONFIG.PMS_UUID, img, rating_key, width, height, opacity, background, blur, fallback)
|
||||
img_hash = hashlib.sha256(img_string).hexdigest()
|
||||
|
||||
keys = {'img_hash': img_hash}
|
||||
values = {'img': img,
|
||||
'rating_key': rating_key,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'opacity': opacity,
|
||||
'background': background,
|
||||
'blur': blur,
|
||||
'fallback': fallback}
|
||||
if add_to_db:
|
||||
keys = {'img_hash': img_hash}
|
||||
values = {'img': img,
|
||||
'rating_key': rating_key,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'opacity': opacity,
|
||||
'background': background,
|
||||
'blur': blur,
|
||||
'fallback': fallback}
|
||||
|
||||
db = database.MonitorDatabase()
|
||||
db.upsert('image_hash_lookup', key_dict=keys, value_dict=values)
|
||||
db = database.MonitorDatabase()
|
||||
db.upsert('image_hash_lookup', key_dict=keys, value_dict=values)
|
||||
|
||||
return img_hash
|
||||
|
||||
@@ -1422,6 +1455,10 @@ def get_themoviedb_info(rating_key=None, media_type=None, themoviedb_id=None):
|
||||
|
||||
|
||||
class CustomFormatter(Formatter):
|
||||
def __init__(self, default='{{{0}}}', default_format_spec='{{{0}:{1}}}'):
|
||||
self.default = default
|
||||
self.default_format_spec = default_format_spec
|
||||
|
||||
def convert_field(self, value, conversion):
|
||||
if conversion is None:
|
||||
return value
|
||||
@@ -1450,4 +1487,13 @@ class CustomFormatter(Formatter):
|
||||
else:
|
||||
return value
|
||||
else:
|
||||
return super(CustomFormatter, self).format_field(value, format_spec)
|
||||
try:
|
||||
return super(CustomFormatter, self).format_field(value, format_spec)
|
||||
except ValueError:
|
||||
return self.default_format_spec.format(value[1:-1], format_spec)
|
||||
|
||||
def get_value(self, key, args, kwargs):
|
||||
if isinstance(key, basestring):
|
||||
return kwargs.get(key, self.default.format(key))
|
||||
else:
|
||||
return super(CustomFormatter, self).get_value(key, args, kwargs)
|
||||
|
@@ -91,7 +91,8 @@ AGENT_IDS = {'growl': 0,
|
||||
'androidapp': 21,
|
||||
'groupme': 22,
|
||||
'mqtt': 23,
|
||||
'zapier': 24
|
||||
'zapier': 24,
|
||||
'webhook': 25
|
||||
}
|
||||
|
||||
DEFAULT_CUSTOM_CONDITIONS = [{'parameter': '', 'operator': '', 'value': ''}]
|
||||
@@ -146,10 +147,10 @@ def available_notification_agents():
|
||||
'name': 'xbmc',
|
||||
'id': AGENT_IDS['xbmc']
|
||||
},
|
||||
{'label': 'Notify My Android',
|
||||
'name': 'nma',
|
||||
'id': AGENT_IDS['nma']
|
||||
},
|
||||
# {'label': 'Notify My Android',
|
||||
# 'name': 'nma',
|
||||
# 'id': AGENT_IDS['nma']
|
||||
# },
|
||||
{'label': 'MQTT',
|
||||
'name': 'mqtt',
|
||||
'id': AGENT_IDS['mqtt']
|
||||
@@ -190,6 +191,10 @@ def available_notification_agents():
|
||||
'name': 'twitter',
|
||||
'id': AGENT_IDS['twitter']
|
||||
},
|
||||
{'label': 'Webhook',
|
||||
'name': 'webhook',
|
||||
'id': AGENT_IDS['webhook']
|
||||
},
|
||||
{'label': 'Zapier',
|
||||
'name': 'zapier',
|
||||
'id': AGENT_IDS['zapier']
|
||||
@@ -239,6 +244,14 @@ def available_notification_actions():
|
||||
'icon': 'fa-play',
|
||||
'media_types': ('movie', 'episode', 'track')
|
||||
},
|
||||
{'label': 'Transcode Decision Change',
|
||||
'name': 'on_change',
|
||||
'description': 'Trigger a notification when a stream changes transcode decision.',
|
||||
'subject': 'Tautulli ({server_name})',
|
||||
'body': '{user} ({player}) has changed transcode decision for {title}.',
|
||||
'icon': 'fa-exchange-alt',
|
||||
'media_types': ('movie', 'episode', 'track')
|
||||
},
|
||||
{'label': 'Watched',
|
||||
'name': 'on_watched',
|
||||
'description': 'Trigger a notification when a video stream reaches the specified watch percentage.',
|
||||
@@ -386,6 +399,8 @@ def get_agent_class(agent_id=None, config=None):
|
||||
return MQTT(config=config)
|
||||
elif agent_id == 24:
|
||||
return ZAPIER(config=config)
|
||||
elif agent_id == 25:
|
||||
return WEBHOOK(config=config)
|
||||
else:
|
||||
return Notifier(config=config)
|
||||
else:
|
||||
@@ -396,7 +411,9 @@ def get_notify_agents():
|
||||
return tuple(a['name'] for a in sorted(available_notification_agents(), key=lambda k: k['label']))
|
||||
|
||||
|
||||
def get_notify_actions():
|
||||
def get_notify_actions(return_dict=False):
|
||||
if return_dict:
|
||||
return {a.pop('name'): a for a in available_notification_actions()}
|
||||
return tuple(a['name'] for a in available_notification_actions())
|
||||
|
||||
|
||||
@@ -460,15 +477,23 @@ def get_notifier_config(notifier_id=None):
|
||||
logger.error(u"Tautulli Notifiers :: Failed to get notifier config options: %s." % e)
|
||||
return
|
||||
|
||||
notify_actions = get_notify_actions()
|
||||
notify_actions = get_notify_actions(return_dict=True)
|
||||
|
||||
notifier_actions = {}
|
||||
notifier_text = {}
|
||||
for k in result.keys():
|
||||
if k in notify_actions:
|
||||
subject = result.pop(k + '_subject')
|
||||
body = result.pop(k + '_body')
|
||||
|
||||
if subject is None:
|
||||
subject = "" if result['agent_name'] in ('scripts', 'webhook') else notify_actions[k]['subject']
|
||||
if body is None:
|
||||
body = "" if result['agent_name'] in ('scripts', 'webhook') else notify_actions[k]['body']
|
||||
|
||||
notifier_actions[k] = helpers.cast_to_int(result.pop(k))
|
||||
notifier_text[k] = {'subject': result.pop(k + '_subject'),
|
||||
'body': result.pop(k + '_body')}
|
||||
notifier_text[k] = {'subject': subject,
|
||||
'body': body}
|
||||
|
||||
try:
|
||||
result['custom_conditions'] = json.loads(result['custom_conditions'])
|
||||
@@ -513,7 +538,7 @@ def add_notifier_config(agent_id=None, **kwargs):
|
||||
'custom_conditions_logic': ''
|
||||
}
|
||||
|
||||
if agent['name'] == 'scripts':
|
||||
if agent['name'] in ('scripts', 'webhook'):
|
||||
for a in available_notification_actions():
|
||||
values[a['name'] + '_subject'] = ''
|
||||
values[a['name'] + '_body'] = ''
|
||||
@@ -774,7 +799,7 @@ class Notifier(object):
|
||||
return self._DEFAULT_CONFIG.copy()
|
||||
|
||||
def notify(self, subject='', body='', action='', **kwargs):
|
||||
if self.NAME != 'Script':
|
||||
if self.NAME not in ('Script', 'Webhook'):
|
||||
if not subject and self.config.get('incl_subject', True):
|
||||
logger.error(u"Tautulli Notifiers :: %s notification subject cannot be blank." % self.NAME)
|
||||
return
|
||||
@@ -788,6 +813,7 @@ class Notifier(object):
|
||||
pass
|
||||
|
||||
def make_request(self, url, method='POST', **kwargs):
|
||||
logger.info(u"Tautulli Notifiers :: Sending {name} notification...".format(name=self.NAME))
|
||||
response, err_msg, req_msg = request.request_response2(url, method, **kwargs)
|
||||
|
||||
if response and not err_msg:
|
||||
@@ -1138,7 +1164,7 @@ class DISCORD(Notifier):
|
||||
|
||||
# Build Discord post attachment
|
||||
attachment = {'title': title,
|
||||
'timestamp': helpers.utc_now_iso()
|
||||
'timestamp': pretty_metadata.parameters['utctime']
|
||||
}
|
||||
|
||||
if self.config['color']:
|
||||
@@ -1302,13 +1328,20 @@ class EMAIL(Notifier):
|
||||
msg.replace_header('Content-Transfer-Encoding', 'quoted-printable')
|
||||
msg.set_payload(body, 'utf-8')
|
||||
|
||||
msg['Message-ID'] = email.utils.make_msgid()
|
||||
msg_id = kwargs.get('msg_id', email.utils.make_msgid())
|
||||
reply_msg_id = kwargs.get('reply_msg_id')
|
||||
|
||||
msg['Message-ID'] = msg_id
|
||||
msg['Date'] = email.utils.formatdate(localtime=True)
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = email.utils.formataddr((self.config['from_name'], self.config['from']))
|
||||
msg['To'] = ','.join(self.config['to'])
|
||||
msg['CC'] = ','.join(self.config['cc'])
|
||||
|
||||
if reply_msg_id:
|
||||
msg["In-Reply-To"] = reply_msg_id
|
||||
msg["References"] = reply_msg_id
|
||||
|
||||
recipients = self.config['to'] + self.config['cc'] + self.config['bcc']
|
||||
|
||||
mailserver = None
|
||||
@@ -2076,25 +2109,26 @@ class JOIN(Notifier):
|
||||
if self.config['api_key']:
|
||||
params = {'apikey': self.config['api_key']}
|
||||
|
||||
r = requests.get('https://joinjoaomgcd.appspot.com/_ah/api/registration/v1/listDevices', params=params)
|
||||
try:
|
||||
r = requests.get('https://joinjoaomgcd.appspot.com/_ah/api/registration/v1/listDevices', params=params)
|
||||
|
||||
if r.status_code == 200:
|
||||
response_data = r.json()
|
||||
if response_data.get('success'):
|
||||
response_devices = response_data.get('records', [])
|
||||
devices.update({d['deviceName']: d['deviceName'] for d in response_devices})
|
||||
else:
|
||||
error_msg = response_data.get('errorMessage')
|
||||
logger.error(u"Tautulli Notifiers :: Unable to retrieve {name} devices list: {msg}".format(name=self.NAME, msg=error_msg))
|
||||
|
||||
if r.status_code == 200:
|
||||
response_data = r.json()
|
||||
if response_data.get('success'):
|
||||
response_devices = response_data.get('records', [])
|
||||
devices.update({d['deviceName']: d['deviceName'] for d in response_devices})
|
||||
return devices
|
||||
else:
|
||||
error_msg = response_data.get('errorMessage')
|
||||
logger.info(u"Tautulli Notifiers :: Unable to retrieve {name} devices list: {msg}".format(name=self.NAME, msg=error_msg))
|
||||
return devices
|
||||
else:
|
||||
logger.error(u"Tautulli Notifiers :: Unable to retrieve {name} devices list: [{r.status_code}] {r.reason}".format(name=self.NAME, r=r))
|
||||
logger.debug(u"Tautulli Notifiers :: Request response: {}".format(request.server_message(r, True)))
|
||||
return devices
|
||||
logger.error(u"Tautulli Notifiers :: Unable to retrieve {name} devices list: [{r.status_code}] {r.reason}".format(name=self.NAME, r=r))
|
||||
logger.debug(u"Tautulli Notifiers :: Request response: {}".format(request.server_message(r, True)))
|
||||
|
||||
else:
|
||||
return devices
|
||||
except Exception as e:
|
||||
logger.error(u"Tautulli Notifiers :: Unable to retrieve {name} devices list: {msg}".format(name=self.NAME, msg=e))
|
||||
|
||||
return devices
|
||||
|
||||
def return_config_options(self):
|
||||
config_option = [{'label': 'Join API Key',
|
||||
@@ -2664,27 +2698,28 @@ class PUSHBULLET(Notifier):
|
||||
return self.make_request('https://api.pushbullet.com/v2/pushes', headers=headers, json=data)
|
||||
|
||||
def get_devices(self):
|
||||
devices = {'': ''}
|
||||
|
||||
if self.config['api_key']:
|
||||
headers = {'Content-type': "application/json",
|
||||
'Access-Token': self.config['api_key']
|
||||
}
|
||||
try:
|
||||
r = requests.get('https://api.pushbullet.com/v2/devices', headers=headers)
|
||||
|
||||
r = requests.get('https://api.pushbullet.com/v2/devices', headers=headers)
|
||||
if r.status_code == 200:
|
||||
response_data = r.json()
|
||||
pushbullet_devices = response_data.get('devices', [])
|
||||
devices.update({d['iden']: d['nickname'] for d in pushbullet_devices if d['active']})
|
||||
else:
|
||||
logger.error(u"Tautulli Notifiers :: Unable to retrieve {name} devices list: "
|
||||
u"[{r.status_code}] {r.reason}".format(name=self.NAME, r=r))
|
||||
logger.debug(u"Tautulli Notifiers :: Request response: {}".format(request.server_message(r, True)))
|
||||
|
||||
if r.status_code == 200:
|
||||
response_data = r.json()
|
||||
devices = response_data.get('devices', [])
|
||||
devices = {d['iden']: d['nickname'] for d in devices if d['active']}
|
||||
devices.update({'': ''})
|
||||
return devices
|
||||
else:
|
||||
logger.error(u"Tautulli Notifiers :: Unable to retrieve {name} devices list: "
|
||||
u"[{r.status_code}] {r.reason}".format(name=self.NAME, r=r))
|
||||
logger.debug(u"Tautulli Notifiers :: Request response: {}".format(request.server_message(r, True)))
|
||||
return {'': ''}
|
||||
except Exception as e:
|
||||
logger.error(u"Tautulli Notifiers :: Unable to retrieve {name} devices list: {msg}".format(name=self.NAME, msg=e))
|
||||
|
||||
else:
|
||||
return {'': ''}
|
||||
return devices
|
||||
|
||||
def return_config_options(self):
|
||||
config_option = [{'label': 'Pushbullet Access Token',
|
||||
@@ -2971,7 +3006,9 @@ class SCRIPTS(Notifier):
|
||||
'.sh': ''
|
||||
}
|
||||
|
||||
self.arg_overrides = ('python2', 'python3', 'python', 'pythonw', 'php', 'ruby', 'perl')
|
||||
self.pythonpath_override = 'nopythonpath'
|
||||
self.pythonpath = True
|
||||
self.prefix_overrides = ('python2', 'python3', 'python', 'pythonw', 'php', 'ruby', 'perl')
|
||||
self.script_killed = False
|
||||
|
||||
def list_scripts(self):
|
||||
@@ -2998,11 +3035,14 @@ class SCRIPTS(Notifier):
|
||||
'PLEX_URL': plexpy.CONFIG.PMS_URL,
|
||||
'PLEX_TOKEN': plexpy.CONFIG.PMS_TOKEN,
|
||||
'TAUTULLI_URL': helpers.get_plexpy_url(hostname='localhost'),
|
||||
'TAUTULLI_PUBLIC_URL': plexpy.CONFIG.HTTP_BASE_URL + plexpy.HTTP_ROOT,
|
||||
'TAUTULLI_APIKEY': plexpy.CONFIG.API_KEY,
|
||||
'TAUTULLI_ENCODING': plexpy.SYS_ENCODING,
|
||||
'PYTHONPATH': (';' if os.name == 'nt' else ':').join(sys.path)
|
||||
'TAUTULLI_ENCODING': plexpy.SYS_ENCODING
|
||||
})
|
||||
|
||||
if self.pythonpath:
|
||||
env['PYTHONPATH'] = os.pathsep.join([p for p in sys.path if p])
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(script,
|
||||
stdin=subprocess.PIPE,
|
||||
@@ -3058,7 +3098,7 @@ class SCRIPTS(Notifier):
|
||||
logger.error(u"Tautulli Notifiers :: No script folder specified.")
|
||||
return
|
||||
|
||||
script_args = kwargs.get('script_args', [])
|
||||
script_args = helpers.split_args(kwargs.get('script_args', subject))
|
||||
|
||||
logger.debug(u"Tautulli Notifiers :: Trying to run notify script, action: %s, arguments: %s"
|
||||
% (action, script_args))
|
||||
@@ -3094,9 +3134,15 @@ class SCRIPTS(Notifier):
|
||||
if script_args: # and os.name == 'nt':
|
||||
script_args = [arg.encode(plexpy.SYS_ENCODING, 'ignore') for arg in script_args]
|
||||
|
||||
# Allow overrides for PYTHONPATH
|
||||
if prefix and script_args:
|
||||
if script_args[0] == self.pythonpath_override:
|
||||
self.pythonpath = False
|
||||
del script_args[0]
|
||||
|
||||
# Allow overrides for shitty systems
|
||||
if prefix and script_args:
|
||||
if script_args[0] in self.arg_overrides:
|
||||
if script_args[0] in self.prefix_overrides:
|
||||
script[0] = script_args[0]
|
||||
del script_args[0]
|
||||
|
||||
@@ -3527,6 +3573,53 @@ class TWITTER(Notifier):
|
||||
return config_option
|
||||
|
||||
|
||||
class WEBHOOK(Notifier):
|
||||
"""
|
||||
Webhook notifications
|
||||
"""
|
||||
NAME = 'Webhook'
|
||||
_DEFAULT_CONFIG = {'hook': '',
|
||||
'method': ''
|
||||
}
|
||||
|
||||
def agent_notify(self, subject='', body='', action='', **kwargs):
|
||||
if body:
|
||||
try:
|
||||
webhook_data = json.loads(body)
|
||||
except ValueError as e:
|
||||
logger.error(u"Tautulli Notifiers :: Invalid {name} json data: {e}".format(name=self.NAME, e=e))
|
||||
return False
|
||||
|
||||
else:
|
||||
webhook_data = None
|
||||
|
||||
headers = {'Content-type': 'application/json'}
|
||||
|
||||
return self.make_request(self.config['hook'], method=self.config['method'], headers=headers, json=webhook_data)
|
||||
|
||||
def return_config_options(self):
|
||||
config_option = [{'label': 'Webhook URL',
|
||||
'value': self.config['hook'],
|
||||
'name': 'webhook_hook',
|
||||
'description': 'Your Webhook URL.',
|
||||
'input_type': 'text'
|
||||
},
|
||||
{'label': 'Webhook Method',
|
||||
'value': self.config['method'],
|
||||
'name': 'webhook_method',
|
||||
'description': 'The Webhook HTTP request method.',
|
||||
'input_type': 'select',
|
||||
'select_options': {'': '',
|
||||
'GET': 'GET',
|
||||
'POST': 'POST',
|
||||
'PUT': 'PUT',
|
||||
'DELETE': 'DELETE'}
|
||||
}
|
||||
]
|
||||
|
||||
return config_option
|
||||
|
||||
|
||||
class XBMC(Notifier):
|
||||
"""
|
||||
Kodi notifications
|
||||
|
120
plexpy/plextv.py
120
plexpy/plextv.py
@@ -121,7 +121,7 @@ class PlexTV(object):
|
||||
Plex.tv authentication
|
||||
"""
|
||||
|
||||
def __init__(self, username=None, password=None, token=None):
|
||||
def __init__(self, username=None, password=None, token=None, headers=None):
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.token = token
|
||||
@@ -147,7 +147,8 @@ class PlexTV(object):
|
||||
self.request_handler = http_handler.HTTPHandler(urls=self.urls,
|
||||
token=self.token,
|
||||
timeout=self.timeout,
|
||||
ssl_verify=self.ssl_verify)
|
||||
ssl_verify=self.ssl_verify,
|
||||
headers=headers)
|
||||
|
||||
def get_plex_auth(self, output_format='raw'):
|
||||
uri = '/users/sign_in.xml'
|
||||
@@ -210,22 +211,62 @@ class PlexTV(object):
|
||||
|
||||
|
||||
def get_server_token(self):
|
||||
servers = self.get_plextv_server_list(output_format='xml')
|
||||
servers = self.get_plextv_resources(output_format='xml')
|
||||
server_token = ''
|
||||
|
||||
try:
|
||||
xml_head = servers.getElementsByTagName('Server')
|
||||
xml_head = servers.getElementsByTagName('Device')
|
||||
except Exception as e:
|
||||
logger.warn(u"Tautulli PlexTV :: Unable to parse XML for get_server_token: %s." % e)
|
||||
return None
|
||||
|
||||
for a in xml_head:
|
||||
if helpers.get_xml_attr(a, 'machineIdentifier') == plexpy.CONFIG.PMS_IDENTIFIER:
|
||||
if helpers.get_xml_attr(a, 'clientIdentifier') == plexpy.CONFIG.PMS_IDENTIFIER \
|
||||
and 'server' in helpers.get_xml_attr(a, 'provides'):
|
||||
server_token = helpers.get_xml_attr(a, 'accessToken')
|
||||
break
|
||||
|
||||
return server_token
|
||||
|
||||
def get_plextv_pin(self, pin='', output_format=''):
|
||||
if pin:
|
||||
uri = '/api/v2/pins/' + pin
|
||||
request = self.request_handler.make_request(uri=uri,
|
||||
request_type='GET',
|
||||
output_format=output_format,
|
||||
no_token=True)
|
||||
else:
|
||||
uri = '/api/v2/pins?strong=true'
|
||||
request = self.request_handler.make_request(uri=uri,
|
||||
request_type='POST',
|
||||
output_format=output_format,
|
||||
no_token=True)
|
||||
return request
|
||||
|
||||
def get_pin(self, pin=''):
|
||||
plextv_response = self.get_plextv_pin(pin=pin,
|
||||
output_format='xml')
|
||||
|
||||
if plextv_response:
|
||||
try:
|
||||
xml_head = plextv_response.getElementsByTagName('pin')
|
||||
if xml_head:
|
||||
pin = {'id': xml_head[0].getAttribute('id'),
|
||||
'code': xml_head[0].getAttribute('code'),
|
||||
'token': xml_head[0].getAttribute('authToken')
|
||||
}
|
||||
return pin
|
||||
else:
|
||||
logger.warn(u"Tautulli PlexTV :: Could not get Plex authentication pin.")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warn(u"Tautulli PlexTV :: Unable to parse XML for get_pin: %s." % e)
|
||||
return None
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_plextv_user_data(self):
|
||||
plextv_response = self.get_plex_auth(output_format='dict')
|
||||
|
||||
@@ -645,6 +686,27 @@ class PlexTV(object):
|
||||
|
||||
def discover(self, include_cloud=True, all_servers=False):
|
||||
""" Query plex for all servers online. Returns the ones you own in a selectize format """
|
||||
|
||||
# Try to discover localhost server
|
||||
local_machine_identifier = None
|
||||
request_handler = http_handler.HTTPHandler(urls='http://127.0.0.1:32400', timeout=1,
|
||||
ssl_verify=False, silent=True)
|
||||
request = request_handler.make_request(uri='/identity', request_type='GET', output_format='xml')
|
||||
if request:
|
||||
xml_head = request.getElementsByTagName('MediaContainer')[0]
|
||||
local_machine_identifier = xml_head.getAttribute('machineIdentifier')
|
||||
|
||||
local_server = {'httpsRequired': '0',
|
||||
'clientIdentifier': local_machine_identifier,
|
||||
'label': 'Local',
|
||||
'ip': '127.0.0.1',
|
||||
'port': '32400',
|
||||
'uri': 'http://127.0.0.1:32400',
|
||||
'local': '1',
|
||||
'value': '127.0.0.1:32400',
|
||||
'is_cloud': False
|
||||
}
|
||||
|
||||
servers = self.get_plextv_resources(include_https=True, output_format='xml')
|
||||
clean_servers = []
|
||||
|
||||
@@ -664,8 +726,8 @@ class PlexTV(object):
|
||||
|
||||
for d in devices:
|
||||
if helpers.get_xml_attr(d, 'presence') == '1' and \
|
||||
helpers.get_xml_attr(d, 'owned') == '1' and \
|
||||
helpers.get_xml_attr(d, 'provides') == 'server':
|
||||
helpers.get_xml_attr(d, 'owned') == '1' and \
|
||||
helpers.get_xml_attr(d, 'provides') == 'server':
|
||||
|
||||
is_cloud = (helpers.get_xml_attr(d, 'platform').lower() == 'cloud')
|
||||
if not include_cloud and is_cloud:
|
||||
@@ -677,14 +739,20 @@ class PlexTV(object):
|
||||
if not all_servers:
|
||||
# If this is a remote server don't show any local IPs.
|
||||
if helpers.get_xml_attr(d, 'publicAddressMatches') == '0' and \
|
||||
helpers.get_xml_attr(c, 'local') == '1':
|
||||
helpers.get_xml_attr(c, 'local') == '1':
|
||||
continue
|
||||
|
||||
# If this is a local server don't show any remote IPs.
|
||||
if helpers.get_xml_attr(d, 'publicAddressMatches') == '1' and \
|
||||
helpers.get_xml_attr(c, 'local') == '0':
|
||||
helpers.get_xml_attr(c, 'local') == '0':
|
||||
continue
|
||||
|
||||
if helpers.get_xml_attr(d, 'clientIdentifier') == local_machine_identifier:
|
||||
local_server['httpsRequired'] = helpers.get_xml_attr(d, 'httpsRequired')
|
||||
local_server['label'] = helpers.get_xml_attr(d, 'name')
|
||||
clean_servers.append(local_server)
|
||||
local_machine_identifier = None
|
||||
|
||||
server = {'httpsRequired': '1' if is_cloud else helpers.get_xml_attr(d, 'httpsRequired'),
|
||||
'clientIdentifier': helpers.get_xml_attr(d, 'clientIdentifier'),
|
||||
'label': helpers.get_xml_attr(d, 'name'),
|
||||
@@ -692,11 +760,16 @@ class PlexTV(object):
|
||||
'port': helpers.get_xml_attr(c, 'port'),
|
||||
'uri': helpers.get_xml_attr(c, 'uri'),
|
||||
'local': helpers.get_xml_attr(c, 'local'),
|
||||
'value': helpers.get_xml_attr(c, 'address'),
|
||||
'value': helpers.get_xml_attr(c, 'address') + ':' + helpers.get_xml_attr(c, 'port'),
|
||||
'is_cloud': is_cloud
|
||||
}
|
||||
clean_servers.append(server)
|
||||
|
||||
if local_machine_identifier:
|
||||
clean_servers.append(local_server)
|
||||
|
||||
clean_servers.sort(key=lambda s: (s['label'], -int(s['local']), s['ip']))
|
||||
|
||||
return clean_servers
|
||||
|
||||
def get_plex_downloads(self):
|
||||
@@ -740,7 +813,7 @@ class PlexTV(object):
|
||||
|
||||
# Get proper download
|
||||
releases = platform_downloads.get('releases', [{}])
|
||||
release = next((r for r in releases if r['distro'] == plexpy.CONFIG.PMS_UPDATE_DISTRO and
|
||||
release = next((r for r in releases if r['distro'] == plexpy.CONFIG.PMS_UPDATE_DISTRO and
|
||||
r['build'] == plexpy.CONFIG.PMS_UPDATE_DISTRO_BUILD), releases[0])
|
||||
|
||||
download_info = {'update_available': v_new > v_old,
|
||||
@@ -819,3 +892,28 @@ class PlexTV(object):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def get_plex_account_details(self):
|
||||
account_data = self.get_plextv_user_details(output_format='xml')
|
||||
|
||||
try:
|
||||
xml_head = account_data.getElementsByTagName('user')
|
||||
except Exception as e:
|
||||
logger.warn(u"Tautulli PlexTV :: Unable to parse XML for get_plex_account_details: %s." % e)
|
||||
return None
|
||||
|
||||
for a in xml_head:
|
||||
account_details = {"user_id": helpers.get_xml_attr(a, 'id'),
|
||||
"username": helpers.get_xml_attr(a, 'username'),
|
||||
"thumb": helpers.get_xml_attr(a, 'thumb'),
|
||||
"email": helpers.get_xml_attr(a, 'email'),
|
||||
"is_home_user": helpers.get_xml_attr(a, 'home'),
|
||||
"is_restricted": helpers.get_xml_attr(a, 'restricted'),
|
||||
"filter_all": helpers.get_xml_attr(a, 'filterAll'),
|
||||
"filter_movies": helpers.get_xml_attr(a, 'filterMovies'),
|
||||
"filter_tv": helpers.get_xml_attr(a, 'filterTelevision'),
|
||||
"filter_music": helpers.get_xml_attr(a, 'filterMusic'),
|
||||
"filter_photos": helpers.get_xml_attr(a, 'filterPhotos'),
|
||||
"user_token": helpers.get_xml_attr(a, 'authToken')
|
||||
}
|
||||
return account_details
|
||||
|
@@ -418,25 +418,27 @@ class PmsConnect(object):
|
||||
|
||||
return request
|
||||
|
||||
def get_hub_recently_added(self, start='0', count='0', type='', output_format=''):
|
||||
def get_hub_recently_added(self, start='0', count='0', media_type='', other_video=False, output_format=''):
|
||||
"""
|
||||
Return Plex hub recently added.
|
||||
|
||||
Parameters required: start { item number to start from }
|
||||
count { number of results to return }
|
||||
type { str }
|
||||
media_type { str }
|
||||
Optional parameters: output_format { dict, json }
|
||||
|
||||
Output: array
|
||||
"""
|
||||
uri = '/hubs/home/recentlyAdded?X-Plex-Container-Start=%s&X-Plex-Container-Size=%s&type=%s' % (start, count, type)
|
||||
personal = '&personal=1' if other_video else ''
|
||||
uri = '/hubs/home/recentlyAdded?X-Plex-Container-Start=%s&X-Plex-Container-Size=%s&type=%s%s' \
|
||||
% (start, count, media_type, personal)
|
||||
request = self.request_handler.make_request(uri=uri,
|
||||
request_type='GET',
|
||||
output_format=output_format)
|
||||
|
||||
return request
|
||||
|
||||
def get_recently_added_details(self, start='0', count='0', type='', section_id=''):
|
||||
def get_recently_added_details(self, start='0', count='0', media_type='', section_id=''):
|
||||
"""
|
||||
Return processed and validated list of recently added items.
|
||||
|
||||
@@ -444,14 +446,18 @@ class PmsConnect(object):
|
||||
|
||||
Output: array
|
||||
"""
|
||||
if type in ('movie', 'show', 'artist'):
|
||||
if type == 'movie':
|
||||
type = '1'
|
||||
elif type == 'show':
|
||||
type = '2'
|
||||
elif type == 'artist':
|
||||
type = '8'
|
||||
recent = self.get_hub_recently_added(start, count, type, output_format='xml')
|
||||
if media_type in ('movie', 'show', 'artist', 'other_video'):
|
||||
other_video = False
|
||||
if media_type == 'movie':
|
||||
media_type = '1'
|
||||
elif media_type == 'show':
|
||||
media_type = '2'
|
||||
elif media_type == 'artist':
|
||||
media_type = '8'
|
||||
elif media_type == 'other_video':
|
||||
media_type = '1'
|
||||
other_video = True
|
||||
recent = self.get_hub_recently_added(start, count, media_type, other_video, output_format='xml')
|
||||
elif section_id:
|
||||
recent = self.get_library_recently_added(section_id, start, count, output_format='xml')
|
||||
else:
|
||||
@@ -527,7 +533,9 @@ class PmsConnect(object):
|
||||
'summary': helpers.get_xml_attr(m, 'summary'),
|
||||
'tagline': helpers.get_xml_attr(m, 'tagline'),
|
||||
'rating': helpers.get_xml_attr(m, 'rating'),
|
||||
'rating_image': helpers.get_xml_attr(m, 'ratingImage'),
|
||||
'audience_rating': helpers.get_xml_attr(m, 'audienceRating'),
|
||||
'audience_rating_image': helpers.get_xml_attr(m, 'audienceRatingImage'),
|
||||
'user_rating': helpers.get_xml_attr(m, 'userRating'),
|
||||
'duration': helpers.get_xml_attr(m, 'duration'),
|
||||
'year': helpers.get_xml_attr(m, 'year'),
|
||||
@@ -680,7 +688,9 @@ class PmsConnect(object):
|
||||
'summary': helpers.get_xml_attr(metadata_main, 'summary'),
|
||||
'tagline': helpers.get_xml_attr(metadata_main, 'tagline'),
|
||||
'rating': helpers.get_xml_attr(metadata_main, 'rating'),
|
||||
'rating_image': helpers.get_xml_attr(metadata_main, 'ratingImage'),
|
||||
'audience_rating': helpers.get_xml_attr(metadata_main, 'audienceRating'),
|
||||
'audience_rating_image': helpers.get_xml_attr(metadata_main, 'audienceRatingImage'),
|
||||
'user_rating': helpers.get_xml_attr(metadata_main, 'userRating'),
|
||||
'duration': helpers.get_xml_attr(metadata_main, 'duration'),
|
||||
'year': helpers.get_xml_attr(metadata_main, 'year'),
|
||||
@@ -728,7 +738,9 @@ class PmsConnect(object):
|
||||
'summary': helpers.get_xml_attr(metadata_main, 'summary'),
|
||||
'tagline': helpers.get_xml_attr(metadata_main, 'tagline'),
|
||||
'rating': helpers.get_xml_attr(metadata_main, 'rating'),
|
||||
'rating_image': helpers.get_xml_attr(metadata_main, 'ratingImage'),
|
||||
'audience_rating': helpers.get_xml_attr(metadata_main, 'audienceRating'),
|
||||
'audience_rating_image': helpers.get_xml_attr(metadata_main, 'audienceRatingImage'),
|
||||
'user_rating': helpers.get_xml_attr(metadata_main, 'userRating'),
|
||||
'duration': duration,
|
||||
'year': helpers.get_xml_attr(metadata_main, 'year'),
|
||||
@@ -773,7 +785,9 @@ class PmsConnect(object):
|
||||
'summary': show_details['summary'],
|
||||
'tagline': helpers.get_xml_attr(metadata_main, 'tagline'),
|
||||
'rating': helpers.get_xml_attr(metadata_main, 'rating'),
|
||||
'rating_image': helpers.get_xml_attr(metadata_main, 'ratingImage'),
|
||||
'audience_rating': helpers.get_xml_attr(metadata_main, 'audienceRating'),
|
||||
'audience_rating_image': helpers.get_xml_attr(metadata_main, 'audienceRatingImage'),
|
||||
'user_rating': helpers.get_xml_attr(metadata_main, 'userRating'),
|
||||
'duration': show_details['duration'],
|
||||
'year': helpers.get_xml_attr(metadata_main, 'year'),
|
||||
@@ -801,11 +815,27 @@ class PmsConnect(object):
|
||||
elif metadata_type == 'episode':
|
||||
grandparent_rating_key = helpers.get_xml_attr(metadata_main, 'grandparentRatingKey')
|
||||
show_details = self.get_metadata_details(grandparent_rating_key)
|
||||
|
||||
parent_rating_key = helpers.get_xml_attr(metadata_main, 'parentRatingKey')
|
||||
parent_media_index = helpers.get_xml_attr(metadata_main, 'parentIndex')
|
||||
parent_thumb = helpers.get_xml_attr(metadata_main, 'parentThumb')
|
||||
|
||||
if not parent_rating_key:
|
||||
# Try getting the parent_rating_key from the parent_thumb
|
||||
if parent_thumb.startswith('/library/metadata/'):
|
||||
parent_rating_key = parent_thumb.split('/')[3]
|
||||
|
||||
# Try getting the parent_rating_key from the grandparent's children
|
||||
if not parent_rating_key:
|
||||
children_list = self.get_item_children(grandparent_rating_key)
|
||||
parent_rating_key = next((c['rating_key'] for c in children_list['children_list']
|
||||
if c['media_index'] == parent_media_index), '')
|
||||
|
||||
metadata = {'media_type': metadata_type,
|
||||
'section_id': section_id,
|
||||
'library_name': library_name,
|
||||
'rating_key': helpers.get_xml_attr(metadata_main, 'ratingKey'),
|
||||
'parent_rating_key': helpers.get_xml_attr(metadata_main, 'parentRatingKey'),
|
||||
'parent_rating_key': parent_rating_key,
|
||||
'grandparent_rating_key': helpers.get_xml_attr(metadata_main, 'grandparentRatingKey'),
|
||||
'title': helpers.get_xml_attr(metadata_main, 'title'),
|
||||
'parent_title': 'Season %s' % helpers.get_xml_attr(metadata_main, 'parentIndex'),
|
||||
@@ -813,18 +843,20 @@ class PmsConnect(object):
|
||||
'original_title': helpers.get_xml_attr(metadata_main, 'originalTitle'),
|
||||
'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
|
||||
'media_index': helpers.get_xml_attr(metadata_main, 'index'),
|
||||
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
|
||||
'parent_media_index': parent_media_index,
|
||||
'studio': show_details['studio'],
|
||||
'content_rating': helpers.get_xml_attr(metadata_main, 'contentRating'),
|
||||
'summary': helpers.get_xml_attr(metadata_main, 'summary'),
|
||||
'tagline': helpers.get_xml_attr(metadata_main, 'tagline'),
|
||||
'rating': helpers.get_xml_attr(metadata_main, 'rating'),
|
||||
'rating_image': helpers.get_xml_attr(metadata_main, 'ratingImage'),
|
||||
'audience_rating': helpers.get_xml_attr(metadata_main, 'audienceRating'),
|
||||
'audience_rating_image': helpers.get_xml_attr(metadata_main, 'audienceRatingImage'),
|
||||
'user_rating': helpers.get_xml_attr(metadata_main, 'userRating'),
|
||||
'duration': helpers.get_xml_attr(metadata_main, 'duration'),
|
||||
'year': helpers.get_xml_attr(metadata_main, 'year'),
|
||||
'thumb': helpers.get_xml_attr(metadata_main, 'thumb'),
|
||||
'parent_thumb': helpers.get_xml_attr(metadata_main, 'parentThumb'),
|
||||
'parent_thumb': parent_thumb,
|
||||
'grandparent_thumb': helpers.get_xml_attr(metadata_main, 'grandparentThumb'),
|
||||
'art': helpers.get_xml_attr(metadata_main, 'art'),
|
||||
'banner': show_details['banner'],
|
||||
@@ -863,7 +895,9 @@ class PmsConnect(object):
|
||||
'summary': helpers.get_xml_attr(metadata_main, 'summary'),
|
||||
'tagline': helpers.get_xml_attr(metadata_main, 'tagline'),
|
||||
'rating': helpers.get_xml_attr(metadata_main, 'rating'),
|
||||
'rating_image': helpers.get_xml_attr(metadata_main, 'ratingImage'),
|
||||
'audience_rating': helpers.get_xml_attr(metadata_main, 'audienceRating'),
|
||||
'audience_rating_image': helpers.get_xml_attr(metadata_main, 'audienceRatingImage'),
|
||||
'user_rating': helpers.get_xml_attr(metadata_main, 'userRating'),
|
||||
'duration': helpers.get_xml_attr(metadata_main, 'duration'),
|
||||
'year': helpers.get_xml_attr(metadata_main, 'year'),
|
||||
@@ -908,7 +942,9 @@ class PmsConnect(object):
|
||||
'summary': helpers.get_xml_attr(metadata_main, 'summary') or artist_details['summary'],
|
||||
'tagline': helpers.get_xml_attr(metadata_main, 'tagline'),
|
||||
'rating': helpers.get_xml_attr(metadata_main, 'rating'),
|
||||
'rating_image': helpers.get_xml_attr(metadata_main, 'ratingImage'),
|
||||
'audience_rating': helpers.get_xml_attr(metadata_main, 'audienceRating'),
|
||||
'audience_rating_image': helpers.get_xml_attr(metadata_main, 'audienceRatingImage'),
|
||||
'user_rating': helpers.get_xml_attr(metadata_main, 'userRating'),
|
||||
'duration': helpers.get_xml_attr(metadata_main, 'duration'),
|
||||
'year': helpers.get_xml_attr(metadata_main, 'year'),
|
||||
@@ -956,7 +992,9 @@ class PmsConnect(object):
|
||||
'summary': helpers.get_xml_attr(metadata_main, 'summary'),
|
||||
'tagline': helpers.get_xml_attr(metadata_main, 'tagline'),
|
||||
'rating': helpers.get_xml_attr(metadata_main, 'rating'),
|
||||
'rating_image': helpers.get_xml_attr(metadata_main, 'ratingImage'),
|
||||
'audience_rating': helpers.get_xml_attr(metadata_main, 'audienceRating'),
|
||||
'audience_rating_image': helpers.get_xml_attr(metadata_main, 'audienceRatingImage'),
|
||||
'user_rating': helpers.get_xml_attr(metadata_main, 'userRating'),
|
||||
'duration': helpers.get_xml_attr(metadata_main, 'duration'),
|
||||
'year': album_details['year'],
|
||||
@@ -1000,7 +1038,9 @@ class PmsConnect(object):
|
||||
'summary': helpers.get_xml_attr(metadata_main, 'summary'),
|
||||
'tagline': helpers.get_xml_attr(metadata_main, 'tagline'),
|
||||
'rating': helpers.get_xml_attr(metadata_main, 'rating'),
|
||||
'rating_image': helpers.get_xml_attr(metadata_main, 'ratingImage'),
|
||||
'audience_rating': helpers.get_xml_attr(metadata_main, 'audienceRating'),
|
||||
'audience_rating_image': helpers.get_xml_attr(metadata_main, 'audienceRatingImage'),
|
||||
'user_rating': helpers.get_xml_attr(metadata_main, 'userRating'),
|
||||
'duration': helpers.get_xml_attr(metadata_main, 'duration'),
|
||||
'year': helpers.get_xml_attr(metadata_main, 'year'),
|
||||
@@ -1045,7 +1085,9 @@ class PmsConnect(object):
|
||||
'summary': helpers.get_xml_attr(metadata_main, 'summary'),
|
||||
'tagline': helpers.get_xml_attr(metadata_main, 'tagline'),
|
||||
'rating': helpers.get_xml_attr(metadata_main, 'rating'),
|
||||
'rating_image': helpers.get_xml_attr(metadata_main, 'ratingImage'),
|
||||
'audience_rating': helpers.get_xml_attr(metadata_main, 'audienceRating'),
|
||||
'audience_rating_image': helpers.get_xml_attr(metadata_main, 'audienceRatingImage'),
|
||||
'user_rating': helpers.get_xml_attr(metadata_main, 'userRating'),
|
||||
'duration': helpers.get_xml_attr(metadata_main, 'duration'),
|
||||
'year': helpers.get_xml_attr(metadata_main, 'year'),
|
||||
@@ -1090,7 +1132,9 @@ class PmsConnect(object):
|
||||
'summary': helpers.get_xml_attr(metadata_main, 'summary'),
|
||||
'tagline': helpers.get_xml_attr(metadata_main, 'tagline'),
|
||||
'rating': helpers.get_xml_attr(metadata_main, 'rating'),
|
||||
'rating_image': helpers.get_xml_attr(metadata_main, 'ratingImage'),
|
||||
'audience_rating': helpers.get_xml_attr(metadata_main, 'audienceRating'),
|
||||
'audience_rating_image': helpers.get_xml_attr(metadata_main, 'audienceRatingImage'),
|
||||
'user_rating': helpers.get_xml_attr(metadata_main, 'userRating'),
|
||||
'duration': helpers.get_xml_attr(metadata_main, 'duration'),
|
||||
'year': helpers.get_xml_attr(metadata_main, 'year'),
|
||||
@@ -1136,7 +1180,9 @@ class PmsConnect(object):
|
||||
'summary': helpers.get_xml_attr(metadata_main, 'summary'),
|
||||
'tagline': helpers.get_xml_attr(metadata_main, 'tagline'),
|
||||
'rating': helpers.get_xml_attr(metadata_main, 'rating'),
|
||||
'rating_image': helpers.get_xml_attr(metadata_main, 'ratingImage'),
|
||||
'audience_rating': helpers.get_xml_attr(metadata_main, 'audienceRating'),
|
||||
'audience_rating_image': helpers.get_xml_attr(metadata_main, 'audienceRatingImage'),
|
||||
'user_rating': helpers.get_xml_attr(metadata_main, 'userRating'),
|
||||
'duration': helpers.get_xml_attr(metadata_main, 'duration'),
|
||||
'year': helpers.get_xml_attr(metadata_main, 'year'),
|
||||
@@ -1189,7 +1235,8 @@ class PmsConnect(object):
|
||||
'video_width': helpers.get_xml_attr(stream, 'width'),
|
||||
'video_language': helpers.get_xml_attr(stream, 'language'),
|
||||
'video_language_code': helpers.get_xml_attr(stream, 'languageCode'),
|
||||
'video_profile': helpers.get_xml_attr(stream, 'profile')
|
||||
'video_profile': helpers.get_xml_attr(stream, 'profile'),
|
||||
'selected': int(helpers.get_xml_attr(stream, 'selected') == '1')
|
||||
})
|
||||
|
||||
elif helpers.get_xml_attr(stream, 'streamType') == '2':
|
||||
@@ -1203,7 +1250,8 @@ class PmsConnect(object):
|
||||
'audio_sample_rate': helpers.get_xml_attr(stream, 'samplingRate'),
|
||||
'audio_language': helpers.get_xml_attr(stream, 'language'),
|
||||
'audio_language_code': helpers.get_xml_attr(stream, 'languageCode'),
|
||||
'audio_profile': helpers.get_xml_attr(stream, 'profile')
|
||||
'audio_profile': helpers.get_xml_attr(stream, 'profile'),
|
||||
'selected': int(helpers.get_xml_attr(stream, 'selected') == '1')
|
||||
})
|
||||
|
||||
elif helpers.get_xml_attr(stream, 'streamType') == '3':
|
||||
@@ -1215,14 +1263,16 @@ class PmsConnect(object):
|
||||
'subtitle_forced': int(helpers.get_xml_attr(stream, 'forced') == '1'),
|
||||
'subtitle_location': 'external' if helpers.get_xml_attr(stream, 'key') else 'embedded',
|
||||
'subtitle_language': helpers.get_xml_attr(stream, 'language'),
|
||||
'subtitle_language_code': helpers.get_xml_attr(stream, 'languageCode')
|
||||
'subtitle_language_code': helpers.get_xml_attr(stream, 'languageCode'),
|
||||
'selected': int(helpers.get_xml_attr(stream, 'selected') == '1')
|
||||
})
|
||||
|
||||
parts.append({'id': helpers.get_xml_attr(part, 'id'),
|
||||
'file': helpers.get_xml_attr(part, 'file'),
|
||||
'file_size': helpers.get_xml_attr(part, 'size'),
|
||||
'indexes': int(helpers.get_xml_attr(part, 'indexes') == 'sd'),
|
||||
'streams': streams
|
||||
'streams': streams,
|
||||
'selected': int(helpers.get_xml_attr(part, 'selected') == '1')
|
||||
})
|
||||
|
||||
audio_channels = helpers.get_xml_attr(media, 'audioChannels')
|
||||
@@ -1714,7 +1764,9 @@ class PmsConnect(object):
|
||||
'summary': helpers.get_xml_attr(session, 'summary'),
|
||||
'tagline': helpers.get_xml_attr(session, 'tagline'),
|
||||
'rating': helpers.get_xml_attr(session, 'rating'),
|
||||
'rating_image': helpers.get_xml_attr(session, 'ratingImage'),
|
||||
'audience_rating': helpers.get_xml_attr(session, 'audienceRating'),
|
||||
'audience_rating_image': helpers.get_xml_attr(session, 'audienceRatingImage'),
|
||||
'user_rating': helpers.get_xml_attr(session, 'userRating'),
|
||||
'duration': helpers.get_xml_attr(session, 'duration'),
|
||||
'year': helpers.get_xml_attr(session, 'year'),
|
||||
@@ -2037,7 +2089,9 @@ class PmsConnect(object):
|
||||
'summary': helpers.get_xml_attr(m, 'summary'),
|
||||
'tagline': helpers.get_xml_attr(m, 'tagline'),
|
||||
'rating': helpers.get_xml_attr(m, 'rating'),
|
||||
'rating_image': helpers.get_xml_attr(m, 'ratingImage'),
|
||||
'audience_rating': helpers.get_xml_attr(m, 'audienceRating'),
|
||||
'audience_rating_image': helpers.get_xml_attr(m, 'audienceRatingImage'),
|
||||
'user_rating': helpers.get_xml_attr(m, 'userRating'),
|
||||
'duration': helpers.get_xml_attr(m, 'duration'),
|
||||
'year': helpers.get_xml_attr(m, 'year'),
|
||||
@@ -2242,6 +2296,7 @@ class PmsConnect(object):
|
||||
libraries_output = {'section_id': helpers.get_xml_attr(result, 'key'),
|
||||
'section_type': helpers.get_xml_attr(result, 'type'),
|
||||
'section_name': helpers.get_xml_attr(result, 'title'),
|
||||
'agent': helpers.get_xml_attr(result, 'agent'),
|
||||
'thumb': helpers.get_xml_attr(result, 'thumb'),
|
||||
'art': helpers.get_xml_attr(result, 'art')
|
||||
}
|
||||
@@ -2402,6 +2457,7 @@ class PmsConnect(object):
|
||||
library_stats = {'section_id': section_id,
|
||||
'section_name': library['section_name'],
|
||||
'section_type': section_type,
|
||||
'agent': library['agent'],
|
||||
'thumb': library['thumb'],
|
||||
'art': library['art'],
|
||||
'count': children_list['library_count']
|
||||
|
@@ -1,2 +1,2 @@
|
||||
PLEXPY_BRANCH = "beta"
|
||||
PLEXPY_RELEASE_VERSION = "v2.1.15-beta"
|
||||
PLEXPY_RELEASE_VERSION = "v2.1.23-beta"
|
||||
|
@@ -131,6 +131,30 @@ def getVersion():
|
||||
return None, 'origin', common.BRANCH
|
||||
|
||||
|
||||
def check_update(auto_update=False, notify=False):
|
||||
check_github(auto_update=auto_update, notify=notify)
|
||||
|
||||
if not plexpy.CURRENT_VERSION:
|
||||
plexpy.UPDATE_AVAILABLE = None
|
||||
elif plexpy.COMMITS_BEHIND > 0 and plexpy.common.BRANCH in ('master', 'beta') and \
|
||||
plexpy.common.RELEASE != plexpy.LATEST_RELEASE:
|
||||
plexpy.UPDATE_AVAILABLE = 'release'
|
||||
elif plexpy.COMMITS_BEHIND > 0 and plexpy.CURRENT_VERSION != plexpy.LATEST_VERSION and \
|
||||
plexpy.INSTALL_TYPE != 'win':
|
||||
plexpy.UPDATE_AVAILABLE = 'commit'
|
||||
else:
|
||||
plexpy.UPDATE_AVAILABLE = False
|
||||
|
||||
if plexpy.WIN_SYS_TRAY_ICON:
|
||||
if plexpy.UPDATE_AVAILABLE:
|
||||
icon = os.path.join(plexpy.PROG_DIR, 'data/interfaces/', plexpy.CONFIG.INTERFACE, 'images/logo_tray-update.ico')
|
||||
hover_text = common.PRODUCT + ' - Update Available!'
|
||||
else:
|
||||
icon = os.path.join(plexpy.PROG_DIR, 'data/interfaces/', plexpy.CONFIG.INTERFACE, 'images/logo_tray.ico')
|
||||
hover_text = common.PRODUCT + ' - No Update Available'
|
||||
plexpy.WIN_SYS_TRAY_ICON.update(icon=icon, hover_text=hover_text)
|
||||
|
||||
|
||||
def check_github(auto_update=False, notify=False):
|
||||
plexpy.COMMITS_BEHIND = 0
|
||||
|
||||
|
@@ -31,6 +31,8 @@ import logger
|
||||
name = 'websocket'
|
||||
opcode_data = (websocket.ABNF.OPCODE_TEXT, websocket.ABNF.OPCODE_BINARY)
|
||||
ws_shutdown = False
|
||||
pong_timer = None
|
||||
pong_count = 0
|
||||
|
||||
|
||||
def start_thread():
|
||||
@@ -58,6 +60,8 @@ def on_connect():
|
||||
plexpy.PLEX_SERVER_UP = True
|
||||
|
||||
plexpy.initialize_scheduler()
|
||||
if plexpy.CONFIG.WEBSOCKET_MONITOR_PING_PONG:
|
||||
send_ping()
|
||||
|
||||
|
||||
def on_disconnect():
|
||||
@@ -91,6 +95,37 @@ def close():
|
||||
plexpy.WS_CONNECTED = False
|
||||
|
||||
|
||||
def send_ping():
|
||||
if plexpy.WS_CONNECTED:
|
||||
# logger.debug(u"Tautulli WebSocket :: Sending ping.")
|
||||
plexpy.WEBSOCKET.ping("Hi?")
|
||||
|
||||
global pong_timer
|
||||
pong_timer = threading.Timer(5.0, wait_pong)
|
||||
pong_timer.daemon = True
|
||||
pong_timer.start()
|
||||
|
||||
|
||||
def wait_pong():
|
||||
global pong_count
|
||||
pong_count += 1
|
||||
|
||||
logger.warning(u"Tautulli WebSocket :: Failed to receive pong from websocket, ping attempt %s." % str(pong_count))
|
||||
|
||||
if pong_count >= plexpy.CONFIG.WEBSOCKET_CONNECTION_ATTEMPTS:
|
||||
pong_count = 0
|
||||
close()
|
||||
|
||||
|
||||
def receive_pong():
|
||||
# logger.debug(u"Tautulli WebSocket :: Received pong.")
|
||||
global pong_timer
|
||||
global pong_count
|
||||
if pong_timer:
|
||||
pong_timer = pong_timer.cancel()
|
||||
pong_count = 0
|
||||
|
||||
|
||||
def run():
|
||||
from websocket import create_connection
|
||||
|
||||
@@ -115,24 +150,13 @@ def run():
|
||||
reconnects = 0
|
||||
|
||||
# Try an open the websocket connection
|
||||
while not plexpy.WS_CONNECTED and reconnects < plexpy.CONFIG.WEBSOCKET_CONNECTION_ATTEMPTS:
|
||||
if reconnects == 0:
|
||||
logger.info(u"Tautulli WebSocket :: Opening %swebsocket." % secure)
|
||||
|
||||
reconnects += 1
|
||||
|
||||
# Sleep 5 between connection attempts
|
||||
if reconnects > 1:
|
||||
time.sleep(plexpy.CONFIG.WEBSOCKET_CONNECTION_TIMEOUT)
|
||||
|
||||
logger.info(u"Tautulli WebSocket :: Connection attempt %s." % str(reconnects))
|
||||
|
||||
try:
|
||||
plexpy.WEBSOCKET = create_connection(uri, header=header)
|
||||
logger.info(u"Tautulli WebSocket :: Ready")
|
||||
plexpy.WS_CONNECTED = True
|
||||
except (websocket.WebSocketException, IOError, Exception) as e:
|
||||
logger.error("Tautulli WebSocket :: %s." % e)
|
||||
logger.info(u"Tautulli WebSocket :: Opening %swebsocket." % secure)
|
||||
try:
|
||||
plexpy.WEBSOCKET = create_connection(uri, header=header)
|
||||
logger.info(u"Tautulli WebSocket :: Ready")
|
||||
plexpy.WS_CONNECTED = True
|
||||
except (websocket.WebSocketException, IOError, Exception) as e:
|
||||
logger.error("Tautulli WebSocket :: %s." % e)
|
||||
|
||||
if plexpy.WS_CONNECTED:
|
||||
on_connect()
|
||||
@@ -196,7 +220,10 @@ def receive(ws):
|
||||
ws.send_close()
|
||||
return frame.opcode, None
|
||||
elif frame.opcode == websocket.ABNF.OPCODE_PING:
|
||||
# logger.debug(u"Tautulli WebSocket :: Received ping, sending pong.")
|
||||
ws.pong("Hi!")
|
||||
elif frame.opcode == websocket.ABNF.OPCODE_PONG:
|
||||
receive_pong()
|
||||
|
||||
return None, None
|
||||
|
||||
|
@@ -19,7 +19,6 @@
|
||||
# Session tool to be loaded.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
from urllib import quote, unquote
|
||||
|
||||
import cherrypy
|
||||
@@ -37,17 +36,27 @@ JWT_ALGORITHM = 'HS256'
|
||||
JWT_COOKIE_NAME = 'tautulli_token_'
|
||||
|
||||
|
||||
def user_login(username=None, password=None):
|
||||
if not username or not password:
|
||||
return None
|
||||
def plex_user_login(username=None, password=None, 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
|
||||
plex_tv = PlexTV(username=username, password=password)
|
||||
plex_user = plex_tv.get_token()
|
||||
if plex_user:
|
||||
user_token = plex_user['auth_token']
|
||||
user_id = plex_user['user_id']
|
||||
if username and password:
|
||||
plex_tv = PlexTV(username=username, password=password, headers=headers)
|
||||
plex_user = plex_tv.get_token()
|
||||
if plex_user:
|
||||
user_token = plex_user['auth_token']
|
||||
user_id = plex_user['user_id']
|
||||
elif token:
|
||||
plex_tv = PlexTV(token=token, headers=headers)
|
||||
plex_user = plex_tv.get_plex_account_details()
|
||||
if plex_user:
|
||||
user_token = token
|
||||
user_id = plex_user['user_id']
|
||||
else:
|
||||
return None
|
||||
|
||||
if user_token and user_id:
|
||||
# Try to retrieve the user from the database.
|
||||
# Also make sure guest access is enabled for the user and the user is not deleted.
|
||||
user_data = Users()
|
||||
@@ -57,7 +66,7 @@ def user_login(username=None, password=None):
|
||||
return None
|
||||
elif plexpy.CONFIG.HTTP_PLEX_ADMIN and user_details['is_admin']:
|
||||
# Plex admin login
|
||||
return 'admin'
|
||||
return user_details, 'admin'
|
||||
elif not user_details['allow_guest'] or user_details['deleted_user']:
|
||||
# Guest access is disabled or the user is deleted.
|
||||
return None
|
||||
@@ -68,56 +77,65 @@ def user_login(username=None, password=None):
|
||||
|
||||
# The user is in the database, and guest access is enabled, so try to retrieve a server token.
|
||||
# If a server token is returned, then the user is a valid friend of the server.
|
||||
plex_tv = PlexTV(token=user_token)
|
||||
plex_tv = PlexTV(token=user_token, headers=headers)
|
||||
server_token = plex_tv.get_server_token()
|
||||
if server_token:
|
||||
|
||||
# Register the new user / update the access tokens.
|
||||
monitor_db = MonitorDatabase()
|
||||
try:
|
||||
logger.debug(u"Tautulli WebAuth :: Regestering tokens for user '%s' in the database." % username)
|
||||
result = monitor_db.action('UPDATE users SET user_token = ?, server_token = ? WHERE user_id = ?',
|
||||
[user_token, server_token, user_id])
|
||||
logger.debug(u"Tautulli WebAuth :: Registering token for user '%s' in the database."
|
||||
% user_details['username'])
|
||||
result = monitor_db.action('UPDATE users SET server_token = ? WHERE user_id = ?',
|
||||
[server_token, user_details['user_id']])
|
||||
|
||||
if result:
|
||||
# Refresh the users list to make sure we have all the correct permissions.
|
||||
refresh_users()
|
||||
# Successful login
|
||||
return 'guest'
|
||||
return user_details, 'guest'
|
||||
else:
|
||||
logger.warn(u"Tautulli WebAuth :: Unable to register user '%s' in database." % username)
|
||||
logger.warn(u"Tautulli WebAuth :: Unable to register user '%s' in database."
|
||||
% user_details['username'])
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warn(u"Tautulli WebAuth :: Unable to register user '%s' in database: %s." % (username, e))
|
||||
logger.warn(u"Tautulli WebAuth :: Unable to register user '%s' in database: %s."
|
||||
% (user_details['username'], e))
|
||||
return None
|
||||
else:
|
||||
logger.warn(u"Tautulli WebAuth :: Unable to retrieve Plex.tv server token for user '%s'." % username)
|
||||
logger.warn(u"Tautulli WebAuth :: Unable to retrieve Plex.tv server token for user '%s'."
|
||||
% user_details['username'])
|
||||
return None
|
||||
else:
|
||||
elif username:
|
||||
logger.warn(u"Tautulli WebAuth :: Unable to retrieve Plex.tv user token for user '%s'." % username)
|
||||
return None
|
||||
|
||||
return None
|
||||
elif token:
|
||||
logger.warn(u"Tautulli WebAuth :: Unable to retrieve Plex.tv user token for Plex OAuth.")
|
||||
return None
|
||||
|
||||
|
||||
def check_credentials(username, password, admin_login='0'):
|
||||
def check_credentials(username=None, password=None, token=None, admin_login='0', headers=None):
|
||||
"""Verifies credentials for username and password.
|
||||
Returns True and the user group on success or False and no user group"""
|
||||
|
||||
if plexpy.CONFIG.HTTP_PASSWORD:
|
||||
if plexpy.CONFIG.HTTP_HASHED_PASSWORD and \
|
||||
username == plexpy.CONFIG.HTTP_USERNAME and check_hash(password, plexpy.CONFIG.HTTP_PASSWORD):
|
||||
return True, 'tautulli admin'
|
||||
elif not plexpy.CONFIG.HTTP_HASHED_PASSWORD and \
|
||||
username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD:
|
||||
return True, 'tautulli admin'
|
||||
if username and password:
|
||||
if plexpy.CONFIG.HTTP_PASSWORD:
|
||||
user_details = {'user_id': None, 'username': username}
|
||||
|
||||
if plexpy.CONFIG.HTTP_HASHED_PASSWORD and \
|
||||
username == plexpy.CONFIG.HTTP_USERNAME and check_hash(password, plexpy.CONFIG.HTTP_PASSWORD):
|
||||
return True, user_details, 'admin'
|
||||
elif not plexpy.CONFIG.HTTP_HASHED_PASSWORD and \
|
||||
username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD:
|
||||
return True, user_details, 'admin'
|
||||
|
||||
if plexpy.CONFIG.HTTP_PLEX_ADMIN or (not admin_login == '1' and plexpy.CONFIG.ALLOW_GUEST_ACCESS):
|
||||
plex_login = user_login(username, password)
|
||||
plex_login = plex_user_login(username=username, password=password, token=token, headers=headers)
|
||||
if plex_login is not None:
|
||||
return True, plex_login
|
||||
return True, plex_login[0], plex_login[1]
|
||||
|
||||
return False, None
|
||||
return False, None, None
|
||||
|
||||
|
||||
def check_jwt_token():
|
||||
@@ -220,7 +238,7 @@ class AuthController(object):
|
||||
return
|
||||
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
|
||||
|
||||
def on_login(self, username, user_id=None, user_group=None, success=0):
|
||||
def on_login(self, username=None, user_id=None, user_group=None, success=False, oauth=False):
|
||||
"""Called on successful login"""
|
||||
|
||||
# Save login to the database
|
||||
@@ -236,8 +254,10 @@ class AuthController(object):
|
||||
user_agent=user_agent,
|
||||
success=success)
|
||||
|
||||
if success == 1:
|
||||
logger.debug(u"Tautulli WebAuth :: %s user '%s' logged into Tautulli." % (user_group.capitalize(), username))
|
||||
if success:
|
||||
use_oauth = 'Plex OAuth' if oauth else 'form'
|
||||
logger.debug(u"Tautulli WebAuth :: %s user '%s' logged into Tautulli using %s login."
|
||||
% (user_group.capitalize(), username, use_oauth))
|
||||
|
||||
def on_logout(self, username, user_group):
|
||||
"""Called on logout"""
|
||||
@@ -279,43 +299,37 @@ class AuthController(object):
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def signin(self, username=None, password=None, remember_me='0', admin_login='0', *args, **kwargs):
|
||||
def signin(self, username=None, password=None, token=None, remember_me='0', admin_login='0', *args, **kwargs):
|
||||
if cherrypy.request.method != 'POST':
|
||||
cherrypy.response.status = 405
|
||||
return {'status': 'error', 'message': 'Sign in using POST.'}
|
||||
|
||||
error_message = {'status': 'error', 'message': 'Incorrect username or password.'}
|
||||
error_message = {'status': 'error', 'message': 'Invalid credentials.'}
|
||||
|
||||
valid_login, user_group = check_credentials(username, password, admin_login)
|
||||
valid_login, user_details, user_group = check_credentials(username=username,
|
||||
password=password,
|
||||
token=token,
|
||||
admin_login=admin_login,
|
||||
headers=kwargs)
|
||||
|
||||
if valid_login:
|
||||
if user_group == 'tautulli admin':
|
||||
user_group = 'admin'
|
||||
user_id = None
|
||||
else:
|
||||
if re.match(r"[^@]+@[^@]+\.[^@]+", username):
|
||||
user_details = Users().get_details(email=username)
|
||||
else:
|
||||
user_details = Users().get_details(user=username)
|
||||
|
||||
user_id = user_details['user_id']
|
||||
|
||||
time_delta = timedelta(days=30) if remember_me == '1' else timedelta(minutes=60)
|
||||
expiry = datetime.utcnow() + time_delta
|
||||
|
||||
payload = {
|
||||
'user_id': user_id,
|
||||
'user': username,
|
||||
'user_id': user_details['user_id'],
|
||||
'user': user_details['username'],
|
||||
'user_group': user_group,
|
||||
'exp': expiry
|
||||
}
|
||||
|
||||
jwt_token = jwt.encode(payload, plexpy.CONFIG.JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
self.on_login(username=username,
|
||||
user_id=user_id,
|
||||
self.on_login(username=user_details['username'],
|
||||
user_id=user_details['user_id'],
|
||||
user_group=user_group,
|
||||
success=1)
|
||||
success=True,
|
||||
oauth=bool(token))
|
||||
|
||||
jwt_cookie = JWT_COOKIE_NAME + plexpy.CONFIG.PMS_UUID
|
||||
cherrypy.response.cookie[jwt_cookie] = jwt_token
|
||||
@@ -326,14 +340,20 @@ class AuthController(object):
|
||||
cherrypy.response.status = 200
|
||||
return {'status': 'success', 'token': jwt_token.decode('utf-8'), 'uuid': plexpy.CONFIG.PMS_UUID}
|
||||
|
||||
elif admin_login == '1':
|
||||
elif admin_login == '1' and username:
|
||||
self.on_login(username=username)
|
||||
logger.debug(u"Tautulli WebAuth :: Invalid admin login attempt from '%s'." % username)
|
||||
cherrypy.response.status = 401
|
||||
return error_message
|
||||
|
||||
else:
|
||||
elif username:
|
||||
self.on_login(username=username)
|
||||
logger.debug(u"Tautulli WebAuth :: Invalid login attempt from '%s'." % username)
|
||||
logger.debug(u"Tautulli WebAuth :: Invalid user login attempt from '%s'." % username)
|
||||
cherrypy.response.status = 401
|
||||
return error_message
|
||||
|
||||
elif token:
|
||||
self.on_login(username='Plex OAuth', oauth=True)
|
||||
logger.debug(u"Tautulli WebAuth :: Invalid Plex OAuth login attempt.")
|
||||
cherrypy.response.status = 401
|
||||
return error_message
|
||||
|
@@ -110,6 +110,7 @@ class WebInterface(object):
|
||||
"pms_is_cloud": plexpy.CONFIG.PMS_IS_CLOUD,
|
||||
"pms_token": plexpy.CONFIG.PMS_TOKEN,
|
||||
"pms_uuid": plexpy.CONFIG.PMS_UUID,
|
||||
"pms_name": plexpy.CONFIG.PMS_NAME,
|
||||
"logging_ignore_interval": plexpy.CONFIG.LOGGING_IGNORE_INTERVAL
|
||||
}
|
||||
|
||||
@@ -172,10 +173,6 @@ class WebInterface(object):
|
||||
def home(self, **kwargs):
|
||||
config = {
|
||||
"home_sections": plexpy.CONFIG.HOME_SECTIONS,
|
||||
"home_stats_length": plexpy.CONFIG.HOME_STATS_LENGTH,
|
||||
"home_stats_type": plexpy.CONFIG.HOME_STATS_TYPE,
|
||||
"home_stats_count": plexpy.CONFIG.HOME_STATS_COUNT,
|
||||
"home_stats_recently_added_count": plexpy.CONFIG.HOME_STATS_RECENTLY_ADDED_COUNT,
|
||||
"home_refresh_interval": plexpy.CONFIG.HOME_REFRESH_INTERVAL,
|
||||
"pms_name": plexpy.CONFIG.PMS_NAME,
|
||||
"pms_is_cloud": plexpy.CONFIG.PMS_IS_CLOUD,
|
||||
@@ -277,7 +274,7 @@ class WebInterface(object):
|
||||
def return_plex_xml_url(self, endpoint='', plextv=False, **kwargs):
|
||||
kwargs['X-Plex-Token'] = plexpy.CONFIG.PMS_TOKEN
|
||||
|
||||
if plextv:
|
||||
if plextv == 'true':
|
||||
base_url = 'https://plex.tv'
|
||||
else:
|
||||
if plexpy.CONFIG.PMS_URL_OVERRIDE:
|
||||
@@ -292,7 +289,7 @@ class WebInterface(object):
|
||||
|
||||
@cherrypy.expose
|
||||
@requireAuth()
|
||||
def home_stats(self, time_range=30, stats_type=0, stats_count=10, **kwargs):
|
||||
def home_stats(self, time_range=30, stats_type='plays', stats_count=10, **kwargs):
|
||||
data_factory = datafactory.DataFactory()
|
||||
stats_data = data_factory.get_home_stats(time_range=time_range,
|
||||
stats_type=stats_type,
|
||||
@@ -300,24 +297,6 @@ class WebInterface(object):
|
||||
|
||||
return serve_template(templatename="home_stats.html", title="Stats", data=stats_data)
|
||||
|
||||
@cherrypy.expose
|
||||
@requireAuth(member_of("admin"))
|
||||
def set_home_stats_config(self, time_range=None, stats_type=None, stats_count=None, recently_added_count=None, **kwargs):
|
||||
if time_range:
|
||||
plexpy.CONFIG.__setattr__('HOME_STATS_LENGTH', time_range)
|
||||
plexpy.CONFIG.write()
|
||||
if stats_type:
|
||||
plexpy.CONFIG.__setattr__('HOME_STATS_TYPE', stats_type)
|
||||
plexpy.CONFIG.write()
|
||||
if stats_count:
|
||||
plexpy.CONFIG.__setattr__('HOME_STATS_COUNT', stats_count)
|
||||
plexpy.CONFIG.write()
|
||||
if recently_added_count:
|
||||
plexpy.CONFIG.__setattr__('HOME_STATS_RECENTLY_ADDED_COUNT', recently_added_count)
|
||||
plexpy.CONFIG.write()
|
||||
|
||||
return "Updated home stats config values."
|
||||
|
||||
@cherrypy.expose
|
||||
@requireAuth()
|
||||
def library_stats(self, **kwargs):
|
||||
@@ -331,11 +310,11 @@ class WebInterface(object):
|
||||
|
||||
@cherrypy.expose
|
||||
@requireAuth()
|
||||
def get_recently_added(self, count='0', type='', **kwargs):
|
||||
def get_recently_added(self, count='0', media_type='', **kwargs):
|
||||
|
||||
try:
|
||||
pms_connect = pmsconnect.PmsConnect()
|
||||
result = pms_connect.get_recently_added_details(count=count, type=type)
|
||||
result = pms_connect.get_recently_added_details(count=count, media_type=media_type)
|
||||
except IOError as e:
|
||||
return serve_template(templatename="recently_added.html", data=None)
|
||||
|
||||
@@ -1717,7 +1696,7 @@ class WebInterface(object):
|
||||
custom_where.append(['session_history_metadata.section_id', section_id])
|
||||
if 'media_type' in kwargs:
|
||||
media_type = kwargs.get('media_type', "")
|
||||
if media_type:
|
||||
if media_type != 'all':
|
||||
custom_where.append(['session_history.media_type', media_type])
|
||||
if 'transcode_decision' in kwargs:
|
||||
transcode_decision = kwargs.get('transcode_decision', "")
|
||||
@@ -1837,34 +1816,7 @@ class WebInterface(object):
|
||||
@cherrypy.expose
|
||||
@requireAuth()
|
||||
def graphs(self, **kwargs):
|
||||
|
||||
config = {
|
||||
"graph_type": plexpy.CONFIG.GRAPH_TYPE,
|
||||
"graph_days": plexpy.CONFIG.GRAPH_DAYS,
|
||||
"graph_months": plexpy.CONFIG.GRAPH_MONTHS,
|
||||
"graph_tab": plexpy.CONFIG.GRAPH_TAB,
|
||||
"music_logging_enable": plexpy.CONFIG.MUSIC_LOGGING_ENABLE
|
||||
}
|
||||
|
||||
return serve_template(templatename="graphs.html", title="Graphs", config=config)
|
||||
|
||||
@cherrypy.expose
|
||||
@requireAuth(member_of("admin"))
|
||||
def set_graph_config(self, graph_type=None, graph_days=None, graph_months=None, graph_tab=None, **kwargs):
|
||||
if graph_type:
|
||||
plexpy.CONFIG.__setattr__('GRAPH_TYPE', graph_type)
|
||||
plexpy.CONFIG.write()
|
||||
if graph_days:
|
||||
plexpy.CONFIG.__setattr__('GRAPH_DAYS', graph_days)
|
||||
plexpy.CONFIG.write()
|
||||
if graph_months:
|
||||
plexpy.CONFIG.__setattr__('GRAPH_MONTHS', graph_months)
|
||||
plexpy.CONFIG.write()
|
||||
if graph_tab:
|
||||
plexpy.CONFIG.__setattr__('GRAPH_TAB', graph_tab)
|
||||
plexpy.CONFIG.write()
|
||||
|
||||
return "Updated graphs config values."
|
||||
return serve_template(templatename="graphs.html", title="Graphs")
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@@ -2802,6 +2754,7 @@ class WebInterface(object):
|
||||
"pms_url_manual": checked(plexpy.CONFIG.PMS_URL_MANUAL),
|
||||
"pms_uuid": plexpy.CONFIG.PMS_UUID,
|
||||
"pms_web_url": plexpy.CONFIG.PMS_WEB_URL,
|
||||
"pms_name": plexpy.CONFIG.PMS_NAME,
|
||||
"date_format": plexpy.CONFIG.DATE_FORMAT,
|
||||
"time_format": plexpy.CONFIG.TIME_FORMAT,
|
||||
"week_start_monday": checked(plexpy.CONFIG.WEEK_START_MONDAY),
|
||||
@@ -2853,7 +2806,8 @@ class WebInterface(object):
|
||||
"newsletter_auth": plexpy.CONFIG.NEWSLETTER_AUTH,
|
||||
"newsletter_password": plexpy.CONFIG.NEWSLETTER_PASSWORD,
|
||||
"newsletter_inline_styles": checked(plexpy.CONFIG.NEWSLETTER_INLINE_STYLES),
|
||||
"newsletter_custom_dir": plexpy.CONFIG.NEWSLETTER_CUSTOM_DIR
|
||||
"newsletter_custom_dir": plexpy.CONFIG.NEWSLETTER_CUSTOM_DIR,
|
||||
"win_sys_tray": checked(plexpy.CONFIG.WIN_SYS_TRAY)
|
||||
}
|
||||
|
||||
return serve_template(templatename="settings.html", title="Settings", config=config, kwargs=kwargs)
|
||||
@@ -2875,7 +2829,7 @@ class WebInterface(object):
|
||||
"allow_guest_access", "cache_images", "http_proxy", "http_basic_auth", "notify_concurrent_by_ip",
|
||||
"history_table_activity", "plexpy_auto_update",
|
||||
"themoviedb_lookup", "tvmaze_lookup", "http_plex_admin",
|
||||
"newsletter_self_hosted", "newsletter_inline_styles"
|
||||
"newsletter_self_hosted", "newsletter_inline_styles", "win_sys_tray"
|
||||
]
|
||||
for checked_config in checked_configs:
|
||||
if checked_config not in kwargs:
|
||||
@@ -2987,7 +2941,8 @@ class WebInterface(object):
|
||||
# Get new server URLs for SSL communications and get new server friendly name
|
||||
if server_changed:
|
||||
plextv.get_server_resources()
|
||||
web_socket.reconnect()
|
||||
if plexpy.WS_CONNECTED:
|
||||
web_socket.reconnect()
|
||||
|
||||
# If first run, start websocket
|
||||
if first_run:
|
||||
@@ -3800,16 +3755,15 @@ class WebInterface(object):
|
||||
}
|
||||
```
|
||||
"""
|
||||
versioncheck.check_github()
|
||||
versioncheck.check_update()
|
||||
|
||||
if not plexpy.CURRENT_VERSION:
|
||||
if plexpy.UPDATE_AVAILABLE is None:
|
||||
return {'result': 'error',
|
||||
'update': None,
|
||||
'message': 'You are running an unknown version of Tautulli.'
|
||||
}
|
||||
|
||||
elif plexpy.COMMITS_BEHIND > 0 and plexpy.common.BRANCH in ('master', 'beta') and \
|
||||
plexpy.common.RELEASE != plexpy.LATEST_RELEASE:
|
||||
elif plexpy.UPDATE_AVAILABLE == 'release':
|
||||
return {'result': 'success',
|
||||
'update': True,
|
||||
'release': True,
|
||||
@@ -3822,8 +3776,7 @@ class WebInterface(object):
|
||||
plexpy.LATEST_RELEASE))
|
||||
}
|
||||
|
||||
elif plexpy.COMMITS_BEHIND > 0 and plexpy.CURRENT_VERSION != plexpy.LATEST_VERSION and \
|
||||
plexpy.INSTALL_TYPE != 'win':
|
||||
elif plexpy.UPDATE_AVAILABLE == 'commit':
|
||||
return {'result': 'success',
|
||||
'update': True,
|
||||
'release': False,
|
||||
@@ -4030,7 +3983,7 @@ class WebInterface(object):
|
||||
return self.real_pms_image_proxy(**kwargs)
|
||||
|
||||
@addtoapi('pms_image_proxy')
|
||||
def real_pms_image_proxy(self, img='', rating_key=None, width=0, height=0,
|
||||
def real_pms_image_proxy(self, img=None, rating_key=None, width=750, height=1000,
|
||||
opacity=100, background='000000', blur=0, img_format='png',
|
||||
fallback=None, refresh=False, clip=False, **kwargs):
|
||||
""" Gets an image from the PMS and saves it to the image cache directory.
|
||||
@@ -4050,6 +4003,7 @@ class WebInterface(object):
|
||||
img_format (str): png
|
||||
fallback (str): "poster", "cover", "art"
|
||||
refresh (bool): True or False whether to refresh the image cache
|
||||
return_hash (bool): True or False to return the self-hosted image hash instead of the image
|
||||
|
||||
Returns:
|
||||
None
|
||||
@@ -4059,6 +4013,8 @@ class WebInterface(object):
|
||||
logger.warn('No image input received.')
|
||||
return
|
||||
|
||||
return_hash = (kwargs.get('return_hash') == 'true')
|
||||
|
||||
if rating_key and not img:
|
||||
if fallback == 'art':
|
||||
img = '/library/metadata/{}/art'.format(rating_key)
|
||||
@@ -4069,9 +4025,13 @@ class WebInterface(object):
|
||||
img = '/'.join(img_split[:5])
|
||||
rating_key = rating_key or img_split[3]
|
||||
|
||||
img_string = '{}.{}.{}.{}.{}.{}.{}.{}'.format(
|
||||
plexpy.CONFIG.PMS_UUID, img, rating_key, width, height, opacity, background, blur, fallback)
|
||||
img_hash = hashlib.sha256(img_string).hexdigest()
|
||||
img_hash = notification_handler.set_hash_image_info(
|
||||
img=img, rating_key=rating_key, width=width, height=height,
|
||||
opacity=opacity, background=background, blur=blur, fallback=fallback,
|
||||
add_to_db=return_hash)
|
||||
|
||||
if return_hash:
|
||||
return {'img_hash': img_hash}
|
||||
|
||||
fp = '{}.{}'.format(img_hash, img_format) # we want to be able to preview the thumbs
|
||||
c_dir = os.path.join(plexpy.CONFIG.CACHE_DIR, 'images')
|
||||
@@ -4560,6 +4520,7 @@ class WebInterface(object):
|
||||
"added_at": "1461572396",
|
||||
"art": "/library/metadata/1219/art/1462175063",
|
||||
"audience_rating": "8",
|
||||
"audience_rating_image": "rottentomatoes://image.rating.upright",
|
||||
"banner": "/library/metadata/1219/banner/1462175063",
|
||||
"collections": [],
|
||||
"content_rating": "TV-MA",
|
||||
@@ -4613,7 +4574,8 @@ class WebInterface(object):
|
||||
"video_language_code": "",
|
||||
"video_profile": "high",
|
||||
"video_ref_frames": "4",
|
||||
"video_width": "1920"
|
||||
"video_width": "1920",
|
||||
"selected": 0
|
||||
},
|
||||
{
|
||||
"audio_bitrate": "384",
|
||||
@@ -4626,7 +4588,8 @@ class WebInterface(object):
|
||||
"audio_profile": "",
|
||||
"audio_sample_rate": "48000",
|
||||
"id": "511664",
|
||||
"type": "2"
|
||||
"type": "2",
|
||||
"selected": 1
|
||||
},
|
||||
{
|
||||
"id": "511953",
|
||||
@@ -4637,7 +4600,8 @@ class WebInterface(object):
|
||||
"subtitle_language": "English",
|
||||
"subtitle_language_code": "eng",
|
||||
"subtitle_location": "external",
|
||||
"type": "3"
|
||||
"type": "3",
|
||||
"selected": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4657,6 +4621,7 @@ class WebInterface(object):
|
||||
"parent_thumb": "/library/metadata/153036/thumb/1462175062",
|
||||
"parent_title": "",
|
||||
"rating": "7.8",
|
||||
"rating_image": "rottentomatoes://image.rating.ripe",
|
||||
"rating_key": "153037",
|
||||
"section_id": "2",
|
||||
"sort_title": "Game of Thrones",
|
||||
@@ -4687,8 +4652,8 @@ class WebInterface(object):
|
||||
@cherrypy.tools.json_out()
|
||||
@requireAuth(member_of("admin"))
|
||||
@addtoapi("get_recently_added")
|
||||
def get_recently_added_details(self, start='0', count='0', type='', section_id='', **kwargs):
|
||||
""" Get all items that where recelty added to plex.
|
||||
def get_recently_added_details(self, start='0', count='0', media_type='', section_id='', **kwargs):
|
||||
""" Get all items that where recently added to plex.
|
||||
|
||||
```
|
||||
Required parameters:
|
||||
@@ -4696,7 +4661,7 @@ class WebInterface(object):
|
||||
|
||||
Optional parameters:
|
||||
start (str): The item number to start at
|
||||
type (str): The media type: movie, show, artist
|
||||
media_type (str): The media type: movie, show, artist
|
||||
section_id (str): The id of the Plex library section
|
||||
|
||||
Returns:
|
||||
@@ -4726,8 +4691,12 @@ class WebInterface(object):
|
||||
}
|
||||
```
|
||||
"""
|
||||
# For backwards compatibility
|
||||
if 'type' in kwargs:
|
||||
media_type = kwargs['type']
|
||||
|
||||
pms_connect = pmsconnect.PmsConnect()
|
||||
result = pms_connect.get_recently_added_details(start=start, count=count, type=type, section_id=section_id)
|
||||
result = pms_connect.get_recently_added_details(start=start, count=count, media_type=media_type, section_id=section_id)
|
||||
|
||||
if result:
|
||||
return result
|
||||
@@ -4893,7 +4862,7 @@ class WebInterface(object):
|
||||
@cherrypy.tools.json_out()
|
||||
@requireAuth()
|
||||
@addtoapi()
|
||||
def get_activity(self, session_key=None, **kwargs):
|
||||
def get_activity(self, session_key=None, session_id=None, **kwargs):
|
||||
""" Get the current activity on the PMS.
|
||||
|
||||
```
|
||||
@@ -4901,7 +4870,8 @@ class WebInterface(object):
|
||||
None
|
||||
|
||||
Optional parameters:
|
||||
None
|
||||
session_key (int): Session key for the session info to return, OR
|
||||
session_id (str): Session ID for the session info to return
|
||||
|
||||
Returns:
|
||||
json:
|
||||
@@ -4920,6 +4890,7 @@ class WebInterface(object):
|
||||
"art": "/library/metadata/1219/art/1503306930",
|
||||
"aspect_ratio": "1.78",
|
||||
"audience_rating": "",
|
||||
"audience_rating_image": "rottentomatoes://image.rating.upright",
|
||||
"audio_bitrate": "384",
|
||||
"audio_bitrate_mode": "",
|
||||
"audio_channel_layout": "5.1(side)",
|
||||
@@ -4996,6 +4967,7 @@ class WebInterface(object):
|
||||
"progress_percent": "0",
|
||||
"quality_profile": "Original",
|
||||
"rating": "7.8",
|
||||
"rating_image": "rottentomatoes://image.rating.ripe",
|
||||
"rating_key": "153037",
|
||||
"relay": 0,
|
||||
"section_id": "2",
|
||||
@@ -5130,6 +5102,8 @@ class WebInterface(object):
|
||||
if result:
|
||||
if session_key:
|
||||
return next((s for s in result['sessions'] if s['session_key'] == session_key), {})
|
||||
if session_id:
|
||||
return next((s for s in result['sessions'] if s['session_id'] == session_id), {})
|
||||
|
||||
counts = {'stream_count_direct_play': 0,
|
||||
'stream_count_direct_stream': 0,
|
||||
@@ -5316,7 +5290,7 @@ class WebInterface(object):
|
||||
@cherrypy.tools.json_out()
|
||||
@requireAuth(member_of("admin"))
|
||||
@addtoapi()
|
||||
def get_home_stats(self, grouping=0, time_range='30', stats_type=0, stats_count='10', **kwargs):
|
||||
def get_home_stats(self, grouping=0, time_range=30, stats_type='plays', stats_count=10, **kwargs):
|
||||
""" Get the homepage watch statistics.
|
||||
|
||||
```
|
||||
@@ -5326,7 +5300,7 @@ class WebInterface(object):
|
||||
Optional parameters:
|
||||
grouping (int): 0 or 1
|
||||
time_range (str): The time range to calculate statistics, '30'
|
||||
stats_type (int): 0 for plays, 1 for duration
|
||||
stats_type (str): plays or duration
|
||||
stats_count (str): The number of top items to list, '5'
|
||||
|
||||
Returns:
|
||||
@@ -5390,6 +5364,12 @@ class WebInterface(object):
|
||||
]
|
||||
```
|
||||
"""
|
||||
# For backwards compatibility
|
||||
if stats_type in (0, "0"):
|
||||
stats_type = 'plays'
|
||||
elif stats_type in (1, '1'):
|
||||
stats_type = 'duration'
|
||||
|
||||
data_factory = datafactory.DataFactory()
|
||||
result = data_factory.get_home_stats(grouping=grouping,
|
||||
time_range=time_range,
|
||||
|
@@ -67,6 +67,10 @@ def initialize(options):
|
||||
else:
|
||||
protocol = "http"
|
||||
|
||||
if options['http_proxy']:
|
||||
# Overwrite cherrypy.tools.proxy with our own proxy handler
|
||||
cherrypy.tools.proxy = cherrypy.Tool('before_handler', proxy, priority=1)
|
||||
|
||||
if options['http_password']:
|
||||
login_allowed = ["Tautulli admin (username is '%s')" % options['http_username']]
|
||||
if plexpy.CONFIG.HTTP_PLEX_ADMIN:
|
||||
@@ -80,7 +84,7 @@ def initialize(options):
|
||||
else:
|
||||
auth_enabled = True
|
||||
basic_auth_enabled = False
|
||||
cherrypy.tools.auth = cherrypy.Tool('before_handler', webauth.check_auth)
|
||||
cherrypy.tools.auth = cherrypy.Tool('before_handler', webauth.check_auth, priority=2)
|
||||
else:
|
||||
auth_enabled = basic_auth_enabled = False
|
||||
|
||||
@@ -94,7 +98,7 @@ def initialize(options):
|
||||
conf = {
|
||||
'/': {
|
||||
'tools.staticdir.root': os.path.join(plexpy.PROG_DIR, 'data'),
|
||||
'tools.proxy.on': options['http_proxy'], # pay attention to X-Forwarded-Proto header
|
||||
'tools.proxy.on': bool(options['http_proxy']),
|
||||
'tools.gzip.on': True,
|
||||
'tools.gzip.mime_types': ['text/html', 'text/plain', 'text/css',
|
||||
'text/javascript', 'application/json',
|
||||
@@ -202,6 +206,8 @@ def initialize(options):
|
||||
# Prevent time-outs
|
||||
cherrypy.engine.timeout_monitor.unsubscribe()
|
||||
cherrypy.tree.mount(WebInterface(), options['http_root'], config=conf)
|
||||
if plexpy.HTTP_ROOT != '/':
|
||||
cherrypy.tree.mount(BaseRedirect(), '/')
|
||||
|
||||
try:
|
||||
logger.info(u"Tautulli WebStart :: Starting Tautulli web server on %s://%s:%d%s", protocol,
|
||||
@@ -218,3 +224,32 @@ def initialize(options):
|
||||
sys.exit(1)
|
||||
|
||||
cherrypy.server.wait()
|
||||
|
||||
|
||||
class BaseRedirect(object):
|
||||
@cherrypy.expose
|
||||
def index(self):
|
||||
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
|
||||
|
||||
|
||||
def proxy():
|
||||
# logger.debug(u"REQUEST URI: %s, HEADER [X-Forwarded-Host]: %s, [X-Host]: %s, [Origin]: %s, [Host]: %s",
|
||||
# cherrypy.request.wsgi_environ['REQUEST_URI'],
|
||||
# cherrypy.request.headers.get('X-Forwarded-Host'),
|
||||
# cherrypy.request.headers.get('X-Host'),
|
||||
# cherrypy.request.headers.get('Origin'),
|
||||
# cherrypy.request.headers.get('Host'))
|
||||
|
||||
# Change cherrpy.tools.proxy.local header if X-Forwarded-Host header is not present
|
||||
local = 'X-Forwarded-Host'
|
||||
if not cherrypy.request.headers.get('X-Forwarded-Host'):
|
||||
if cherrypy.request.headers.get('X-Host'): # lighttpd
|
||||
local = 'X-Host'
|
||||
elif cherrypy.request.headers.get('Origin'): # Squid
|
||||
local = 'Origin'
|
||||
elif cherrypy.request.headers.get('Host'): # nginx
|
||||
local = 'Host'
|
||||
# logger.debug(u"cherrypy.tools.proxy.local set to [%s]", local)
|
||||
|
||||
# Call original cherrypy proxy tool with the new local
|
||||
cherrypy.lib.cptools.proxy(local=local)
|
||||
|
Reference in New Issue
Block a user