Compare commits

...

204 Commits

Author SHA1 Message Date
JonnyWong16
a08bce2073 v2.0.22 2018-03-10 09:32:08 -08:00
JonnyWong16
4e9c8322c3 Don't overwrite tautulli db on move 2018-03-10 09:32:05 -08:00
JonnyWong16
89bfe85be3 Workaround for duration reported as minutes for a show 2018-03-10 08:58:15 -08:00
JonnyWong16
98d994591c Fix runtime round to minutes 2018-03-09 19:12:12 -08:00
JonnyWong16
a29bc7f4f9 v2.0.22-beta 2018-03-09 17:58:40 -08:00
JonnyWong16
288f4c5f7f Fix expanding selectize box 2018-03-09 15:50:53 -08:00
JonnyWong16
a6bf78ed56 Check is schedulers running before shutdown 2018-03-08 18:32:47 -08:00
JonnyWong16
8dbb05931e Fix library refresh when missing library 2018-03-08 18:23:12 -08:00
JonnyWong16
ac8a712ff0 Fix refreshing activity after losing connection 2018-03-06 20:01:11 -08:00
JonnyWong16
39406c25c3 Add retry and expire for Pushover priority 2 2018-03-06 09:57:06 -08:00
JonnyWong16
48d7c2c54c Fix photo library count and media info table 2018-03-05 09:49:48 -08:00
JonnyWong16
0217188274 Fix update check 2018-03-04 22:49:32 -08:00
JonnyWong16
fd762e71de Fix cherrypy sending wrong Content-Type header for svg 2018-03-04 22:32:26 -08:00
JonnyWong16
4d5c3b6df0 v2.0.21-beta 2018-03-04 14:51:27 -08:00
JonnyWong16
7df54e4d1b Replace Flattr with Patreon 2018-03-04 14:25:38 -08:00
JonnyWong16
5d085de9d3 Rename logger name 2018-03-04 12:24:25 -08:00
JonnyWong16
a8a4299086 Add execute permission to PlexPy.py 2018-03-04 12:17:09 -08:00
JonnyWong16
86f0e8425c Add execute permission to Tautulli.py 2018-03-04 12:15:05 -08:00
JonnyWong16
d2e879be4a Add PlexPy.py file to run Tautulli.py 2018-03-04 12:01:31 -08:00
JonnyWong16
544114fffe Rename css files to tautulli 2018-03-04 11:40:38 -08:00
JonnyWong16
3b3e207b11 Rename log files to tautulli 2018-03-04 11:38:31 -08:00
JonnyWong16
84aad638ac Rename database backup to tautulli 2018-03-04 11:28:35 -08:00
JonnyWong16
2bb691966e Rename default notifier settings to tautulli 2018-03-04 11:18:04 -08:00
JonnyWong16
8f5e788270 Rename plexpy.db to tautulli.db 2018-03-04 11:17:35 -08:00
JonnyWong16
7c43ea2f46 Rename PlexPy.py to Tautulli.py 2018-03-04 11:17:11 -08:00
JonnyWong16
8146e1e3cf Capitalize Tautulli folder in init scripts 2018-03-04 10:28:28 -08:00
JonnyWong16
51b1ff6d4a Rename variables in Ubuntu script 2018-03-04 10:17:16 -08:00
JonnyWong16
403e8dfbea Update all init scripts to Tautulli 2018-03-04 09:44:02 -08:00
JonnyWong16
9d08717c83 Fix missing country in whois lookup causing error 2018-03-02 15:39:05 -08:00
JonnyWong16
66167d5960 Remove word "allowed" 2018-03-02 10:24:28 -08:00
JonnyWong16
624863d826 Hide number input spinners on Firefox 2018-03-02 08:48:31 -08:00
JonnyWong16
d4b3810fbc Reduce number input width 2018-03-01 19:34:52 -08:00
JonnyWong16
6056e1d3b9 Hide arrows on number inputes 2018-03-01 13:08:43 -08:00
JonnyWong16
1a293d525f Update database session on state change 2018-02-28 13:34:21 -08:00
JonnyWong16
b87eb68bdd Identify if a stream is using Plex Relay 2018-02-27 20:03:31 -08:00
JonnyWong16
8620546d07 Move import from a082109 2018-02-27 15:17:35 -08:00
JonnyWong16
a082109045 Don't ping for activity if websocket is not connected 2018-02-27 15:02:17 -08:00
JonnyWong16
559a9b393e Catch failure to send analytics event 2018-02-24 15:08:58 -08:00
JonnyWong16
ae41b22e59 Forgot one version number in 754fd24 2018-02-24 14:51:19 -08:00
JonnyWong16
754fd24421 Refactor some code 2018-02-24 10:09:02 -08:00
JonnyWong16
ab34a74210 v2.0.20-beta 2018-02-24 09:22:47 -08:00
JonnyWong16
cfa6de4d91 Remove content group 2018-02-24 09:02:46 -08:00
JonnyWong16
a5608c7a1e Revert to png for logos 2018-02-22 19:30:10 -08:00
JonnyWong16
88a7b52e51 Add content group metric dev/production 2018-02-22 12:39:32 -08:00
JonnyWong16
e444bad4de Switch metric dimensions 2018-02-22 12:28:22 -08:00
JonnyWong16
5403b0b547 Install or update event 2018-02-22 11:41:22 -08:00
JonnyWong16
51b5e615f5 Add some more system metrics 2018-02-22 09:20:58 -08:00
JonnyWong16
700547b63b Separate system analytics 2018-02-22 08:12:15 -08:00
JonnyWong16
3f3d1962c7 Hash client ID 2018-02-22 07:29:56 -08:00
JonnyWong16
655a359ef4 Clean up tracker 2018-02-22 07:28:13 -08:00
JonnyWong16
90647628c9 Test sending install metrics on startup 2018-02-21 10:28:03 -08:00
JonnyWong16
681c3ed6e3 Add Google Universal Analytics 2018-02-21 10:27:28 -08:00
JonnyWong16
7f255943c6 Commit to link to the commit 2018-02-20 15:48:50 -08:00
JonnyWong16
b6e73b5dea Fix fallback thumb for home stats cards 2018-02-20 13:39:41 -08:00
JonnyWong16
eacb7f6ae5 Proper image name for poster uploads 2018-02-19 19:38:36 -08:00
JonnyWong16
7b300bb87e Add "Note" tag for Imgur and 3rd party API message 2018-02-19 19:19:27 -08:00
JonnyWong16
a81ad27d85 Add include subject line for Pushover 2018-02-19 19:10:08 -08:00
JonnyWong16
8eed14ff3b Add posters to Pushbullet notifications 2018-02-19 19:08:52 -08:00
JonnyWong16
82446acdf0 Telegram upload image in single message 2018-02-19 19:05:44 -08:00
JonnyWong16
88770b8805 Imgur upload not required for Pushover posters 2018-02-19 12:36:12 -08:00
JonnyWong16
f9f05bbea3 Add posters to Pushover notifications 2018-02-19 12:32:35 -08:00
JonnyWong16
17dd767c22 Send HipChat header 2018-02-19 11:57:21 -08:00
JonnyWong16
25b1dc6dd8 Fix refresh login logs on user page 2018-02-19 11:42:14 -08:00
JonnyWong16
b2b1277e37 Don't reload table again when switching tabs on user and library pages 2018-02-19 10:59:40 -08:00
JonnyWong16
8e1a588ced Fix conflicting history and sync delete mode on user page 2018-02-19 10:59:10 -08:00
JonnyWong16
9eddfafeae Correct poster height on the watch statistic cards 2018-02-19 10:05:19 -08:00
JonnyWong16
d24a922ccb Adjust media screen size for button bar 2018-02-19 10:00:17 -08:00
JonnyWong16
bbc6482c99 Add edit mode to sync table on user page 2018-02-19 09:11:35 -08:00
JonnyWong16
36ff1fb674 Fix button layout on mobile site 2018-02-19 08:39:27 -08:00
JonnyWong16
f0aa793262 Update wording for group history setting 2018-02-18 13:01:09 -08:00
JonnyWong16
681627a656 Fix user filtering on graphs with grouping 2018-02-18 12:58:40 -08:00
JonnyWong16
87c6ad66fb Add grouping to the remaining graphs 2018-02-18 12:50:28 -08:00
JonnyWong16
4ab9eb3bfa Fix popovers in history table modal 2018-02-18 12:15:11 -08:00
JonnyWong16
2d56ac027b Add plays graph grouping to API docs 2018-02-18 11:06:17 -08:00
JonnyWong16
836c4293d6 Respect group history setting in graphs 2018-02-18 11:04:31 -08:00
JonnyWong16
07092e8aa5 Don't reconnect server when saving settings if server settings are not changed 2018-02-18 11:03:57 -08:00
JonnyWong16
66743c1401 Add conditions bypass message for manual recently added notification trigger 2018-02-18 08:41:55 -08:00
JonnyWong16
bfe34e060b Fix KeyError from 868aeb3 2018-02-18 08:36:53 -08:00
JonnyWong16
5ed4236a22 "Commit" if only one commit behind 2018-02-18 08:33:14 -08:00
JonnyWong16
868aeb3902 Fix notification update parameter types 2018-02-18 08:29:40 -08:00
JonnyWong16
cbcdac5b04 Update message to show release instead of commits for master and beta 2018-02-18 08:28:44 -08:00
JonnyWong16
d473bb3058 Prevent dismissing the modal on the shutdown page 2018-02-17 15:05:54 -08:00
JonnyWong16
066a95d209 v2.0.19-beta 2018-02-16 22:22:51 -08:00
JonnyWong16
c7cc476623 Change "Close" to "Dismiss" in update bar 2018-02-16 15:24:48 -08:00
JonnyWong16
bd44eb7fe4 Redraw table after refresh 2018-02-16 11:22:39 -08:00
JonnyWong16
6ec4f51077 Don't delete session cache folder on startup 2018-02-16 11:18:56 -08:00
JonnyWong16
b4a4f60b04 Fix manual refreshing the libraries/users list 2018-02-16 11:17:30 -08:00
JonnyWong16
dc4e6edc9a Fix update bar dismiss if it was not shown originally 2018-02-16 11:00:01 -08:00
JonnyWong16
60b362b19e Transparent update bar 2018-02-16 10:58:00 -08:00
JonnyWong16
7e81ce8c06 Fade in/out update message 2018-02-16 10:47:59 -08:00
JonnyWong16
c7f9e2f721 Change update bar css 2018-02-16 10:31:22 -08:00
JonnyWong16
cab8b1c041 Check for updates without refreshing the page 2018-02-16 10:24:55 -08:00
JonnyWong16
16f270691d Check on watched notification states before adding to the queue 2018-02-15 15:16:50 -08:00
JonnyWong16
d94a1efe75 Add media info table refresh to API docs 2018-02-15 15:15:36 -08:00
JonnyWong16
12755970b7 Fix failure to make session cache folder on startup 2018-02-15 12:21:44 -08:00
JonnyWong16
93e4853ea2 Fix delete media info cache 2018-02-14 11:19:53 -08:00
JonnyWong16
5e0c0365fb Change button colours on setup wizard 2018-02-14 09:48:04 -08:00
JonnyWong16
c2713c53dd Only connect if first run is complete 2018-02-14 08:53:49 -08:00
JonnyWong16
90443b4028 Catch failed to retrieve Plex Cloud status 2018-02-14 08:53:27 -08:00
JonnyWong16
e0109ed179 Combine connection function for cloud and non-cloud servers 2018-02-14 08:45:45 -08:00
JonnyWong16
a53afe05a2 Check cloud status on startup before connecting websocket 2018-02-14 06:55:44 -08:00
JonnyWong16
a5d2467bfe Less log spam of cloud server status 2018-02-14 06:39:42 -08:00
JonnyWong16
8447663e27 Fix server up/down status on Tautulli startup 2018-02-14 06:35:59 -08:00
JonnyWong16
64d67d8209 Hide remote access check message 2018-02-13 22:48:28 -08:00
JonnyWong16
78034b82a9 Send Use SSL and Remote Server checkbox values when disabled 2018-02-13 22:06:09 -08:00
JonnyWong16
f77bd6c17b Move server selectize dropdown container 2018-02-13 19:30:20 -08:00
JonnyWong16
2621da7d36 Add server selection dropdown to settings 2018-02-13 19:22:11 -08:00
JonnyWong16
e1dca1509a Reconnect Plex Cloud without keeping the server awake 2018-02-13 10:49:11 -08:00
JonnyWong16
df016243dd Refactor some websocket connection code 2018-02-13 08:48:54 -08:00
JonnyWong16
be72693fec Catch WebSocketException when attempting to reconnect 2018-02-13 07:08:35 -08:00
JonnyWong16
33a1ebdb1a Show location for masked session info 2018-02-12 17:40:11 -08:00
JonnyWong16
030f9d334b Improve server selectize on setup wizard 2018-02-12 17:33:35 -08:00
JonnyWong16
dc743ac378 Fix show full changelog on fresh install 2018-02-12 17:16:43 -08:00
JonnyWong16
0010cbe21f Update masked info for guest access 2018-02-12 11:35:34 -08:00
JonnyWong16
3a5d5918de v2.0.18-beta 2018-02-12 09:44:57 -08:00
JonnyWong16
3380e39de2 Add button to delete 3rd party API lookup info 2018-02-12 09:31:44 -08:00
JonnyWong16
7d31079897 Change group history table on by default 2018-02-12 08:20:58 -08:00
JonnyWong16
c287b6df77 Fix DepreciationWarning error for URIs with query string parameters 2018-02-12 08:15:21 -08:00
JonnyWong16
dab1f8ba20 Save The Movie Database info after lookup 2018-02-11 22:03:15 -08:00
JonnyWong16
a26de7f6c2 Move repository 2018-02-11 20:23:37 -08:00
JonnyWong16
ab32b2cbc2 Compressed screenshot for readme 2018-02-11 19:15:20 -08:00
JonnyWong16
503c249fc3 Update API docs 2018-02-11 19:11:31 -08:00
JonnyWong16
2a03ce757e Add toggle for advanced settings 2018-02-11 17:55:37 -08:00
JonnyWong16
373a15524f Remove redundant settings headers 2018-02-11 17:17:08 -08:00
JonnyWong16
13036183d3 Move extra settings to other tabs 2018-02-11 16:38:21 -08:00
JonnyWong16
170591c79e Move some settings, split notifications agents back out 2018-02-11 16:11:00 -08:00
JonnyWong16
a15d225a5f Fix missing Host in login logs for Firefox 2018-02-10 12:23:05 -08:00
JonnyWong16
a0106874e2 Fix paused and resume notifications only triggering once 2018-02-08 21:19:43 -08:00
JonnyWong16
ab157d1c0e Fix default text on Tautulli update notification 2018-02-08 12:21:21 -08:00
JonnyWong16
0b95c9fe2e Add Imgur poster deletion 2018-02-07 17:59:08 -08:00
JonnyWong16
d693514ca9 Notification exclusion tags change media item to media type 2018-02-07 11:44:16 -08:00
JonnyWong16
56987b3aaa Add note to notification exclusion tags 2018-02-07 11:42:17 -08:00
JonnyWong16
3ca1bd5d78 Change custom conditions negative operators to "and" 2018-02-03 17:02:13 -08:00
JonnyWong16
5d2219f2f8 v2.0.17-beta 2018-02-03 09:35:05 -08:00
JonnyWong16
56dc28eed3 Clear session metadata cache on startup 2018-02-03 09:06:05 -08:00
JonnyWong16
3e723d4373 Fix photo album media type 2018-02-02 23:49:38 -08:00
JonnyWong16
f5e341e655 Don't sanitize tags for Slack and Discord 2018-02-02 23:22:41 -08:00
JonnyWong16
3c81100957 Fix media info table sorting 2018-02-02 23:03:48 -08:00
JonnyWong16
304378f93b Add Zapier notification agent 2018-02-01 22:11:33 -08:00
JonnyWong16
de6b6e8124 Check for any falsy value in sync item filters 2018-01-31 08:59:37 -08:00
JonnyWong16
d15223fb1a v2.0.16-beta 2018-01-30 23:20:34 -08:00
JonnyWong16
d29a12b6db Add user filter to the synced table 2018-01-30 23:07:21 -08:00
JonnyWong16
9100e25a21 Pass copy of notification data to prevent multithreading issues 2018-01-30 23:04:44 -08:00
JonnyWong16
7672f1955e Fix sync table not loading 2018-01-30 21:19:37 -08:00
JonnyWong16
5f52171fc4 Add "Use Server Setting" as Plex update channel 2018-01-30 19:56:48 -08:00
JonnyWong16
31ac82ad71 Comment out logging for writing session history to database 2018-01-30 19:06:10 -08:00
JonnyWong16
38ca4e37a6 Fix matching of synced playback 2018-01-30 19:04:30 -08:00
JonnyWong16
3c55550702 Add logging for writing session history to database 2018-01-30 10:04:28 -08:00
JonnyWong16
7dff6b121b Log force stopped message 2018-01-30 09:31:13 -08:00
JonnyWong16
d77d889695 Fix activity callback function argument 2018-01-30 09:13:06 -08:00
JonnyWong16
318a21438f Fix sometimes time showing as "0:60" 2018-01-28 20:19:49 -08:00
JonnyWong16
7175b57a28 Fix "unknown" stream resolution in graphs 2018-01-28 10:06:47 -08:00
JonnyWong16
e1e5a050c2 v2.0.15-beta 2018-01-27 11:08:45 -08:00
JonnyWong16
58996c1115 Unused now time 2018-01-27 10:59:48 -08:00
JonnyWong16
7301fe5f6e Remove 24 hour limit for recently added 2018-01-26 12:29:38 -08:00
JonnyWong16
a27c423569 Line up cards on the homepage 2018-01-24 21:37:02 -08:00
JonnyWong16
19680d3bc7 Refresh stream location on activity cards 2018-01-24 21:14:18 -08:00
JonnyWong16
ecaca4e5dc Change hover text from "View in" to "View on" 2018-01-24 21:07:12 -08:00
JonnyWong16
191de0b577 Add "View On" to Plex Web click-through 2018-01-24 21:04:34 -08:00
JonnyWong16
ebcc073b32 Add more server notification parameters. Rename plexpy parameters to tautulli. 2018-01-22 17:50:48 -08:00
JonnyWong16
043b3fd57b Update state for "Check server response" task 2018-01-22 13:44:51 -08:00
JonnyWong16
dd50502dcb Update Discord link to welcome channel 2018-01-22 11:27:04 -08:00
JonnyWong16
f159a1014d Don't add view_offset to live progress bar 2018-01-21 19:46:23 -08:00
JonnyWong16
abb801535c Add line break for Live progress 2018-01-21 16:09:48 -08:00
JonnyWong16
2732dbf1b1 Fix progress time for live tv 2018-01-21 16:07:32 -08:00
JonnyWong16
095d893005 Improve Live TV info on activity cards 2018-01-21 15:54:38 -08:00
JonnyWong16
5d8455d141 Get rating key for live sessions from websocket data 2018-01-21 13:09:02 -08:00
JonnyWong16
aa3450bfcc Add Labels and Collections to notification parameters 2018-01-20 20:01:01 -08:00
JonnyWong16
770f12b632 Hide advanced settings 2018-01-20 14:03:23 -08:00
JonnyWong16
45c2ccdffe v2.0.14-beta 2018-01-20 11:42:36 -08:00
JonnyWong16
fc14c3165f Remove email line break message 2018-01-20 11:30:11 -08:00
JonnyWong16
0fad245148 Try to cleanly shutdown loggers 2018-01-20 11:27:58 -08:00
JonnyWong16
79609c384e Show all changelogs when updated since previous version 2018-01-20 10:27:08 -08:00
JonnyWong16
09054ddb4b Correct clear logs message 2018-01-19 19:11:55 -08:00
JonnyWong16
6f912d4aa2 Add date header to Emails and do not add HTML line breaks automatically 2018-01-19 15:54:58 -08:00
JonnyWong16
96033a8214 Rename Tautulli update notification parameters 2018-01-19 14:59:00 -08:00
JonnyWong16
5ca65f4797 Catch json ValueError in metadata cache 2018-01-19 07:13:53 -08:00
JonnyWong16
d2fccbde68 Json dump custom conditions 2018-01-18 14:02:47 -08:00
JonnyWong16
e6b48d7baf Check for browser proxy compatibility 2018-01-17 21:02:31 -08:00
JonnyWong16
3e51310511 Re-enable browser notifications 2018-01-17 17:01:44 -08:00
JonnyWong16
32b43202c2 Attempt at fixing stuck sessions which require flishing the database 2018-01-15 18:55:37 -08:00
JonnyWong16
446170f8de Reduce websocket logging to playing and timeline only 2018-01-15 17:59:12 -08:00
JonnyWong16
c5a9ecd4ac Make sure websocket events are for library items 2018-01-15 14:49:49 -08:00
JonnyWong16
2af5f817a3 Plex Web url for tracks should go to the album page 2018-01-15 14:37:40 -08:00
JonnyWong16
4e55cf3cd4 Add all other bandwidth to WAN 2018-01-15 14:25:42 -08:00
JonnyWong16
eeb0478813 Use font-awesome arrow on activity cards 2018-01-14 20:45:27 -08:00
JonnyWong16
33739f1cb2 Fix check activity session write success 2018-01-13 21:16:07 -08:00
JonnyWong16
515e6a8071 Sort selectize when rendered 2018-01-13 17:47:24 -08:00
JonnyWong16
2b22f8eb4f Add select/remove all options for emails 2018-01-13 17:18:09 -08:00
JonnyWong16
e9725a0081 v2.0.13-beta 2018-01-13 12:34:57 -08:00
JonnyWong16
8fd159d2fe Use added_at to check recently added more than 24 hours ago 2018-01-12 12:34:07 -08:00
JonnyWong16
3d7e6c8b2c Update circle logo 2018-01-12 09:01:48 -08:00
JonnyWong16
0c048d61b1 Add message that Imgur upload may be required 2018-01-12 01:00:58 -08:00
JonnyWong16
f05b8e5cd1 Add error message when notification subject/body is required 2018-01-12 00:48:53 -08:00
JonnyWong16
0b38fec827 Add more options for Join notifications 2018-01-12 00:35:37 -08:00
JonnyWong16
547dc9ed33 Fix checking login permissions 2018-01-11 23:28:10 -08:00
JonnyWong16
896a37bea9 Allow Plex admin to login as Tautulli admin 2018-01-11 22:58:30 -08:00
JonnyWong16
3f90037db3 Make custom conditions message clearer 2018-01-11 22:52:52 -08:00
JonnyWong16
380ca11ced Fix some parameter types 2018-01-11 22:07:29 -08:00
JonnyWong16
ab3a288e49 Add "or" between condition values 2018-01-10 18:31:14 -08:00
JonnyWong16
638e225f80 Add selectize resources 2018-01-10 00:36:01 -08:00
JonnyWong16
5089ede207 Add selectize for email input 2018-01-10 00:32:24 -08:00
JonnyWong16
a3e6e76158 Implement JWT instead of using cherrypy sessions 2018-01-08 22:25:52 -08:00
JonnyWong16
7c4c7bfc90 Add pyjwt 1.4.0 2018-01-08 22:24:36 -08:00
93 changed files with 7105 additions and 2680 deletions

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@
*.db*
*.db-journal
*.ini
release.lock
version.lock
logs/*
cache/*

853
API.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,161 @@
# Changelog
## v2.0.22 (2018-03-10)
* Tautulli v2 release!
## v2.0.22-beta (2018-03-09)
* Notifications:
* Fix: Pushover notifications failing with priority 2 is set.
* Fix: Expanding selectize box for some notification agent settings.
* Other:
* Fix: Update check failing when an update is available.
* Fix: Item count incorrect for photo libraries.
## v2.0.21-beta (2018-03-04)
* Monitoring:
* New: Identify if a stream is using Plex Relay.
* Change: Don't ping the Plex server if the websocket is disconnected.
* Notifications:
* Fix: Pause/resume state not being sent correctly in some instances.
* Other:
* New: Add Patreon donation method.
* Fix: Catch failure to send analytics.
* Fix: IP address connection lookup error when the country is missing.
* Change: Updated all init scripts to Tautulli.
* Change: Move database to tautulli.db.
* Change: Move logs to tautulli.log.
* Change: Move startup file to Tautulli.py.
## v2.0.20-beta (2018-02-24)
* Notifications:
* New: Add poster support for Pushover notifications.
* New: Add poster support for Pushbullet notifications.
* Fix: Incorrect Plex/Tautulli update notification parameter types.
* Change: Poster and text sent as a single message for Telegram.
* Change: Posters uploaded directly to Telegram without Imgur.
* UI:
* New: Add "Delete" button to synced items table on user pages.
* Fix: Button spacing/positioning on mobile site.
* Fix: Music statistic cards not using the fallback thumbnail.
* Fix: Logo not showing up when using an SVG.
* Change: Graphs now respect the "Group History" setting.
* API:
* New: Add grouping to graph API commands.
* Other:
* New: Added Google Analytics to collect installation metrics.
* Fix: Reconnecting to the Plex server when server settings are not changed.
## v2.0.19-beta (2018-02-16)
* Monitoring:
* Fix: Connect to Plex Cloud server without keeping it awake.
* Fix: Reconnect to Plex Cloud server after the server wakes up from sleeping.
* Notifications:
* Fix: Don't send Plex Server Up/Down notifications when Tautulli starts up.
* Change: Better handling of Watched notifications.
* UI:
* New: Added Plex server selection dropdown in the settings.
* Fix: Libraries and Users tables not refreshing properly.
* Change: Updated the masked info shown to guests.
* Change: Check for updates without refreshing to the homepage.
* API:
* New: Added update_check to the API.
* Fix: delete_media_info_cache not deleting the cache.
* Change: Document "refresh" parameter for get_library_media_info.
* Other:
* Fix: Show the full changelog since v2 on a fresh install.
## v2.0.18-beta (2018-02-12)
* Notifications:
* Fix: Default text for Tautulli update notifications using the wrong parameter.
* Fix: Playback pause and resume notifications only triggering once.
* Change: Negative operators for custom conditions now use "and" instead of "or".
* UI:
* New: Added button to delete the 3rd party lookup info from the info pages.
* Fix: Missing host info in the login logs when logging in using Firefox.
* Change: Cleaned up settings. Advanced settings are now hidden behind a toggle.
* API:
* New: Updated API documentation for v2.
* Other:
* Fix: DeprecationWarning when using HTTPS with self-signed certificates.
* Change: Deleting the Imgur poster URL also deletes the poster from Imgur (only available for new uploads).
* Change: GitHub repository moved to Tautulli/Tautulli. Old GitHub URLs will still work.
## v2.0.17-beta (2018-02-03)
* Notifications:
* Fix: Unable to use @ mentions tags for Discord and Slack.
* New: Added Zapier notification agent.
* API:
* Fix: get_synced_items returning no results.
* Fix: get_library_media_info returning incorrect media type for photo albums.
* Fix: get_library_media_info not being able to sort by title.
## v2.0.16-beta (2018-01-30)
* Monitoring:
* Fix: Timestamp sometimes showing as "0:60" on the activity cards.
* Fix: Incorrect session information being shown for playback of synced content.
* Fix: Sessions not being stopped when "Playback Stopped" notifications were enabled.
* UI:
* Fix: Stream resolution showing up as "unknown" on the graphs.
* New: Added user filter to the Synced Items table.
* Other:
* New: Option to use the Plex server update channel when checking for updates.
## v2.0.15-beta (2018-01-27)
* Monitoring:
* Fix: Live TV sessions not being stopped in History.
* Fix: Stream location showing as "unknown" on the activity cards.
* New: Improved Live TV details on the activity cards.
* Notifications:
* New: Added labels and collections to notification parameters.
* New: Added more server details to notification parameters.
* Change: Renamed "PlexPy" update notification parameters to "Tautulli".
## v2.0.14-beta (2018-01-20)
* Monitoring:
* Change: Added "Cellular" bandwidth to "WAN" in activity header.
* Notifications:
* Fix: Plex Web URL for tracks now go to the album page.
* Fix: Recently added notifications being sent for the entire library when DVR EPG data was refreshed.
* Fix: Notifier settings not loading with an apostrophe in the custom condition values.
* Fix: Custom email addresses not being saved when closing the notifier settings.
* Change: Re-enabled Browser notifications.
* Change: Renamed "PlexPy" update notification parameters to "Tautulli".
* Change: Emails no longer automatically insert HTML line breaks.
* Change: "Date" header added to email notifications.
* UI:
* Change: Show all changelogs since the previous version when updating.
## v2.0.13-beta (2018-01-13)
* Notifications:
* New: Added dropdown selection for email addresses of shared users.
* New: Added more notification options for Join.
* Change: Show "OR" between custom condition values.
* Other:
* New: Use JSON Web Tokens for authentication. Login now works with SSO applications.
* New: Allow the Plex server admin to login as a Tautulli admin using their Plex.tv account.
## v2.0.12-beta (2018-01-07)
* Notifications:

View File

@@ -1,48 +1,7 @@
# Contributing to PlexPy
## Issues
In case you read this because you are posting an issue, please take a minute and conside the things below. The issue tracker is not a support forum. It is primarily intended to submit bugs. However, we are glad to help you, and make sure the problem is not caused by PlexPy, but don't expect step-by-step answers.
##### Many issues can simply be solved by:
- Making sure you update to the latest version.
- Turning your device off and on again.
- Analyzing your logs, you just might find the solution yourself!
- Using the **search** function to see if this issue has already been reported/solved.
- Checking the [Wiki](https://github.com/JonnyWong16/plexpy/wiki) for
[ [Installation] ](https://github.com/JonnyWong16/plexpy/wiki/Installation) and
[ [FAQs] ](https://github.com/JonnyWong16/plexpy/wiki/Frequently-Asked-Questions-(FAQ)).
- For basic questions try asking on [Gitter](https://gitter.im/plexpy/general) or the [Plex Forums](https://forums.plex.tv/discussion/169591/plexpy-another-plex-monitoring-program) first before opening an issue.
##### If nothing has worked:
1. Open a new issue on the GitHub [issue tracker](http://github.com/JonnyWong16/plexpy/issues).
2. Provide a clear title to easily help identify your problem.
3. Use proper [markdown syntax](https://help.github.com/articles/github-flavored-markdown) to structure your post (i.e. code/log in code blocks).
4. Make sure you provide the following information:
- [ ] Version
- [ ] Branch
- [ ] Commit hash
- [ ] Operating system
- [ ] Python version
- [ ] What you did?
- [ ] What happened?
- [ ] What you expected?
- [ ] How can we reproduce your issue?
- [ ] What are your (relevant) settings?
- [ ] Include a link to your **FULL** (not just a few lines!) log file that has the error. Please use [Gist](http://gist.github.com) or [Pastebin](http://pastebin.com/).
5. Close your issue when it's solved! If you found the solution yourself please comment so that others benefit from it.
## Feature Requests
Feature requests are handled on [FeatHub](http://feathub.com/JonnyWong16/plexpy).
1. Search the existing requests to see if your suggestion has already been submitted.
2. If a similar request exists, give it a thumbs up (+1), or add additional comments to the request.
3. If no similar requests exist, you can create a new one. Make sure to provide a clear title to easily identify the feature request.
# Contributing to Tautulli
## Pull Requests
If you think you can contribute code to the PlexPy repository, do not hesitate to submit a pull request.
If you think you can contribute code to the Tautulli repository, do not hesitate to submit a pull request.
### Branches
All pull requests should be based on the `dev` branch, to minimize cross merges. When you want to develop a new feature, clone the repository with `git clone origin/dev -b FEATURE_NAME`. Use meaningful commit messages.
@@ -50,12 +9,12 @@ All pull requests should be based on the `dev` branch, to minimize cross merges.
### Python Code
#### Compatibility
The code should work with Python 2.6 and 2.7. Note that PlexPy runs on different platforms, including Network Attached Storage devices such as Synology.
The code should work with Python 2.7. Note that Tautulli runs on different platforms, including Network Attached Storage devices such as Synology.
Re-use existing code. Do not hesitate to add logging in your code. You can the logger module `plexpy.logger.*` for this. Web requests are invoked via `plexpy.request.*` and derived ones. Use these methods to automatically add proper and meaningful error handling.
#### Code conventions
Although PlexPy did not adapt a code convention in the past, we try to follow the [PEP8](http://legacy.python.org/dev/peps/pep-0008/) conventions for future code. A short summary to remind you (copied from http://wiki.ros.org/PyStyleGuide):
Although Tautulli did not adapt a code convention in the past, we try to follow the [PEP8](http://legacy.python.org/dev/peps/pep-0008/) conventions for future code. A short summary to remind you (copied from http://wiki.ros.org/PyStyleGuide):
* 4 space indentation
* 80 characters per line
@@ -71,12 +30,12 @@ Although PlexPy did not adapt a code convention in the past, we try to follow th
Document your code. Use docstrings See [PEP-257](https://www.python.org/dev/peps/pep-0257/) for more information.
#### Continuous Integration
PlexPy has a configuration file for [travis-ci](https://travis-ci.org/). You can add your forked repo to Travis to have it check your code against PEP8, PyLint, and PyFlakes for you. Your pull request will show a green check mark or a red cross on each tested commit, depending on if linting passes.
Tautulli has a configuration file for [travis-ci](https://travis-ci.org/). You can add your forked repo to Travis to have it check your code against PEP8, PyLint, and PyFlakes for you. Your pull request will show a green check mark or a red cross on each tested commit, depending on if linting passes.
### HTML/Template code
#### Compatibility
HTML5 compatible browsers are targetted. There is no specific mobile version of PlexPy yet.
HTML5 compatible browsers are targetted. There is no specific mobile version of Tautulli yet.
#### Conventions
* 4 space indentation

View File

@@ -8,7 +8,7 @@ Reporting Issues:
Please use [Gist](http://gist.github.com) or [Pastebin](http://pastebin.com/).
Feature Requests:
* Feature requests are handled on FeatHub: http://feathub.com/JonnyWong16/plexpy
* Feature requests are handled on FeatHub: http://feathub.com/Tautulli/Tautulli
* Do not post them on the GitHub issues tracker.
-->

242
PlexPy.py
View File

@@ -21,246 +21,8 @@
# You should have received a copy of the GNU General Public License
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
import os
import sys
from Tautulli import main
# Ensure lib added to path, before any other imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib/'))
import argparse
import locale
import signal
import time
import plexpy
from plexpy import config, database, logger, web_socket, webstart
# Register signals, such as CTRL + C
signal.signal(signal.SIGINT, plexpy.sig_handler)
signal.signal(signal.SIGTERM, plexpy.sig_handler)
def main():
"""
Tautulli application entry point. Parses arguments, setups encoding and
initializes the application.
"""
# Fixed paths to Tautulli
if hasattr(sys, 'frozen'):
plexpy.FULL_PATH = os.path.abspath(sys.executable)
else:
plexpy.FULL_PATH = os.path.abspath(__file__)
plexpy.PROG_DIR = os.path.dirname(plexpy.FULL_PATH)
plexpy.ARGS = sys.argv[1:]
# From sickbeard
plexpy.SYS_PLATFORM = sys.platform
plexpy.SYS_ENCODING = None
try:
locale.setlocale(locale.LC_ALL, "")
plexpy.SYS_ENCODING = locale.getpreferredencoding()
except (locale.Error, IOError):
pass
# for OSes that are poorly configured I'll just force UTF-8
if not plexpy.SYS_ENCODING or plexpy.SYS_ENCODING in ('ANSI_X3.4-1968', 'US-ASCII', 'ASCII'):
plexpy.SYS_ENCODING = 'UTF-8'
# Set up and gather command line arguments
parser = argparse.ArgumentParser(
description='A Python based monitoring and tracking tool for Plex Media Server.')
parser.add_argument(
'-v', '--verbose', action='store_true', help='Increase console logging verbosity')
parser.add_argument(
'-q', '--quiet', action='store_true', help='Turn off console logging')
parser.add_argument(
'-d', '--daemon', action='store_true', help='Run as a daemon')
parser.add_argument(
'-p', '--port', type=int, help='Force Tautulli to run on a specified port')
parser.add_argument(
'--dev', action='store_true', help='Start Tautulli in the development environment')
parser.add_argument(
'--datadir', help='Specify a directory where to store your data files')
parser.add_argument(
'--config', help='Specify a config file to use')
parser.add_argument(
'--nolaunch', action='store_true', help='Prevent browser from launching on startup')
parser.add_argument(
'--pidfile', help='Create a pid file (only relevant when running as a daemon)')
parser.add_argument(
'--nofork', action='store_true', help='Start Tautulli as a service, do not fork when restarting')
args = parser.parse_args()
if args.verbose:
plexpy.VERBOSE = True
if args.quiet:
plexpy.QUIET = True
# Do an intial setup of the logger.
logger.initLogger(console=not plexpy.QUIET, log_dir=False,
verbose=plexpy.VERBOSE)
if args.dev:
plexpy.DEV = True
logger.debug(u"Tautulli is running in the dev environment.")
if args.daemon:
if sys.platform == 'win32':
sys.stderr.write(
"Daemonizing not supported under Windows, starting normally\n")
else:
plexpy.DAEMON = True
plexpy.QUIET = True
if args.nofork:
plexpy.NOFORK = True
logger.info("Tautulli is running as a service, it will not fork when restarted.")
if args.pidfile:
plexpy.PIDFILE = str(args.pidfile)
# If the pidfile already exists, plexpy may still be running, so
# exit
if os.path.exists(plexpy.PIDFILE):
try:
with open(plexpy.PIDFILE, 'r') as fp:
pid = int(fp.read())
os.kill(pid, 0)
except IOError as e:
raise SystemExit("Unable to read PID file: %s", e)
except OSError:
logger.warn("PID file '%s' already exists, but PID %d is " \
"not running. Ignoring PID file." %
(plexpy.PIDFILE, pid))
else:
# The pidfile exists and points to a live PID. plexpy may
# still be running, so exit.
raise SystemExit("PID file '%s' already exists. Exiting." %
plexpy.PIDFILE)
# The pidfile is only useful in daemon mode, make sure we can write the
# file properly
if plexpy.DAEMON:
plexpy.CREATEPID = True
try:
with open(plexpy.PIDFILE, 'w') as fp:
fp.write("pid\n")
except IOError as e:
raise SystemExit("Unable to write PID file: %s", e)
else:
logger.warn("Not running in daemon mode. PID file creation " \
"disabled.")
# Determine which data directory and config file to use
if args.datadir:
plexpy.DATA_DIR = args.datadir
else:
plexpy.DATA_DIR = plexpy.PROG_DIR
if args.config:
config_file = args.config
else:
config_file = os.path.join(plexpy.DATA_DIR, config.FILENAME)
# Try to create the DATA_DIR if it doesn't exist
if not os.path.exists(plexpy.DATA_DIR):
try:
os.makedirs(plexpy.DATA_DIR)
except OSError:
raise SystemExit(
'Could not create data directory: ' + plexpy.DATA_DIR + '. Exiting....')
# Make sure the DATA_DIR is writeable
if not os.access(plexpy.DATA_DIR, os.W_OK):
raise SystemExit(
'Cannot write to the data directory: ' + plexpy.DATA_DIR + '. Exiting...')
# Put the database in the DATA_DIR
plexpy.DB_FILE = os.path.join(plexpy.DATA_DIR, database.FILENAME)
if plexpy.DAEMON:
plexpy.daemonize()
# Read config and start logging
plexpy.initialize(config_file)
# Start the background threads
plexpy.start()
# Open connection for websocket
try:
web_socket.start_thread()
except:
logger.warn(u"Websocket :: Unable to open connection.")
plexpy.initialize_scheduler()
# Force the http port if neccessary
if args.port:
http_port = args.port
logger.info('Using forced web server port: %i', http_port)
else:
http_port = int(plexpy.CONFIG.HTTP_PORT)
# Check if pyOpenSSL is installed. It is required for certificate generation
# and for CherryPy.
if plexpy.CONFIG.ENABLE_HTTPS:
try:
import OpenSSL
except ImportError:
logger.warn("The pyOpenSSL module is missing. Install this " \
"module to enable HTTPS. HTTPS will be disabled.")
plexpy.CONFIG.ENABLE_HTTPS = False
# Try to start the server. Will exit here is address is already in use.
web_config = {
'http_port': http_port,
'http_host': plexpy.CONFIG.HTTP_HOST,
'http_root': plexpy.CONFIG.HTTP_ROOT,
'http_environment': plexpy.CONFIG.HTTP_ENVIRONMENT,
'http_proxy': plexpy.CONFIG.HTTP_PROXY,
'enable_https': plexpy.CONFIG.ENABLE_HTTPS,
'https_cert': plexpy.CONFIG.HTTPS_CERT,
'https_cert_chain': plexpy.CONFIG.HTTPS_CERT_CHAIN,
'https_key': plexpy.CONFIG.HTTPS_KEY,
'http_username': plexpy.CONFIG.HTTP_USERNAME,
'http_password': plexpy.CONFIG.HTTP_PASSWORD,
'http_basic_auth': plexpy.CONFIG.HTTP_BASIC_AUTH
}
webstart.initialize(web_config)
# 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)
# Wait endlessy for a signal to happen
while True:
if not plexpy.SIGNAL:
try:
time.sleep(1)
except KeyboardInterrupt:
plexpy.SIGNAL = 'shutdown'
else:
logger.info('Received signal: %s', plexpy.SIGNAL)
if plexpy.SIGNAL == 'shutdown':
plexpy.shutdown()
elif plexpy.SIGNAL == 'restart':
plexpy.shutdown(restart=True)
elif plexpy.SIGNAL == 'checkout':
plexpy.shutdown(restart=True, checkout=True)
else:
plexpy.shutdown(restart=True, update=True)
plexpy.SIGNAL = None
# Call main()
# Call main() from Tautulli.py
if __name__ == "__main__":
main()

View File

@@ -1,8 +1,8 @@
# Tautulli
[![Discord](https://img.shields.io/badge/Discord-Tautulli-7289DA.svg?style=flat-square)](https://discord.gg/36ggawe)
[![Discord](https://img.shields.io/badge/Discord-Tautulli-7289DA.svg?style=flat-square)](https://discord.gg/tQcWEUp)
[![Reddit](https://img.shields.io/badge/Reddit-Tautulli-FF5700.svg?style=flat-square)](https://www.reddit.com/r/Tautulli/)
[![Plex Forums](https://img.shields.io/badge/Plex%20Forums-Tautulli-E5A00D.svg?style=flat-square)](https://forums.plex.tv/discussion/169591/plexpy-another-plex-monitoring-program)
[![Plex Forums](https://img.shields.io/badge/Plex%20Forums-Tautulli-E5A00D.svg?style=flat-square)](https://forums.plex.tv/discussion/307821/tautulli-monitor-your-plex-media-server)
A python based web application for monitoring, analytics and notifications for [Plex Media Server](https://plex.tv).
@@ -27,56 +27,19 @@ This project is based on code from [Headphones](https://github.com/rembo10/headp
## Preview
* [Full preview gallery on our website](http://tautulli.com)
* [Full preview gallery available on our website](http://tautulli.com)
![Tautulli Homepage](http://tautulli.com/images/screenshots/activity.png?v=2)
![Tautulli Homepage](http://tautulli.com/images/screenshots/activity-compressed.jpg?v=2)
## Installation and Support
* [Installation Guides](https://github.com/JonnyWong16/plexpy/wiki/Installation) shows you how to install Tautulli.
* [FAQs](https://github.com/JonnyWong16/plexpy/wiki/Frequently-Asked-Questions-(FAQ)) in the wiki can help you with common problems.
* Read the [Installation Guides](https://github.com/Tautulli/Tautulli-Wiki/wiki/Installation) for instructions to install Tautulli.
* The [Frequently Asked Questions](https://github.com/Tautulli/Tautulli-Wiki/wiki/Frequently-Asked-Questions) in the wiki can help you with common problems.
* Support is available on [Discord](https://discord.gg/tQcWEUp), [Reddit](https://www.reddit.com/r/Tautulli), or the [Plex Forums](https://forums.plex.tv/discussion/307821/tautulli-monitor-your-plex-media-server).
**Support** the project by implementing new features, solving support tickets and provide bug fixes.
## Issues & Feature Requests
## Issues
##### Many issues can simply be solved by:
- Making sure you update to the latest version.
- Turning your device off and on again.
- Analyzing your logs, you just might find the solution yourself!
- Using the **search** function to see if this issue has already been reported/solved.
- Checking the [Wiki](https://github.com/JonnyWong16/plexpy/wiki) for
[ [Installation] ](https://github.com/JonnyWong16/plexpy/wiki/Installation) and
[ [FAQs] ](https://github.com/JonnyWong16/plexpy/wiki/Frequently-Asked-Questions-(FAQ)).
- For basic questions try asking on [Discord](https://discord.gg/36ggawe), [Reddit](https://www.reddit.com/r/Tautulli), or the [Plex Forums](https://forums.plex.tv/discussion/169591/plexpy-another-plex-monitoring-program) first before opening an issue.
##### If nothing has worked:
1. Open a new issue on the GitHub [issue tracker](http://github.com/JonnyWong16/plexpy/issues).
2. Provide a clear title to easily help identify your problem.
3. Use proper [markdown syntax](https://help.github.com/articles/github-flavored-markdown) to structure your post (i.e. code/log in code blocks).
4. Make sure you provide the following information:
- [ ] Version
- [ ] Branch
- [ ] Commit hash
- [ ] Operating system
- [ ] Python version
- [ ] What you did?
- [ ] What happened?
- [ ] What you expected?
- [ ] How can we reproduce your issue?
- [ ] What are your (relevant) settings?
- [ ] Include a link to your **FULL** (not just a few lines!) log file that has the error. Please use [Gist](http://gist.github.com) or [Pastebin](http://pastebin.com/).
5. Close your issue when it's solved! If you found the solution yourself please comment so that others benefit from it.
## Feature Requests
Feature requests are handled on [FeatHub](http://feathub.com/JonnyWong16/plexpy).
1. Search the existing requests to see if your suggestion has already been submitted.
2. If a similar request exists, give it a thumbs up (+1), or add additional comments to the request.
3. If no similar requests exist, you can create a new one. Make sure to provide a clear title to easily identify the feature request.
* Please see the [Issues Repository](https://github.com/Tautulli/Tautulli-Issues).
## License

264
Tautulli.py Executable file
View File

@@ -0,0 +1,264 @@
#!/bin/sh
''''which python >/dev/null 2>&1 && exec python "$0" "$@" # '''
''''which python2 >/dev/null 2>&1 && exec python2 "$0" "$@" # '''
''''which python2.7 >/dev/null 2>&1 && exec python2.7 "$0" "$@" # '''
''''exec echo "Error: Python not found!" # '''
# -*- coding: utf-8 -*-
# This file is part of Tautulli.
#
# Tautulli is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Tautulli is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
import os
import sys
# Ensure lib added to path, before any other imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib/'))
import argparse
import locale
import signal
import time
import plexpy
from plexpy import config, database, logger, webstart
# Register signals, such as CTRL + C
signal.signal(signal.SIGINT, plexpy.sig_handler)
signal.signal(signal.SIGTERM, plexpy.sig_handler)
def main():
"""
Tautulli application entry point. Parses arguments, setups encoding and
initializes the application.
"""
# Fixed paths to Tautulli
if hasattr(sys, 'frozen'):
plexpy.FULL_PATH = os.path.abspath(sys.executable)
else:
plexpy.FULL_PATH = os.path.abspath(__file__)
plexpy.PROG_DIR = os.path.dirname(plexpy.FULL_PATH)
plexpy.ARGS = sys.argv[1:]
# From sickbeard
plexpy.SYS_PLATFORM = sys.platform
plexpy.SYS_ENCODING = None
try:
locale.setlocale(locale.LC_ALL, "")
plexpy.SYS_LANGUAGE, plexpy.SYS_ENCODING = locale.getdefaultlocale()
except (locale.Error, IOError):
pass
# for OSes that are poorly configured I'll just force UTF-8
if not plexpy.SYS_ENCODING or plexpy.SYS_ENCODING in ('ANSI_X3.4-1968', 'US-ASCII', 'ASCII'):
plexpy.SYS_ENCODING = 'UTF-8'
# Set up and gather command line arguments
parser = argparse.ArgumentParser(
description='A Python based monitoring and tracking tool for Plex Media Server.')
parser.add_argument(
'-v', '--verbose', action='store_true', help='Increase console logging verbosity')
parser.add_argument(
'-q', '--quiet', action='store_true', help='Turn off console logging')
parser.add_argument(
'-d', '--daemon', action='store_true', help='Run as a daemon')
parser.add_argument(
'-p', '--port', type=int, help='Force Tautulli to run on a specified port')
parser.add_argument(
'--dev', action='store_true', help='Start Tautulli in the development environment')
parser.add_argument(
'--datadir', help='Specify a directory where to store your data files')
parser.add_argument(
'--config', help='Specify a config file to use')
parser.add_argument(
'--nolaunch', action='store_true', help='Prevent browser from launching on startup')
parser.add_argument(
'--pidfile', help='Create a pid file (only relevant when running as a daemon)')
parser.add_argument(
'--nofork', action='store_true', help='Start Tautulli as a service, do not fork when restarting')
args = parser.parse_args()
if args.verbose:
plexpy.VERBOSE = True
if args.quiet:
plexpy.QUIET = True
# Do an intial setup of the logger.
logger.initLogger(console=not plexpy.QUIET, log_dir=False,
verbose=plexpy.VERBOSE)
if args.dev:
plexpy.DEV = True
logger.debug(u"Tautulli is running in the dev environment.")
if args.daemon:
if sys.platform == 'win32':
sys.stderr.write(
"Daemonizing not supported under Windows, starting normally\n")
else:
plexpy.DAEMON = True
plexpy.QUIET = True
if args.nofork:
plexpy.NOFORK = True
logger.info("Tautulli is running as a service, it will not fork when restarted.")
if args.pidfile:
plexpy.PIDFILE = str(args.pidfile)
# If the pidfile already exists, plexpy may still be running, so
# exit
if os.path.exists(plexpy.PIDFILE):
try:
with open(plexpy.PIDFILE, 'r') as fp:
pid = int(fp.read())
os.kill(pid, 0)
except IOError as e:
raise SystemExit("Unable to read PID file: %s", e)
except OSError:
logger.warn("PID file '%s' already exists, but PID %d is " \
"not running. Ignoring PID file." %
(plexpy.PIDFILE, pid))
else:
# The pidfile exists and points to a live PID. plexpy may
# still be running, so exit.
raise SystemExit("PID file '%s' already exists. Exiting." %
plexpy.PIDFILE)
# The pidfile is only useful in daemon mode, make sure we can write the
# file properly
if plexpy.DAEMON:
plexpy.CREATEPID = True
try:
with open(plexpy.PIDFILE, 'w') as fp:
fp.write("pid\n")
except IOError as e:
raise SystemExit("Unable to write PID file: %s", e)
else:
logger.warn("Not running in daemon mode. PID file creation " \
"disabled.")
# Determine which data directory and config file to use
if args.datadir:
plexpy.DATA_DIR = args.datadir
else:
plexpy.DATA_DIR = plexpy.PROG_DIR
if args.config:
config_file = args.config
else:
config_file = os.path.join(plexpy.DATA_DIR, config.FILENAME)
# Try to create the DATA_DIR if it doesn't exist
if not os.path.exists(plexpy.DATA_DIR):
try:
os.makedirs(plexpy.DATA_DIR)
except OSError:
raise SystemExit(
'Could not create data directory: ' + plexpy.DATA_DIR + '. Exiting....')
# Make sure the DATA_DIR is writeable
if not os.access(plexpy.DATA_DIR, os.W_OK):
raise SystemExit(
'Cannot write to the data directory: ' + plexpy.DATA_DIR + '. Exiting...')
# Put the database in the DATA_DIR
plexpy.DB_FILE = os.path.join(plexpy.DATA_DIR, database.FILENAME)
# Move 'plexpy.db' to 'tautulli.db'
if os.path.isfile(os.path.join(plexpy.DATA_DIR, 'plexpy.db')) and \
not os.path.isfile(os.path.join(plexpy.DATA_DIR, plexpy.DB_FILE)):
os.rename(os.path.join(plexpy.DATA_DIR, 'plexpy.db'), plexpy.DB_FILE)
if plexpy.DAEMON:
plexpy.daemonize()
# Read config and start logging
plexpy.initialize(config_file)
# Start the background threads
plexpy.start()
# Force the http port if neccessary
if args.port:
http_port = args.port
logger.info('Using forced web server port: %i', http_port)
else:
http_port = int(plexpy.CONFIG.HTTP_PORT)
# Check if pyOpenSSL is installed. It is required for certificate generation
# and for CherryPy.
if plexpy.CONFIG.ENABLE_HTTPS:
try:
import OpenSSL
except ImportError:
logger.warn("The pyOpenSSL module is missing. Install this " \
"module to enable HTTPS. HTTPS will be disabled.")
plexpy.CONFIG.ENABLE_HTTPS = False
# Try to start the server. Will exit here is address is already in use.
web_config = {
'http_port': http_port,
'http_host': plexpy.CONFIG.HTTP_HOST,
'http_root': plexpy.CONFIG.HTTP_ROOT,
'http_environment': plexpy.CONFIG.HTTP_ENVIRONMENT,
'http_proxy': plexpy.CONFIG.HTTP_PROXY,
'enable_https': plexpy.CONFIG.ENABLE_HTTPS,
'https_cert': plexpy.CONFIG.HTTPS_CERT,
'https_cert_chain': plexpy.CONFIG.HTTPS_CERT_CHAIN,
'https_key': plexpy.CONFIG.HTTPS_KEY,
'http_username': plexpy.CONFIG.HTTP_USERNAME,
'http_password': plexpy.CONFIG.HTTP_PASSWORD,
'http_basic_auth': plexpy.CONFIG.HTTP_BASIC_AUTH
}
webstart.initialize(web_config)
# 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)
# Wait endlessy for a signal to happen
while True:
if not plexpy.SIGNAL:
try:
time.sleep(1)
except KeyboardInterrupt:
plexpy.SIGNAL = 'shutdown'
else:
logger.info('Received signal: %s', plexpy.SIGNAL)
if plexpy.SIGNAL == 'shutdown':
plexpy.shutdown()
elif plexpy.SIGNAL == 'restart':
plexpy.shutdown(restart=True)
elif plexpy.SIGNAL == 'checkout':
plexpy.shutdown(restart=True, checkout=True)
else:
plexpy.shutdown(restart=True, update=True)
plexpy.SIGNAL = None
# Call main()
if __name__ == "__main__":
main()

View File

@@ -2,6 +2,7 @@
import plexpy
from plexpy import version
from plexpy.helpers import anon_url
from plexpy.notifiers import BROWSER_NOTIFIERS
%>
<!doctype html>
@@ -14,7 +15,7 @@
<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/plexpy.css${cache_param}" 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.min.css" rel="stylesheet">
${next.headIncludes()}
@@ -43,16 +44,24 @@
% if _session['user_group'] == 'admin':
% if plexpy.CONFIG.CHECK_GITHUB and not plexpy.CURRENT_VERSION:
<div id="updatebar" style="display: none;">
You're running an unknown version of Tautulli.<br />
<a href="update">Update</a> or <a href="#" id="updateDismiss">Close</a>
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.CURRENT_VERSION != plexpy.LATEST_VERSION and plexpy.COMMITS_BEHIND > 0 and plexpy.INSTALL_TYPE != 'win':
% elif plexpy.CONFIG.CHECK_GITHUB and plexpy.COMMITS_BEHIND > 0 and plexpy.common.BRANCH in ('master', 'beta') and plexpy.common.RELEASE != plexpy.LATEST_RELEASE:
<div id="updatebar" style="display: none;">
A <a href="${anon_url('https://github.com/%s/plexpy/compare/%s...%s' % (plexpy.CONFIG.GIT_USER, plexpy.CURRENT_VERSION, plexpy.LATEST_VERSION))}" target="_blank">
newer version</a> is available.<br />
You're ${plexpy.COMMITS_BEHIND} commits behind.<br />
<a href="update">Update</a> or <a href="#" id="updateDismiss">Close</a>
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':
<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 />
You are ${plexpy.COMMITS_BEHIND} commit${'s' if plexpy.COMMITS_BEHIND > 1 else ''} behind.<br />
<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>
</div>
% else:
<div id="updatebar" style="display: none;"></div>
% endif
% endif
<nav class="navbar navbar-fixed-top">
@@ -65,7 +74,7 @@
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="home" title="Tautulli">
<object data="${http_root}images/logo-tautulli.svg" type="image/svg+xml" style="height: 45px;"></object>
<img src="${http_root}images/logo-tautulli-45.png" height="45" alt="PlexPy">
</a>
</div>
<div class="collapse navbar-collapse navbar-right" id="navbar-collapse-1">
@@ -124,7 +133,7 @@
<li><a href="settings"><i class="fa fa-fw fa-cogs"></i> Settings</a></li>
<li role="separator" class="divider"></li>
<li><a href="logs"><i class="fa fa-fw fa-list-alt"></i> View Logs</a></li>
<li><a href="${anon_url('https://github.com/%s/plexpy/wiki/Frequently-Asked-Questions-(FAQ)' % plexpy.CONFIG.GIT_USER)}" target="_blank"><i class="fa fa-fw fa-question-circle"></i> FAQ</a></li>
<li><a href="${anon_url('https://github.com/%s/%s-Wiki/wiki/Frequently-Asked-Questions' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank"><i class="fa fa-fw fa-question-circle"></i> FAQ</a></li>
<li><a href="settings?support=true"><i class="fa fa-fw fa-comment"></i> Support</a></li>
<li role="separator" class="divider"></li>
<li><a href="#" data-target="#donate-modal" data-toggle="modal"><i class="fa fa-fw fa-heart"></i> Donate</a></li>
@@ -138,7 +147,7 @@
<li><a href="#" data-target="#admin-login-modal" data-toggle="modal"><i class="fa fa-fw fa-lock"></i> Admin Login</a></li>
<li role="separator" class="divider"></li>
% endif
% if _session['expiry']:
% if _session['exp']:
<li><a href="${http_root}auth/logout"><i class="fa fa-fw fa-sign-out"></i> Sign Out</a></li>
% endif
</ul>
@@ -161,7 +170,7 @@ ${next.modalIncludes()}
<div id="admin-login-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="admin-login-modal">
<div class="modal-dialog" role="document">
<div class="modal-content">
<form action="${http_root}auth/login" method="post">
<form id="login-form">
<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">Admin Login</h4>
@@ -190,7 +199,8 @@ ${next.modalIncludes()}
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Sign In</button>
<span id="incorrect-login" style="padding-right: 25px; display: none;">Incorrect username or password.</span>
<button id="sign-in" type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Sign In</button>
</div>
<input type="hidden" id="admin_login" name="admin_login" value="1" />
</form>
@@ -217,15 +227,23 @@ ${next.modalIncludes()}
</div>
</div>
<ul id="donation_type" class="nav nav-pills" role="tablist" style="display: flex; justify-content: center; margin: 10px 0;">
<li class="active"><a href="#paypal-donation" role="tab" data-toggle="tab">PayPal</a></li>
<li><a href="#flattr-donation" role="tab" data-toggle="tab">Flattr</a></li>
<li class="active"><a href="#patreon-donation" role="tab" data-toggle="tab">Patreon</a></li>
<li><a href="#paypal-donation" role="tab" data-toggle="tab">PayPal</a></li>
<li><a href="#crypto-donation" role="tab" data-toggle="tab" class="crypto-donation" data-coin="bitcoin" data-name="Bitcoin" data-address="3FdfJAyNWU15Sf11U9FTgPHuP1hPz32eEN">Bitcoin</a></li>
<li><a href="#crypto-donation" role="tab" data-toggle="tab" class="crypto-donation" data-coin="bitcoincash" data-name="Bitcoin Cash" data-address="1H2atabxAQGaFAWYQEiLkXKSnK9CZZvt2n">Bitcoin Cash</a></li>
<li><a href="#crypto-donation" role="tab" data-toggle="tab" class="crypto-donation" data-coin="ethereum" data-name="Ethereum" data-address="0x77ae4c2b8de1a1ccfa93553db39971da58c873d3">Ethereum</a></li>
<li><a href="#crypto-donation" role="tab" data-toggle="tab" class="crypto-donation" data-coin="litecoin" data-name="Litecoin" data-address="LWpPmUqQYHBhMV83XSCsHzPmKLhJt6r57J">Litecoin</a></li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="paypal-donation" style="text-align: center">
<div role="tabpanel" class="tab-pane active" id="patreon-donation" style="text-align: center">
<p>
Click the button below to continue to Patreon.
</p>
<a href="${anon_url('https://www.patreon.com/bePatron?u=10078609')}" target="_blank">
<img src="images/become_a_patron_button.png" alt="Become a Patron" height="40">
</a>
</div>
<div role="tabpanel" class="tab-pane" id="paypal-donation" style="text-align: center">
<p>
Click the button below to continue to PayPal.
</p>
@@ -233,14 +251,6 @@ ${next.modalIncludes()}
<img src="images/gold-rect-paypal-34px.png" alt="PayPal">
</a>
</div>
<div role="tabpanel" class="tab-pane" id="flattr-donation" style="text-align: center">
<p>
Click the button below to continue to Flattr.
</p>
<a href="${anon_url('https://flattr.com/submit/auto?user_id=JonnyWong16&url=https://github.com/JonnyWong16/plexpy&title=Tautulli&language=en_GB&tags=github&category=software')}" target="_blank">
<img src="images/flattr-badge-large.png" alt="Flattr">
</a>
</div>
<div role="tabpanel" class="tab-pane" id="crypto-donation">
<label>QR Code</label>
<pre id="crypto_qr_code" style="text-align: center"></pre>
@@ -282,16 +292,53 @@ ${next.modalIncludes()}
<script src="${http_root}js/pnotify.custom.min.js"></script>
<script src="${http_root}js/script.js${cache_param}"></script>
<script src="${http_root}js/jquery.qrcode.min.js"></script>
% if _session['user_group'] == 'admin' and BROWSER_NOTIFIERS:
<script src="${http_root}js/ajaxNotifications.js"></script>
% endif
<script>
% if _session['user_group'] == 'admin':
$('#updateDismiss').click(function() {
$('#updatebar').slideUp('slow');
$('body').on('click', '#updateDismiss', function() {
$('#updatebar').fadeOut();
// Set cookie to remember dismiss decision for 1 hour.
setCookie('updateDismiss', 'true', 1/24);
});
if (!getCookie('updateDismiss')) {
$('#updatebar').show();
if ($('#updatebar').html().length > 0) {
$('#updatebar').show();
}
}
function checkUpdate(_callback) {
// Allow the update bar to show again if previously dismissed.
setCookie('updateDismiss', 'true', 0);
$.ajax({
url: 'update_check',
complete: function (xhr, status) {
var result = $.parseJSON(xhr.responseText);
var msg = '';
if (result.update === null) {
msg = 'You are running an unknown version of Tautulli.<br />' +
'<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>';
$('#updatebar').html(msg).fadeIn();
} else if (result.update === true && result.release === true) {
msg = 'A <a href="' + result.release_url + '" target="_blank">new release (' + result.latest_release + ')</a> of Tautulli is available!<br />' +
'<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>';
$('#updatebar').html(msg).fadeIn();
} else if (result.update === true && result.release === false) {
msg = 'A <a href="' + result.compare_url + '" target="_blank">newer version</a> of Tautulli is available!<br />' +
'You are '+ result.commits_behind + ' commit' + (result.commits_behind > 1 ? 's' : '') + ' behind.<br />' +
'<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>';
$('#updatebar').html(msg).fadeIn();
} else if (result.update === false) {
showMsg('<i class="fa fa-check"></i> ' + result.message, false, true, 2000);
}
if (_callback) {
_callback();
}
}
});
}
$("#nav-shutdown").click(function() {
@@ -310,11 +357,9 @@ ${next.modalIncludes()}
});
});
$("#nav-update").first().one("click", function () {
// Allow the update bar to show again if previously dismissed.
setCookie('updateDismiss', 'true', 0);
$(this).html('<i class="fa fa-spin fa-refresh"></i> Checking');
window.location.href = "checkGithub";
$('#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'); });
});
$('#donation_type a.crypto-donation').on('shown.bs.tab', function () {
@@ -386,6 +431,29 @@ ${next.modalIncludes()}
$('#admin-login-modal').on('shown.bs.modal', function () {
$('#admin-login-modal #username').focus()
})
$('#login-form').submit(function(event) {
event.preventDefault();
$('#sign-in').prop('disabled', true).html('<i class="fa fa-refresh fa-spin"></i>&nbsp; Sign In');
$.ajax({
url: '${http_root}auth/signin',
type: 'POST',
data: $(this).serialize(),
dataType: 'json',
statusCode: {
200: function() {
window.location = "${http_root}";
},
401: function() {
$('#incorrect-login').show();
$('#username').focus();
}
},
complete: function() {
$('#sign-in').prop('disabled', false).html('<i class="fa fa-sign-in"></i>&nbsp; Sign In');
}
});
});
% endif
</script>
${next.javascriptIncludes()}

View File

@@ -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/plexpy/tree/%s' % (plexpy.CONFIG.GIT_USER, 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))}">${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/plexpy/commit/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_BRANCH))}">${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))}">${plexpy.CURRENT_VERSION}</a></td>
</tr>
% endif
<tr>
@@ -75,18 +75,18 @@ DOCUMENTATION :: END
<td class="top-line">Resources:</td>
<td class="top-line">
<a class="no-highlight" href="${anon_url('http://tautulli.com')}" target="_blank">Tautulli Website</a> |
<a class="no-highlight" href="${anon_url('https://github.com/%s/plexpy' % plexpy.CONFIG.GIT_USER)}" target="_blank">GitHub Source</a> |
<a class="no-highlight guidelines-modal-link" href="${anon_url('https://github.com/%s/plexpy/issues' % plexpy.CONFIG.GIT_USER)}" data-id="issue">GitHub Issues</a> |
<a class="no-highlight" href="${anon_url('https://github.com/%s/plexpy/wiki' % plexpy.CONFIG.GIT_USER)}" target="_blank">GitHub Wiki &amp; FAQ</a> |
<a class="no-highlight guidelines-modal-link" href="${anon_url('http://feathub.com/%s/plexpy' % plexpy.CONFIG.GIT_USER)}" data-id="feature request">FeatHub Feature Requests</a> |
<a class="no-highlight" href="${anon_url('https://github.com/%s/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank">GitHub Source</a> |
<a class="no-highlight guidelines-modal-link" href="${anon_url('https://github.com/%s/%s-Issues' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" data-id="issue">GitHub Issues</a> |
<a class="no-highlight" href="${anon_url('https://github.com/%s/%s-Wiki' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank">GitHub Wiki</a> |
<a class="no-highlight guidelines-modal-link" href="${anon_url('http://feathub.com/%s/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" data-id="feature request">FeatHub Feature Requests</a>
</td>
</tr>
<tr>
<td>Support:</td>
<td>
<a class="no-highlight support-modal-link" href="${anon_url('https://discord.gg/36ggawe')}" target="_blank">Tautulli Discord Server</a> |
<a class="no-highlight support-modal-link" href="${anon_url('https://discord.gg/tQcWEUp')}" target="_blank">Tautulli Discord Server</a> |
<a class="no-highlight support-modal-link" href="${anon_url('https://www.reddit.com/r/Tautulli')}" target="_blank">Tautulli Subreddit</a> |
<a class="no-highlight support-modal-link" href="${anon_url('https://forums.plex.tv/discussion/169591/plexpy-another-plex-monitoring-program')}" target="_blank">Plex Forums</a>
<a class="no-highlight support-modal-link" href="${anon_url('https://forums.plex.tv/discussion/307821/tautulli-monitor-your-plex-media-server')}" target="_blank">Plex Forums</a>
</td>
</tr>
</tbody>

View File

@@ -359,11 +359,13 @@ table.display tr.shown + tr:hover {
}
table.display tr.shown + tr:hover a,
table.display tr.shown + tr td:hover a,
table.display tr.shown + tr td:hover a .fa,
table.display tr.shown + tr .pagination > .active > a,
table.display tr.shown + tr .pagination > .active > a:hover {
color: #fff;
}
table.display tr.shown + tr table[id^='history_child'] td:hover a,
table.display tr.shown + tr table[id^='history_child'] td:hover a .fa,
table.display tr.shown + tr table[id^='media_info_child'] > tr > td:hover a,
table.display tr.shown + tr table[id^='media_info_child'] tr.shown + tr table[id^='media_info_child'] td:hover a {
color: #cc7b19;

View File

@@ -60,12 +60,12 @@ select[multiple] option {
-moz-border-radius: 2px;
border-radius: 2px;
}
select.form-control {
select.form-control,
div.form-control .selectize-input {
margin: 5px 0 5px 0;
color: #fff;
border: 0px solid #444;
background: #555;
height: 32px;
padding: 6px 12px;
background-color: #555;
border-radius: 3px;
@@ -79,6 +79,39 @@ select.form-control {
padding: 1px 2px;
transition: background-color .3s;
}
.selectize-control.form-control .selectize-input {
display: flex !important;
align-items: center;
flex-wrap: wrap;
margin-bottom: 4px;
padding-left: 5px;
}
.selectize-control.form-control.selectize-pms-ip .selectize-input {
padding-left: 12px !important;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
min-height: 32px !important;
height: 32px !important;
}
.input-group .selectize-control.form-control.selectize-pms-ip .selectize-input > div {
max-width: 450px;
overflow: hidden;
text-overflow: ellipsis;
}
.wizard-input-section .selectize-control.form-control.selectize-pms-ip .selectize-input > div {
max-width: 360px;
overflow: hidden;
text-overflow: ellipsis;
}
#selectize-pms-ip-container .selectize-dropdown.form-control.selectize-pms-ip {
margin-left: 15px;
}
.wizard-input-section .selectize-control.form-control.selectize-pms-ip .selectize-dropdown .selectize-dropdown-content {
max-height: 150px;
}
.wizard-input-section .selectize-dropdown.form-control.selectize-pms-ip {
margin-top: 0 !important;
}
.react-selectize.root-node .react-selectize-control .react-selectize-placeholder {
color: #fff !important;
}
@@ -95,7 +128,16 @@ select.form-control {
.react-selectize.root-node .simple-value span {
padding-bottom: 2px !important;
}
.react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .resizable-input{
.react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .value-wrapper:not(:first-child):before {
content: "or";
padding: 0 3px;
text-transform: uppercase;
font-size: 10px;
}
.react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values.negative-operator .value-wrapper:not(:first-child):before {
content: "and" !important;
}
.react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .resizable-input {
padding-top: 3px !important;
padding-bottom: 3px !important;
}
@@ -110,7 +152,7 @@ select.form-control:focus,
}
.react-selectize.root-node.open .simple-value,
.selectize-control.multi .selectize-input.focus > div,
.selectize-control.multi .selectize-input > div.active{
.selectize-control.multi .selectize-input > div.active {
background: #efefef !important;
color: #333333 !important;
transition: background-color .3s;
@@ -118,6 +160,54 @@ select.form-control:focus,
.react-selectize.root-node.open .react-selectize-control .react-selectize-toggle-button path {
fill: #999 !important;
}
.selectize-input > div .item-text {
white-space: nowrap;
}
.selectize-input > div .item-value {
opacity: 0.8;
font-size: 12px;
white-space: nowrap;
}
.selectize-input > div .item-text + .item-value {
margin-left: 5px;
}
.selectize-input > div .item-value:before {
content: '<';
opacity: 0.8;
font-size: 12px;
}
.selectize-input > div .item-value:after {
content: '>';
opacity: 0.8;
font-size: 12px;
}
.selectize-dropdown .caption {
font-size: 12px;
display: block;
color: #a0a0a0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.selectize-dropdown .select-all,
.selectize-dropdown .remove-all {
font-weight: bold;
}
.selectize-dropdown .border-all {
pointer-events: none;
display: block;
height: 1px;
margin: 9px -12px 9px -12px;
padding: 0 !important;
overflow: hidden;
background-color: #e5e5e5;
}
.selectize-dropdown .border-all:last-child {
display: none;
}
.selectize-dropdown .optgroup-header {
font-weight: bold;
}
select.form-control option {
color: #555;
background-color: #fff;
@@ -166,7 +256,7 @@ object {
}
.nav .open > a, .nav .open > a:hover, .nav .open > a:focus {
background-color: #2f2f2f;
border-color: none;
border-color: unset;
}
.dropdown-menu {
background-color: #282828;
@@ -562,18 +652,8 @@ textarea.form-control:focus {
color: #fff;
}
.form-control-feedback {
position: absolute;
color: #F9AA03;
top: 0;
right: 0;
margin: 5px 10px 5px 0;
z-index: 2;
display: block;
width: 32px;
height: 32px;
line-height: 32px;
text-align: center;
pointer-events: none;
margin: 5px 40px 5px 0;
}
.form-control[readonly] {
background-color: #555;
@@ -652,8 +732,8 @@ a .users-poster-face:hover {
height: 290px;
min-width: 350px;
max-width: 500px;
margin-right: 20px;
margin-bottom: 20px;
margin-right: 25px;
margin-bottom: 25px;
}
.dashboard-activity-container {
height: 240px;
@@ -934,7 +1014,6 @@ a .users-poster-face:hover {
background-image: -o-linear-gradient(top, #fbb450, #f89406);
background-image: linear-gradient(to bottom, #fbb450, #f89406);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffbb450', endColorstr='#fff89406', GradientType=0);
position: absolute;
height: 100%;
max-width: 100%;
@@ -1071,8 +1150,8 @@ a .dashboard-activity-metadata-user-thumb:hover {
height: 160px;
min-width: 350px;
max-width: 500px;
margin-right: 20px;
margin-bottom: 20px;
margin-right: 25px;
margin-bottom: 25px;
}
.dashboard-stats-container {
height: 160px;
@@ -1340,7 +1419,7 @@ a .dashboard-activity-metadata-user-thumb:hover {
}
.dashboard-stats-info-item .sub-count {
height: 100%;
margin-left: 10px;
margin-left: 5px;
color: #f9be03;
font-size: 12px;
text-align: right;
@@ -1351,7 +1430,7 @@ a .dashboard-activity-metadata-user-thumb:hover {
}
.dashboard-stats-info-item .sub-divider {
height: 100%;
margin-left: 10px;
margin-left: 5px;
color: #aaa;
font-size: 12px;
text-align: left;
@@ -1689,7 +1768,6 @@ a:hover .dashboard-recent-media-cover {
background-image: -moz-linear-gradient(top,rgba(0,0,0,.7) 0,rgba(0,0,0,.9) 100%);
background-image: linear-gradient(to bottom,rgba(0,0,0,.7) 0,rgba(0,0,0,.9) 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#b3000000', endColorstr='#e6000000', GradientType=0);
webkit-box-shadow: inset 0 0 0 2px #e9a049;
-moz-box-shadow: inset 0 0 0 2px #e9a049;
box-shadow: inset 0 0 0 2px #e9a049;
@@ -1707,6 +1785,18 @@ a:hover .dashboard-recent-media-cover {
opacity: 0;
transition: opacity .3s;
}
.summary-poster-face-overlay span:before {
content: "View On";
color: #999;
font-size: 13px;
font-weight: bold;
text-transform: uppercase;
text-align: center;
display: block;
position: absolute;
top: calc(50% - 34px);
width: 100%;
}
a:hover .summary-poster-face .summary-poster-face-overlay,
a:hover .summary-poster-face-episode .summary-poster-face-overlay,
a:hover .summary-poster-face-track .summary-poster-face-overlay,
@@ -2070,6 +2160,20 @@ a:hover .item-children-poster {
top: 5px;
left: 12px;
}
#menu_link_show_advanced_settings.active {
color: #fff;
background-color: #cc7b19;
}
.advanced-setting {
display: none;
}
div.advanced-setting {
border-left: 1px solid #cc7b19;
padding-left: 10px;
}
li.advanced-setting {
border-left: 1px solid #cc7b19;
}
.user-info-wrapper {
}
.user-info-poster-face {
@@ -2268,21 +2372,6 @@ a .library-user-instance-box:hover {
#watched-stats-days-selection label {
margin-bottom: 0;
}
#watched-stats-days {
margin: 0;
width: 75px;
height: 34px;
}
#watched-stats-count {
margin: 0;
width: 75px;
height: 34px;
}
#recently-added-count {
margin: 0;
width: 75px;
height: 34px;
}
.home-padded-header {
margin: 25px 0;
height: 34px;
@@ -2291,6 +2380,9 @@ a .library-user-instance-box:hover {
margin-top: 9px;
width: 175px;
}
.home-padded-header .button-bar {
float: left;
}
.home-platforms {
}
.home-platforms ul,
@@ -3042,7 +3134,7 @@ div.dataTables_info {
border-radius: 2px;
}
.history-thumbnail-popover {
z-index: 2;
z-index: 2000;
padding: 0;
border: 0;
}
@@ -3131,16 +3223,16 @@ div.dataTables_info {
}
#updatebar {
background-color: #444;
opacity: 0.95;
color: #999999;
display: none;
font-size: 14px;
right: 10px;
padding: 7px 10px;
padding: 10px 10px;
position: fixed;
text-align: center;
bottom: 10px;
min-height: 22px;
width: 250px;
width: 400px;
z-index: 9999;
display: block;
}
@@ -3212,6 +3304,48 @@ pre::-webkit-scrollbar-thumb {
width: 100%;
}
}
@media only screen
and (min-device-width: 300px)
and (max-device-width: 740px) {
.header-bar {
display: block;
float: none !important;
}
.button-bar {
float: left !important;
clear: both;
margin-top: 15px;
}
.button-bar > div,
.button-bar > button,
.button-bar > span {
float: left !important;
clear: both !important;
margin-bottom: 10px;
}
.button-bar > div > button.btn {
float: left !important;
clear: both !important;
}
.home-padded-header .button-bar {
margin-top: 10px;
margin-bottom: 15px;
}
}
@media only screen
and (min-device-width: 740px)
and (max-device-width: 1024px) {
.button-bar {
float: right !important;
}
.button-bar > div > button.btn {
float: left !important;
clear: both !important;
}
.home-padded-header .button-bar {
float: left !important;
}
}
#search_form {
width: 300px;
padding: 8px 15px;
@@ -3286,22 +3420,10 @@ pre::-webkit-scrollbar-thumb {
.notification-params tr:nth-child(even) td {
background-color: rgba(255,255,255,0.010);
}
#days-selection label {
margin-bottom: 0;
}
#graph-days {
margin: 0;
width: 75px;
height: 34px;
}
#days-selection label,
#months-selection label {
margin-bottom: 0;
}
#graph-months {
margin: 0;
width: 75px;
height: 34px;
}
.card-sortable {
height: 36px;
padding: 0 20px 0 0;
@@ -3818,3 +3940,14 @@ a:hover .overlay-refresh-image:hover {
.stream-info tr:nth-child(even) td {
background-color: rgba(255,255,255,0.010);
}
.number-input {
margin: 0 !important;
width: 55px !important;
height: 34px !important;
-moz-appearance: textfield;
}
.number-input::-webkit-inner-spin-button,
.number-input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}

View File

@@ -67,8 +67,15 @@ DOCUMENTATION :: END
from plexpy.common import VIDEO_RESOLUTION_OVERRIDES, AUDIO_CODEC_OVERRIDES
import plexpy
%>
<% data = defaultdict(lambda: 'Unknown', **session) %>
<% sk = data['session_key'] %>
<%
data = defaultdict(lambda: 'Unknown', **session)
sk = data['session_key']
href = 'info?rating_key={}'.format(data['rating_key']) if data['rating_key'] else '#'
parent_href = 'info?rating_key={}'.format(data['parent_rating_key']) if data['parent_rating_key'] else '#'
grandparent_href = 'info?rating_key={}'.format(data['grandparent_rating_key']) if data['grandparent_rating_key'] else '#'
user_href = 'user?user_id={}'.format(data['user_id']) if data['user_id'] else '#'
%>
<div class="dashboard-activity-instance" id="activity-instance-${sk}" data-key="${sk}" data-id="${data['session_id']}"
data-rating_key="${data['rating_key']}" data-parent_rating_key="${data['parent_rating_key']}" data-grandparent_rating_key="${data['grandparent_rating_key']}">
<div class="dashboard-activity-container">
@@ -89,15 +96,15 @@ DOCUMENTATION :: END
% endif
% if data['channel_stream'] == 0:
% if data['media_type'] == 'movie':
<a id="poster-url-${sk}" href="info?rating_key=${data['rating_key']}" title="${data['title']}">
<a id="poster-url-${sk}" href="${href}" title="${data['title']}">
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['thumb']}&width=300&height=450&fallback=poster&refresh=true);"></div>
</a>
% elif data['media_type'] == 'episode':
<a id="poster-url-${sk}" href="info?rating_key=${data['grandparent_rating_key']}" title="${data['grandparent_title']}">
<a id="poster-url-${sk}" href="${grandparent_href}" title="${data['grandparent_title']}">
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['grandparent_thumb']}&width=300&height=450&fallback=poster&refresh=true);"></div>
</a>
% elif data['media_type'] == 'track':
<a id="poster-url-${sk}" href="info?rating_key=${data['parent_rating_key']}" title="${data['parent_title']}">
<a id="poster-url-${sk}" href="${parent_href}" title="${data['parent_title']}">
<div id="poster-${sk}" class="dashboard-activity-cover" style="background-image: url(pms_image_proxy?img=${data['parent_thumb']}&width=300&height=300&fallback=cover&refresh=true);"></div>
</a>
% elif data['media_type'] in ('photo', 'clip'):
@@ -201,8 +208,8 @@ DOCUMENTATION :: END
<li class="dashboard-activity-info-item">
<div class="sub-heading">Container</div>
<div class="sub-value" id="transcode_container-${sk}">
% if data.get('stream_container_decision') == 'transcode':
Transcode (${data['container'].upper()} &rarr; ${data['stream_container'].upper()})
% if data['stream_container_decision'] == 'transcode':
Transcode (${data['container'].upper()} <i class="fa fa-long-arrow-right"></i> ${data['stream_container'].upper()})
% else:
Direct Play (${data['container'].upper()})
% endif
@@ -213,13 +220,13 @@ DOCUMENTATION :: END
<div class="sub-heading">Video</div>
<div class="sub-value" id="video_decision-${sk}">
% if data['media_type'] in ('movie', 'episode', 'clip'):
% if data.get('stream_video_decision') == 'transcode':
% if data['stream_video_decision'] == 'transcode':
<%
hw_d = ' (HW)' if data['transcode_hw_decoding'] else ''
hw_e = ' (HW)' if data['transcode_hw_encoding'] else ''
%>
Transcode (${data['video_codec'].upper()}${hw_d} ${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])} &rarr; ${data['stream_video_codec'].upper()}${hw_e} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])})
% elif data.get('stream_video_decision') == 'copy':
Transcode (${data['video_codec'].upper()}${hw_d} ${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])} <i class="fa fa-long-arrow-right"></i> ${data['stream_video_codec'].upper()}${hw_e} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])})
% elif data['stream_video_decision'] == 'copy':
Direct Stream (${data['stream_video_codec'].upper()} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])})
% else:
Direct Play (${data['video_codec'].upper()} ${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])})
@@ -234,9 +241,9 @@ DOCUMENTATION :: END
<li class="dashboard-activity-info-item">
<div class="sub-heading">Audio</div>
<div class="sub-value" id="audio_decision-${sk}">
% if data.get('stream_audio_decision') == 'transcode':
Transcode (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()} &rarr; ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% elif data.get('stream_audio_decision') == 'copy':
% if data['stream_audio_decision'] == 'transcode':
Transcode (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()} <i class="fa fa-long-arrow-right"></i> ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% elif data['stream_audio_decision'] == 'copy':
Direct Stream (${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% else:
Direct Play (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()})
@@ -250,7 +257,7 @@ DOCUMENTATION :: END
<div class="sub-value" id="subtitle_decision-${sk}">
% if data['subtitles'] == 1:
% if data['stream_subtitle_decision'] == 'transcode':
Transcode (${data['subtitle_codec'].upper()} &rarr; ${data['stream_subtitle_codec'].upper()})
Transcode (${data['subtitle_codec'].upper()} <i class="fa fa-long-arrow-right"></i> ${data['stream_subtitle_codec'].upper()})
% elif data['stream_subtitle_decision'] == 'copy':
Direct Stream (${data['subtitle_codec'].upper()})
% elif data['stream_subtitle_decision'] == 'burn':
@@ -269,18 +276,23 @@ DOCUMENTATION :: END
<li class="dashboard-activity-info-item">
<div class="sub-heading">Location</div>
<div class="sub-value time-right">
<span id="location-${sk}">${data['location'].upper()}</span>:
% if data['ip_address'] != 'N/A':
${data['location'].upper()}: <span class="ip-container"><span class="ip-address">${data['ip_address']}</span></span>
<a href="#" class="external_ip-modal" data-toggle="modal" data-target="#ip-info-modal" data-ip="${data['ip_address']}">
<span id="external_ip-${sk}" class="external-ip-tooltip" data-toggle="tooltip" title="Lookup External IP" style="display: none;"><i class="fa fa-map-marker"></i></span>
</a>
<script>
isPrivateIP("${data['ip_address']}").then(function () {
$("#external_ip-${sk}").hide();
}, function () {
$("#external_ip-${sk}").show();
});
</script>
<span class="ip-container"><span class="ip-address">${data['ip_address']}</span></span>
% if data['relay']:
<span data-toggle="tooltip" title="Plex Relay"><i class="fa fa-exclamation-circle"></i></span>
% else:
<a href="#" class="external_ip-modal" data-toggle="modal" data-target="#ip-info-modal" data-ip="${data['ip_address']}">
<span id="external_ip-${sk}" class="external-ip-tooltip" data-toggle="tooltip" title="Lookup External IP" style="display: none;"><i class="fa fa-map-marker"></i></span>
</a>
<script>
isPrivateIP("${data['ip_address']}").then(function () {
$("#external_ip-${sk}").hide();
}, function () {
$("#external_ip-${sk}").show();
});
</script>
% endif
% else:
N/A
% endif
@@ -312,7 +324,9 @@ DOCUMENTATION :: END
</div>
% if data['media_type'] != 'photo':
<div class="dashboard-activity-info-time">
% if data['view_offset']:
% if data['live'] == 1:
<br />Live
% elif data['view_offset']:
ETA:
<span id="stream-eta-${sk}">
<script>
@@ -340,19 +354,19 @@ DOCUMENTATION :: END
</div>
<div class="dashboard-activity-progress">
<div class="dashboard-activity-progress-bar">
% if data['live'] == 1:
<div id="progress-bar-${sk}" class="progress-bar" style="width: 100%" data-toggle="tooltip" title="Stream Progress Live">Live</div>
% else:
<div id="buffer-bar-${sk}" class="buffer-bar" style="width: ${data['transcode_progress']}%" data-toggle="tooltip" title="Transcoder Progress ${data['transcode_progress']}%">${data['transcode_progress']}%</div>
<div id="progress-bar-${sk}" class="progress-bar" style="width: ${data['progress_percent']}%" data-last_view_offset="${data['view_offset']}" data-view_offset="${data['view_offset']}" data-stream_duration="${data['stream_duration']}" data-state="${data['state']}" data-toggle="tooltip" title="Stream Progress ${data['progress_percent']}%">${data['progress_percent']}%</div>
% endif
</div>
</div>
</div>
<div class="dashboard-activity-metadata-wrapper">
% if data['user_id']:
<a href="user?user_id=${data['user_id']}" title="${data['friendly_name']}">
<a href="${user_href}" title="${data['friendly_name']}">
<div class="dashboard-activity-metadata-user-thumb" style="background-image: url(${data['user_thumb']});"></div>
</a>
% else:
<div class="dashboard-activity-metadata-user-thumb" style="background-image: url(${data['user_thumb']});"></div>
% endif
<div class="dashboard-activity-metadata-title-container">
<div id="play-state-${sk}" class="dashboard-activity-metadata-play_state-icon" title="${data['state'].capitalize()}">
% if data['state'] == 'playing':
@@ -365,21 +379,21 @@ DOCUMENTATION :: END
</div>
<div class="dashboard-activity-metadata-title">
% if data['channel_stream'] == 0:
% if data['media_type'] == 'movie':
<a href="info?rating_key=${data['rating_key']}" title="${data['title']}">${data['title']}</a>
% elif data['media_type'] == 'episode':
<a href="info?rating_key=${data['grandparent_rating_key']}" title="${data['grandparent_title']}">${data['grandparent_title']}</a>
- <a href="info?rating_key=${data['rating_key']}" title="${data['title']}">${data['title']}</a>
% elif data['media_type'] == 'track':
<a id="metadata-grandparent_title-${sk}" href="info?rating_key=${data['grandparent_rating_key']}" title="${data['grandparent_title']}">${data['grandparent_title']}</a>
- <a id="metadata-title-${sk}" href="info?rating_key=${data['rating_key']}" title="${data['title']}">${data['title']}</a>
% elif data['media_type'] == 'photo':
<span title="${data['parent_title']}">${data['parent_title']}</span>
% elif data['media_type'] == 'clip':
<span title="${data['title']}">${data['title']}</span>
% else:
<span title="${data['title']}">${data['title']}</span>
% endif
% if data['media_type'] == 'movie':
<a href="${href}" title="${data['title']}">${data['title']}</a>
% elif data['media_type'] == 'episode':
<a href="${grandparent_href}" title="${data['grandparent_title']}">${data['grandparent_title']}</a>
- <a href="${href}" title="${data['title']}">${data['title']}</a>
% elif data['media_type'] == 'track':
<a id="metadata-grandparent_title-${sk}" href="${grandparent_href}" title="${data['grandparent_title']}">${data['grandparent_title']}</a>
- <a id="metadata-title-${sk}" href="${href}" title="${data['title']}">${data['title']}</a>
% elif data['media_type'] == 'photo':
<span title="${data['parent_title']}">${data['parent_title']}</span>
% elif data['media_type'] == 'clip':
<span title="${data['title']}">${data['title']}</span>
% else:
<span title="${data['title']}">${data['title']}</span>
% endif
% elif data['media_type'] == 'episode' and data['grandparent_title']:
<span title="${data['grandparent_title']}">${data['grandparent_title']}</span>
- <span title="${data['title']}">${data['title']}</span>
@@ -389,7 +403,11 @@ DOCUMENTATION :: END
</div>
</div>
<div class="dashboard-activity-metadata-subtitle-container">
% if data['channel_stream'] == 0:
% if data['live'] == 1:
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="Plex Live TV">
<i class="fa fa-fw fa-television"></i>&nbsp;
</div>
% elif data['channel_stream'] == 0:
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="${data['media_type'].capitalize()}">
% if data['media_type'] == 'movie':
<i class="fa fa-fw fa-film"></i>&nbsp;
@@ -404,19 +422,21 @@ DOCUMENTATION :: END
% endif
</div>
% else:
<div id="media-type-${sk}" title="Channel">
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="Channel">
<i class="fa fa-fw fa-cloud"></i>&nbsp;
</div>
% endif
<div class="dashboard-activity-metadata-subtitle">
% if data['channel_stream'] == 0:
% if data['live'] == 1:
<span title="Plex Live TV" class="sub-heading">Plex Live TV</span>
% elif data['channel_stream'] == 0:
% if data['media_type'] == 'movie':
<span title="${data['year']}" class="sub-heading">${data['year']}</span>
% elif data['media_type'] == 'episode':
<a href="info?rating_key=${data['parent_rating_key']}" title="Season ${data['parent_media_index']}" class="sub-heading">S${data['parent_media_index']}</a>
&middot; <a href="info?rating_key=${data['rating_key']}" title="Episode ${data['media_index']}" class="sub-heading">E${data['media_index']}</a>
<a href="${parent_href}" title="Season ${data['parent_media_index']}" class="sub-heading">S${data['parent_media_index']}</a>
&middot; <a href="${href}" title="Episode ${data['media_index']}" class="sub-heading">E${data['media_index']}</a>
% elif data['media_type'] == 'track':
<a id="metadata-parent_title-${sk}" href="info?rating_key=${data['parent_rating_key']}" title="${data['parent_title']}" class="sub-heading">${data['parent_title']}</a>
<a id="metadata-parent_title-${sk}" href="${parent_href}" title="${data['parent_title']}" class="sub-heading">${data['parent_title']}</a>
% elif data['media_type'] == 'photo':
<span title="${data['title']}" class="sub-heading">${data['title']}</span>
% else:
@@ -441,11 +461,7 @@ DOCUMENTATION :: END
% endif
</div>
<div class="dashboard-activity-metadata-user">
% if data['user_id']:
<a href="user?user_id=${data['user_id']}" title="${data['friendly_name']}">${data['friendly_name']}</a>
% else:
${data['friendly_name']}
% endif
<a href="${user_href}" title="${data['friendly_name']}">${data['friendly_name']}</a>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@
<%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
</%def>
<%def name="body()">
@@ -11,7 +11,7 @@
<div class="header-bar">
<span><i class="fa fa-bar-chart"></i> Graphs</span>
</div>
<div class="button-bar hidden-xs">
<div class="button-bar">
<div class="btn-group" id="user-selection">
<label>
<select name="graph-user" id="graph-user" class="btn" style="color: inherit;">
@@ -39,12 +39,12 @@
</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" 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="${config['graph_days']}" 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" 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="${config['graph_months']}" min="1" data-default="12" data-toggle="tooltip" title="Min: 1 month" />
<span class="input-group-addon btn-dark inactive">months</span>
</div>
</div>

View File

@@ -3,7 +3,7 @@
<%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
</%def>
<%def name="body()">
@@ -114,7 +114,7 @@
$.ajax({
url: 'get_user_names',
type: 'get',
dataType: "json",
dataType: 'json',
success: function (data) {
var select = $('#history-user');
data.sort(function (a, b) {
@@ -130,7 +130,6 @@
function loadHistoryTable(media_type, selected_user_id) {
history_table_options.ajax = {
url: 'get_history',
type: 'post',
data: function (d) {
return {
json_data: JSON.stringify(d),
@@ -138,9 +137,13 @@
user_id: selected_user_id
};
}
}
};
history_table = $('#history_table').DataTable(history_table_options);
var colvis = new $.fn.dataTable.ColVis(history_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 11] });
var colvis = new $.fn.dataTable.ColVis(history_table, {
buttonText: '<i class="fa fa-columns"></i> Select columns',
buttonClass: 'btn btn-dark',
exclude: [0, 11]
});
$(colvis.button()).appendTo('div.colvis-button-bar');
clearSearchButton('history_table', history_table);
@@ -160,7 +163,7 @@
}
var media_type = null;
var selected_user_id = "${_session['user_id']}" == "None" ? null : "${_session['user_id']}"
var selected_user_id = "${_session['user_id']}" == "None" ? null : "${_session['user_id']}";
loadHistoryTable(media_type, selected_user_id);
% if _session['user_group'] == 'admin':

View File

@@ -5,7 +5,15 @@
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
<h4 class="modal-title" id="myModalLabel">
<strong><span id="modal_header_ip_address">
% if data.get('media_type'):
<% h = {'episode': 'TV Show', 'track': 'Music'} %>
<i class="fa fa-history"></i> ${h.get(data['media_type'], data['media_type'].title())} History for <span id="date-header">${data['start_date']}</span>
% elif data.get('transcode_decision'):
<% h = {'copy': 'Direct Stream'} %>
<i class="fa fa-history"></i> ${h.get(data['transcode_decision'], data['transcode_decision'].title())} History for <span id="date-header">${data['start_date']}</span>
% else:
<i class="fa fa-history"></i> History for <span id="date-header">${data['start_date']}</span>
% endif
</span></strong>
</h4>
</div>
@@ -13,11 +21,18 @@
<table class="display history_table" id="history_table_modal" width="100%">
<thead>
<tr>
<th align="left" id="started">Started</th>
<th align="left" id="stopped">Stopped</th>
<th align="left" id="delete_row">Delete</th>
<th align="left" id="date">Date</th>
<th align="left" id="friendly_name">User</th>
<th align="left" id="player">Player</th>
<th align="left" id="ip_address">IP Address</th>
<th align="left" id="platform">Platform</th>
<th align="left" id="device">Player</th>
<th align="left" id="title">Title</th>
<th align="left" id="started">Started</th>
<th align="left" id="paused_counter">Paused</th>
<th align="left" id="stopped">Stopped</th>
<th align="left" id="duration">Duration</th>
<th align="left" id="percent_complete"></th>
</tr>
</thead>
<tbody>
@@ -28,28 +43,31 @@
</div>
</div>
<script src="${http_root}js/tables/history_table_modal.js${cache_param}"></script>
<script src="${http_root}js/tables/history_table.js${cache_param}"></script>
<script>
$(document).ready(function() {
$('#date-header').html(moment('${data["start_date"]}','YYYY-MM-DD').format('ddd MMM Do YYYY'));
history_table_modal_options.ajax = {
history_table_options.ajax = {
url: 'get_history',
type: 'post',
data: function ( d ) {
return {
json_data: JSON.stringify(d),
grouping: false,
user_id: "${data['user_id']}",
start_date: "${data['start_date']}",
media_type: "${data.get('media_type')}",
transcode_decision: "${data.get('transcode_decision')}"
};
}
}
};
history_table = $('#history_table_modal').DataTable(history_table_options);
history_table.columns([0, 3, 4, 8, 10, 11]).visible(false);
history_table = $('#history_table_modal').DataTable(history_table_modal_options);
clearSearchButton('history_table_modal', history_table);
$('#history-modal').on('shown.bs.modal', function() {
history_table.columns.adjust().draw();
});
});
</script>
% else:

View File

@@ -88,17 +88,19 @@ DOCUMENTATION :: END
% if stat_id in ('top_music', 'popular_music'):
<div id="stats-thumb-${stat_id}-bg" class="dashboard-stats-poster-blur" style="background-image: url(pms_image_proxy?img=${row0['thumb']}&width=300&height=300&fallback=cover);"></div>
% endif
<a id="stats-thumb-url-${stat_id}" href="info?rating_key=${row0['rating_key']}" title="${row0['title']}">
<% type = 'cover' if stat_id in ('top_music', 'popular_music') else 'poster' %>
<% height, type = ('300', 'cover') if stat_id in ('top_music', 'popular_music') else ('450', 'poster') %>
<% href = 'info?rating_key={}'.format(row0['rating_key']) if row0['rating_key'] else '#' %>
<a id="stats-thumb-url-${stat_id}" href="${href}" title="${row0['title']}">
% if row0['thumb']:
<div id="stats-thumb-${stat_id}" class="dashboard-stats-${type}" style="background-image: url(pms_image_proxy?img=${row0['thumb']}&width=300&height=300&fallback=${type});"></div>
<div id="stats-thumb-${stat_id}" class="dashboard-stats-${type}" style="background-image: url(pms_image_proxy?img=${row0['thumb']}&width=300&height=${height}&fallback=${type});"></div>
% else:
<div id="stats-thumb-${stat_id}" class="dashboard-stats-${type}" style="background-image: url(images/${type}.png);"></div>
% endif
</a>
</div>
% elif stat_id == 'top_users':
<a id="stats-thumb-url-${stat_id}" href="user?user_id=${row0['user_id']}" title="${row0['friendly_name']}" class="hidden-xs">
<% user_href = 'user?user_id={}'.format(row0['user_id']) if row0['user_id'] else '#' %>
<a id="stats-thumb-url-${stat_id}" href="${user_href}" title="${row0['friendly_name']}" class="hidden-xs">
<div id="stats-thumb-${stat_id}" class="dashboard-stats-circle" style="background-image: url(${row0['user_thumb'] or 'images/gravatar-default.png'})"></div>
</a>
% elif stat_id == 'top_platforms':
@@ -127,26 +129,20 @@ DOCUMENTATION :: END
% for row in top_stat['rows']:
<li class="dashboard-stats-info-item ${'expanded' if loop.index == 0 else ''}" data-stat_id="${stat_id}" data-rating_key="${row.get('rating_key')}" data-title="${row.get('title')}"
data-art="${row.get('art')}" data-thumb="${row.get('thumb')}" data-platform="${row.get('platform_name')}"
data-user_id="${row.get('user_id')}" data-friendly_name="${row.get('friendly_name')}"
data-user_id="${row.get('user_id')}" data-friendly_name="${row.get('friendly_name')}" data-user_thumb="${row.get('user_thumb')}"
data-last_watch="${row.get('last_watch')}" data-started="${row.get('started')}">
<div class="sub-list">${loop.index + 1}</div>
<div class="sub-value">
% if stat_id in ('top_movies', 'popular_movies', 'top_tv', 'popular_tv', 'top_music', 'popular_music', 'last_watched'):
% if top_stat['rows'][loop.index]['rating_key']:
<a href="info?rating_key=${row['rating_key']}" title="${row['title']}">
<% href = 'info?rating_key={}'.format(row['rating_key']) if row['rating_key'] else '#' %>
<a href="${href}" title="${row['title']}">
${row['title']}
</a>
% else:
${row['title']}
% endif
% elif stat_id == 'top_users':
% if top_stat['rows'][loop.index]['user_id']:
<a href="user?user_id=${row['user_id']}" title="${row['friendly_name']}">
<% user_href = 'user?user_id={}'.format(row['user_id']) if row['user_id'] else '#' %>
<a href="${user_href}" title="${row['friendly_name']}">
${row['friendly_name']}
</a>
% else:
${row['friendly_name']}
% endif
% elif stat_id == 'top_platforms':
${row['platform']}
% elif stat_id == 'most_concurrent':
@@ -182,13 +178,22 @@ DOCUMENTATION :: END
var stat_id = $(elem).data('stat_id');
var art = $(elem).data('art');
var thumb = $(elem).data('thumb');
var user_id = $(elem).data('user_id');
var user_thumb = $(elem).data('user_thumb');
var rating_key = $(elem).data('rating_key');
var [height, fallback] = ($.inArray(stat_id, ['top_music', 'popular_music']) > -1) ? [300, 'cover'] : [450, 'poster'];
var href;
if (stat_id == 'most_concurrent') {
return
} else if (stat_id == 'top_users') {
$('#stats-thumb-' + stat_id).css('background-image', 'url(' + (thumb || 'images/gravatar-default.png') + ')');
$('#stats-thumb-url-' + stat_id).attr('href', 'user?user_id=' + $(elem).data('user_id')).prop('title', $(elem).data('friendly_name'));
$('#stats-thumb-' + stat_id).css('background-image', 'url(' + (user_thumb || 'images/gravatar-default.png') + ')');
if (user_id) {
href = 'user?user_id=' + user_id;
} else {
href = '#';
}
$('#stats-thumb-url-' + stat_id).attr('href', href).prop('title', $(elem).data('friendly_name'));
} else if (stat_id == 'top_platforms') {
$('#stats-thumb-' + stat_id).removeClass(function (index, className) {
return (className.match (/(^|\s)platform-\S+/g) || []).join(' ');
@@ -197,7 +202,12 @@ DOCUMENTATION :: END
return (className.match (/(^|\s)platform-\S+/g) || []).join(' ');
}).addClass('platform-' + $(elem).data('platform'));
} else {
$('#stats-thumb-url-' + stat_id).attr('href', 'info?rating_key=' + $(elem).data('rating_key')).prop('title', $(elem).data('title'));
if (rating_key) {
href = 'info?rating_key=' + rating_key;
} else {
href = '#';
}
$('#stats-thumb-url-' + stat_id).attr('href', href).prop('title', $(elem).data('title'));
if (art) {
$('#stats-background-' + stat_id).css('background-image', 'url(pms_image_proxy?img=' + art + '&width=500&height=280&fallback=art)');
} else {
@@ -207,7 +217,8 @@ DOCUMENTATION :: END
$('#stats-thumb-' + stat_id).css('background-image', 'url(pms_image_proxy?img=' + thumb + '&width=300&height=' + height + '&fallback=' + fallback + ')');
$('#stats-thumb-' + stat_id + '-bg').css('background-image', 'url(pms_image_proxy?img=' + thumb + '&width=300&height=' + height + '&fallback=' + fallback + ')');
} else {
$('#stats-background-' + stat_id).css('background-image', 'url(images/' + fallback + '.png)');
$('#stats-thumb-' + stat_id).css('background-image', 'url(images/' + fallback + '.png)');
$('#stats-thumb-' + stat_id + '-bg').css('background-image', 'url(images/' + fallback + '.png)');
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -22,7 +22,16 @@
</h3>
</div>
<div id="currentActivity">
<% from plexpy import PLEX_SERVER_UP %>
% if PLEX_SERVER_UP:
<div class="text-muted" id="dashboard-checking-activity"><i class="fa fa-refresh fa-spin"></i> Checking for activity...</div>
% else:
<div id="dashboard-no-activity" class="text-muted">There was an error communicating with your Plex Server.
% if _session['user_group'] == 'admin':
Check the <a href="logs">logs</a> and verify your server connection in the <a href="settings#tab_tabs-plex_media_server">settings</a>.
% endif
</div>
% endif
</div>
</div>
</div>
@@ -31,27 +40,29 @@
<div class="col-md-12">
<div class="home-padded-header padded-header">
<h3 class="pull-left">Watch Statistics</h3>
<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>
<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
</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" 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" />
<span class="input-group-addon btn-dark inactive">days</span>
<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>
<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
</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" />
<span class="input-group-addon btn-dark inactive">days</span>
</div>
</div>
</div>
</div>
@@ -69,7 +80,9 @@
<div class="col-md-12">
<div class="home-padded-header padded-header" id="library-statistics-header">
<h3 class="pull-left">Library Statistics</h3>
<span class="btn btn-dark active" style="cursor: default">${config['pms_name']}</span>
<div class="button-bar">
<span class="btn btn-dark active" style="cursor: default">${config['pms_name']}</span>
</div>
</div>
</div>
</div>
@@ -94,23 +107,25 @@
<a href="#" id="recently-added-page-right" class="paginate btn-gray disabled" data-id="-1"><i class="fa fa-lg fa-chevron-right"></i></a>
</li>
</ul>
<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>
<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
</label>
<label class="btn btn-dark" id="recently-added-label-tv">
<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
</label>
</div>
<div class="input-group pull-left" style="width: 1px;" id="recently-added-count-selection">
<input type="number" class="form-control" name="recently-added-count" id="recently-added-count" value="${config['home_stats_recently_added_count']}" min="1" max="100" data-default="50" data-toggle="tooltip" title="Min: 1 item<br>Max: 100 items" />
<span class="input-group-addon btn-dark inactive">items</span>
<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>
<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
</label>
<label class="btn btn-dark" id="recently-added-label-tv">
<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
</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="100" data-default="50" data-toggle="tooltip" title="Min: 1 item<br>Max: 100 items" />
<span class="input-group-addon btn-dark inactive">items</span>
</div>
</div>
</div>
</div>
@@ -131,12 +146,13 @@
<%def name="modalIncludes()">
% if _session['user_group'] == 'admin' and config['update_show_changelog']:
<% from plexpy.common import RELEASE %>
<div id="changelog-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="changelog-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">Tautulli Updated</h4>
<h4 class="modal-title">Tautulli Updated to <strong>${RELEASE}</strong></h4>
</div>
<div class="modal-body">
</div>
@@ -234,9 +250,10 @@
});
}
});
};
}
</script>
% if 'current_activity' in config['home_sections']:
<% from plexpy import PLEX_SERVER_UP %>
% if 'current_activity' in config['home_sections'] and PLEX_SERVER_UP:
<script>
var defaultHandler = {
get: function(target, name) {
@@ -259,6 +276,7 @@
async: true,
error: function (xhr, status, error) {
console.log(status + ': ' + error);
activity_ready = true;
},
complete: function (xhr, status) {
$('#dashboard-checking-activity').remove();
@@ -273,9 +291,9 @@
if (!(current_activity)) {
% if _session['user_group'] == 'admin':
var msg_settings = ' Verify your server connection in the <a href="settings#tab_tabs-plex_media_server">settings</a>.';
var msg_settings = ' Check the <a href="logs">logs</a> and verify your server connection in the <a href="settings#tab_tabs-plex_media_server">settings</a>.';
% else:
var msg_settings = ''
var msg_settings = '';
% endif
$('#currentActivityHeader').hide();
$('#currentActivity').html('<div id="dashboard-no-activity" class="text-muted">There was an error communicating with your Plex Server.' + msg_settings + '</div>');
@@ -308,20 +326,23 @@
streams_header = streams_header.replace(/, $/, '') + ')';
$('#currentActivityHeader-streams').text(streams_header);
var bandwidth_header = ((total_bw > 1000) ? ((total_bw / 1000).toFixed(1) + ' Mbps') : (total_bw + ' kbps')) + ' (';
var bandwidth_header = ((total_bw > 1000) ? ((total_bw / 1000).toFixed(1) + ' Mbps') : (total_bw + ' kbps'));
var lan_wan_bandwidth_header = '';
if (lan_bw) {
bandwidth_header += 'LAN: ' + ((lan_bw > 1000) ? ((lan_bw / 1000).toFixed(1) + ' Mbps') : (lan_bw + ' kbps')) + ', ';
lan_wan_bandwidth_header += 'LAN: ' + ((lan_bw > 1000) ? ((lan_bw / 1000).toFixed(1) + ' Mbps') : (lan_bw + ' kbps')) + ', ';
}
if (wan_bw) {
bandwidth_header += 'WAN: ' + ((wan_bw > 1000) ? ((wan_bw / 1000).toFixed(1) + ' Mbps') : (wan_bw + ' kbps')) + ', ';
lan_wan_bandwidth_header += 'WAN: ' + ((wan_bw > 1000) ? ((wan_bw / 1000).toFixed(1) + ' Mbps') : (wan_bw + ' kbps')) + ', ';
}
if (lan_wan_bandwidth_header) {
bandwidth_header += ' (' + lan_wan_bandwidth_header.replace(/, $/, '') + ')';
}
bandwidth_header = bandwidth_header.replace(/, $/, '') + ')';
$('#currentActivityHeader-bandwidth').text(bandwidth_header);
$('#currentActivityHeader').show();
sessions.forEach(function (session) {
var s = new Proxy(session, defaultHandler);
var s = (typeof Proxy === "function") ? new Proxy(session, defaultHandler) : session;
var key = s.session_key;
var session_id = s.session_id;
var instance = $('#activity-instance-' + key);
@@ -395,7 +416,7 @@
var transcode_container = '';
if (s.stream_container_decision === 'transcode') {
transcode_container = 'Transcode (' + s.container.toUpperCase() + ' &rarr; ' + s.stream_container.toUpperCase() + ')';
transcode_container = 'Transcode (' + s.container.toUpperCase() + ' <i class="fa fa-long-arrow-right"></i> ' + s.stream_container.toUpperCase() + ')';
} else {
transcode_container = 'Direct Play (' + s.container.toUpperCase() + ')';
}
@@ -428,7 +449,7 @@
if (s.stream_video_decision === 'transcode') {
var hw_d = (s.transcode_hw_decoding === 1) ? ' (HW)' : '';
var hw_e = (s.transcode_hw_encoding === 1) ? ' (HW)' : '';
video_decision = 'Transcode (' + s.video_codec.toUpperCase() + hw_d + ' ' + v_res + ' &rarr; ' + s.stream_video_codec.toUpperCase() + hw_e + ' ' + sv_res + ')';
video_decision = 'Transcode (' + s.video_codec.toUpperCase() + hw_d + ' ' + v_res + ' <i class="fa fa-long-arrow-right"></i> ' + s.stream_video_codec.toUpperCase() + hw_e + ' ' + sv_res + ')';
} else if (s.stream_video_decision === 'copy') {
video_decision = 'Direct Stream (' + s.stream_video_codec.toUpperCase() + ' ' + sv_res + ')';
} else {
@@ -444,7 +465,7 @@
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') {
audio_decision = 'Transcode (' + a_codec + ' ' + capitalizeFirstLetter(s.audio_channel_layout.split('(')[0]) + ' &rarr; ' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
audio_decision = 'Transcode (' + a_codec + ' ' + capitalizeFirstLetter(s.audio_channel_layout.split('(')[0]) + ' <i class="fa fa-long-arrow-right"></i> ' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
} else if (s.stream_audio_decision === 'copy') {
audio_decision = 'Direct Stream (' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
} else {
@@ -456,7 +477,7 @@
var subtitle_decision = 'None';
if (['movie', 'episode', 'clip'].indexOf(s.media_type) > -1 && s.subtitles === 1) {
if (s.stream_subtitle_decision === 'transcode') {
subtitle_decision = 'Transcode (' + s.subtitle_codec.toUpperCase() + ' &rarr; ' + s.stream_subtitle_codec.toUpperCase() + ')';
subtitle_decision = 'Transcode (' + s.subtitle_codec.toUpperCase() + ' <i class="fa fa-long-arrow-right"></i> ' + s.stream_subtitle_codec.toUpperCase() + ')';
} else if (s.stream_subtitle_decision === 'copy') {
subtitle_decision = 'Direct Stream (' + s.subtitle_codec.toUpperCase() + ')';
} else if (s.stream_subtitle_decision === 'burn') {
@@ -484,6 +505,8 @@
$('#optimized_version-' + key).html(s.optimized_version_profile + ' (' + s.optimized_version_title + ')');
$('#synced_quality_profile-' + key).html(s.synced_quality_profile);
$('#location-' + key).html(s.location.toUpperCase());
if (s.media_type !== 'photo' && parseInt(s.bandwidth)) {
var bw = parseInt(s.bandwidth);
if (bw !== "Unknown") {
@@ -509,7 +532,7 @@
.attr('data-original-title', 'Transcoder Progress ' + s.transcode_progress + '%');
var progress_bar = $('#progress-bar-' + key);
progress_bar.data('state', s.state);
if (progress_bar.data('last_view_offset') !== s.view_offset) {
if (progress_bar.data('last_view_offset') && progress_bar.data('last_view_offset') !== s.view_offset) {
progress_bar.data('last_view_offset', s.view_offset).data('view_offset', s.view_offset);
}
@@ -817,7 +840,7 @@
$.ajax({
url: 'get_changelog',
data: {
latest_only: true,
since_prev_release: true,
update_shown: true
},
cache: false,

View File

@@ -64,7 +64,7 @@ DOCUMENTATION :: END
<%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
</%def>
<%def name="body()">
@@ -117,9 +117,9 @@ DOCUMENTATION :: END
<div class="col-md-9">
<div class="summary-content-poster hidden-xs hidden-sm">
% if data['media_type'] == 'track':
<a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${data['parent_rating_key']}" target="_blank" title="View in Plex Web">
<a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${data['parent_rating_key']}" target="_blank" title="View on Plex Web">
% else:
<a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${data['rating_key']}" target="_blank" title="View in Plex Web">
<a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${data['rating_key']}" target="_blank" title="View on Plex Web">
% endif
% if data['media_type'] == 'episode':
<div class="summary-poster-face-episode" style="background-image: url(pms_image_proxy?img=${data['thumb']}&width=500&height=280&fallback=art);">
@@ -388,6 +388,15 @@ DOCUMENTATION :: END
</a>
</div>
% endif
% if data.get('tvmaze_id') or data.get('themoviedb_id'):
<div class="btn-group">
<button class="btn btn-danger btn-edit" data-toggle="modal" aria-pressed="false" autocomplete="off" id="delete-lookup-info"
data-id="${data['grandparent_rating_key'] if data['media_type'] in ('episode', 'track') else data['parent_rating_key'] if data['media_type'] in ('season', 'album') else data['rating_key']}"
data-title="${data['grandparent_title'] if data['media_type'] in ('episode', 'track') else data['parent_title'] if data['media_type'] in ('season', 'album') else data['title']}">
<i class="fa fa-search"></i> Delete Lookup Info
</button>
</div>
% endif
% if data.get('poster_url'):
<div class="btn-group">
% if data['media_type'] == 'artist' or data['media_type'] == 'album' or data['media_type'] == 'track':
@@ -396,8 +405,9 @@ DOCUMENTATION :: END
<span class="imgur-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="120" data-width="80" style="display: inline-flex;">
% endif
<button class="btn btn-danger btn-edit" data-toggle="modal" aria-pressed="false" autocomplete="off" id="delete-imgur-poster"
data-id="${data['parent_rating_key'] if data['media_type'] in ('episode', 'track') else data['rating_key']}">
<i class="fa fa-picture-o"></i> Reset Imgur Poster
data-id="${data['parent_rating_key'] if data['media_type'] in ('episode', 'track') else data['rating_key']}"
data-title="${data["poster_title"]}">
<i class="fa fa-picture-o"></i> Delete Imgur Poster
</button>
</span>
</div>
@@ -511,6 +521,7 @@ DOCUMENTATION :: END
% endfor
</select>
</div>
<p class="help-block">Note: All custom notification conditions will be bypassed.</p>
</div>
</div>
<div class="modal-footer">
@@ -706,13 +717,28 @@ DOCUMENTATION :: END
});
$('#delete-imgur-poster').on('click', function () {
var msg = 'Are you sure you want to reset the Imgur poster for <strong>${data["poster_title"]}</strong>?';
var url = 'delete_poster_url';
var data = { rating_key: $(this).data('id') }
var msg = 'Are you sure you want to delete the Imgur poster for <strong>' + $(this).data('title') + '</strong>?<br><br>' +
'All previous links to this image will no longer work.';
var url = 'delete_imgur_poster';
var data = { rating_key: $(this).data('id') };
var callback = function () {
$('.imgur-poster-tooltip').popover('destroy');
$('#delete-imgur-poster').closest('span').remove();
}
$('#delete-imgur-poster').closest('.btn-group').remove();
};
confirmAjaxCall(url, msg, data, false, callback);
});
</script>
% endif
% if data.get('tvmaze_id') or data.get('themoviedb_id'):
<script>
$('#delete-lookup-info').on('click', function () {
var msg = 'Are you sure you want to delete the 3rd party API lookup for <strong>' + $(this).data('title') + '</strong>?<br><br>' +
'The info will be looked up again the next time a notification is sent.';
var url = 'delete_lookup_info';
var data = { rating_key: $(this).data('id'), title: $(this).data('title') };
var callback = function () {
$('#delete-lookup-info').closest('.btn-group').remove();
};
confirmAjaxCall(url, msg, data, false, callback);
});
</script>

View File

@@ -2,7 +2,7 @@
PNotify.prototype.options.addclass = "stack-bottomright";
PNotify.prototype.options.buttons.closer_hover = false;
PNotify.prototype.options.desktop = { desktop: true, icon: 'images/logo.png' }
PNotify.prototype.options.desktop = { desktop: true, icon: 'images/logo-circle.png' };
PNotify.prototype.options.history = false;
PNotify.prototype.options.shadow = false;
PNotify.prototype.options.stack = { dir1: 'up', dir2: 'left', firstpos1: 25, firstpos2: 25 };
@@ -21,7 +21,7 @@ function check_notifications() {
$.getJSON('get_browser_notifications', function (data) {
if (data) {
$.each(data, function (i, notification) {
if (notification.delay == 0) {
if (notification.delay === 0) {
PNotify.prototype.options.hide = false;
} else {
PNotify.prototype.options.hide = true;
@@ -34,7 +34,7 @@ function check_notifications() {
setTimeout(function () {
"use strict";
check_notifications();
}, 3000);
}, 5000);
}
$(document).ready(function () {

View File

@@ -26,7 +26,7 @@ function refreshTab() {
function showMsg(msg, loader, timeout, ms, error) {
var feedback = $("#ajaxMsg");
update = $("#updatebar");
var update = $("#updatebar");
if (update.is(":visible")) {
var height = update.height() + 35;
feedback.css("bottom", height + "px");
@@ -35,7 +35,7 @@ function showMsg(msg, loader, timeout, ms, error) {
}
var message = $("<div class='msg'>" + msg + "</div>");
if (loader) {
var message = $("<i class='fa fa-refresh fa-spin'></i> " + msg + "</div>");
message = $("<i class='fa fa-refresh fa-spin'></i> " + msg + "</div>");
feedback.css("padding", "14px 10px")
}
if (error) {
@@ -290,19 +290,13 @@ String.prototype.toProperCase = function () {
function millisecondsToMinutes(ms, roundToMinute) {
if (ms > 0) {
seconds = ms / 1000;
minutes = seconds / 60;
if (roundToMinute) {
output = Math.round(minutes, 0)
} else {
minutesFloor = Math.floor(minutes);
secondsReal = Math.round((seconds - (minutesFloor * 60)), 0);
if (secondsReal < 10) {
secondsReal = '0' + secondsReal;
}
output = minutesFloor + ':' + secondsReal;
}
return output;
var minutes = Math.floor(ms / 60000);
var seconds = ((ms % 60000) / 1000).toFixed(0);
if (roundToMinute) {
return (seconds >= 30 ? (minutes + 1) : minutes);
} else {
return (seconds == 60 ? (minutes + 1) + ":00" : minutes + ":" + (seconds < 10 ? "0" : "") + seconds);
}
} else {
if (roundToMinute) {
return '0';

View File

@@ -21,7 +21,7 @@ history_table_options = {
"infoFiltered": "<span class='hidden-md hidden-sm hidden-xs'>(filtered from _MAX_ total entries)</span>",
"emptyTable": "No data in table",
"loadingRecords": '<i class="fa fa-refresh fa-spin"></i> Loading items...</div>'
},
},
"pagingType": "full_numbers",
"stateSave": true,
"processing": false,
@@ -172,7 +172,7 @@ history_table_options = {
},
"width": "33%",
"className": "datatable-wrap"
},
},
{
"targets": [7],
"data":"started",
@@ -270,7 +270,7 @@ history_table_options = {
});
if ($('#row-edit-mode').hasClass('active')) {
$('.delete-control').each(function () {
$('.history_table .delete-control').each(function () {
$(this).removeClass('hidden');
});
}
@@ -290,7 +290,9 @@ history_table_options = {
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
showMsg(msg, false, false, 0)
showMsg(msg, false, false, 0);
$('[data-toggle="tooltip"]').tooltip('destroy');
$('[data-toggle="popover"]').popover('destroy');
},
"rowCallback": function (row, rowData, rowIndex) {
if (rowData['group_count'] == 1) {
@@ -322,7 +324,7 @@ history_table_options = {
$(row).addClass('current-activity-row');
}
}
}
};
// Parent table platform modal
$('.history_table').on('click', '> tbody > tr > td.modal-control', function () {
@@ -464,7 +466,7 @@ function childTableOptions(rowData) {
});
if ($('#row-edit-mode').hasClass('active')) {
$('.delete-control').each(function () {
$('.history_table .delete-control').each(function () {
$(this).removeClass('hidden');
});
}

View File

@@ -113,7 +113,7 @@ login_log_table_options = {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
showMsg(msg, false, false, 0)
}
}
};
$('.login_log_table').on('click', '> tbody > tr > td.modal-control-ip', function () {
var tr = $(this).closest('tr');

View File

@@ -54,7 +54,7 @@ media_info_table_options = {
} else if (rowData['media_type'] === 'album') {
expand_details = '<span class="expand-media-info-tooltip" data-toggle="tooltip" title="Show Tracks"><i class="fa fa-plus-circle fa-fw"></i></span>';
$(td).html('<div><a href="#"><div style="float: left;">' + expand_details + '&nbsp;' + date + '</div></a></div>');
} else if (rowData['media_type'] === 'photo' && rowData['parent_rating_key'] == '') {
} else if (rowData['media_type'] === 'photo_album') {
expand_details = '<span class="expand-media-info-tooltip" data-toggle="tooltip" title="Show Photos"><i class="fa fa-plus-circle fa-fw"></i></span>';
$(td).html('<div><a href="#"><div style="float: left;">' + expand_details + '&nbsp;' + date + '</div></a></div>');
} else {
@@ -77,32 +77,44 @@ media_info_table_options = {
if (rowData['media_type'] === 'movie') {
if (rowData['year']) { parent_info = ' (' + rowData['year'] + ')'; }
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Movie"><i class="fa fa-film fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + parent_info + '</span>'
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + parent_info + '</span>';
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'show') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="TV Show"><i class="fa fa-television fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + '</span>'
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'season') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Season"><i class="fa fa-television fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + '</span>'
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'episode') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Episode"><i class="fa fa-television fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=art" data-height="80" data-width="140">E' + rowData['media_index'] + ' - ' + rowData['title'] + '</span>'
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=art" data-height="80" data-width="140">E' + rowData['media_index'] + ' - ' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left; padding-left: 30px;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'artist') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Artist"><i class="fa fa-music fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=cover" data-height="80" data-width="80">' + rowData['title'] + '</span>'
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=cover" data-height="80" data-width="80">' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'album') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Album"><i class="fa fa-music fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=cover" data-height="80" data-width="80">' + rowData['title'] + '</span>'
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=cover" data-height="80" data-width="80">' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'track') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Track"><i class="fa fa-music fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=cover" data-height="80" data-width="80">T' + rowData['media_index'] + ' - ' + rowData['title'] + '</span>'
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=300&fallback=cover" data-height="80" data-width="80">T' + rowData['media_index'] + ' - ' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><a href="info?rating_key=' + rowData['rating_key'] + '"><div style="float: left; padding-left: 30px;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'photo_album') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Photo Album"><i class="fa fa-camera fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></div>');
} else if (rowData['media_type'] === 'photo') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Photo"><i class="fa fa-picture-o fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></div>');
} else if (rowData['media_type'] === 'clip') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Video"><i class="fa fa-video-camera fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=art" data-height="80" data-width="140">' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></div>');
} else {
$(td).html(cellData);
}
@@ -335,7 +347,7 @@ function childTableOptionsMedia(rowData) {
case 'album':
section_type = 'track';
break;
case 'photo':
case 'photo_album':
section_type = 'picture';
break;
}

View File

@@ -98,7 +98,7 @@ sync_table_options = {
"data": "total_size",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData > 0 ) {
megabytes = Math.round((cellData/1024)/1024, 0)
megabytes = Math.round((cellData/1024)/1024, 0);
$(td).html(megabytes + 'MB');
} else {
$(td).html('0MB');
@@ -139,19 +139,28 @@ sync_table_options = {
// $('html,body').scrollTop(0);
$('#ajaxMsg').fadeOut();
if ($('#sync-row-edit-mode').hasClass('active')) {
$('.sync_table .delete-control').each(function () {
$(this).removeClass('hidden');
});
}
},
"preDrawCallback": function (settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
showMsg(msg, false, false, 0)
}
}
};
$('#sync_table').on('click', 'td.delete-control > .edit-sync-toggles > button.delete-sync', function () {
$('.sync_table').on('click', 'td.delete-control > .edit-sync-toggles > button.delete-sync', function () {
var tr = $(this).parents('tr');
var row = sync_table.row(tr);
var rowData = row.data();
var index_delete = syncs_to_delete.findIndex(x => x.client_id == rowData['client_id'] && x.sync_id == rowData['sync_id']);
var index_delete = syncs_to_delete.findIndex(function (x) {
return x.client_id === rowData['client_id'] && x.sync_id === rowData['sync_id'];
});
if (index_delete === -1) {
syncs_to_delete.push({ client_id: rowData['client_id'], sync_id: rowData['sync_id'] });

View File

@@ -3,7 +3,7 @@
<%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
</%def>
<%def name="body()">
@@ -180,18 +180,20 @@
% if _session['user_group'] == 'admin':
$("#refresh-libraries-list").click(function () {
showMsg('Refreshing libraries list...', true, false);
$.ajax({
url: 'refresh_libraries_list',
cache: false,
async: true,
success: function (data) {
showMsg('<i class="fa fa-refresh"></i>&nbspLibraries list refresh started...', false, true, 2000, false);
},
complete: function (data) {
showMsg('<i class="fa fa-check"></i>&nbspLibraries list refreshed.', false, true, 2000, false);
},
error: function (jqXHR, textStatus, errorThrown) {
showMsg('<i class="fa fa-exclamation-circle"></i>&nbspUnable to refresh libraries list.', false, true, 2000, true);
complete: function (xhr, status) {
var result = $.parseJSON(xhr.responseText);
var msg = result.message;
if (result.result == 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 2000, false);
libraries_list_table.draw();
} else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 2000, true);
}
}
});
});

View File

@@ -30,7 +30,7 @@ DOCUMENTATION :: END
<%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
</%def>
<%def name="body()">
@@ -382,7 +382,7 @@ DOCUMENTATION :: END
user_id: "${_session['user_id']}" == "None" ? null : "${_session['user_id']}"
};
}
}
};
history_table = $('#history_table-SID-${data["section_id"]}').DataTable(history_table_options);
var colvis = new $.fn.dataTable.ColVis(history_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 11] });
@@ -392,7 +392,13 @@ DOCUMENTATION :: END
}
$('a[href="#tabs-history"]').on('shown.bs.tab', function() {
loadHistoryTable();
if (typeof(history_table) === 'undefined') {
loadHistoryTable();
}
});
$("#refresh-history-list").click(function () {
history_table.draw();
});
% if _session['user_group'] == 'admin':
@@ -408,7 +414,7 @@ DOCUMENTATION :: END
refresh: refresh_table
};
}
}
};
media_info_table = $('#media_info_table-SID-${data["section_id"]}').DataTable(media_info_table_options);
var colvis = new $.fn.dataTable.ColVis(media_info_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
@@ -418,7 +424,9 @@ DOCUMENTATION :: END
}
$('a[href="#tabs-mediainfo"]').on('shown.bs.tab', function() {
loadMediaInfoTable();
if (typeof(media_info_table) === 'undefined') {
loadMediaInfoTable();
}
});
$("#refresh-media-info-table").click(function () {
@@ -484,10 +492,6 @@ DOCUMENTATION :: END
});
% endif
$("#refresh-history-list").click(function () {
history_table.draw();
});
function recentlyWatched() {
// Populate recently watched
$.ajax({

View File

@@ -29,7 +29,7 @@ DOCUMENTATION :: END
headers = {'movie': ('Movie Libraries', ('Movies', '', '')),
'show': ('TV Show Libraries', ('Shows', 'Seasons', 'Episodes')),
'artist': ('Music Libraries', ('Artists', 'Albums', 'Tracks')),
'photo': ('Photo Libraries', ('Albums', '', 'Photos'))}
'photo': ('Photo Libraries', ('Albums', 'Photos', 'Videos'))}
%>
% for section_type in types:
% if section_type in data:

View File

@@ -9,7 +9,7 @@
<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/plexpy.css${cache_param}" 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.min.css" rel="stylesheet">
@@ -37,21 +37,19 @@
<div class="row">
<div class="login-container">
<div class="login-logo">
<object data="${http_root}images/logo-tautulli.svg" type="image/svg+xml" style="height: 100px;"></object>
<img src="${http_root}images/logo-tautulli-100.png" height="100" alt="PlexPy">
</div>
<div class="row">
<div class="col-sm-6 col-sm-offset-3">
<form action="${http_root}auth/login" method="post">
% if msg:
<div class="alert alert-danger" style="text-align: center; padding: 8px;">
${msg}
<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>
% endif
<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" value="${username}" autofocus>
<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">
@@ -65,7 +63,7 @@
<input type="checkbox" id="remember_me" name="remember_me" title="for 30 days" value="1" checked="checked" /> Remember me
</label>
</div>
<button type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Sign In</button>
<button id="sign-in" type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Sign In</button>
</div>
</form>
</div>
@@ -75,5 +73,30 @@
</div>
</div>
<script src="${http_root}js/jquery-2.1.4.min.js"></script>
<script>
$('#login-form').submit(function(event) {
event.preventDefault();
$('#sign-in').prop('disabled', true).html('<i class="fa fa-refresh fa-spin"></i>&nbsp; Sign In');
$.ajax({
url: '${http_root}auth/signin',
type: 'POST',
data: $(this).serialize(),
dataType: 'json',
statusCode: {
200: function() {
window.location = "${http_root}";
},
401: function() {
$('#incorrect-login').show();
$('#username').focus();
}
},
complete: function() {
$('#sign-in').prop('disabled', false).html('<i class="fa fa-sign-in"></i>&nbsp; Sign In');
}
});
});
</script>
</body>
</html>

View File

@@ -5,7 +5,7 @@
<%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
<style>
td {word-break: break-all;}
</style>
@@ -21,9 +21,9 @@
<span><i class="fa fa-list-alt"></i> Logs</span>
</div>
<div class="button-bar">
<div class="btn-group" id="plexpy-log-levels">
<div class="btn-group" id="tautulli-log-levels">
<label>
<select name="plexpy-log-level-filter" id="plexpy-log-level-filter" class="btn" style="color: inherit;">
<select name="tautulli-log-level-filter" id="tautulli-log-level-filter" class="btn" style="color: inherit;">
<option value="">All log levels</option>
<option disabled>&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;</option>
<option value="DEBUG">Debug</option>
@@ -45,7 +45,7 @@
</select>
</label>
</div>
<button class="btn btn-dark" id="download-plexpylog"><i class="fa fa-download"></i> Download logs</button>
<button class="btn btn-dark" id="download-tautullilog"><i class="fa fa-download"></i> Download logs</button>
<button class="btn btn-dark" id="download-plexserverlog" style="display: none;"><i class="fa fa-download"></i> Download logs</button>
<button class="btn btn-dark" id="download-plexscannerlog" style="display: none;"><i class="fa fa-download"></i> Download logs</button>
<button class="btn btn-dark" id="clear-logs"><i class="fa fa-trash-o"></i> Clear logs</button>
@@ -56,17 +56,17 @@
<div class='table-card-back'>
<div>
<ul id="log_tabs" class="nav nav-pills" role="tablist">
<li role="presentation" class="active"><a id="plexpy-logs-btn" href="#tabs-plexpy_log" aria-controls="tabs-plexpy_log" role="tab" data-toggle="tab">Tautulli Logs</a></li>
<li role="presentation"><a id="plexpy-api-logs-btn" href="#tabs-plexpy_api_log" aria-controls="tabs-plexpy_api_log" role="tab" data-toggle="tab">Tautulli API Logs</a></li>
<li role="presentation" class="active"><a id="tautulli-logs-btn" href="#tabs-tautulli_log" aria-controls="tabs-tautulli_log" role="tab" data-toggle="tab">Tautulli Logs</a></li>
<li role="presentation"><a id="tautulli-api-logs-btn" href="#tabs-tautulli_api_log" aria-controls="tabs-tautulli_api_log" role="tab" data-toggle="tab">Tautulli API Logs</a></li>
<li role="presentation"><a id="plex-logs-btn" href="#tabs-plex_log" aria-controls="tabs-plex_log" role="tab" data-toggle="tab">Plex Media Server Logs</a></li>
<li role="presentation"><a id="plex-scanner-logs-btn" href="#tabs-plex_scanner_log" aria-controls="tabs-plex_scanner_log" role="tab" data-toggle="tab">Plex Media Scanner Logs</a></li>
<li role="presentation"><a id="plexpy-websocket-logs-btn" href="#tabs-plex_websocket_log" aria-controls="tabs-plex_websocket_log" role="tab" data-toggle="tab">Plex Websocket Logs</a></li>
<li role="presentation"><a id="plex-websocket-logs-btn" href="#tabs-plex_websocket_log" aria-controls="tabs-plex_websocket_log" role="tab" data-toggle="tab">Plex Websocket Logs</a></li>
<li role="presentation"><a id="notification-logs-btn" href="#tabs-notification_log" aria-controls="tabs-notification_log" role="tab" data-toggle="tab">Notification Logs</a></li>
<li role="presentation"><a id="login-logs-btn" href="#tabs-login_log" aria-controls="tabs-login_log" role="tab" data-toggle="tab">Login Logs</a></li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="tabs-plexpy_log" data-logfile="plexpy">
<table class="display" id="plexpy_log_table" width="100%">
<div role="tabpanel" class="tab-pane active" id="tabs-tautulli_log" data-logfile="tautulli">
<table class="display" id="tautulli_log_table" width="100%">
<thead>
<tr>
<th class="min-tablet" align="left" id="timestamp">Timestamp</th>
@@ -77,8 +77,8 @@
<tbody></tbody>
</table>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-plexpy_api_log" data-logfile="plexpy_api">
<table class="display" id="plexpy_api_log_table" width="100%">
<div role="tabpanel" class="tab-pane" id="tabs-tautulli_api_log" data-logfile="tautulli_api">
<table class="display" id="tautulli_api_log_table" width="100%">
<thead>
<tr>
<th class="min-tablet" align="left" id="timestamp">Timestamp</th>
@@ -195,8 +195,8 @@
<script>
$(document).ready(function() {
loadPlexPyLogs('plexpy', selected_log_level);
clearSearchButton('plexpy_log_table', log_table);
loadtautullilogs('tautulli', selected_log_level);
clearSearchButton('tautulli_log_table', log_table);
});
var log_levels = ['DEBUG', 'INFO', 'WARN', 'ERROR'];
@@ -227,7 +227,7 @@
}
var selected_log_level = null;
function loadPlexPyLogs(logfile, selected_log_level) {
function loadtautullilogs(logfile, selected_log_level) {
log_table_options.ajax = {
url: "get_log",
type: 'post',
@@ -238,10 +238,10 @@
log_level: selected_log_level
};
}
}
};
log_table = $('#' + logfile + '_log_table').DataTable(log_table_options);
$('#plexpy-log-level-filter').on('change', function () {
$('#tautulli-log-level-filter').on('change', function () {
selected_log_level = $(this).val() || null;
log_table.draw();
});
@@ -250,7 +250,7 @@
function loadPlexLogs() {
plex_log_table_options.ajax = {
url: "get_plex_log?log_type=server"
}
};
plex_log_table_options.initComplete = bindLogLevelFilter;
plex_log_table = $('#plex_log_table').DataTable(plex_log_table_options);
}
@@ -258,7 +258,7 @@
function loadPlexScannerLogs() {
plex_log_table_options.ajax = {
url: "get_plex_log?log_type=scanner"
}
};
plex_log_table_options.initComplete = bindLogLevelFilter;
plex_scanner_log_table = $('#plex_scanner_log_table').DataTable(plex_log_table_options);
}
@@ -271,7 +271,7 @@
json_data: JSON.stringify(d)
};
}
}
};
notification_log_table = $('#notification_log_table').DataTable(notification_log_table_options);
}
@@ -284,56 +284,56 @@
json_data: JSON.stringify(d)
};
}
}
};
login_log_table = $('#login_log_table').DataTable(login_log_table_options);
}
$("#plexpy-logs-btn").click(function () {
$("#plexpy-log-levels").show();
$("#tautulli-logs-btn").click(function () {
$("#tautulli-log-levels").show();
$("#plex-log-levels").hide();
$("#clear-logs").show();
$("#download-plexpylog").show()
$("#download-plexserverlog").hide()
$("#download-plexscannerlog").hide()
$("#download-tautullilog").show();
$("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide();
$("#clear-login-logs").hide();
loadPlexPyLogs('plexpy', selected_log_level);
clearSearchButton('plexpy_log_table', log_table);
loadtautullilogs('tautulli', selected_log_level);
clearSearchButton('tautulli_log_table', log_table);
});
$("#plexpy-api-logs-btn").click(function () {
$("#plexpy-log-levels").show();
$("#tautulli-api-logs-btn").click(function () {
$("#tautulli-log-levels").show();
$("#plex-log-levels").hide();
$("#clear-logs").show();
$("#download-plexpylog").show()
$("#download-plexserverlog").hide()
$("#download-plexscannerlog").hide()
$("#download-tautullilog").show();
$("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide();
$("#clear-login-logs").hide();
loadPlexPyLogs('plexpy_api', selected_log_level);
clearSearchButton('plexpy_api_log_table', log_table);
loadtautullilogs('tautulli_api', selected_log_level);
clearSearchButton('tautulli_api_log_table', log_table);
});
$("#plexpy-websocket-logs-btn").click(function () {
$("#plexpy-log-levels").show();
$("#plex-websocket-logs-btn").click(function () {
$("#tautulli-log-levels").show();
$("#plex-log-levels").hide();
$("#clear-logs").show();
$("#download-plexpylog").show()
$("#download-plexserverlog").hide()
$("#download-plexscannerlog").hide()
$("#download-tautullilog").show();
$("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide();
$("#clear-login-logs").hide();
loadPlexPyLogs('plex_websocket', selected_log_level);
loadtautullilogs('plex_websocket', selected_log_level);
clearSearchButton('plex_websocket_log_table', log_table);
});
$("#plex-logs-btn").click(function () {
$("#plexpy-log-levels").hide();
$("#tautulli-log-levels").hide();
$("#plex-log-levels").show();
$("#clear-logs").hide();
$("#download-plexpylog").hide()
$("#download-plexserverlog").show()
$("#download-plexscannerlog").hide()
$("#download-tautullilog").hide();
$("#download-plexserverlog").show();
$("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide();
$("#clear-login-logs").hide();
loadPlexLogs();
@@ -341,12 +341,12 @@
});
$("#plex-scanner-logs-btn").click(function () {
$("#plexpy-log-levels").hide();
$("#tautulli-log-levels").hide();
$("#plex-log-levels").show();
$("#clear-logs").hide();
$("#download-plexpylog").hide()
$("#download-plexserverlog").hide()
$("#download-plexscannerlog").show()
$("#download-tautullilog").hide();
$("#download-plexserverlog").hide();
$("#download-plexscannerlog").show();
$("#clear-notify-logs").hide();
$("#clear-login-logs").hide();
loadPlexScannerLogs();
@@ -354,12 +354,12 @@
});
$("#notification-logs-btn").click(function () {
$("#plexpy-log-levels").hide();
$("#tautulli-log-levels").hide();
$("#plex-log-levels").hide();
$("#clear-logs").hide();
$("#download-plexpylog").hide()
$("#download-plexserverlog").hide()
$("#download-plexscannerlog").hide()
$("#download-tautullilog").hide();
$("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide();
$("#clear-notify-logs").show();
$("#clear-login-logs").hide();
loadNotificationLogs();
@@ -367,12 +367,12 @@
});
$("#login-logs-btn").click(function () {
$("#plexpy-log-levels").hide();
$("#tautulli-log-levels").hide();
$("#plex-log-levels").hide();
$("#clear-logs").hide();
$("#download-plexpylog").hide()
$("#download-plexserverlog").hide()
$("#download-plexscannerlog").hide()
$("#download-tautullilog").hide();
$("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide();
$("#clear-login-logs").show();
loadLoginLogs();
@@ -384,9 +384,10 @@
});
$("#clear-logs").click(function () {
var logfile = $(".tab-pane.active").data('logfile')
var logfile = $(".tab-pane.active").data('logfile');
var title = $("#log_tabs li.active a").text();
$("#confirm-message").text("Are you sure you want to clear the Tautulli logs?");
$("#confirm-message").text("Are you sure you want to clear the " + title + "?");
$('#confirm-modal').modal();
$('#confirm-modal').one('click', '#confirm-button', function () {
$.ajax({
@@ -396,7 +397,7 @@
complete: function (xhr, status) {
result = $.parseJSON(xhr.responseText);
msg = result.message;
if (result.result == 'success') {
if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
} else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
@@ -407,7 +408,7 @@
});
});
$("#download-plexpylog").click(function () {
$("#download-tautullilog").click(function () {
var logfile = $(".tab-pane.active").data('logfile');
window.location.href = "download_log?logfile=" + logfile;
});
@@ -421,7 +422,7 @@
});
$("#clear-notify-logs").click(function () {
$("#confirm-message").text("Are you sure you want to clear the Tautulli notification logs?");
$("#confirm-message").text("Are you sure you want to clear the Tautulli Notification Logs?");
$('#confirm-modal').modal();
$('#confirm-modal').one('click', '#confirm-button', function () {
$.ajax({
@@ -430,7 +431,7 @@
complete: function (xhr, status) {
result = $.parseJSON(xhr.responseText);
msg = result.message;
if (result.result == 'success') {
if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
} else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
@@ -442,7 +443,7 @@
});
$("#clear-login-logs").click(function () {
$("#confirm-message").text("Are you sure you want to clear the Tautulli login logs?");
$("#confirm-message").text("Are you sure you want to clear the Tautulli Login Logs?");
$('#confirm-modal').modal();
$('#confirm-modal').one('click', '#confirm-button', function () {
$.ajax({
@@ -451,7 +452,7 @@
complete: function (xhr, status) {
result = $.parseJSON(xhr.responseText);
msg = result.message;
if (result.result == 'success') {
if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
} else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
@@ -472,10 +473,10 @@
{
clearInterval(timer);
}
if(refreshrate.value != 0)
if(refreshrate.value !== 0)
{
timer = setInterval(function() {
if ($("#tabs-plexpy_log").hasClass("active") || $("#tabs-plexpy_api_log").hasClass("active") || $("#tabs-plex_websocket_log").hasClass("active")) {
if ($("#tabs-tautulli_log").hasClass("active") || $("#tabs-tautulli_api_log").hasClass("active") || $("#tabs-plex_websocket_log").hasClass("active")) {
log_table.ajax.reload();
} else if ($("#tabs-plex_log").hasClass("active")) {
plex_log_table.ajax.reload();

View File

@@ -1,6 +1,10 @@
<%!
from plexpy import helpers, notifiers
import json
from plexpy import helpers, notifiers, users
available_notification_actions = notifiers.available_notification_actions()
user_emails = [{'user': u['friendly_name'] or u['username'], 'email': u['email']} for u in users.Users().get_users() if u['email']]
sorted(user_emails, key=lambda u: u['user'])
%>
% if notifier:
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
@@ -39,7 +43,7 @@
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-8">
<div class="col-md-12">
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
% if item['name'] == 'osx_notify_app':
<a href="javascript:void(0)" id="osxnotifyregister">Register</a>
@@ -62,7 +66,7 @@
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-8">
<div class="col-md-12">
<input type="button" class="btn btn-bright" id="${item['name']}" name="${item['name']}" value="${item['value']}">
</div>
</div>
@@ -80,7 +84,7 @@
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-8">
<div class="col-md-12">
<select class="form-control" id="${item['name']}" name="${item['name']}">
% for key, value in sorted(item['select_options'].iteritems()):
% if key == item['value']:
@@ -94,6 +98,33 @@
</div>
<p class="help-block">${item['description'] | n}</p>
</div>
% elif item['input_type'] == 'selectize':
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="${item['name']}" name="${item['name']}">
<option value="select-all">Select All</option>
<option value="remove-all">Remove All</option>
% if isinstance(item['select_options'], dict):
% for section, options in item['select_options'].iteritems():
<optgroup label="${section}">
% for option in sorted(options, key=lambda x: x['text'].lower()):
<option value="${option['value']}">${option['text']}</option>
% endfor
</optgroup>
% endfor
% else:
<option value="border-all"></option>
% for option in sorted(item['select_options'], key=lambda x: x['text'].lower()):
<option value="${option['value']}">${option['text']}</option>
% endfor
% endif
</select>
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
</div>
% endif
% endfor
</div>
@@ -101,7 +132,7 @@
<div class="form-group">
<label for="friendly_name">Description</label>
<div class="row">
<div class="col-md-8">
<div class="col-md-12">
<input type="text" class="form-control" id="friendly_name" name="friendly_name" value="${notifier['friendly_name']}" size="30">
</div>
</div>
@@ -132,7 +163,7 @@
<div role="tabpanel" class="tab-pane" id="tabs-notify_conditions">
<label>Notification Conditions</label>
<p class="help-block">
Add custom conditions to filter out notifications.
Add custom conditions to only <em>allow certain notifications</em>. By default, all notifications will be sent if there are no conditions.
<a href="#notify-text-sub-modal" data-toggle="modal">Click here</a> for a description of all the parameters.
</p>
<div id="condition-widget"></div>
@@ -185,7 +216,7 @@
</div>
<div class="form-group">
<div class="row">
<div class="col-md-8">
<div class="col-md-12">
<input type="button" class="btn btn-bright notifier-text-preview" data-action="${action['name']}" value="Preview Arguments">
</div>
</div>
@@ -212,7 +243,7 @@
</div>
<div class="form-group">
<div class="row">
<div class="col-md-8">
<div class="col-md-12">
<input type="button" class="btn btn-bright notifier-text-preview" data-action="${action['name']}" value="Preview Text">
</div>
</div>
@@ -278,7 +309,7 @@
% endif
<div class="form-group">
<div class="row">
<div class="col-md-8">
<div class="col-md-12">
<input type="button" class="btn btn-bright" id="test_notifier" name="test_notifier" value="Test ${notifier['agent_label']}">
</div>
</div>
@@ -302,7 +333,7 @@
$('#notifier-config-modal').unbind('hidden.bs.modal');
// Need this for setting conditions since conditions contain the character "
$('#custom_conditions').val('${notifier['custom_conditions'] | n}')
$('#custom_conditions').val(${json.dumps(notifier["custom_conditions"]) | n});
$('#condition-widget').filterer({
parameters: ${parameters | n},
@@ -310,8 +341,23 @@
updateConditions: function(newConditions){
$('#custom_conditions').val(JSON.stringify(newConditions));
}
})
});
function setNegativeOperator(select) {
if (select.val() === 'does not contain' || select.val() === 'is not') {
select.closest('.form-group').find('.react-selectize-search-field-and-selected-values').addClass('negative-operator');
} else {
select.closest('.form-group').find('.react-selectize-search-field-and-selected-values').removeClass('negative-operator');
}
}
$('#condition-widget select[name=operator]').each(function () {
setNegativeOperator($(this));
});
$('#condition-widget').on('change', 'select[name=operator]', function () {
setNegativeOperator($(this));
});
function reloadModal() {
$.ajax({
url: 'get_notifier_config_modal',
@@ -328,7 +374,7 @@
if (jqXHR) {
var result = $.parseJSON(jqXHR.responseText);
var msg = result.message;
if (result.result == 'success') {
if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
} else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
@@ -388,7 +434,7 @@
% if notifier['agent_name'] == 'facebook':
function disableFacebookRequest() {
if ($('#facebook_app_id').val() != '' && $('#facebook_app_secret').val() != '') { $('#facebook_facebookStep1').prop('disabled', false); }
if ($('#facebook_app_id').val() !== '' && $('#facebook_app_secret').val() !== '') { $('#facebook_facebookStep1').prop('disabled', false); }
else { $('#facebook_facebookStep1').prop('disabled', true); }
}
disableFacebookRequest();
@@ -402,19 +448,20 @@
$('#facebook_redirect_uri').val($('#facebook_redirect_uri').val().slice(0, -1));
}
var facebook_token;
$.ajax({
url: 'facebookStep1',
data: {
app_id: $('#facebook_app_id').val(),
app_secret: $('#facebook_app_secret').val(),
redirect_uri: $('#facebook_redirect_uri').val(),
redirect_uri: $('#facebook_redirect_uri').val()
},
cache: false,
async: true,
complete: function (xhr, status) {
var result = $.parseJSON(xhr.responseText);
var msg = result.msg;
if (result.result == 'success') {
if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
window.open(result.url);
@@ -453,18 +500,128 @@
$('#notifier-config-modal').on('hidden.bs.modal', function () {
facebook_token = false;
})
});
% elif notifier['agent_name'] == 'browser':
$('#browser_allow_browser').click(function () {
PNotify.desktop.permission();
})
});
% elif notifier['agent_name'] == 'osx':
$('#osxnotifyregister').click(function () {
var osx_notify_app = $('#osx_notify_app').val();
$.get('osxnotifyregister', { 'app': osx_notify_app }, function (data) { showMsg('<i class="fa fa-check"></i> ' + data, false, true, 3000); });
})
});
% elif notifier['agent_name'] == 'email':
var REGEX_EMAIL = '([a-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&\'*+/=?^_`{|}~-]+)*@' +
'(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)';
var $email_selectors = $('#email_to, #email_cc, #email_bcc').selectize({
plugins: ['remove_button'],
maxItems: null,
render: {
item: function(item, escape) {
return '<div>' +
(item.text ? '<span class="item-text">' + escape(item.text) + '</span>' : '') +
(item.value ? '<span class="item-value">' + escape(item.value) + '</span>' : '') +
'</div>';
},
option: function(item, escape) {
var label = item.text || item.value;
var caption = item.text ? item.value : null;
if (item.value.endsWith('-all')) {
return '<div class="' + item.value + '">' + escape(label) + '</div>'
}
return '<div>' +
escape(label) +
(caption ? '<span class="caption">' + escape(caption) + '</span>' : '') +
'</div>';
}
},
onItemAdd: function(value) {
if (value === 'select-all') {
var all_keys = $.map(this.options, function(option){
return option.value.endsWith('-all') ? null : option.value;
});
this.setValue(all_keys);
} else if (value === 'remove-all') {
this.clear();
this.refreshOptions();
this.positionDropdown();
}
},
createFilter: function(input) {
var match, regex;
// email@address.com
regex = new RegExp('^' + REGEX_EMAIL + '$', 'i');
match = input.match(regex);
if (match) return !this.options.hasOwnProperty(match[0]);
// user <email@address.com>
regex = new RegExp('^([^<]*)\<' + REGEX_EMAIL + '\>$', 'i');
match = input.match(regex);
if (match) return !this.options.hasOwnProperty(match[2]);
return false;
},
create: function(input) {
if ((new RegExp('^' + REGEX_EMAIL + '$', 'i')).test(input)) {
return {value: input};
}
var match = input.match(new RegExp('^([^<]*)\<' + REGEX_EMAIL + '\>$', 'i'));
if (match) {
return {
value : match[2],
text : $.trim(match[1])
};
}
return false;
}
});
var email_to = $email_selectors[0].selectize;
var email_cc = $email_selectors[1].selectize;
var email_bcc = $email_selectors[2].selectize;
email_to.setValue(${json.dumps(next((c['value'] for c in notifier['config_options'] if c['name'] == 'email_to'), [])) | n});
email_cc.setValue(${json.dumps(next((c['value'] for c in notifier['config_options'] if c['name'] == 'email_cc'), [])) | n});
email_bcc.setValue(${json.dumps(next((c['value'] for c in notifier['config_options'] if c['name'] == 'email_bcc'), [])) | n});
% elif notifier['agent_name'] == 'join':
var $join_device_names = $('#join_device_names').selectize({
plugins: ['remove_button'],
maxItems: null,
create: true
});
var join_device_names = $join_device_names[0].selectize;
join_device_names.setValue(${json.dumps(next((c['value'] for c in notifier['config_options'] if c['name'] == 'join_device_names'), [])) | n});
% elif notifier['agent_name'] == 'zapier':
$('#zapier_test_hook').click(function () {
$.get('zapier_test_hook', { 'zapier_hook': $('#zapier_hook').val() }, function (data) {
if (data.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + data.msg, false, true, 5000);
} else {
showMsg('<i class="fa fa-times"></i> ' + data.msg, false, true, 5000, true);
}
});
});
% elif notifier['agent_name'] == 'pushover':
function pushoverPriority() {
if ($('#pushover_priority').val() == '2') {
$('#pushover_retry').closest('.form-group').show();
$('#pushover_expire').closest('.form-group').show();
} else {
$('#pushover_retry').closest('.form-group').hide();
$('#pushover_expire').closest('.form-group').hide();
}
}
pushoverPriority();
$('#pushover_priority').change( function () {
pushoverPriority();
});
% endif
function validateLogic() {
@@ -595,7 +752,7 @@
});
function sendTestNotification() {
if ('${notifier["agent_name"]}' != 'browser') {
if ('${notifier["agent_name"]}' !== 'browser') {
$.ajax({
url: 'send_notification',
data: {
@@ -619,7 +776,7 @@
}
});
} else {
if ($('#browser_auto_hide_delay').val() == "0") {
if ($('#browser_auto_hide_delay').val() === "0") {
PNotify.prototype.options.hide = false;
} else {
PNotify.prototype.options.hide = true;

View File

@@ -42,7 +42,7 @@ DOCUMENTATION :: END
<td>${arrow.get(next_run_interval).format('HH:mm:ss')}</td>
<td>${arrow.get(sched_job.next_run_time).format('YYYY-MM-DD HH:mm:ss')}</td>
</tr>
% elif job in ('Check for active sessions', 'Check for recently added items') and plexpy.WS_CONNECTED:
% elif job in ('Check for server response', 'Check for active sessions', 'Check for recently added items') and plexpy.WS_CONNECTED:
<tr>
<td>${job}</td>
<td><i class="fa fa-sm fa-fw fa-check"></i> Websocket</td>

File diff suppressed because it is too large Load Diff

View File

@@ -41,13 +41,16 @@
<%def name="javascriptIncludes()">
<script>
// Remove the update bar
$('#updatebar').remove();
// Use p.countdown as container, pass redirect, duration, and optional message
$(".countdown").countdown(reloadPage, ${timer}, "");
$('#state-change-modal').modal({
keyboard: false
})
// Make modal visible
$('#state-change-modal').modal('show')
$('#state-change-modal').modal({
backdrop: 'static',
keyboard: false
}).show();
// Redirect to home page after countdown.
function reloadPage() {

View File

@@ -2,7 +2,7 @@
<%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
<style>
td {word-wrap: break-word}
@@ -20,13 +20,23 @@
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
<div class="alert alert-danger alert-edit" role="alert" id="row-edit-mode-alert"><i class="fa fa-exclamation-triangle"></i>&nbspSelect syncs to delete. Data is deleted upon exiting edit mode.</div>
<div class="alert alert-danger alert-edit" role="alert" id="sync-row-edit-mode-alert"><i class="fa fa-exclamation-triangle"></i>&nbspSelect syncs to delete. Data is deleted upon exiting delete mode.</div>
<div class="btn-group">
<button class="btn btn-danger btn-edit" data-toggle="button" aria-pressed="false" autocomplete="off" id="row-edit-mode">
<i class="fa fa-pencil"></i> Edit mode
<button class="btn btn-danger btn-edit" data-toggle="button" aria-pressed="false" autocomplete="off" id="sync-row-edit-mode">
<i class="fa fa-trash-o"></i> Delete mode
</button>&nbsp
</div>
% endif
% if _session['user_group'] == 'admin':
<div class="btn-group" id="user-selection">
<label>
<select name="sync-user" id="sync-user" class="btn" style="color: inherit;">
<option value="">All Users</option>
<option disabled>&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;</option>
</select>
</label>
</div>
% endif
<div class="btn-group">
<button class="btn btn-dark refresh-syncs-button" id="refresh-syncs-list"><i class="fa fa-refresh"></i> Refresh synced items</button>
</div>
@@ -34,7 +44,7 @@
</div>
</div>
<div class='table-card-back'>
<table class="display" id="sync_table" width="100%">
<table class="display sync_table" id="sync_table" width="100%">
<thead>
<tr>
<th align="left" id="delete_row">Delete</th>
@@ -87,21 +97,49 @@
<script src="${http_root}js/tables/sync_table.js${cache_param}"></script>
<script>
$(document).ready(function() {
sync_table_options.ajax = {
url: 'get_sync',
data: function (d) {
d.user_id = "${_session['user_id']}" == "None" ? null : "${_session['user_id']}"
// Load user ids and names (for the selector)
$.ajax({
url: 'get_user_names',
type: 'get',
dataType: 'json',
success: function (data) {
var select = $('#sync-user');
data.sort(function (a, b) {
return a.friendly_name.localeCompare(b.friendly_name);
});
data.forEach(function (item) {
select.append('<option value="' + item.user_id + '">' +
item.friendly_name + '</option>');
});
}
}
sync_table = $('#sync_table').DataTable(sync_table_options);
var colvis = new $.fn.dataTable.ColVis( sync_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0] } );
$( colvis.button() ).appendTo('div.colvis-button-bar');
});
clearSearchButton('sync_table', sync_table);
function loadSyncTable(selected_user_id) {
sync_table_options.ajax = {
url: 'get_sync?user_id=' + selected_user_id
};
sync_table = $('#sync_table').DataTable(sync_table_options);
var colvis = new $.fn.dataTable.ColVis(sync_table, {
buttonText: '<i class="fa fa-columns"></i> Select columns',
buttonClass: 'btn btn-dark',
exclude: [0]
});
$(colvis.button()).appendTo('div.colvis-button-bar');
clearSearchButton('sync_table', sync_table);
$('#sync-user').on('change', function () {
selected_user_id = $(this).val() || null;
sync_table.ajax.url('get_sync?user_id=' + selected_user_id).load();
});
}
var selected_user_id = "${_session['user_id']}" == "None" ? null : "${_session['user_id']}";
loadSyncTable(selected_user_id);
% if _session['user_group'] == 'admin':
$('#row-edit-mode').on('click', function() {
$('#row-edit-mode-alert').fadeIn(200);
$('#sync-row-edit-mode').on('click', function() {
$('#sync-row-edit-mode-alert').fadeIn(200);
if ($(this).hasClass('active')) {
if (syncs_to_delete.length > 0) {
@@ -123,13 +161,13 @@
}
});
});
sync_table.draw();
sync_table.ajax.reload();
});
}
$('.delete-control').each(function () {
$(this).addClass('hidden');
$('#row-edit-mode-alert').fadeOut(200);
$('#sync-row-edit-mode-alert').fadeOut(200);
});
} else {
@@ -144,7 +182,7 @@
});
$("#refresh-syncs-list").click(function() {
sync_table.draw();
sync_table.ajax.reload();
});
</script>
</%def>

View File

@@ -32,7 +32,7 @@ DOCUMENTATION :: END
<%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
</%def>
<%def name="body()">
@@ -213,13 +213,25 @@ DOCUMENTATION :: END
</span>
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
<div class="alert alert-danger alert-edit" role="alert" id="sync-row-edit-mode-alert"><i class="fa fa-exclamation-triangle"></i>&nbspSelect syncs to delete. Data is deleted upon exiting delete mode.</div>
<div class="btn-group">
<button class="btn btn-danger btn-edit" data-toggle="button" aria-pressed="false" autocomplete="off" id="sync-row-edit-mode">
<i class="fa fa-trash-o"></i> Delete mode
</button>&nbsp
</div>
% endif
<div class="btn-group">
<button class="btn btn-dark refresh-syncs-button" id="refresh-syncs-list"><i class="fa fa-refresh"></i> Refresh synced items</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-sync"></div>
</div>
</div>
<div class="table-card-back">
<table class="display" id="sync_table-UID-${data['user_id']}" width="100%">
<table class="display sync_table" id="sync_table-UID-${data['user_id']}" width="100%">
<thead>
<tr>
<th align="left" id="delete_row">Delete</th>
<th align="left" id="state">State</th>
<th align="left" id="username">Username</th>
<th align="left" id="sync_title">Title</th>
@@ -252,6 +264,11 @@ DOCUMENTATION :: END
</strong>
</span>
</div>
<div class="button-bar">
<div class="btn-group">
<button class="btn btn-dark refresh-ip-address-button" id="refresh-ip-address-list"><i class="fa fa-refresh"></i> Refresh IP addresses</button>
</div>
</div>
</div>
<div class="table-card-back">
<table class="display user_ip_table" id="user_ip_table-UID-${data['user_id']}" width="100%">
@@ -284,6 +301,9 @@ DOCUMENTATION :: END
</span>
</div>
<div class="button-bar">
<div class="btn-group">
<button class="btn btn-dark refresh-login-button" id="refresh-login-list"><i class="fa fa-refresh"></i> Refresh logins</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-login"></div>
</div>
</div>
@@ -298,6 +318,7 @@ DOCUMENTATION :: END
<th align="left" id="host">Host</th>
<th align="left" id="os">Operating System</th>
<th align="left" id="browser">Browser</th>
<th align="left" id="login_success"></th>
</tr>
</thead>
<tbody></tbody>
@@ -351,7 +372,7 @@ DOCUMENTATION :: END
<h4 class="modal-title" id="myModalLabel">Confirm Delete</h4>
</div>
<div class="modal-body" style="text-align: center;">
<p>Are you REALLY sure you want to delete <strong><span id="deleteCount"></span></strong> history item(s)?</p>
<p>Are you REALLY sure you want to delete <strong><span id="deleteCount"></span></strong> <span id="deleteType"></span> item(s)?</p>
<p>This is permanent and cannot be undone!</p>
</div>
<div class="modal-footer">
@@ -388,11 +409,6 @@ DOCUMENTATION :: END
$.fn.dataTable.tables({ visible: true, api: true }).columns.adjust();
});
$('a[href="#tabs-profile"]').on('shown.bs.tab', function() {
var media_type = null;
loadHistoryTable(media_type);
});
function loadHistoryTable(media_type) {
// Build watch history table
history_table_options.ajax = {
@@ -405,7 +421,7 @@ DOCUMENTATION :: END
media_type: media_type
};
}
}
};
history_table = $('#history_table-UID-${data["user_id"]}').DataTable(history_table_options);
history_table.column(2).visible(false);
@@ -423,29 +439,21 @@ DOCUMENTATION :: END
});
}
$('a[href="#tabs-history"]').on('shown.bs.tab', function() {
var media_type = null;
loadHistoryTable(media_type);
});
$('a[href="#tabs-synceditems"]').on('shown.bs.tab', function() {
function loadSyncTable() {
// Build user sync table
sync_table_options.ajax = {
url: 'get_sync',
data: function(d) {
d.user_id = user_id;
}
}
url: 'get_sync?user_id=' + user_id
};
sync_table = $('#sync_table-UID-${data["user_id"]}').DataTable(sync_table_options);
sync_table.column(1).visible(false);
sync_table.column(2).visible(false);
var colvis_sync = new $.fn.dataTable.ColVis( sync_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' } );
var colvis_sync = new $.fn.dataTable.ColVis( sync_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0] } );
$( colvis_sync.button() ).appendTo('#button-bar-sync');
clearSearchButton('sync_table-UID-${data["user_id"]}', sync_table);
});
}
$('a[href="#tabs-ipaddresses"]').on('shown.bs.tab', function() {
function loadIPAddressTable() {
// Build user IP table
user_ip_table_options.ajax = {
url: 'get_user_ips',
@@ -456,27 +464,71 @@ DOCUMENTATION :: END
user_id: user_id
};
}
}
};
user_ip_table = $('#user_ip_table-UID-${data["user_id"]}').DataTable(user_ip_table_options);
clearSearchButton('user_ip_table-UID-${data["user_id"]}', user_ip_table);
});
}
$('a[href="#tabs-tautullilogins"]').on('shown.bs.tab', function() {
function loadLoginTable() {
// Build user login table
login_log_table_options.ajax = {
url: 'get_user_logins',
data: function(d) {
d.user_id = user_id;
return {
json_data: JSON.stringify(d),
user_id: user_id
};
}
}
};
login_log_table = $('#login_log_table-UID-${data["user_id"]}').DataTable(login_log_table_options);
login_log_table.columns([1, 2]).visible(false);
var colvis_login = new $.fn.dataTable.ColVis( login_log_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' } );
var colvis_login = new $.fn.dataTable.ColVis( login_log_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [7] } );
$( colvis_login.button() ).appendTo('#button-bar-login');
clearSearchButton('login_log_table-UID-${data["user_id"]}', login_log_table);
}
$('a[href="#tabs-history"]').on('shown.bs.tab', function() {
if (typeof(history_table) === 'undefined') {
var media_type = null;
loadHistoryTable(media_type);
}
});
$('a[href="#tabs-synceditems"]').on('shown.bs.tab', function() {
if (typeof(sync_table) === 'undefined') {
loadSyncTable(user_id);
}
});
$('a[href="#tabs-ipaddresses"]').on('shown.bs.tab', function() {
if (typeof(user_ip_table) === 'undefined') {
loadIPAddressTable(user_id);
}
});
$('a[href="#tabs-tautullilogins"]').on('shown.bs.tab', function() {
if (typeof(login_log_table) === 'undefined') {
loadLoginTable(user_id);
}
});
$("#refresh-history-list").click(function () {
history_table.draw();
});
$("#refresh-syncs-list").click(function() {
sync_table.ajax.reload();
});
$("#refresh-ip-address-list").click(function () {
user_ip_table.draw();
});
$("#refresh-login-list").click(function () {
login_log_table.draw();
});
% if _session['user_group'] == 'admin':
@@ -502,6 +554,7 @@ DOCUMENTATION :: END
if ($(this).hasClass('active')) {
if (history_to_delete.length > 0) {
$('#deleteCount').text(history_to_delete.length);
$('#deleteType').text('history');
$('#confirm-modal-delete').modal();
$('#confirm-modal-delete').one('click', '#confirm-delete', function () {
history_to_delete.forEach(function(row, idx) {
@@ -520,14 +573,56 @@ DOCUMENTATION :: END
});
}
$('.delete-control').each(function () {
$('.history_table .delete-control').each(function () {
$(this).addClass('hidden');
$('#row-edit-mode-alert').fadeOut(200);
});
} else {
history_to_delete = [];
$('.delete-control').each(function() {
$('.history_table .delete-control').each(function() {
$(this).find('button.btn-danger').toggleClass('btn-warning').toggleClass('btn-danger');
$(this).removeClass('hidden');
});
}
});
$('#sync-row-edit-mode').on('click', function() {
$('#sync-row-edit-mode-alert').fadeIn(200);
if ($(this).hasClass('active')) {
if (syncs_to_delete.length > 0) {
$('#deleteCount').text(syncs_to_delete.length);
$('#deleteType').text('sync');
$('#confirm-modal-delete').modal();
$('#confirm-modal-delete').one('click', '#confirm-delete', function () {
syncs_to_delete.forEach(function(row, idx) {
$.ajax({
url: 'delete_sync_rows',
type: 'POST',
data: {
client_id: row.client_id,
sync_id: row.sync_id
},
async: true,
success: function (data) {
var msg = "Sync deleted";
showMsg(msg, false, true, 2000);
}
});
});
sync_table.ajax.reload();
});
}
$('.sync_table .delete-control').each(function () {
$(this).addClass('hidden');
$('#sync-row-edit-mode-alert').fadeOut(200);
});
} else {
syncs_to_delete = [];
$('.sync_table .delete-control').each(function() {
$(this).find('button.btn-danger').toggleClass('btn-warning').toggleClass('btn-danger');
$(this).removeClass('hidden');
});
@@ -535,10 +630,6 @@ DOCUMENTATION :: END
});
% endif
$("#refresh-history-list").click(function () {
history_table.draw();
});
function recentlyWatched() {
// Populate recently watched
$.ajax({

View File

@@ -3,7 +3,7 @@
<%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css">
<link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
<link rel="stylesheet" href="${http_root}css/plexpy-dataTables.css">
<link rel="stylesheet" href="${http_root}css/tautulli-dataTables.css">
</%def>
<%def name="body()">
@@ -184,18 +184,20 @@
% if _session['user_group'] == 'admin':
$("#refresh-users-list").click(function() {
showMsg('Refreshing users list...', true, false);
$.ajax({
url: 'refresh_users_list',
cache: false,
async: true,
success: function(data) {
showMsg('<i class="fa fa-check"></i>&nbspUsers list refresh started...', false, true, 2000, false);
},
complete: function (data) {
showMsg('<i class="fa fa-check"></i>&nbspUsers list refreshed.', false, true, 2000, false);
},
error: function (jqXHR, textStatus, errorThrown) {
showMsg('<i class="fa fa-exclamation-circle"></i>&nbspUnable to refresh users list.', false, true, 2000, true);
complete: function (xhr, status) {
var result = $.parseJSON(xhr.responseText);
var msg = result.message;
if (result.result == 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 2000, false);
users_list_table.draw();
} else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 2000, true);
}
}
});
});

View File

@@ -1,6 +1,6 @@
<%
import plexpy
from plexpy import common
from plexpy import common, helpers
%>
<!doctype html>
@@ -14,7 +14,7 @@
<meta name="author" content="">
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet">
<link href="${http_root}css/bootstrap-wizard.css" rel="stylesheet">
<link href="${http_root}css/plexpy.css${cache_param}" rel="stylesheet">
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet">
<link href="${http_root}css/opensans.min.css" rel="stylesheet">
<link href="${http_root}css/font-awesome.min.css" rel="stylesheet">
@@ -47,11 +47,11 @@
<body>
<div class="container-fluid">
<div class="row">
<div class="wizard" id="some-wizard" data-title="Tautulli Setup Wizard">
<div class="wizard" id="setup-wizard" data-title="Tautulli Setup Wizard">
<form>
<div class="wizard-card" data-cardname="card1">
<div style="float: right;">
<object data="${http_root}images/logo-tautulli.svg" type="image/svg+xml" style="height: 45px;"></object>
<img src="${http_root}images/logo-tautulli-45.png" height="45" alt="PlexPy">
</div>
<h3 style="line-height: 50px;">Welcome!</h3>
<br />
@@ -82,22 +82,26 @@
</div>
</div>
</div>
<input type="hidden" class="form-control pms-auth" name="pms_token" id="pms_token" value="${config['pms_token']}" data-validate="validatePMStoken">
<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>
</div>
<div class="wizard-card" data-cardname="card3">
<h3>Plex Media Server</h3>
<p class="help-block">Enter your Plex Server details and then click the Verify button to make sure Tautulli can reach the server.</p>
<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>
<div class="row">
<div class="col-xs-8">
<select id="pms_ip" name="pms_ip"></select>
<div class="col-xs-12">
<select class="form-control selectize-pms-ip" id="pms_ip" name="pms_ip">
<option value="${config['pms_ip']}" selected>${config['pms_ip']}</option>
</select>
</div>
</div>
</div>
<div class="wizard-input-section">
<label for="pms_port">Port Number</label>
<label for="pms_port">Plex Port</label>
<div class="row">
<div class="col-xs-3">
<input type="text" class="form-control pms_settings" name="pms_port" id="pms_port" placeholder="32400" value="${config['pms_port']}" required>
@@ -105,20 +109,23 @@
<div class="col-xs-4">
<div class="checkbox">
<label>
<input type="checkbox" id="pms_ssl" name="pms_ssl" value="1"> Use SSL
<input type="checkbox" id="pms_ssl_checkbox" class="checkbox-toggle" data-id="pms_ssl" value="1" ${helpers.checked(config['pms_ssl'])}> Use SSL
<input type="hidden" id="pms_ssl" name="pms_ssl" value="${config['pms_ssl']}">
</label>
</div>
</div>
<div class="col-xs-4">
<div class="checkbox">
<label>
<input type="checkbox" id="pms_is_remote" name="pms_is_remote" value="1"> Remote Server
<input type="checkbox" id="pms_is_remote_checkbox" class="checkbox-toggle" data-id="pms_is_remote" value="1" ${helpers.checked(config['pms_is_remote'])}> Remote Server
<input type="hidden" id="pms_is_remote" name="pms_is_remote" value="${config['pms_is_remote']}">
</label>
</div>
</div>
</div>
</div>
<input type="hidden" class="form-control pms-settings" id="pms_valid" data-validate="validatePMSip" value="">
<input type="hidden" id="pms_is_cloud" name="pms_is_cloud" value="${config['pms_is_cloud']}">
<input type="hidden" class="form-control pms-settings" 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>
</div>
@@ -200,106 +207,6 @@
<script src="${http_root}js/script.js${cache_param}"></script>
<script src="${http_root}js/bootstrap-wizard.min.js"></script>
<script>
$(document).ready(function() {
$.fn.wizard.logging = false;
var options = {
keyboard : false,
contentHeight : 400,
contentWidth : 700,
backdrop: 'static',
buttons: {submitText: 'Finish'},
submitUrl: "configUpdate"
};
var wizard = $("#some-wizard").wizard(options);
wizard.show();
wizard.on("submit", function(wizard) {
// Probably should not success before we know, but hopefully validation is good enough.
wizard.submitSuccess();
$.ajax({
url: "configUpdate",
type: "POST",
url: wizard.args.submitUrl,
data: wizard.serialize(),
dataType: "json",
complete: function (data) {
$(".countdown").countdown(function () { location.reload(); }, 5, "");
}
})
});
$select_pms = $('#pms_ip').selectize({
create: true,
createOnBlur: true,
openOnFocus: true,
maxItems: 1,
closeAfterSelect: true,
onInitialize: function () {
var s = this;
this.revertSettings.$children.each(function () {
$.extend(s.options[this.value], $(this).data());
});
},
render: {
option: function (item, escape) {
return '<div data-use_ssl="' + item.httpsRequired + '" data-local="' + item.local + '" data-ci="' + item.clientIdentifier + '" data-ip="' + item.ip + '" data-port="' + item.port + '" data-label="' + item.label + '">' + item.value + ' (' + item.label + ')</div>';
},
item: function (item, escape) {
// first item is rendered before initialization bug?
if (!item.ci) {
$.extend(item,
$(this.revertSettings.$children)
.filter('[value="' + item.value + '"]').data());
}
return '<div data-use_ssl="' + item.httpsRequired + '" data-local="' + item.local + '" data-ci="' + item.clientIdentifier + '" data-ip="' + item.ip + '" data-port="' + item.port + '" data-label="' + item.label + '">' + item.value + ' (' + item.label + ')</div>';
}
},
onChange: function (item) {
var ci = $('.selectize-input').find('div').attr('data-ci');
var port = $('.selectize-input').find('div').attr('data-port')
var local = $('.selectize-input').find('div').attr('data-local')
var ssl = $('.selectize-input').find('div').attr('data-use_ssl')
$("#pms-verify-status").html("");
// If a option was added by a user its
// data-xxx="undefined"
if (ci != "undefined") {
// To allow next step in the guide.
// servers with clientIdentifier is verified
$("#pms_identifier").val(ci);
$("#pms_valid").val("valid");
$("#pms-verify-status").html('<i class="fa fa-check"></i> Server found!').show();
} else {
// Self made options must be verified
$("#pms_valid").val("");
$("#pms-verify-status").html("").hide();
}
// If the server is verified set the correct port
if (port != "undefined") {
$('#pms_port').val(port);
} else {
// set default port
$('#pms_port').val("32400");
}
if (local != "undefined" && local == '0') {
$('#pms_is_remote').prop('checked', true);
} else {
$('#pms_is_remote').prop('checked', false);
}
if (ssl != "undefined" && ssl == "1") {
$('#pms_ssl').prop('checked', true);
} else {
$('#pms_ssl').prop('checked', false);
}
}
});
});
function validatePMSip(el) {
var valid_pms_ip = el.val();
var retValue = {};
@@ -352,6 +259,146 @@
return $.isNumeric(n) && (Math.floor(n) == n) && (n >= 0)
}
$(document).ready(function() {
$.fn.wizard.logging = false;
var options = {
keyboard : false,
contentHeight : 400,
contentWidth : 700,
backdrop: 'static',
buttons: {submitText: 'Finish'},
submitUrl: "configUpdate"
};
var wizard = $("#setup-wizard").wizard(options);
wizard.show();
// Change button classes
wizard.find('.wizard-back').addClass('btn-dark');
wizard.on('incrementCard', function(wizard) {
wizard.find('.wizard-next.btn-success').removeClass('btn-success').addClass('btn-bright');
});
wizard.on('decrementCard', function(wizard) {
wizard.find('.wizard-next').removeClass('btn-bright').text('Next');
});
wizard.on("submit", function(wizard) {
// Probably should not success before we know, but hopefully validation is good enough.
wizard.submitSuccess();
$.ajax({
type: "POST",
url: wizard.args.submitUrl,
data: wizard.serialize(),
dataType: "json",
complete: function (data) {
$(".countdown").countdown(function () { location.reload(); }, 5, "");
}
})
});
$('.checkbox-toggle').click(function () {
var configToggle = $(this).data('id');
if ($(this).is(':checked')) {
$('#'+configToggle).val(1);
} else {
$('#'+configToggle).val(0);
}
});
var $select_pms = $('#pms_ip').selectize({
createOnBlur: true,
openOnFocus: true,
maxItems: 1,
closeAfterSelect: true,
sortField: 'label',
searchField: ['label', 'value'],
inputClass: 'form-control selectize-input',
render: {
item: 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 +
'" data-ip="' + item.ip +
'" data-port="' + item.port +
'" data-is_cloud="' + item.is_cloud +
'" data-label="' + item.label + '">' +
'<span class="item-text">' + escape(label) + '</span>' +
(caption ? '<span class="item-value">' + escape(caption) + '</span>' : '') +
'</div>';
},
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 +
'" data-ip="' + item.ip +
'" data-port="' + item.port +
'" data-is_cloud="' + item.is_cloud +
'" data-label="' + item.label + '">' +
escape(label) +
(caption ? '<span class="caption">' + escape(caption) + '</span>' : '') +
'</div>';
}
},
create: function(input) {
return {label: '', value: input};
},
onChange: function (item) {
var pms_ip_selected = this.getItem(item)[0];
var identifier = $(pms_ip_selected).data('identifier');
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');
$("#pms_valid").val(identifier !== 'undefined' ? 'valid' : '');
$("#pms-verify-status").html(identifier !== 'undefined' ? '<i class="fa fa-check"></i> Server found!' : '').fadeIn('fast');
$("#pms_identifier").val(identifier !== 'undefined' ? identifier : '');
$('#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);
$('#pms_ssl_checkbox').prop('checked', (ssl !== 'undefined' && ssl === 1));
$('#pms_ssl').val(ssl !== 'undefined' && ssl === 1 ? 1 : 0);
$('#pms_is_cloud').val(is_cloud !== 'undefined' && is_cloud === true ? 1 : 0);
if (is_cloud === true) {
$('#pms_port').prop('readonly', true);
$('#pms_is_remote_checkbox').prop('disabled', true);
$('#pms_ssl_checkbox').prop('disabled', true);
} else {
$('#pms_port').prop('readonly', false);
$('#pms_is_remote_checkbox').prop('disabled', false);
$('#pms_ssl_checkbox').prop('disabled', false);
}
}
});
var select_pms = $select_pms[0].selectize;
function getServerOptions(token) {
/* Set token and returns server options */
$.ajax({
url: 'discover',
data: {
token: token
},
success: function (result) {
if (result) {
var existing_value = $('#pms_ip').val();
result.forEach(function (item) {
if (item.value === existing_value) {
select_pms.updateOption(item.value, item);
} else {
select_pms.addOption(item);
}
});
}
}
})
}
var pms_verified = false;
var authenticated = false;
@@ -360,14 +407,19 @@
var pms_ip = $("#pms_ip").val().trim();
var pms_port = $("#pms_port").val().trim();
var pms_identifier = $("#pms_identifier").val();
var pms_ssl = $("#pms_ssl").is(':checked') ? 1 : 0;
var pms_is_remote = $("#pms_is_remote").is(':checked') ? 1 : 0;
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').fadeIn('fast');
$.ajax({
url: 'get_server_id',
data: { hostname: pms_ip, port: pms_port, identifier: pms_identifier, ssl: pms_ssl, remote: pms_is_remote },
data: {
hostname: pms_ip,
port: pms_port,
identifier: pms_identifier,
ssl: pms_ssl,
remote: pms_is_remote },
cache: true,
async: true,
timeout: 5000,
@@ -444,39 +496,7 @@
$('#pms-token-status').fadeIn('fast');
}
});
// Send database path to import script
//$("#plexwatch-import").click(function() {
// var database_path = $("#db_location").val();
// var table_name = 'processed';
// var import_ignore_interval = 0;
// $.ajax({
// url: 'get_plexwatch_export_data',
// data: {database_path: database_path, table_name:table_name, import_ignore_interval:import_ignore_interval},
// cache: false,
// async: true,
// success: function(data) {
// if (data === 'Import has started. Check the Tautulli logs to monitor any problems.') {
// $("#plexwatch-import-status").html('Started');
// } else {
// $("#plexwatch-import-status").html(data);
// }
// $("#db_location").val('')
// }
// });
//});
function getServerOptions(token) {
/* Set token and returns server options */
$.ajax({
url: "discover/" + token,
success: function (result) {
$('#pms_ip').html("");
// Add all servers to the "combobox"
$select_pms[0].selectize.addOption(result);
}
})
}
});
</script>
</body>

View File

@@ -1,54 +1,54 @@
#!/bin/sh
#
# PROVIDE: plexpy
# REQUIRE: plexpy
# PROVIDE: tautulli
# REQUIRE: tautulli
# KEYWORD: shutdown
#
# Add the following lines to /etc/rc.conf.local or /etc/rc.conf
# to enable this service:
#
# plexpy_enable (bool): Set to NO by default.
# tautulli_enable (bool): Set to NO by default.
# Set it to YES to enable it.
# plexpy_user: The user account PlexPy daemon runs as what
# you want it to be. It uses 'plexpy' user by
# tautulli_user: The user account Tautulli daemon runs as what
# you want it to be. It uses 'tautulli' user by
# default. Do not sets it as empty or it will run
# as root.
# plexpy_dir: Directory where PlexPy lives.
# Default: /usr/local/plexpy
# plexpy_chdir: Change to this directory before running PlexPy.
# Default is same as plexpy_dir.
# plexpy_pid: The name of the pidfile to create.
# Default is plexpy.pid in plexpy_dir.
# tautulli_dir: Directory where Tautulli lives.
# Default: /usr/local/share/Tautulli
# tautulli_chdir: Change to this directory before running Tautulli.
# Default is same as tautulli_dir.
# tautulli_pid: The name of the pidfile to create.
# Default is tautulli.pid in tautulli_dir.
. /etc/rc.subr
name="plexpy"
name="tautulli"
rcvar=${name}_enable
load_rc_config ${name}
: ${plexpy_enable:="NO"}
: ${plexpy_user:="plexpy"}
: ${plexpy_dir:="/usr/local/plexpy"}
: ${plexpy_chdir:="${plexpy_dir}"}
: ${plexpy_pid:="${plexpy_dir}/plexpy.pid"}
: ${plexpy_conf:="${plexpy_dir}/config.ini"}
: ${tautulli_enable:="NO"}
: ${tautulli_user:="tautulli"}
: ${tautulli_dir:="/usr/local/share/Tautulli"}
: ${tautulli_chdir:="${tautulli_dir}"}
: ${tautulli_pid:="${tautulli_dir}/tautulli.pid"}
: ${tautulli_conf:="${tautulli_dir}/config.ini"}
WGET="/usr/local/bin/wget" # You need wget for this script to safely shutdown PlexPy.
if [ -e "${plexpy_conf}" ]; then
HOST=`grep -A64 "\[General\]" "${plexpy_conf}"|egrep "^http_host"|perl -wple 's/^http_host = (.*)$/$1/'`
PORT=`grep -A64 "\[General\]" "${plexpy_conf}"|egrep "^http_port"|perl -wple 's/^http_port = (.*)$/$1/'`
WGET="/usr/local/bin/wget" # You need wget for this script to safely shutdown Tautulli.
if [ -e "${tautulli_conf}" ]; then
HOST=`grep -A64 "\[General\]" "${tautulli_conf}"|egrep "^http_host"|perl -wple 's/^http_host = (.*)$/$1/'`
PORT=`grep -A64 "\[General\]" "${tautulli_conf}"|egrep "^http_port"|perl -wple 's/^http_port = (.*)$/$1/'`
fi
status_cmd="${name}_status"
stop_cmd="${name}_stop"
command="${plexpy_dir}/PlexPy.py"
command_args="--daemon --quiet --nolaunch --port ${PORT} --pidfile ${plexpy_pid} --config ${plexpy_conf}"
command="${tautulli_dir}/Tautulli.py"
command_args="--daemon --quiet --nolaunch --port ${PORT} --pidfile ${tautulli_pid} --config ${tautulli_conf}"
# Check for wget and refuse to start without it.
if [ ! -x "${WGET}" ]; then
warn "PlexPy not started: You need wget to safely shut down PlexPy."
warn "Tautulli not started: You need wget to safely shut down Tautulli."
exit 1
fi
@@ -58,21 +58,21 @@ if [ `id -u` != "0" ]; then
exit 1
fi
verify_plexpy_pid() {
# Make sure the pid corresponds to the PlexPy process.
pid=`cat ${plexpy_pid} 2>/dev/null`
ps -p ${pid} | grep -q "python ${plexpy_dir}/PlexPy.py"
verify_tautulli_pid() {
# Make sure the pid corresponds to the Tautulli process.
pid=`cat ${tautulli_pid} 2>/dev/null`
ps -p ${pid} | grep -q "python ${tautulli_dir}/Tautulli.py"
return $?
}
# Try to stop PlexPy cleanly by calling shutdown over http.
plexpy_stop() {
if [ ! -e "${plexpy_conf}" ]; then
echo "PlexPy' settings file does not exist. Try starting PlexPy, as this should create the file."
# Try to stop Tautulli cleanly by calling shutdown over http.
tautulli_stop() {
if [ ! -e "${tautulli_conf}" ]; then
echo "Tautulli' settings file does not exist. Try starting Tautulli, as this should create the file."
exit 1
fi
echo "Stopping $name"
verify_plexpy_pid
verify_tautulli_pid
${WGET} -O - -q --user=${SBUSR} --password=${SBPWD} "http://${HOST}:${PORT}/shutdown/" >/dev/null
if [ -n "${pid}" ]; then
@@ -81,8 +81,8 @@ plexpy_stop() {
fi
}
plexpy_status() {
verify_plexpy_pid && echo "$name is running as ${pid}" || echo "$name is not running"
tautulli_status() {
verify_tautulli_pid && echo "$name is running as ${pid}" || echo "$name is not running"
}
run_rc_command "$1"

View File

@@ -1,25 +1,25 @@
#!/bin/sh
#
### BEGIN INIT INFO
# Provides: PlexPy
# Provides: Tautulli
# Required-Start: $all
# Required-Stop: $all
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: starts PlexPy
# Description: starts PlexPy
# Short-Description: starts Tautulli
# Description: starts Tautulli
### END INIT INFO
# Source function library.
. /etc/init.d/functions
## Variables
prog=plexpy
prog=tautulli
lockfile=/var/lock/subsys/$prog
homedir=/opt/plexpy
datadir=/opt/plexpy
configfile=/opt/plexpy/config.ini
pidfile=/var/run/plexpy.pid
homedir=/opt/Tautulli
datadir=/opt/Tautulli
configfile=/opt/Tautulli/config.ini
pidfile=/var/run/tautulli.pid
nice=
# The following line must point to your Python 2.7 install
python27=/usr/src/Python-2.7.11/python
@@ -30,7 +30,7 @@ options=" --daemon --config $configfile --pidfile $pidfile --datadir $datadir --
start() {
# Start daemon.
echo -n $"Starting $prog: "
daemon --pidfile=$pidfile $nice $python27 $homedir/PlexPy.py $options
daemon --pidfile=$pidfile $nice $python27 $homedir/Tautulli.py $options
RETVAL=$?
echo
[ $RETVAL -eq 0 ] && touch $lockfile

View File

@@ -1,45 +1,45 @@
#!/bin/sh
#
# PROVIDE: plexpy
# REQUIRE: DAEMON plexpy
# PROVIDE: tautulli
# REQUIRE: DAEMON tautulli
# KEYWORD: shutdown
#
# Add the following lines to /etc/rc.conf.local or /etc/rc.conf
# to enable this service:
#
# plexpy_enable (bool): Set to NO by default.
# tautulli_enable (bool): Set to NO by default.
# Set it to YES to enable it.
# plexpy_user: The user account PlexPy daemon runs as what
# you want it to be. It uses 'plexpy' user by
# tautulli_user: The user account Tautulli daemon runs as what
# you want it to be. It uses 'tautulli' user by
# default. Do not sets it as empty or it will run
# as root.
# plexpy_dir: Directory where PlexPy lives.
# Default: /usr/local/share/plexpy
# plexpy_chdir: Change to this directory before running PlexPy.
# Default is same as plexpy_dir.
# plexpy_pid: The name of the pidfile to create.
# Default is plexpy.pid in plexpy_dir.
# tautulli_dir: Directory where Tautulli lives.
# Default: /usr/local/share/Tautulli
# tautulli_chdir: Change to this directory before running Tautulli.
# Default is same as tautulli_dir.
# tautulli_pid: The name of the pidfile to create.
# Default is tautulli.pid in tautulli_dir.
PATH="/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin"
. /etc/rc.subr
name="plexpy"
name="tautulli"
rcvar=${name}_enable
load_rc_config ${name}
: ${plexpy_enable:="NO"}
: ${plexpy_user:="plexpy"}
: ${plexpy_dir:="/usr/local/share/plexpy"}
: ${plexpy_chdir:="${plexpy_dir}"}
: ${plexpy_pid:="${plexpy_dir}/plexpy.pid"}
: ${plexpy_flags:=""}
: ${tautulli_enable:="NO"}
: ${tautulli_user:="tautulli"}
: ${tautulli_dir:="/usr/local/share/Tautulli"}
: ${tautulli_chdir:="${tautulli_dir}"}
: ${tautulli_pid:="${tautulli_dir}/tautulli.pid"}
: ${tautulli_flags:=""}
status_cmd="${name}_status"
stop_cmd="${name}_stop"
command="${plexpy_dir}/PlexPy.py"
command_args="--daemon --pidfile ${plexpy_pid} --quiet --nolaunch ${plexpy_flags}"
command="${tautulli_dir}/Tautulli.py"
command_args="--daemon --pidfile ${tautulli_pid} --quiet --nolaunch ${tautulli_flags}"
# Ensure user is root when running this script.
if [ `id -u` != "0" ]; then
@@ -47,21 +47,21 @@ if [ `id -u` != "0" ]; then
exit 1
fi
verify_plexpy_pid() {
# Make sure the pid corresponds to the PlexPy process.
if [ -f ${plexpy_pid} ]; then
pid=`cat ${plexpy_pid} 2>/dev/null`
ps -p ${pid} | grep -q "python2 ${plexpy_dir}/PlexPy.py"
verify_tautulli_pid() {
# Make sure the pid corresponds to the Tautulli process.
if [ -f ${tautulli_pid} ]; then
pid=`cat ${tautulli_pid} 2>/dev/null`
ps -p ${pid} | grep -q "python2 ${tautulli_dir}/Tautulli.py"
return $?
else
return 0
fi
}
# Try to stop PlexPy cleanly by sending SIGTERM
plexpy_stop() {
# Try to stop Tautulli cleanly by sending SIGTERM
tautulli_stop() {
echo "Stopping $name"
verify_plexpy_pid
verify_tautulli_pid
if [ -n "${pid}" ]; then
kill ${pid}
wait_for_pids ${pid}
@@ -69,8 +69,8 @@ plexpy_stop() {
fi
}
plexpy_status() {
verify_plexpy_pid
tautulli_status() {
verify_tautulli_pid
if [ -n "${pid}" ]; then
echo "$name is running as ${pid}."
else

View File

@@ -1,45 +1,45 @@
#!/bin/sh
#
# PROVIDE: plexpy
# REQUIRE: DAEMON plexpy
# PROVIDE: tautulli
# REQUIRE: DAEMON tautulli
# KEYWORD: shutdown
#
# Add the following lines to /etc/rc.conf.local or /etc/rc.conf
# to enable this service:
#
# plexpy_enable (bool): Set to NO by default.
# tautulli_enable (bool): Set to NO by default.
# Set it to YES to enable it.
# plexpy_user: The user account PlexPy daemon runs as what
# you want it to be. It uses 'plexpy' user by
# tautulli_user: The user account Tautulli daemon runs as what
# you want it to be. It uses 'tautulli' user by
# default. Do not sets it as empty or it will run
# as root.
# plexpy_dir: Directory where PlexPy lives.
# Default: /usr/local/share/plexpy
# plexpy_chdir: Change to this directory before running PlexPy.
# Default is same as plexpy_dir.
# plexpy_pid: The name of the pidfile to create.
# Default is plexpy.pid in plexpy_dir.
# tautulli_dir: Directory where Tautulli lives.
# Default: /usr/local/share/Tautulli
# tautulli_chdir: Change to this directory before running Tautulli.
# Default is same as tautulli_dir.
# tautulli_pid: The name of the pidfile to create.
# Default is tautulli.pid in tautulli_dir.
PATH="/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin"
. /etc/rc.subr
name="plexpy"
name="tautulli"
rcvar=${name}_enable
load_rc_config ${name}
: ${plexpy_enable:="NO"}
: ${plexpy_user:="plexpy"}
: ${plexpy_dir:="/usr/local/share/plexpy"}
: ${plexpy_chdir:="${plexpy_dir}"}
: ${plexpy_pid:="${plexpy_dir}/plexpy.pid"}
: ${plexpy_flags:=""}
: ${tautulli_enable:="NO"}
: ${tautulli_user:="tautulli"}
: ${tautulli_dir:="/usr/local/share/Tautulli"}
: ${tautulli_chdir:="${tautulli_dir}"}
: ${tautulli_pid:="${tautulli_dir}/tautulli.pid"}
: ${tautulli_flags:=""}
status_cmd="${name}_status"
stop_cmd="${name}_stop"
command="${plexpy_dir}/PlexPy.py"
command_args="--daemon --pidfile ${plexpy_pid} --quiet --nolaunch ${plexpy_flags}"
command="${tautulli_dir}/Tautulli.py"
command_args="--daemon --pidfile ${tautulli_pid} --quiet --nolaunch ${tautulli_flags}"
# Ensure user is root when running this script.
if [ `id -u` != "0" ]; then
@@ -47,21 +47,21 @@ if [ `id -u` != "0" ]; then
exit 1
fi
verify_plexpy_pid() {
# Make sure the pid corresponds to the PlexPy process.
if [ -f ${plexpy_pid} ]; then
pid=`cat ${plexpy_pid} 2>/dev/null`
ps -p ${pid} | grep -q "python2 ${plexpy_dir}/PlexPy.py"
verify_tautulli_pid() {
# Make sure the pid corresponds to the Tautulli process.
if [ -f ${tautulli_pid} ]; then
pid=`cat ${tautulli_pid} 2>/dev/null`
ps -p ${pid} | grep -q "python2 ${tautulli_dir}/Tautulli.py"
return $?
else
return 0
fi
}
# Try to stop PlexPy cleanly by sending SIGTERM
plexpy_stop() {
# Try to stop Tautulli cleanly by sending SIGTERM
tautulli_stop() {
echo "Stopping $name."
verify_plexpy_pid
verify_tautulli_pid
if [ -n "${pid}" ]; then
kill ${pid}
wait_for_pids ${pid}
@@ -69,8 +69,8 @@ plexpy_stop() {
fi
}
plexpy_status() {
verify_plexpy_pid
tautulli_status() {
verify_tautulli_pid
if [ -n "${pid}" ]; then
echo "$name is running as ${pid}."
else

View File

@@ -3,12 +3,12 @@
<plist version="1.0">
<dict>
<key>Label</key>
<string>plexpy</string>
<string>tautulli</string>
<key>ProgramArguments</key>
<array>
<!-- Modify these two lines if you need to to reflect your python location and PlexPy install location -->
<!-- Modify these two lines if you need to to reflect your python location and Tautulli install location -->
<string>/usr/bin/python</string>
<string>/Applications/PlexPy/PlexPy.py</string>
<string>/Applications/Tautulli/Tautulli.py</string>
</array>
<key>RunAtLoad</key>
<true/>

View File

@@ -2,9 +2,9 @@
<!DOCTYPE service_bundle SYSTEM "/usr/share/lib/xml/dtd/service_bundle.dtd.1">
<!--
Created by Manifold
--><service_bundle type="manifest" name="plexpy">
--><service_bundle type="manifest" name="tautulli">
<service name="application/plexpy" type="service" version="1">
<service name="application/tautulli" type="service" version="1">
<create_default_instance enabled="true"/>
@@ -19,10 +19,10 @@
</dependency>
<method_context>
<method_credential user="plexpy" group="nogroup"/>
<method_credential user="tautulli" group="nogroup"/>
</method_context>
<exec_method type="method" name="start" exec="python /opt/plexpy/PlexPy.py --daemon --quiet --nolaunch" timeout_seconds="60"/>
<exec_method type="method" name="start" exec="python /opt/Tautulli/Tautulli.py --daemon --quiet --nolaunch" timeout_seconds="60"/>
<exec_method type="method" name="stop" exec=":kill" timeout_seconds="60"/>
@@ -37,7 +37,7 @@
<template>
<common_name>
<loctext xml:lang="C">
PlexPy
Tautulli
</loctext>
</common_name>
</template>

View File

@@ -1,11 +1,11 @@
# PlexPy - Stats for Plex Media Server usage
# Tautulli - Stats for Plex Media Server usage
#
# Service Unit file for systemd system manager
#
# INSTALLATION NOTES
#
# 1. Rename this file as you want, ensuring that it ends in .service
# e.g. 'plexpy.service'
# e.g. 'tautulli.service'
#
# 2. Adjust configuration settings as required. More details in the
# "CONFIGURATION NOTES" section shown below.
@@ -15,39 +15,39 @@
#
# 4. Enable boot-time autostart with the following commands:
# systemctl daemon-reload
# systemctl enable plexpy.service
# systemctl enable tautulli.service
#
# 5. Start now with the following command:
# systemctl start plexpy.service
# systemctl start tautulli.service
#
# CONFIGURATION NOTES
#
# - The example settings in this file assume that you will run PlexPy as user: plexpy
# - To create this user and give it ownership of the plexpy directory:
# sudo adduser --system --no-create-home plexpy
# sudo chown plexpy:nogroup -R /opt/plexpy
# - 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)
#
# - Adjust ExecStart= to point to:
# 1. Your PlexPy executable,
# 1. Your Tautulli executable,
# 2. Your config file (recommended is to put it somewhere in /etc)
# 3. Your datadir (recommended is to NOT put it in your PlexPy exec dir)
# 3. Your datadir (recommended is to NOT put it in your Tautulli exec dir)
#
# - Adjust User= and Group= to the user/group you want PlexPy to run as.
# - Adjust User= and Group= to the user/group you want Tautulli to run as.
#
# - WantedBy= specifies which target (i.e. runlevel) to start PlexPy for.
# - 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)
[Unit]
Description=PlexPy - Stats for Plex Media Server usage
Description=Tautulli - Stats for Plex Media Server usage
[Service]
ExecStart=/opt/plexpy/PlexPy.py --quiet --daemon --nolaunch --config /opt/plexpy/config.ini --datadir /opt/plexpy
ExecStart=/opt/Tautulli/Tautulli.py --quiet --daemon --nolaunch --config /opt/Tautulli/config.ini --datadir /opt/Tautulli
GuessMainPID=no
Type=forking
User=plexpy
User=tautulli
Group=nogroup
[Install]

View File

@@ -1,71 +1,71 @@
#!/bin/sh
#
## Don't edit this file
## Edit user configuation in /etc/default/plexpy to change
## Edit user configuation in /etc/default/tautulli to change
##
## Make sure init script is executable
## sudo chmod +x /path/to/init.ubuntu
##
## Install the init script
## sudo ln -s /path/to/init.ubuntu /etc/init.d/plexpy
## sudo ln -s /path/to/init.ubuntu /etc/init.d/tautulli
##
## Create the plexpy daemon user:
## sudo adduser --system --no-create-home plexpy
## Create the tautulli daemon user:
## sudo adduser --system --no-create-home tautulli
##
## Make sure /opt/plexpy is owned by the plexpy user
## sudo chown plexpy:nogroup -R /opt/plexpy
## Make sure /opt/Tautulli is owned by the tautulli user
## sudo chown tautulli:nogroup -R /opt/Tautulli
##
## Touch the default file to stop the warning message when starting
## sudo touch /etc/default/plexpy
## sudo touch /etc/default/tautulli
##
## To start PlexPy automatically
## sudo update-rc.d plexpy defaults
## To start Tautulli automatically
## sudo update-rc.d tautulli defaults
##
## To start/stop/restart PlexPy
## sudo service plexpy start
## sudo service plexpy stop
## sudo service plexpy restart
## To start/stop/restart Tautulli
## sudo service tautulli start
## sudo service tautulli stop
## sudo service tautulli restart
##
## HP_USER= #$RUN_AS, username to run plexpy under, the default is plexpy
## HP_HOME= #$APP_PATH, the location of PlexPy.py, the default is /opt/plexpy
## HP_DATA= #$DATA_DIR, the location of plexpy.db, cache, logs, the default is /opt/plexpy
## HP_PIDFILE= #$PID_FILE, the location of plexpy.pid, the default is /var/run/plexpy/plexpy.pid
## TAUTULLI_USER= #$RUN_AS, username to run Tautulli under, the default is tautulli
## TAUTULLI_HOME= #$APP_PATH, the location of Tautulli.py, the default is /opt/Tautulli
## TAUTULLI_DATA= #$DATA_DIR, the location of plexpy.db, cache, logs, the default is /opt/Tautulli
## TAUTULLI_PIDFILE= #$PID_FILE, the location of tautulli.pid, the default is /var/run/tautulli/tautulli.pid
## PYTHON_BIN= #$DAEMON, the location of the python binary, the default is /usr/bin/python
## HP_OPTS= #$EXTRA_DAEMON_OPTS, extra cli option for plexpy, i.e. " --config=/home/plexpy/config.ini"
## TAUTULLI_OPTS= #$EXTRA_DAEMON_OPTS, extra cli option for Tautulli, i.e. " --config=/home/Tautulli/config.ini"
## SSD_OPTS= #$EXTRA_SSD_OPTS, extra start-stop-daemon option like " --group=users"
## HP_PORT= #$PORT_OPTS, hardcoded port for the webserver, overrides value in config.ini
## TAUTULLI_PORT= #$PORT_OPTS, hardcoded port for the webserver, overrides value in config.ini
##
## EXAMPLE if want to run as different user
## add HP_USER=username to /etc/default/plexpy
## otherwise default plexpy is used
## add TAUTULLI_USER=username to /etc/default/tautulli
## otherwise default tautulli is used
#
### BEGIN INIT INFO
# Provides: plexpy
# Provides: tautulli
# Required-Start: $local_fs $network $remote_fs
# Required-Stop: $local_fs $network $remote_fs
# Should-Start: $NetworkManager
# Should-Stop: $NetworkManager
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: starts instance of PlexPy
# Description: starts instance of PlexPy using start-stop-daemon
# Short-Description: starts instance of Tautulli
# Description: starts instance of Tautulli using start-stop-daemon
### END INIT INFO
# Script name
NAME=plexpy
NAME=tautulli
# App name
DESC=PlexPy
DESC=Tautulli
SETTINGS_LOADED=FALSE
. /lib/lsb/init-functions
# Source PlexPy configuration
if [ -f /etc/default/plexpy ]; then
SETTINGS=/etc/default/plexpy
# Source Tautulli configuration
if [ -f /etc/default/tautulli ]; then
SETTINGS=/etc/default/tautulli
else
log_warning_msg "/etc/default/plexpy not found using default settings.";
log_warning_msg "/etc/default/tautulli not found using default settings.";
fi
check_retval() {
@@ -84,32 +84,32 @@ load_settings() {
## The defaults
# Run as username
RUN_AS=${HP_USER-plexpy}
RUN_AS=${TAUTULLI_USER-tautulli}
# Path to app HP_HOME=path_to_app_PlexPy.py
APP_PATH=${HP_HOME-/opt/plexpy}
# Path to app TAUTULLI_HOME=path_to_app_Tautulli.py
APP_PATH=${TAUTULLI_HOME-/opt/Tautulli}
# Data directory where plexpy.db, cache and logs are stored
DATA_DIR=${HP_DATA-/opt/plexpy}
DATA_DIR=${TAUTULLI_DATA-/opt/Tautulli}
# Path to store PID file
PID_FILE=${HP_PIDFILE-/var/run/plexpy/plexpy.pid}
PID_FILE=${TAUTULLI_PIDFILE-/var/run/tautulli/tautulli.pid}
# Path to python bin
DAEMON=${PYTHON_BIN-/usr/bin/python}
# Extra daemon option like: HP_OPTS=" --config=/home/plexpy/config.ini"
EXTRA_DAEMON_OPTS=${HP_OPTS-}
# Extra daemon option like: TAUTULLI_OPTS=" --config=/home/Tautulli/config.ini"
EXTRA_DAEMON_OPTS=${TAUTULLI_OPTS-}
# Extra start-stop-daemon option like START_OPTS=" --group=users"
EXTRA_SSD_OPTS=${SSD_OPTS-}
# Hardcoded port to run on, overrides config.ini settings
[ -n "$HP_PORT" ] && {
PORT_OPTS=" --port=${HP_PORT} "
[ -n "$TAUTULLI_PORT" ] && {
PORT_OPTS=" --port=${TAUTULLI_PORT} "
}
DAEMON_OPTS=" PlexPy.py --quiet --daemon --nolaunch --pidfile=${PID_FILE} --datadir=${DATA_DIR} ${PORT_OPTS}${EXTRA_DAEMON_OPTS}"
DAEMON_OPTS=" Tautulli.py --quiet --daemon --nolaunch --pidfile=${PID_FILE} --datadir=${DATA_DIR} ${PORT_OPTS}${EXTRA_DAEMON_OPTS}"
SETTINGS_LOADED=TRUE
fi
@@ -162,7 +162,7 @@ handle_updates () {
return 0; }
}
start_plexpy () {
start_tautulli () {
handle_pid
handle_datadir
handle_updates
@@ -175,7 +175,7 @@ start_plexpy () {
fi
}
stop_plexpy () {
stop_tautulli () {
if is_running; then
log_daemon_msg "Stopping $DESC"
start-stop-daemon -o --stop --pidfile $PID_FILE --retry 15
@@ -187,14 +187,14 @@ stop_plexpy () {
case "$1" in
start)
start_plexpy
start_tautulli
;;
stop)
stop_plexpy
stop_tautulli
;;
restart|force-reload)
stop_plexpy
start_plexpy
stop_tautulli
start_tautulli
;;
status)
status_of_proc -p "$PID_FILE" "$DAEMON" "$DESC"

View File

@@ -1,18 +1,18 @@
# plexpy
# tautulli
#
# This is a session/user job. Install this file into /usr/share/upstart/sessions
# if plexpy is installed system wide, and into $XDG_CONFIG_HOME/upstart if
# plexpy is installed per user. Change the executable path appropiately.
# if Tautulli is installed system wide, and into $XDG_CONFIG_HOME/upstart if
# Tautulli is installed per user. Change the executable path appropiately.
start on desktop-start
stop on desktop-end
env CONFIG=""$XDG_CONFIG_HOME"/plexpy"
env DATA=""$XDG_DATA_HOME"/plexpy"
env CONFIG=""$XDG_CONFIG_HOME"/Tautulli"
env DATA=""$XDG_DATA_HOME"/Tautulli"
pre-start script
[ -d "$CONFIG" ] || mkdir -p "$CONFIG"
[ -d "$DATA" ] || mkdir -p "$DATA"
end script
exec PlexPy.py --nolaunch --config "$CONFIG"/config.ini --datadir "$DATA"
exec Tautulli.py --nolaunch --config "$CONFIG"/config.ini --datadir "$DATA"

View File

@@ -0,0 +1,121 @@
#!/usr/bin/python
###############################################################################
# Formatting filter for urllib2's HTTPHandler(debuglevel=1) output
# Copyright (c) 2013, Analytics Pros
#
# This project is free software, distributed under the BSD license.
# Analytics Pros offers consulting and integration services if your firm needs
# assistance in strategy, implementation, or auditing existing work.
###############################################################################
import sys, re, os
from cStringIO import StringIO
class BufferTranslator(object):
""" Provides a buffer-compatible interface for filtering buffer content.
"""
parsers = []
def __init__(self, output):
self.output = output
self.encoding = getattr(output, 'encoding', None)
def write(self, content):
content = self.translate(content)
self.output.write(content)
@staticmethod
def stripslashes(content):
return content.decode('string_escape')
@staticmethod
def addslashes(content):
return content.encode('string_escape')
def translate(self, line):
for pattern, method in self.parsers:
match = pattern.match(line)
if match:
return method(match)
return line
class LineBufferTranslator(BufferTranslator):
""" Line buffer implementation supports translation of line-format input
even when input is not already line-buffered. Caches input until newlines
occur, and then dispatches translated input to output buffer.
"""
def __init__(self, *a, **kw):
self._linepending = []
super(LineBufferTranslator, self).__init__(*a, **kw)
def write(self, _input):
lines = _input.splitlines(True)
for i in range(0, len(lines)):
last = i
if lines[i].endswith('\n'):
prefix = len(self._linepending) and ''.join(self._linepending) or ''
self.output.write(self.translate(prefix + lines[i]))
del self._linepending[0:]
last = -1
if last >= 0:
self._linepending.append(lines[ last ])
def __del__(self):
if len(self._linepending):
self.output.write(self.translate(''.join(self._linepending)))
class HTTPTranslator(LineBufferTranslator):
""" Translates output from |urllib2| HTTPHandler(debuglevel = 1) into
HTTP-compatible, readible text structures for human analysis.
"""
RE_LINE_PARSER = re.compile(r'^(?:([a-z]+):)\s*(\'?)([^\r\n]*)\2(?:[\r\n]*)$')
RE_LINE_BREAK = re.compile(r'(\r?\n|(?:\\r)?\\n)')
RE_HTTP_METHOD = re.compile(r'^(POST|GET|HEAD|DELETE|PUT|TRACE|OPTIONS)')
RE_PARAMETER_SPACER = re.compile(r'&([a-z0-9]+)=')
@classmethod
def spacer(cls, line):
return cls.RE_PARAMETER_SPACER.sub(r' &\1= ', line)
def translate(self, line):
parsed = self.RE_LINE_PARSER.match(line)
if parsed:
value = parsed.group(3)
stage = parsed.group(1)
if stage == 'send': # query string is rendered here
return '\n# HTTP Request:\n' + self.stripslashes(value)
elif stage == 'reply':
return '\n\n# HTTP Response:\n' + self.stripslashes(value)
elif stage == 'header':
return value + '\n'
else:
return value
return line
def consume(outbuffer = None): # Capture standard output
sys.stdout = HTTPTranslator(outbuffer or sys.stdout)
return sys.stdout
if __name__ == '__main__':
consume(sys.stdout).write(sys.stdin.read())
print '\n'
# vim: set nowrap tabstop=4 shiftwidth=4 softtabstop=0 expandtab textwidth=0 filetype=python foldmethod=indent foldcolumn=4

View File

@@ -0,0 +1,433 @@
###############################################################################
# Universal Analytics for Python
# Copyright (c) 2013, Analytics Pros
#
# This project is free software, distributed under the BSD license.
# Analytics Pros offers consulting and integration services if your firm needs
# assistance in strategy, implementation, or auditing existing work.
###############################################################################
from urllib2 import urlopen, build_opener, install_opener
from urllib2 import Request, HTTPSHandler
from urllib2 import URLError, HTTPError
from urllib import urlencode
import random
import datetime
import time
import uuid
import hashlib
import socket
def generate_uuid(basedata=None):
""" Provides a _random_ UUID with no input, or a UUID4-format MD5 checksum of any input data provided """
if basedata is None:
return str(uuid.uuid4())
elif isinstance(basedata, basestring):
checksum = hashlib.md5(basedata).hexdigest()
return '%8s-%4s-%4s-%4s-%12s' % (
checksum[0:8], checksum[8:12], checksum[12:16], checksum[16:20], checksum[20:32])
class Time(datetime.datetime):
""" Wrappers and convenience methods for processing various time representations """
@classmethod
def from_unix(cls, seconds, milliseconds=0):
""" Produce a full |datetime.datetime| object from a Unix timestamp """
base = list(time.gmtime(seconds))[0:6]
base.append(milliseconds * 1000) # microseconds
return cls(*base)
@classmethod
def to_unix(cls, timestamp):
""" Wrapper over time module to produce Unix epoch time as a float """
if not isinstance(timestamp, datetime.datetime):
raise TypeError, 'Time.milliseconds expects a datetime object'
base = time.mktime(timestamp.timetuple())
return base
@classmethod
def milliseconds_offset(cls, timestamp, now=None):
""" Offset time (in milliseconds) from a |datetime.datetime| object to now """
if isinstance(timestamp, (int, float)):
base = timestamp
else:
base = cls.to_unix(timestamp)
base = base + (timestamp.microsecond / 1000000)
if now is None:
now = time.time()
return (now - base) * 1000
class HTTPRequest(object):
""" URL Construction and request handling abstraction.
This is not intended to be used outside this module.
Automates mapping of persistent state (i.e. query parameters)
onto transcient datasets for each query.
"""
endpoint = 'https://www.google-analytics.com/collect'
@staticmethod
def debug():
""" Activate debugging on urllib2 """
handler = HTTPSHandler(debuglevel=1)
opener = build_opener(handler)
install_opener(opener)
# Store properties for all requests
def __init__(self, user_agent=None, *args, **opts):
self.user_agent = user_agent or 'Analytics Pros - Universal Analytics (Python)'
@classmethod
def fixUTF8(cls, data): # Ensure proper encoding for UA's servers...
""" Convert all strings to UTF-8 """
for key in data:
if isinstance(data[key], basestring):
data[key] = data[key].encode('utf-8')
return data
# Apply stored properties to the given dataset & POST to the configured endpoint
def send(self, data):
request = Request(
self.endpoint + '?' + urlencode(self.fixUTF8(data)),
headers={
'User-Agent': self.user_agent
}
)
self.open(request)
def open(self, request):
try:
return urlopen(request)
except HTTPError as e:
return False
except URLError as e:
self.cache_request(request)
return False
def cache_request(self, request):
# TODO: implement a proper caching mechanism here for re-transmitting hits
# record = (Time.now(), request.get_full_url(), request.get_data(), request.headers)
pass
class HTTPPost(HTTPRequest):
# Apply stored properties to the given dataset & POST to the configured endpoint
def send(self, data):
request = Request(
self.endpoint,
data=urlencode(self.fixUTF8(data)),
headers={
'User-Agent': self.user_agent
}
)
self.open(request)
class Tracker(object):
""" Primary tracking interface for Universal Analytics """
params = None
parameter_alias = {}
valid_hittypes = ('pageview', 'event', 'social', 'screenview', 'transaction', 'item', 'exception', 'timing')
@classmethod
def alias(cls, typemap, base, *names):
""" Declare an alternate (humane) name for a measurement protocol parameter """
cls.parameter_alias[base] = (typemap, base)
for i in names:
cls.parameter_alias[i] = (typemap, base)
@classmethod
def coerceParameter(cls, name, value=None):
if isinstance(name, basestring) and name[0] == '&':
return name[1:], str(value)
elif name in cls.parameter_alias:
typecast, param_name = cls.parameter_alias.get(name)
return param_name, typecast(value)
else:
raise KeyError, 'Parameter "{0}" is not recognized'.format(name)
def payload(self, data):
for key, value in data.iteritems():
try:
yield self.coerceParameter(key, value)
except KeyError:
continue
option_sequence = {
'pageview': [(basestring, 'dp')],
'event': [(basestring, 'ec'), (basestring, 'ea'), (basestring, 'el'), (int, 'ev')],
'social': [(basestring, 'sn'), (basestring, 'sa'), (basestring, 'st')],
'timing': [(basestring, 'utc'), (basestring, 'utv'), (basestring, 'utt'), (basestring, 'utl')]
}
@classmethod
def consume_options(cls, data, hittype, args):
""" Interpret sequential arguments related to known hittypes based on declared structures """
opt_position = 0
data['t'] = hittype # integrate hit type parameter
if hittype in cls.option_sequence:
for expected_type, optname in cls.option_sequence[hittype]:
if opt_position < len(args) and isinstance(args[opt_position], expected_type):
data[optname] = args[opt_position]
opt_position += 1
@classmethod
def hittime(cls, timestamp=None, age=None, milliseconds=None):
""" Returns an integer represeting the milliseconds offset for a given hit (relative to now) """
if isinstance(timestamp, (int, float)):
return int(Time.milliseconds_offset(Time.from_unix(timestamp, milliseconds=milliseconds)))
if isinstance(timestamp, datetime.datetime):
return int(Time.milliseconds_offset(timestamp))
if isinstance(age, (int, float)):
return int(age * 1000) + (milliseconds or 0)
@property
def account(self):
return self.params.get('tid', None)
def __init__(self, account, name=None, client_id=None, hash_client_id=False, user_id=None, user_agent=None,
use_post=True):
if use_post is False:
self.http = HTTPRequest(user_agent=user_agent)
else:
self.http = HTTPPost(user_agent=user_agent)
self.params = {'v': 1, 'tid': account}
if client_id is None:
client_id = generate_uuid()
self.params['cid'] = client_id
self.hash_client_id = hash_client_id
if user_id is not None:
self.params['uid'] = user_id
def set_timestamp(self, data):
""" Interpret time-related options, apply queue-time parameter as needed """
if 'hittime' in data: # an absolute timestamp
data['qt'] = self.hittime(timestamp=data.pop('hittime', None))
if 'hitage' in data: # a relative age (in seconds)
data['qt'] = self.hittime(age=data.pop('hitage', None))
def send(self, hittype, *args, **data):
""" Transmit HTTP requests to Google Analytics using the measurement protocol """
if hittype not in self.valid_hittypes:
raise KeyError('Unsupported Universal Analytics Hit Type: {0}'.format(repr(hittype)))
self.set_timestamp(data)
self.consume_options(data, hittype, args)
for item in args: # process dictionary-object arguments of transcient data
if isinstance(item, dict):
for key, val in self.payload(item):
data[key] = val
for k, v in self.params.iteritems(): # update only absent parameters
if k not in data:
data[k] = v
data = dict(self.payload(data))
if self.hash_client_id:
data['cid'] = generate_uuid(data['cid'])
# Transmit the hit to Google...
self.http.send(data)
# Setting persistent attibutes of the session/hit/etc (inc. custom dimensions/metrics)
def set(self, name, value=None):
if isinstance(name, dict):
for key, value in name.iteritems():
try:
param, value = self.coerceParameter(key, value)
self.params[param] = value
except KeyError:
pass
elif isinstance(name, basestring):
try:
param, value = self.coerceParameter(name, value)
self.params[param] = value
except KeyError:
pass
def __getitem__(self, name):
param, value = self.coerceParameter(name, None)
return self.params.get(param, None)
def __setitem__(self, name, value):
param, value = self.coerceParameter(name, value)
self.params[param] = value
def __delitem__(self, name):
param, value = self.coerceParameter(name, None)
if param in self.params:
del self.params[param]
def safe_unicode(obj):
""" Safe convertion to the Unicode string version of the object """
try:
return unicode(obj)
except UnicodeDecodeError:
return obj.decode('utf-8')
# Declaring name mappings for Measurement Protocol parameters
MAX_CUSTOM_DEFINITIONS = 200
MAX_EC_LISTS = 11 # 1-based index
MAX_EC_PRODUCTS = 11 # 1-based index
MAX_EC_PROMOTIONS = 11 # 1-based index
Tracker.alias(int, 'v', 'protocol-version')
Tracker.alias(safe_unicode, 'cid', 'client-id', 'clientId', 'clientid')
Tracker.alias(safe_unicode, 'tid', 'trackingId', 'account')
Tracker.alias(safe_unicode, 'uid', 'user-id', 'userId', 'userid')
Tracker.alias(safe_unicode, 'uip', 'user-ip', 'userIp', 'ipaddr')
Tracker.alias(safe_unicode, 'ua', 'userAgent', 'userAgentOverride', 'user-agent')
Tracker.alias(safe_unicode, 'dp', 'page', 'path')
Tracker.alias(safe_unicode, 'dt', 'title', 'pagetitle', 'pageTitle' 'page-title')
Tracker.alias(safe_unicode, 'dl', 'location')
Tracker.alias(safe_unicode, 'dh', 'hostname')
Tracker.alias(safe_unicode, 'sc', 'sessioncontrol', 'session-control', 'sessionControl')
Tracker.alias(safe_unicode, 'dr', 'referrer', 'referer')
Tracker.alias(int, 'qt', 'queueTime', 'queue-time')
Tracker.alias(safe_unicode, 't', 'hitType', 'hittype')
Tracker.alias(int, 'aip', 'anonymizeIp', 'anonIp', 'anonymize-ip')
Tracker.alias(safe_unicode, 'ds', 'dataSource', 'data-source')
# Campaign attribution
Tracker.alias(safe_unicode, 'cn', 'campaign', 'campaignName', 'campaign-name')
Tracker.alias(safe_unicode, 'cs', 'source', 'campaignSource', 'campaign-source')
Tracker.alias(safe_unicode, 'cm', 'medium', 'campaignMedium', 'campaign-medium')
Tracker.alias(safe_unicode, 'ck', 'keyword', 'campaignKeyword', 'campaign-keyword')
Tracker.alias(safe_unicode, 'cc', 'content', 'campaignContent', 'campaign-content')
Tracker.alias(safe_unicode, 'ci', 'campaignId', 'campaignID', 'campaign-id')
# Technical specs
Tracker.alias(safe_unicode, 'sr', 'screenResolution', 'screen-resolution', 'resolution')
Tracker.alias(safe_unicode, 'vp', 'viewport', 'viewportSize', 'viewport-size')
Tracker.alias(safe_unicode, 'de', 'encoding', 'documentEncoding', 'document-encoding')
Tracker.alias(int, 'sd', 'colors', 'screenColors', 'screen-colors')
Tracker.alias(safe_unicode, 'ul', 'language', 'user-language', 'userLanguage')
# Mobile app
Tracker.alias(safe_unicode, 'an', 'appName', 'app-name', 'app')
Tracker.alias(safe_unicode, 'cd', 'contentDescription', 'screenName', 'screen-name', 'content-description')
Tracker.alias(safe_unicode, 'av', 'appVersion', 'app-version', 'version')
Tracker.alias(safe_unicode, 'aid', 'appID', 'appId', 'application-id', 'app-id', 'applicationId')
Tracker.alias(safe_unicode, 'aiid', 'appInstallerId', 'app-installer-id')
# Ecommerce
Tracker.alias(safe_unicode, 'ta', 'affiliation', 'transactionAffiliation', 'transaction-affiliation')
Tracker.alias(safe_unicode, 'ti', 'transaction', 'transactionId', 'transaction-id')
Tracker.alias(float, 'tr', 'revenue', 'transactionRevenue', 'transaction-revenue')
Tracker.alias(float, 'ts', 'shipping', 'transactionShipping', 'transaction-shipping')
Tracker.alias(float, 'tt', 'tax', 'transactionTax', 'transaction-tax')
Tracker.alias(safe_unicode, 'cu', 'currency', 'transactionCurrency',
'transaction-currency') # Currency code, e.g. USD, EUR
Tracker.alias(safe_unicode, 'in', 'item-name', 'itemName')
Tracker.alias(float, 'ip', 'item-price', 'itemPrice')
Tracker.alias(float, 'iq', 'item-quantity', 'itemQuantity')
Tracker.alias(safe_unicode, 'ic', 'item-code', 'sku', 'itemCode')
Tracker.alias(safe_unicode, 'iv', 'item-variation', 'item-category', 'itemCategory', 'itemVariation')
# Events
Tracker.alias(safe_unicode, 'ec', 'event-category', 'eventCategory', 'category')
Tracker.alias(safe_unicode, 'ea', 'event-action', 'eventAction', 'action')
Tracker.alias(safe_unicode, 'el', 'event-label', 'eventLabel', 'label')
Tracker.alias(int, 'ev', 'event-value', 'eventValue', 'value')
Tracker.alias(int, 'ni', 'noninteractive', 'nonInteractive', 'noninteraction', 'nonInteraction')
# Social
Tracker.alias(safe_unicode, 'sa', 'social-action', 'socialAction')
Tracker.alias(safe_unicode, 'sn', 'social-network', 'socialNetwork')
Tracker.alias(safe_unicode, 'st', 'social-target', 'socialTarget')
# Exceptions
Tracker.alias(safe_unicode, 'exd', 'exception-description', 'exceptionDescription', 'exDescription')
Tracker.alias(int, 'exf', 'exception-fatal', 'exceptionFatal', 'exFatal')
# User Timing
Tracker.alias(safe_unicode, 'utc', 'timingCategory', 'timing-category')
Tracker.alias(safe_unicode, 'utv', 'timingVariable', 'timing-variable')
Tracker.alias(float, 'utt', 'time', 'timingTime', 'timing-time')
Tracker.alias(safe_unicode, 'utl', 'timingLabel', 'timing-label')
Tracker.alias(float, 'dns', 'timingDNS', 'timing-dns')
Tracker.alias(float, 'pdt', 'timingPageLoad', 'timing-page-load')
Tracker.alias(float, 'rrt', 'timingRedirect', 'timing-redirect')
Tracker.alias(safe_unicode, 'tcp', 'timingTCPConnect', 'timing-tcp-connect')
Tracker.alias(safe_unicode, 'srt', 'timingServerResponse', 'timing-server-response')
# Custom dimensions and metrics
for i in range(0, 200):
Tracker.alias(safe_unicode, 'cd{0}'.format(i), 'dimension{0}'.format(i))
Tracker.alias(int, 'cm{0}'.format(i), 'metric{0}'.format(i))
# Content groups
for i in range(0, 5):
Tracker.alias(safe_unicode, 'cg{0}'.format(i), 'contentGroup{0}'.format(i))
# Enhanced Ecommerce
Tracker.alias(str, 'pa') # Product action
Tracker.alias(str, 'tcc') # Coupon code
Tracker.alias(unicode, 'pal') # Product action list
Tracker.alias(int, 'cos') # Checkout step
Tracker.alias(str, 'col') # Checkout step option
Tracker.alias(str, 'promoa') # Promotion action
for product_index in range(1, MAX_EC_PRODUCTS):
Tracker.alias(str, 'pr{0}id'.format(product_index)) # Product SKU
Tracker.alias(unicode, 'pr{0}nm'.format(product_index)) # Product name
Tracker.alias(unicode, 'pr{0}br'.format(product_index)) # Product brand
Tracker.alias(unicode, 'pr{0}ca'.format(product_index)) # Product category
Tracker.alias(unicode, 'pr{0}va'.format(product_index)) # Product variant
Tracker.alias(str, 'pr{0}pr'.format(product_index)) # Product price
Tracker.alias(int, 'pr{0}qt'.format(product_index)) # Product quantity
Tracker.alias(str, 'pr{0}cc'.format(product_index)) # Product coupon code
Tracker.alias(int, 'pr{0}ps'.format(product_index)) # Product position
for custom_index in range(MAX_CUSTOM_DEFINITIONS):
Tracker.alias(str, 'pr{0}cd{1}'.format(product_index, custom_index)) # Product custom dimension
Tracker.alias(int, 'pr{0}cm{1}'.format(product_index, custom_index)) # Product custom metric
for list_index in range(1, MAX_EC_LISTS):
Tracker.alias(str, 'il{0}pi{1}id'.format(list_index, product_index)) # Product impression SKU
Tracker.alias(unicode, 'il{0}pi{1}nm'.format(list_index, product_index)) # Product impression name
Tracker.alias(unicode, 'il{0}pi{1}br'.format(list_index, product_index)) # Product impression brand
Tracker.alias(unicode, 'il{0}pi{1}ca'.format(list_index, product_index)) # Product impression category
Tracker.alias(unicode, 'il{0}pi{1}va'.format(list_index, product_index)) # Product impression variant
Tracker.alias(int, 'il{0}pi{1}ps'.format(list_index, product_index)) # Product impression position
Tracker.alias(int, 'il{0}pi{1}pr'.format(list_index, product_index)) # Product impression price
for custom_index in range(MAX_CUSTOM_DEFINITIONS):
Tracker.alias(str, 'il{0}pi{1}cd{2}'.format(list_index, product_index,
custom_index)) # Product impression custom dimension
Tracker.alias(int, 'il{0}pi{1}cm{2}'.format(list_index, product_index,
custom_index)) # Product impression custom metric
for list_index in range(1, MAX_EC_LISTS):
Tracker.alias(unicode, 'il{0}nm'.format(list_index)) # Product impression list name
for promotion_index in range(1, MAX_EC_PROMOTIONS):
Tracker.alias(str, 'promo{0}id'.format(promotion_index)) # Promotion ID
Tracker.alias(unicode, 'promo{0}nm'.format(promotion_index)) # Promotion name
Tracker.alias(str, 'promo{0}cr'.format(promotion_index)) # Promotion creative
Tracker.alias(str, 'promo{0}ps'.format(promotion_index)) # Promotion position
# Shortcut for creating trackers
def create(account, *args, **kwargs):
return Tracker(account, *args, **kwargs)
# vim: set nowrap tabstop=4 shiftwidth=4 softtabstop=0 expandtab textwidth=0 filetype=python foldmethod=indent foldcolumn=4

View File

@@ -0,0 +1 @@
import Tracker

29
lib/jwt/__init__.py Normal file
View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# flake8: noqa
"""
JSON Web Token implementation
Minimum implementation based on this spec:
http://self-issued.info/docs/draft-jones-json-web-token-01.html
"""
__title__ = 'pyjwt'
__version__ = '1.4.0'
__author__ = 'José Padilla'
__license__ = 'MIT'
__copyright__ = 'Copyright 2015 José Padilla'
from .api_jwt import (
encode, decode, register_algorithm, unregister_algorithm,
get_unverified_header, PyJWT
)
from .api_jws import PyJWS
from .exceptions import (
InvalidTokenError, DecodeError, InvalidAudienceError,
ExpiredSignatureError, ImmatureSignatureError, InvalidIssuedAtError,
InvalidIssuerError, ExpiredSignature, InvalidAudience, InvalidIssuer,
MissingRequiredClaimError
)

135
lib/jwt/__main__.py Normal file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python
from __future__ import absolute_import, print_function
import json
import optparse
import sys
import time
from . import DecodeError, __package__, __version__, decode, encode
def main():
usage = '''Encodes or decodes JSON Web Tokens based on input.
%prog [options] input
Decoding examples:
%prog --key=secret json.web.token
%prog --no-verify json.web.token
Encoding requires the key option and takes space separated key/value pairs
separated by equals (=) as input. Examples:
%prog --key=secret iss=me exp=1302049071
%prog --key=secret foo=bar exp=+10
The exp key is special and can take an offset to current Unix time.\
'''
p = optparse.OptionParser(
usage=usage,
prog=__package__,
version='%s %s' % (__package__, __version__),
)
p.add_option(
'-n', '--no-verify',
action='store_false',
dest='verify',
default=True,
help='ignore signature verification on decode'
)
p.add_option(
'--key',
dest='key',
metavar='KEY',
default=None,
help='set the secret key to sign with'
)
p.add_option(
'--alg',
dest='algorithm',
metavar='ALG',
default='HS256',
help='set crypto algorithm to sign with. default=HS256'
)
options, arguments = p.parse_args()
if len(arguments) > 0 or not sys.stdin.isatty():
if len(arguments) == 1 and (not options.verify or options.key):
# Try to decode
try:
if not sys.stdin.isatty():
token = sys.stdin.read()
else:
token = arguments[0]
token = token.encode('utf-8')
data = decode(token, key=options.key, verify=options.verify)
print(json.dumps(data))
sys.exit(0)
except DecodeError as e:
print(e)
sys.exit(1)
# Try to encode
if options.key is None:
print('Key is required when encoding. See --help for usage.')
sys.exit(1)
# Build payload object to encode
payload = {}
for arg in arguments:
try:
k, v = arg.split('=', 1)
# exp +offset special case?
if k == 'exp' and v[0] == '+' and len(v) > 1:
v = str(int(time.time()+int(v[1:])))
# Cast to integer?
if v.isdigit():
v = int(v)
else:
# Cast to float?
try:
v = float(v)
except ValueError:
pass
# Cast to true, false, or null?
constants = {'true': True, 'false': False, 'null': None}
if v in constants:
v = constants[v]
payload[k] = v
except ValueError:
print('Invalid encoding input at {}'.format(arg))
sys.exit(1)
try:
token = encode(
payload,
key=options.key,
algorithm=options.algorithm
)
print(token)
sys.exit(0)
except Exception as e:
print(e)
sys.exit(1)
else:
p.print_help()
if __name__ == '__main__':
main()

290
lib/jwt/algorithms.py Normal file
View File

@@ -0,0 +1,290 @@
import hashlib
import hmac
from .compat import constant_time_compare, string_types, text_type
from .exceptions import InvalidKeyError
from .utils import der_to_raw_signature, raw_to_der_signature
try:
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import (
load_pem_private_key, load_pem_public_key, load_ssh_public_key
)
from cryptography.hazmat.primitives.asymmetric.rsa import (
RSAPrivateKey, RSAPublicKey
)
from cryptography.hazmat.primitives.asymmetric.ec import (
EllipticCurvePrivateKey, EllipticCurvePublicKey
)
from cryptography.hazmat.primitives.asymmetric import ec, padding
from cryptography.hazmat.backends import default_backend
from cryptography.exceptions import InvalidSignature
has_crypto = True
except ImportError:
has_crypto = False
def get_default_algorithms():
"""
Returns the algorithms that are implemented by the library.
"""
default_algorithms = {
'none': NoneAlgorithm(),
'HS256': HMACAlgorithm(HMACAlgorithm.SHA256),
'HS384': HMACAlgorithm(HMACAlgorithm.SHA384),
'HS512': HMACAlgorithm(HMACAlgorithm.SHA512)
}
if has_crypto:
default_algorithms.update({
'RS256': RSAAlgorithm(RSAAlgorithm.SHA256),
'RS384': RSAAlgorithm(RSAAlgorithm.SHA384),
'RS512': RSAAlgorithm(RSAAlgorithm.SHA512),
'ES256': ECAlgorithm(ECAlgorithm.SHA256),
'ES384': ECAlgorithm(ECAlgorithm.SHA384),
'ES512': ECAlgorithm(ECAlgorithm.SHA512),
'PS256': RSAPSSAlgorithm(RSAPSSAlgorithm.SHA256),
'PS384': RSAPSSAlgorithm(RSAPSSAlgorithm.SHA384),
'PS512': RSAPSSAlgorithm(RSAPSSAlgorithm.SHA512)
})
return default_algorithms
class Algorithm(object):
"""
The interface for an algorithm used to sign and verify tokens.
"""
def prepare_key(self, key):
"""
Performs necessary validation and conversions on the key and returns
the key value in the proper format for sign() and verify().
"""
raise NotImplementedError
def sign(self, msg, key):
"""
Returns a digital signature for the specified message
using the specified key value.
"""
raise NotImplementedError
def verify(self, msg, key, sig):
"""
Verifies that the specified digital signature is valid
for the specified message and key values.
"""
raise NotImplementedError
class NoneAlgorithm(Algorithm):
"""
Placeholder for use when no signing or verification
operations are required.
"""
def prepare_key(self, key):
if key == '':
key = None
if key is not None:
raise InvalidKeyError('When alg = "none", key value must be None.')
return key
def sign(self, msg, key):
return b''
def verify(self, msg, key, sig):
return False
class HMACAlgorithm(Algorithm):
"""
Performs signing and verification operations using HMAC
and the specified hash function.
"""
SHA256 = hashlib.sha256
SHA384 = hashlib.sha384
SHA512 = hashlib.sha512
def __init__(self, hash_alg):
self.hash_alg = hash_alg
def prepare_key(self, key):
if not isinstance(key, string_types) and not isinstance(key, bytes):
raise TypeError('Expecting a string- or bytes-formatted key.')
if isinstance(key, text_type):
key = key.encode('utf-8')
invalid_strings = [
b'-----BEGIN PUBLIC KEY-----',
b'-----BEGIN CERTIFICATE-----',
b'ssh-rsa'
]
if any([string_value in key for string_value in invalid_strings]):
raise InvalidKeyError(
'The specified key is an asymmetric key or x509 certificate and'
' should not be used as an HMAC secret.')
return key
def sign(self, msg, key):
return hmac.new(key, msg, self.hash_alg).digest()
def verify(self, msg, key, sig):
return constant_time_compare(sig, self.sign(msg, key))
if has_crypto:
class RSAAlgorithm(Algorithm):
"""
Performs signing and verification operations using
RSASSA-PKCS-v1_5 and the specified hash function.
"""
SHA256 = hashes.SHA256
SHA384 = hashes.SHA384
SHA512 = hashes.SHA512
def __init__(self, hash_alg):
self.hash_alg = hash_alg
def prepare_key(self, key):
if isinstance(key, RSAPrivateKey) or \
isinstance(key, RSAPublicKey):
return key
if isinstance(key, string_types):
if isinstance(key, text_type):
key = key.encode('utf-8')
try:
if key.startswith(b'ssh-rsa'):
key = load_ssh_public_key(key, backend=default_backend())
else:
key = load_pem_private_key(key, password=None, backend=default_backend())
except ValueError:
key = load_pem_public_key(key, backend=default_backend())
else:
raise TypeError('Expecting a PEM-formatted key.')
return key
def sign(self, msg, key):
signer = key.signer(
padding.PKCS1v15(),
self.hash_alg()
)
signer.update(msg)
return signer.finalize()
def verify(self, msg, key, sig):
verifier = key.verifier(
sig,
padding.PKCS1v15(),
self.hash_alg()
)
verifier.update(msg)
try:
verifier.verify()
return True
except InvalidSignature:
return False
class ECAlgorithm(Algorithm):
"""
Performs signing and verification operations using
ECDSA and the specified hash function
"""
SHA256 = hashes.SHA256
SHA384 = hashes.SHA384
SHA512 = hashes.SHA512
def __init__(self, hash_alg):
self.hash_alg = hash_alg
def prepare_key(self, key):
if isinstance(key, EllipticCurvePrivateKey) or \
isinstance(key, EllipticCurvePublicKey):
return key
if isinstance(key, string_types):
if isinstance(key, text_type):
key = key.encode('utf-8')
# Attempt to load key. We don't know if it's
# a Signing Key or a Verifying Key, so we try
# the Verifying Key first.
try:
key = load_pem_public_key(key, backend=default_backend())
except ValueError:
key = load_pem_private_key(key, password=None, backend=default_backend())
else:
raise TypeError('Expecting a PEM-formatted key.')
return key
def sign(self, msg, key):
signer = key.signer(ec.ECDSA(self.hash_alg()))
signer.update(msg)
der_sig = signer.finalize()
return der_to_raw_signature(der_sig, key.curve)
def verify(self, msg, key, sig):
try:
der_sig = raw_to_der_signature(sig, key.curve)
except ValueError:
return False
verifier = key.verifier(der_sig, ec.ECDSA(self.hash_alg()))
verifier.update(msg)
try:
verifier.verify()
return True
except InvalidSignature:
return False
class RSAPSSAlgorithm(RSAAlgorithm):
"""
Performs a signature using RSASSA-PSS with MGF1
"""
def sign(self, msg, key):
signer = key.signer(
padding.PSS(
mgf=padding.MGF1(self.hash_alg()),
salt_length=self.hash_alg.digest_size
),
self.hash_alg()
)
signer.update(msg)
return signer.finalize()
def verify(self, msg, key, sig):
verifier = key.verifier(
sig,
padding.PSS(
mgf=padding.MGF1(self.hash_alg()),
salt_length=self.hash_alg.digest_size
),
self.hash_alg()
)
verifier.update(msg)
try:
verifier.verify()
return True
except InvalidSignature:
return False

189
lib/jwt/api_jws.py Normal file
View File

@@ -0,0 +1,189 @@
import binascii
import json
import warnings
from collections import Mapping
from .algorithms import Algorithm, get_default_algorithms # NOQA
from .compat import text_type
from .exceptions import DecodeError, InvalidAlgorithmError
from .utils import base64url_decode, base64url_encode, merge_dict
class PyJWS(object):
header_typ = 'JWT'
def __init__(self, algorithms=None, options=None):
self._algorithms = get_default_algorithms()
self._valid_algs = (set(algorithms) if algorithms is not None
else set(self._algorithms))
# Remove algorithms that aren't on the whitelist
for key in list(self._algorithms.keys()):
if key not in self._valid_algs:
del self._algorithms[key]
if not options:
options = {}
self.options = merge_dict(self._get_default_options(), options)
@staticmethod
def _get_default_options():
return {
'verify_signature': True
}
def register_algorithm(self, alg_id, alg_obj):
"""
Registers a new Algorithm for use when creating and verifying tokens.
"""
if alg_id in self._algorithms:
raise ValueError('Algorithm already has a handler.')
if not isinstance(alg_obj, Algorithm):
raise TypeError('Object is not of type `Algorithm`')
self._algorithms[alg_id] = alg_obj
self._valid_algs.add(alg_id)
def unregister_algorithm(self, alg_id):
"""
Unregisters an Algorithm for use when creating and verifying tokens
Throws KeyError if algorithm is not registered.
"""
if alg_id not in self._algorithms:
raise KeyError('The specified algorithm could not be removed'
' because it is not registered.')
del self._algorithms[alg_id]
self._valid_algs.remove(alg_id)
def get_algorithms(self):
"""
Returns a list of supported values for the 'alg' parameter.
"""
return list(self._valid_algs)
def encode(self, payload, key, algorithm='HS256', headers=None,
json_encoder=None):
segments = []
if algorithm is None:
algorithm = 'none'
if algorithm not in self._valid_algs:
pass
# Header
header = {'typ': self.header_typ, 'alg': algorithm}
if headers:
header.update(headers)
json_header = json.dumps(
header,
separators=(',', ':'),
cls=json_encoder
).encode('utf-8')
segments.append(base64url_encode(json_header))
segments.append(base64url_encode(payload))
# Segments
signing_input = b'.'.join(segments)
try:
alg_obj = self._algorithms[algorithm]
key = alg_obj.prepare_key(key)
signature = alg_obj.sign(signing_input, key)
except KeyError:
raise NotImplementedError('Algorithm not supported')
segments.append(base64url_encode(signature))
return b'.'.join(segments)
def decode(self, jws, key='', verify=True, algorithms=None, options=None,
**kwargs):
payload, signing_input, header, signature = self._load(jws)
if verify:
merged_options = merge_dict(self.options, options)
if merged_options.get('verify_signature'):
self._verify_signature(payload, signing_input, header, signature,
key, algorithms)
else:
warnings.warn('The verify parameter is deprecated. '
'Please use options instead.', DeprecationWarning)
return payload
def get_unverified_header(self, jwt):
"""Returns back the JWT header parameters as a dict()
Note: The signature is not verified so the header parameters
should not be fully trusted until signature verification is complete
"""
return self._load(jwt)[2]
def _load(self, jwt):
if isinstance(jwt, text_type):
jwt = jwt.encode('utf-8')
try:
signing_input, crypto_segment = jwt.rsplit(b'.', 1)
header_segment, payload_segment = signing_input.split(b'.', 1)
except ValueError:
raise DecodeError('Not enough segments')
try:
header_data = base64url_decode(header_segment)
except (TypeError, binascii.Error):
raise DecodeError('Invalid header padding')
try:
header = json.loads(header_data.decode('utf-8'))
except ValueError as e:
raise DecodeError('Invalid header string: %s' % e)
if not isinstance(header, Mapping):
raise DecodeError('Invalid header string: must be a json object')
try:
payload = base64url_decode(payload_segment)
except (TypeError, binascii.Error):
raise DecodeError('Invalid payload padding')
try:
signature = base64url_decode(crypto_segment)
except (TypeError, binascii.Error):
raise DecodeError('Invalid crypto padding')
return (payload, signing_input, header, signature)
def _verify_signature(self, payload, signing_input, header, signature,
key='', algorithms=None):
alg = header.get('alg')
if algorithms is not None and alg not in algorithms:
raise InvalidAlgorithmError('The specified alg value is not allowed')
try:
alg_obj = self._algorithms[alg]
key = alg_obj.prepare_key(key)
if not alg_obj.verify(signing_input, key, signature):
raise DecodeError('Signature verification failed')
except KeyError:
raise InvalidAlgorithmError('Algorithm not supported')
_jws_global_obj = PyJWS()
encode = _jws_global_obj.encode
decode = _jws_global_obj.decode
register_algorithm = _jws_global_obj.register_algorithm
unregister_algorithm = _jws_global_obj.unregister_algorithm
get_unverified_header = _jws_global_obj.get_unverified_header

187
lib/jwt/api_jwt.py Normal file
View File

@@ -0,0 +1,187 @@
import json
import warnings
from calendar import timegm
from collections import Mapping
from datetime import datetime, timedelta
from .api_jws import PyJWS
from .algorithms import Algorithm, get_default_algorithms # NOQA
from .compat import string_types, timedelta_total_seconds
from .exceptions import (
DecodeError, ExpiredSignatureError, ImmatureSignatureError,
InvalidAudienceError, InvalidIssuedAtError,
InvalidIssuerError, MissingRequiredClaimError
)
from .utils import merge_dict
class PyJWT(PyJWS):
header_type = 'JWT'
@staticmethod
def _get_default_options():
return {
'verify_signature': True,
'verify_exp': True,
'verify_nbf': True,
'verify_iat': True,
'verify_aud': True,
'verify_iss': True,
'require_exp': False,
'require_iat': False,
'require_nbf': False
}
def encode(self, payload, key, algorithm='HS256', headers=None,
json_encoder=None):
# Check that we get a mapping
if not isinstance(payload, Mapping):
raise TypeError('Expecting a mapping object, as JWT only supports '
'JSON objects as payloads.')
# Payload
for time_claim in ['exp', 'iat', 'nbf']:
# Convert datetime to a intDate value in known time-format claims
if isinstance(payload.get(time_claim), datetime):
payload[time_claim] = timegm(payload[time_claim].utctimetuple())
json_payload = json.dumps(
payload,
separators=(',', ':'),
cls=json_encoder
).encode('utf-8')
return super(PyJWT, self).encode(
json_payload, key, algorithm, headers, json_encoder
)
def decode(self, jwt, key='', verify=True, algorithms=None, options=None,
**kwargs):
payload, signing_input, header, signature = self._load(jwt)
decoded = super(PyJWT, self).decode(jwt, key, verify, algorithms,
options, **kwargs)
try:
payload = json.loads(decoded.decode('utf-8'))
except ValueError as e:
raise DecodeError('Invalid payload string: %s' % e)
if not isinstance(payload, Mapping):
raise DecodeError('Invalid payload string: must be a json object')
if verify:
merged_options = merge_dict(self.options, options)
self._validate_claims(payload, merged_options, **kwargs)
return payload
def _validate_claims(self, payload, options, audience=None, issuer=None,
leeway=0, **kwargs):
if 'verify_expiration' in kwargs:
options['verify_exp'] = kwargs.get('verify_expiration', True)
warnings.warn('The verify_expiration parameter is deprecated. '
'Please use options instead.', DeprecationWarning)
if isinstance(leeway, timedelta):
leeway = timedelta_total_seconds(leeway)
if not isinstance(audience, (string_types, type(None))):
raise TypeError('audience must be a string or None')
self._validate_required_claims(payload, options)
now = timegm(datetime.utcnow().utctimetuple())
if 'iat' in payload and options.get('verify_iat'):
self._validate_iat(payload, now, leeway)
if 'nbf' in payload and options.get('verify_nbf'):
self._validate_nbf(payload, now, leeway)
if 'exp' in payload and options.get('verify_exp'):
self._validate_exp(payload, now, leeway)
if options.get('verify_iss'):
self._validate_iss(payload, issuer)
if options.get('verify_aud'):
self._validate_aud(payload, audience)
def _validate_required_claims(self, payload, options):
if options.get('require_exp') and payload.get('exp') is None:
raise MissingRequiredClaimError('exp')
if options.get('require_iat') and payload.get('iat') is None:
raise MissingRequiredClaimError('iat')
if options.get('require_nbf') and payload.get('nbf') is None:
raise MissingRequiredClaimError('nbf')
def _validate_iat(self, payload, now, leeway):
try:
iat = int(payload['iat'])
except ValueError:
raise DecodeError('Issued At claim (iat) must be an integer.')
if iat > (now + leeway):
raise InvalidIssuedAtError('Issued At claim (iat) cannot be in'
' the future.')
def _validate_nbf(self, payload, now, leeway):
try:
nbf = int(payload['nbf'])
except ValueError:
raise DecodeError('Not Before claim (nbf) must be an integer.')
if nbf > (now + leeway):
raise ImmatureSignatureError('The token is not yet valid (nbf)')
def _validate_exp(self, payload, now, leeway):
try:
exp = int(payload['exp'])
except ValueError:
raise DecodeError('Expiration Time claim (exp) must be an'
' integer.')
if exp < (now - leeway):
raise ExpiredSignatureError('Signature has expired')
def _validate_aud(self, payload, audience):
if audience is None and 'aud' not in payload:
return
if audience is not None and 'aud' not in payload:
# Application specified an audience, but it could not be
# verified since the token does not contain a claim.
raise MissingRequiredClaimError('aud')
audience_claims = payload['aud']
if isinstance(audience_claims, string_types):
audience_claims = [audience_claims]
if not isinstance(audience_claims, list):
raise InvalidAudienceError('Invalid claim format in token')
if any(not isinstance(c, string_types) for c in audience_claims):
raise InvalidAudienceError('Invalid claim format in token')
if audience not in audience_claims:
raise InvalidAudienceError('Invalid audience')
def _validate_iss(self, payload, issuer):
if issuer is None:
return
if 'iss' not in payload:
raise MissingRequiredClaimError('iss')
if payload['iss'] != issuer:
raise InvalidIssuerError('Invalid issuer')
_jwt_global_obj = PyJWT()
encode = _jwt_global_obj.encode
decode = _jwt_global_obj.decode
register_algorithm = _jwt_global_obj.register_algorithm
unregister_algorithm = _jwt_global_obj.unregister_algorithm
get_unverified_header = _jwt_global_obj.get_unverified_header

52
lib/jwt/compat.py Normal file
View File

@@ -0,0 +1,52 @@
"""
The `compat` module provides support for backwards compatibility with older
versions of python, and compatibility wrappers around optional packages.
"""
# flake8: noqa
import sys
import hmac
PY3 = sys.version_info[0] == 3
if PY3:
string_types = str,
text_type = str
else:
string_types = basestring,
text_type = unicode
def timedelta_total_seconds(delta):
try:
delta.total_seconds
except AttributeError:
# On Python 2.6, timedelta instances do not have
# a .total_seconds() method.
total_seconds = delta.days * 24 * 60 * 60 + delta.seconds
else:
total_seconds = delta.total_seconds()
return total_seconds
try:
constant_time_compare = hmac.compare_digest
except AttributeError:
# Fallback for Python < 2.7
def constant_time_compare(val1, val2):
"""
Returns True if the two strings are equal, False otherwise.
The time taken is independent of the number of characters that match.
"""
if len(val1) != len(val2):
return False
result = 0
for x, y in zip(val1, val2):
result |= ord(x) ^ ord(y)
return result == 0

View File

View File

View File

@@ -0,0 +1,60 @@
# Note: This file is named py_ecdsa.py because import behavior in Python 2
# would cause ecdsa.py to squash the ecdsa library that it depends upon.
import hashlib
import ecdsa
from jwt.algorithms import Algorithm
from jwt.compat import string_types, text_type
class ECAlgorithm(Algorithm):
"""
Performs signing and verification operations using
ECDSA and the specified hash function
This class requires the ecdsa package to be installed.
This is based off of the implementation in PyJWT 0.3.2
"""
SHA256 = hashlib.sha256
SHA384 = hashlib.sha384
SHA512 = hashlib.sha512
def __init__(self, hash_alg):
self.hash_alg = hash_alg
def prepare_key(self, key):
if isinstance(key, ecdsa.SigningKey) or \
isinstance(key, ecdsa.VerifyingKey):
return key
if isinstance(key, string_types):
if isinstance(key, text_type):
key = key.encode('utf-8')
# Attempt to load key. We don't know if it's
# a Signing Key or a Verifying Key, so we try
# the Verifying Key first.
try:
key = ecdsa.VerifyingKey.from_pem(key)
except ecdsa.der.UnexpectedDER:
key = ecdsa.SigningKey.from_pem(key)
else:
raise TypeError('Expecting a PEM-formatted key.')
return key
def sign(self, msg, key):
return key.sign(msg, hashfunc=self.hash_alg,
sigencode=ecdsa.util.sigencode_string)
def verify(self, msg, key, sig):
try:
return key.verify(sig, msg, hashfunc=self.hash_alg,
sigdecode=ecdsa.util.sigdecode_string)
except AssertionError:
return False

View File

@@ -0,0 +1,47 @@
import Crypto.Hash.SHA256
import Crypto.Hash.SHA384
import Crypto.Hash.SHA512
from Crypto.PublicKey import RSA
from Crypto.Signature import PKCS1_v1_5
from jwt.algorithms import Algorithm
from jwt.compat import string_types, text_type
class RSAAlgorithm(Algorithm):
"""
Performs signing and verification operations using
RSASSA-PKCS-v1_5 and the specified hash function.
This class requires PyCrypto package to be installed.
This is based off of the implementation in PyJWT 0.3.2
"""
SHA256 = Crypto.Hash.SHA256
SHA384 = Crypto.Hash.SHA384
SHA512 = Crypto.Hash.SHA512
def __init__(self, hash_alg):
self.hash_alg = hash_alg
def prepare_key(self, key):
if isinstance(key, RSA._RSAobj):
return key
if isinstance(key, string_types):
if isinstance(key, text_type):
key = key.encode('utf-8')
key = RSA.importKey(key)
else:
raise TypeError('Expecting a PEM- or RSA-formatted key.')
return key
def sign(self, msg, key):
return PKCS1_v1_5.new(key).sign(self.hash_alg.new(msg))
def verify(self, msg, key, sig):
return PKCS1_v1_5.new(key).verify(self.hash_alg.new(msg), sig)

48
lib/jwt/exceptions.py Normal file
View File

@@ -0,0 +1,48 @@
class InvalidTokenError(Exception):
pass
class DecodeError(InvalidTokenError):
pass
class ExpiredSignatureError(InvalidTokenError):
pass
class InvalidAudienceError(InvalidTokenError):
pass
class InvalidIssuerError(InvalidTokenError):
pass
class InvalidIssuedAtError(InvalidTokenError):
pass
class ImmatureSignatureError(InvalidTokenError):
pass
class InvalidKeyError(Exception):
pass
class InvalidAlgorithmError(InvalidTokenError):
pass
class MissingRequiredClaimError(InvalidTokenError):
def __init__(self, claim):
self.claim = claim
def __str__(self):
return 'Token is missing the "%s" claim' % self.claim
# Compatibility aliases (deprecated)
ExpiredSignature = ExpiredSignatureError
InvalidAudience = InvalidAudienceError
InvalidIssuer = InvalidIssuerError

67
lib/jwt/utils.py Normal file
View File

@@ -0,0 +1,67 @@
import base64
import binascii
try:
from cryptography.hazmat.primitives.asymmetric.utils import (
decode_rfc6979_signature, encode_rfc6979_signature
)
except ImportError:
pass
def base64url_decode(input):
rem = len(input) % 4
if rem > 0:
input += b'=' * (4 - rem)
return base64.urlsafe_b64decode(input)
def base64url_encode(input):
return base64.urlsafe_b64encode(input).replace(b'=', b'')
def merge_dict(original, updates):
if not updates:
return original
try:
merged_options = original.copy()
merged_options.update(updates)
except (AttributeError, ValueError) as e:
raise TypeError('original and updates must be a dictionary: %s' % e)
return merged_options
def number_to_bytes(num, num_bytes):
padded_hex = '%0*x' % (2 * num_bytes, num)
big_endian = binascii.a2b_hex(padded_hex.encode('ascii'))
return big_endian
def bytes_to_number(string):
return int(binascii.b2a_hex(string), 16)
def der_to_raw_signature(der_sig, curve):
num_bits = curve.key_size
num_bytes = (num_bits + 7) // 8
r, s = decode_rfc6979_signature(der_sig)
return number_to_bytes(r, num_bytes) + number_to_bytes(s, num_bytes)
def raw_to_der_signature(raw_sig, curve):
num_bits = curve.key_size
num_bytes = (num_bits + 7) // 8
if len(raw_sig) != 2 * num_bytes:
raise ValueError('Invalid signature')
r = bytes_to_number(raw_sig[:num_bytes])
s = bytes_to_number(raw_sig[num_bytes:])
return encode_rfc6979_signature(r, s)

View File

@@ -21,6 +21,7 @@ import subprocess
import threading
import datetime
import uuid
# Some cut down versions of Python may not include this module and it's not critical for us
try:
import webbrowser
@@ -31,10 +32,11 @@ except ImportError:
import cherrypy
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from UniversalAnalytics import Tracker
import activity_handler
import activity_pinger
import config
import common
import database
import libraries
import logger
@@ -42,7 +44,6 @@ import mobile_app
import notification_handler
import notifiers
import plextv
import pmsconnect
import users
import versioncheck
import plexpy.config
@@ -54,6 +55,7 @@ ARGS = None
SIGNAL = None
SYS_PLATFORM = None
SYS_LANGUAGE = None
SYS_ENCODING = None
QUIET = False
@@ -71,6 +73,7 @@ NOTIFY_QUEUE = Queue()
INIT_LOCK = threading.Lock()
_INITIALIZED = False
_STARTED = False
_UPDATE = False
DATA_DIR = None
@@ -83,6 +86,8 @@ INSTALL_TYPE = None
CURRENT_VERSION = None
LATEST_VERSION = None
COMMITS_BEHIND = None
PREV_RELEASE = None
LATEST_RELEASE = None
UMASK = None
@@ -91,7 +96,9 @@ HTTP_ROOT = None
DEV = False
WS_CONNECTED = False
PLEX_SERVER_UP = True
PLEX_SERVER_UP = None
TRACKER = None
def initialize(config_file):
@@ -102,7 +109,10 @@ def initialize(config_file):
global _INITIALIZED
global CURRENT_VERSION
global LATEST_VERSION
global PREV_RELEASE
global UMASK
global _UPDATE
CONFIG = plexpy.config.Config(config_file)
CONFIG_FILE = config_file
@@ -175,17 +185,34 @@ def initialize(config_file):
# Check if Tautulli has a uuid
if CONFIG.PMS_UUID == '' or not CONFIG.PMS_UUID:
logger.debug(u"Generating UUID...")
my_uuid = generate_uuid()
CONFIG.__setattr__('PMS_UUID', my_uuid)
CONFIG.PMS_UUID = generate_uuid()
CONFIG.write()
# Check if Tautulli has an API key
if CONFIG.API_KEY == '':
logger.debug(u"Generating API key...")
api_key = generate_uuid()
CONFIG.__setattr__('API_KEY', api_key)
CONFIG.API_KEY = generate_uuid()
CONFIG.write()
# Check if Tautulli has a jwt_secret
if CONFIG.JWT_SECRET == '' or not CONFIG.JWT_SECRET:
logger.debug(u"Generating JWT secret...")
CONFIG.JWT_SECRET = generate_uuid()
CONFIG.write()
# Get the previous version from the file
version_lock_file = os.path.join(DATA_DIR, "version.lock")
prev_version = None
if os.path.isfile(version_lock_file):
try:
with open(version_lock_file, "r") as fp:
prev_version = fp.read()
except IOError as e:
logger.error(u"Unable to read previous version from file '%s': %s" %
(version_lock_file, e))
else:
prev_version = 'cfd30996264b7e9fe4ef87f02d1cc52d1ae8bfca'
# Get the currently installed version. Returns None, 'win32' or the git
# hash.
CURRENT_VERSION, CONFIG.GIT_REMOTE, CONFIG.GIT_BRANCH = versioncheck.getVersion()
@@ -194,8 +221,6 @@ def initialize(config_file):
# This allowes one to restore to that version. The idea is that if we
# arrive here, most parts of Tautulli seem to work.
if CURRENT_VERSION:
version_lock_file = os.path.join(DATA_DIR, "version.lock")
try:
with open(version_lock_file, "w") as fp:
fp.write(CURRENT_VERSION)
@@ -213,6 +238,33 @@ def initialize(config_file):
else:
LATEST_VERSION = CURRENT_VERSION
# Get the previous release from the file
release_file = os.path.join(DATA_DIR, "release.lock")
PREV_RELEASE = common.RELEASE
if os.path.isfile(release_file):
try:
with open(release_file, "r") as fp:
PREV_RELEASE = fp.read()
except IOError as e:
logger.error(u"Unable to read previous release from file '%s': %s" %
(release_file, e))
elif prev_version == 'cfd30996264b7e9fe4ef87f02d1cc52d1ae8bfca': # Commit hash for v1.4.25
PREV_RELEASE = 'v1.4.25'
# Check if the release was updated
if common.RELEASE != PREV_RELEASE:
CONFIG.UPDATE_SHOW_CHANGELOG = 1
CONFIG.write()
_UPDATE = True
# Write current release version to file for update checking
try:
with open(release_file, "w") as fp:
fp.write(common.RELEASE)
except IOError as e:
logger.error(u"Unable to write current release to file '%s': %s" %
(release_file, e))
# Get the real PMS urls for SSL and remote access
if CONFIG.PMS_TOKEN and CONFIG.PMS_IP and CONFIG.PMS_PORT:
plextv.get_server_resources()
@@ -232,6 +284,7 @@ def initialize(config_file):
_INITIALIZED = True
return True
def daemonize():
if threading.activeCount() != 1:
logger.warn(
@@ -341,7 +394,7 @@ def initialize_scheduler():
schedule_job(libraries.refresh_libraries, 'Refresh libraries list',
hours=library_hours, minutes=0, seconds=0)
schedule_job(activity_pinger.check_server_response, 'Check server response',
schedule_job(activity_pinger.connect_server, 'Check for server response',
hours=0, minutes=0, seconds=0)
else:
@@ -359,12 +412,9 @@ def initialize_scheduler():
schedule_job(libraries.refresh_libraries, 'Refresh libraries list',
hours=0, minutes=0, seconds=0)
# Schedule job to reconnect websocket
response_seconds = CONFIG.WEBSOCKET_CONNECTION_ATTEMPTS * CONFIG.WEBSOCKET_CONNECTION_TIMEOUT
response_seconds = 60 if response_seconds < 60 else response_seconds
schedule_job(activity_pinger.check_server_response, 'Check server response',
hours=0, minutes=0, seconds=response_seconds)
# Schedule job to reconnect server
schedule_job(activity_pinger.connect_server, 'Check for server response',
hours=0, minutes=0, seconds=60, args=(False,))
# Start scheduler
if start_jobs and len(SCHED.get_jobs()):
@@ -406,6 +456,23 @@ def start():
# Start background notification thread
notification_handler.start_threads(num_threads=CONFIG.NOTIFICATION_THREADS)
notifiers.check_browser_enabled()
if CONFIG.FIRST_RUN_COMPLETE:
activity_pinger.connect_server(log=True, startup=True)
if CONFIG.SYSTEM_ANALYTICS:
global TRACKER
TRACKER = initialize_tracker()
# Send system analytics events
if not CONFIG.FIRST_RUN_COMPLETE:
analytics_event(category='system', action='install')
elif _UPDATE:
analytics_event(category='system', action='update')
analytics_event(category='system', action='start')
_STARTED = True
@@ -498,7 +565,7 @@ def dbcheck():
c_db.execute(
'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, '
'user_id INTEGER DEFAULT NULL UNIQUE, username TEXT NOT NULL, friendly_name TEXT, '
'thumb TEXT, custom_avatar_url TEXT, email TEXT, is_home_user INTEGER DEFAULT NULL, '
'thumb TEXT, custom_avatar_url TEXT, email TEXT, is_admin INTEGER DEFAULT 0, is_home_user INTEGER DEFAULT NULL, '
'is_allow_sync INTEGER DEFAULT NULL, is_restricted INTEGER DEFAULT NULL, do_notify INTEGER DEFAULT 1, '
'keep_history INTEGER DEFAULT 1, deleted_user INTEGER DEFAULT 0, allow_guest INTEGER DEFAULT 0, '
'user_token TEXT, server_token TEXT, shared_libraries TEXT, filter_all TEXT, filter_movies TEXT, filter_tv TEXT, '
@@ -554,7 +621,7 @@ def dbcheck():
# poster_urls table :: This table keeps record of the notification poster urls
c_db.execute(
'CREATE TABLE IF NOT EXISTS poster_urls (id INTEGER PRIMARY KEY AUTOINCREMENT, '
'rating_key INTEGER, poster_title TEXT, poster_url TEXT)'
'rating_key INTEGER, poster_title TEXT, poster_url TEXT, delete_hash TEXT)'
)
# recently_added table :: This table keeps record of recently added items
@@ -1027,9 +1094,9 @@ def dbcheck():
)
c_db.execute(
'UPDATE session_history_media_info SET transcode_decision = (CASE '
'WHEN video_decision = "transcode" OR audio_decision = "transcode" THEN "transcode" '
'WHEN video_decision = "copy" OR audio_decision = "copy" THEN "copy" '
'WHEN video_decision = "direct play" OR audio_decision = "direct play" THEN "direct play" END)'
'WHEN video_decision = "transcode" OR audio_decision = "transcode" THEN "transcode" '
'WHEN video_decision = "copy" OR audio_decision = "copy" THEN "copy" '
'WHEN video_decision = "direct play" OR audio_decision = "direct play" THEN "direct play" END)'
)
# Upgrade session_history_media_info table from earlier versions
@@ -1188,7 +1255,6 @@ def dbcheck():
'UPDATE session_history_media_info SET subtitle_codec = "" WHERE subtitle_codec IS NULL '
)
# Upgrade session_history_media_info table from earlier versions
try:
result = c_db.execute('SELECT stream_container FROM session_history_media_info '
@@ -1285,6 +1351,15 @@ def dbcheck():
'ALTER TABLE users ADD COLUMN filter_photos TEXT'
)
# Upgrade users table from earlier versions
try:
c_db.execute('SELECT is_admin FROM users')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table users.")
c_db.execute(
'ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0'
)
# Upgrade notify_log table from earlier versions
try:
c_db.execute('SELECT poster_url FROM notify_log')
@@ -1510,32 +1585,47 @@ def dbcheck():
'ALTER TABLE user_login ADD COLUMN success INTEGER DEFAULT 1'
)
# Upgrade poster_urls table from earlier versions
try:
c_db.execute('SELECT delete_hash FROM poster_urls')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table poster_urls.")
c_db.execute(
'ALTER TABLE poster_urls ADD COLUMN delete_hash TEXT'
)
# Add "Local" user to database as default unauthenticated user.
result = c_db.execute('SELECT id FROM users WHERE username = "Local"')
if not result.fetchone():
logger.debug(u"User 'Local' does not exist. Adding user.")
c_db.execute('INSERT INTO users (user_id, username) VALUES (0, "Local")')
# Create table indices
c_db.execute(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_tvmaze_lookup ON tvmaze_lookup (rating_key)'
)
c_db.execute(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_themoviedb_lookup ON themoviedb_lookup (rating_key)'
)
)
conn_db.commit()
c_db.close()
def upgrade():
if CONFIG.UPDATE_NOTIFIERS_DB:
notifiers.upgrade_config_to_db()
if CONFIG.UPDATE_LIBRARIES_DB_NOTIFY:
libraries.update_libraries_db_notify()
def shutdown(restart=False, update=False, checkout=False):
cherrypy.engine.exit()
SCHED.shutdown(wait=False)
if SCHED.running:
SCHED.shutdown(wait=False)
if activity_handler.ACTIVITY_SCHED.running:
activity_handler.ACTIVITY_SCHED.shutdown(wait=False)
# Stop the notification threads
for i in range(CONFIG.NOTIFICATION_THREADS):
@@ -1566,25 +1656,76 @@ def shutdown(restart=False, update=False, checkout=False):
if restart:
logger.info(u"Tautulli is restarting...")
exe = sys.executable
args = [exe, FULL_PATH]
args += ARGS
if '--nolaunch' not in args:
args += ['--nolaunch']
# os.execv fails with spaced names on Windows
# https://bugs.python.org/issue19066
# Separate out logger so we can shutdown logger after
if NOFORK:
logger.info('Running as service, not forking. Exiting...')
elif os.name == 'nt':
logger.info('Restarting Tautulli with %s', args)
subprocess.Popen(args, cwd=os.getcwd())
else:
logger.info('Restarting Tautulli with %s', args)
logger.shutdown()
# os.execv fails with spaced names on Windows
# https://bugs.python.org/issue19066
if NOFORK:
pass
elif os.name == 'nt':
subprocess.Popen(args, cwd=os.getcwd())
else:
os.execv(exe, args)
else:
logger.shutdown()
os._exit(0)
def generate_uuid():
return uuid.uuid4().hex
def initialize_tracker():
data = {
'dataSource': 'server',
'appName': 'Tautulli',
'appVersion': common.RELEASE,
'appId': plexpy.INSTALL_TYPE,
'appInstallerId': plexpy.CONFIG.GIT_BRANCH,
'dimension1': '{} {}'.format(common.PLATFORM, common.PLATFORM_VERSION), # App Platform
'userLanguage': plexpy.SYS_LANGUAGE,
'documentEncoding': plexpy.SYS_ENCODING,
'noninteractive': True
}
tracker = Tracker.create('UA-111522699-2', client_id=CONFIG.PMS_UUID, hash_client_id=True,
user_agent=common.USER_AGENT)
tracker.set(data)
return tracker
def analytics_event(category, action, label=None, value=None, **kwargs):
data = {'category': category, 'action': action}
if label is not None:
data['label'] = label
if value is not None:
data['value'] = value
if kwargs:
data.update(kwargs)
if TRACKER:
try:
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))

View File

@@ -54,7 +54,7 @@ class ActivityHandler(object):
def get_rating_key(self):
if self.is_valid_session():
return int(self.timeline['ratingKey'])
return self.timeline['ratingKey']
return None
@@ -65,14 +65,31 @@ class ActivityHandler(object):
if session_list:
for session in session_list['sessions']:
if int(session['session_key']) == self.get_session_key():
# Live sessions don't have rating keys in sessions
# Get it from the websocket data
if not session['rating_key']:
session['rating_key'] = self.get_rating_key()
return session
return None
def update_db_session(self, session=None):
# Update our session temp table values
monitor_proc = activity_processor.ActivityProcessor()
monitor_proc.write_session(session=session, notify=False)
if session is None:
session = self.get_live_session()
if session:
# Update our session temp table values
ap = activity_processor.ActivityProcessor()
ap.write_session(session=session, notify=False)
self.set_session_state()
def set_session_state(self):
ap = activity_processor.ActivityProcessor()
ap.set_session_state(session_key=self.get_session_key(),
state=self.timeline['state'],
view_offset=self.timeline['viewOffset'],
stopped=int(time.time()))
def on_start(self):
if self.is_valid_session():
@@ -93,14 +110,15 @@ class ActivityHandler(object):
% (str(session['session_key']), str(session['user_id']), session['username'],
str(session['rating_key']), session['full_title']))
plexpy.NOTIFY_QUEUE.put({'stream_data': session, 'notify_action': 'on_play'})
plexpy.NOTIFY_QUEUE.put({'stream_data': session.copy(), 'notify_action': 'on_play'})
# Write the new session to our temp session table
self.update_db_session(session=session)
def on_stop(self, force_stop=False):
if self.is_valid_session():
logger.debug(u"Tautulli ActivityHandler :: Session %s stopped." % str(self.get_session_key()))
logger.debug(u"Tautulli ActivityHandler :: Session %s %sstopped."
% (str(self.get_session_key()), 'force ' if force_stop else ''))
# Set the session last_paused timestamp
ap = activity_processor.ActivityProcessor()
@@ -109,25 +127,28 @@ class ActivityHandler(object):
# Update the session state and viewOffset
# Set force_stop to true to disable the state set
if not force_stop:
ap.set_session_state(session_key=self.get_session_key(),
state=self.timeline['state'],
view_offset=self.timeline['viewOffset'],
stopped=int(time.time()))
self.set_session_state()
# Retrieve the session data from our temp table
db_session = ap.get_session_by_key(session_key=self.get_session_key())
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session, 'notify_action': 'on_stop'})
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session.copy(), 'notify_action': 'on_stop'})
# Write it to the history table
monitor_proc = activity_processor.ActivityProcessor()
monitor_proc.write_session_history(session=db_session)
row_id = monitor_proc.write_session_history(session=db_session)
# Remove the session from our temp session table
logger.debug(u"Tautulli ActivityHandler :: Removing sessionKey %s ratingKey %s from session queue"
% (str(self.get_session_key()), str(self.get_rating_key())))
ap.delete_session(session_key=self.get_session_key())
delete_metadata_cache(self.get_session_key())
if row_id:
schedule_callback('session_key-{}'.format(self.get_session_key()), remove_job=True)
# Remove the session from our temp session table
logger.debug(u"Tautulli ActivityHandler :: Removing sessionKey %s ratingKey %s from session queue"
% (str(self.get_session_key()), str(self.get_rating_key())))
ap.delete_session(row_id=row_id)
delete_metadata_cache(self.get_session_key())
else:
schedule_callback('session_key-{}'.format(self.get_session_key()), func=force_stop_stream,
args=[self.get_session_key()], seconds=30)
def on_pause(self, still_paused=False):
if self.is_valid_session():
@@ -139,16 +160,13 @@ class ActivityHandler(object):
ap.set_session_last_paused(session_key=self.get_session_key(), timestamp=int(time.time()))
# Update the session state and viewOffset
ap.set_session_state(session_key=self.get_session_key(),
state=self.timeline['state'],
view_offset=self.timeline['viewOffset'],
stopped=int(time.time()))
self.update_db_session()
# Retrieve the session data from our temp table
db_session = ap.get_session_by_key(session_key=self.get_session_key())
if not still_paused:
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session, 'notify_action': 'on_pause'})
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session.copy(), 'notify_action': 'on_pause'})
def on_resume(self):
if self.is_valid_session():
@@ -159,15 +177,12 @@ class ActivityHandler(object):
ap.set_session_last_paused(session_key=self.get_session_key(), timestamp=None)
# Update the session state and viewOffset
ap.set_session_state(session_key=self.get_session_key(),
state=self.timeline['state'],
view_offset=self.timeline['viewOffset'],
stopped=int(time.time()))
self.update_db_session()
# Retrieve the session data from our temp table
db_session = ap.get_session_by_key(session_key=self.get_session_key())
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session, 'notify_action': 'on_resume'})
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session.copy(), 'notify_action': 'on_resume'})
def on_buffer(self):
if self.is_valid_session():
@@ -187,10 +202,7 @@ class ActivityHandler(object):
buffer_last_triggered = ap.get_session_buffer_trigger_time(self.get_session_key())
# Update the session state and viewOffset
ap.set_session_state(session_key=self.get_session_key(),
state=self.timeline['state'],
view_offset=self.timeline['viewOffset'],
stopped=int(time.time()))
self.update_db_session()
time_since_last_trigger = 0
if buffer_last_triggered:
@@ -205,7 +217,7 @@ class ActivityHandler(object):
# Retrieve the session data from our temp table
db_session = ap.get_session_by_key(session_key=self.get_session_key())
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session, 'notify_action': 'on_buffer'})
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session.copy(), 'notify_action': 'on_buffer'})
# This function receives events from our websocket connection
def process(self):
@@ -220,7 +232,7 @@ class ActivityHandler(object):
if db_session:
# Re-schedule the callback to reset the 5 minutes timer
schedule_callback('session_key-{}'.format(self.get_session_key()),
function=force_stop_stream, args=[self.get_session_key()], minutes=5)
func=force_stop_stream, args=[self.get_session_key()], minutes=5)
last_state = db_session['state']
last_key = str(db_session['rating_key'])
@@ -232,9 +244,7 @@ class ActivityHandler(object):
# Update the session in our temp session table
# if the last set temporary stopped time exceeds 15 seconds
if int(time.time()) - db_session['stopped'] > 60:
session = self.get_live_session()
if session:
self.update_db_session(session=session)
self.update_db_session()
# Start our state checks
if this_state != last_state:
@@ -245,9 +255,6 @@ class ActivityHandler(object):
elif this_state == 'stopped':
self.on_stop()
# Remove the callback if the stream is stopped
schedule_callback('session_key-{}'.format(self.get_session_key()), remove_job=True)
elif this_state == 'buffering':
self.on_buffer()
@@ -265,13 +272,25 @@ class ActivityHandler(object):
# Monitor if the stream has reached the watch percentage for notifications
# The only purpose of this is for notifications
if this_state != 'buffering':
progress_percent = helpers.get_percent(db_session['view_offset'], db_session['duration'])
notify_states = notification_handler.get_notify_state(session=db_session)
if (db_session['media_type'] == 'movie' and progress_percent >= plexpy.CONFIG.MOVIE_WATCHED_PERCENT or
db_session['media_type'] == 'episode' and progress_percent >= plexpy.CONFIG.TV_WATCHED_PERCENT or
db_session['media_type'] == 'track' and progress_percent >= plexpy.CONFIG.MUSIC_WATCHED_PERCENT) \
and not any(d['notify_action'] == 'on_watched' for d in notify_states):
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session, 'notify_action': 'on_watched'})
progress_percent = helpers.get_percent(self.timeline['viewOffset'], db_session['duration'])
watched_percent = {'movie': plexpy.CONFIG.MOVIE_WATCHED_PERCENT,
'episode': plexpy.CONFIG.TV_WATCHED_PERCENT,
'track': plexpy.CONFIG.MUSIC_WATCHED_PERCENT,
'clip': plexpy.CONFIG.TV_WATCHED_PERCENT
}
if progress_percent >= watched_percent.get(db_session['media_type'], 101):
watched_notifiers = notification_handler.get_notify_state_enabled(
session=db_session, notify_action='on_watched', notified=False)
if watched_notifiers:
logger.debug(u"Tautulli ActivityHandler :: Session %s watched."
% str(self.get_session_key()))
for d in watched_notifiers:
plexpy.NOTIFY_QUEUE.put({'stream_data': db_session.copy(),
'notifier_id': d['notifier_id'],
'notify_action': 'on_watched'})
else:
# We don't have this session in our table yet, start a new one.
@@ -280,7 +299,7 @@ class ActivityHandler(object):
# Schedule a callback to force stop a stale stream 5 minutes later
schedule_callback('session_key-{}'.format(self.get_session_key()),
function=force_stop_stream, args=[self.get_session_key()], minutes=5)
func=force_stop_stream, args=[self.get_session_key()], minutes=5)
class TimelineHandler(object):
@@ -324,6 +343,7 @@ class TimelineHandler(object):
9: 'album',
10: 'track'}
identifier = self.timeline.get('identifier')
state_type = self.timeline.get('state')
media_type = media_types.get(self.timeline.get('type'))
section_id = self.timeline.get('sectionID', 0)
@@ -332,6 +352,10 @@ class TimelineHandler(object):
media_state = self.timeline.get('mediaState')
queue_size = self.timeline.get('queueSize')
# Return if it is not a library event (i.e. DVR EPG event)
if identifier != 'com.plexapp.plugins.library':
return
# Add a new media item to the recently added queue
if media_type and section_id > 0 and \
((state_type == 0 and metadata_state == 'created')): # or \
@@ -358,7 +382,7 @@ class TimelineHandler(object):
% (title, str(rating_key), str(grandparent_rating_key)))
# Schedule a callback to clear the recently added queue
schedule_callback('rating_key-{}'.format(grandparent_rating_key), function=clear_recently_added_queue,
schedule_callback('rating_key-{}'.format(grandparent_rating_key), func=clear_recently_added_queue,
args=[grandparent_rating_key], seconds=plexpy.CONFIG.NOTIFY_RECENTLY_ADDED_DELAY)
elif media_type in ('season', 'album'):
@@ -374,7 +398,7 @@ class TimelineHandler(object):
% (title, str(rating_key), str(parent_rating_key)))
# Schedule a callback to clear the recently added queue
schedule_callback('rating_key-{}'.format(parent_rating_key), function=clear_recently_added_queue,
schedule_callback('rating_key-{}'.format(parent_rating_key), func=clear_recently_added_queue,
args=[parent_rating_key], seconds=plexpy.CONFIG.NOTIFY_RECENTLY_ADDED_DELAY)
else:
@@ -385,7 +409,7 @@ class TimelineHandler(object):
% (title, str(rating_key)))
# Schedule a callback to clear the recently added queue
schedule_callback('rating_key-{}'.format(rating_key), function=clear_recently_added_queue,
schedule_callback('rating_key-{}'.format(rating_key), func=clear_recently_added_queue,
args=[rating_key], seconds=plexpy.CONFIG.NOTIFY_RECENTLY_ADDED_DELAY)
# A movie, show, or artist is done processing
@@ -415,7 +439,7 @@ def del_keys(key):
del_keys(RECENTLY_ADDED_QUEUE.pop(key))
def schedule_callback(id, function=None, remove_job=False, args=None, **kwargs):
def schedule_callback(id, func=None, remove_job=False, args=None, **kwargs):
if ACTIVITY_SCHED.get_job(id):
if remove_job:
ACTIVITY_SCHED.remove_job(id)
@@ -425,7 +449,7 @@ def schedule_callback(id, function=None, remove_job=False, args=None, **kwargs):
run_date=datetime.datetime.now() + datetime.timedelta(**kwargs)))
elif not remove_job:
ACTIVITY_SCHED.add_job(
function, args=args, id=id, trigger=DateTrigger(
func, args=args, id=id, trigger=DateTrigger(
run_date=datetime.datetime.now() + datetime.timedelta(**kwargs)))
@@ -433,13 +457,14 @@ def force_stop_stream(session_key):
ap = activity_processor.ActivityProcessor()
session = ap.get_session_by_key(session_key=session_key)
success = ap.write_session_history(session=session)
row_id = ap.write_session_history(session=session)
if success:
# If session is written to the databaase successfully, remove the session from the session table
if row_id:
# If session is written to the database successfully, remove the session from the session table
logger.info(u"Tautulli ActivityHandler :: Removing stale stream with sessionKey %s ratingKey %s from session queue"
% (session['session_key'], session['rating_key']))
ap.delete_session(session_key=session_key)
ap.delete_session(row_id=row_id)
delete_metadata_cache(session_key)
else:
session['write_attempts'] += 1
@@ -451,7 +476,7 @@ def force_stop_stream(session_key):
ap.increment_write_attempts(session_key=session_key)
# Reschedule for 30 seconds later
schedule_callback('session_key={}'.format(session_key), function=force_stop_stream,
schedule_callback('session_key-{}'.format(session_key), func=force_stop_stream,
args=[session_key], seconds=30)
else:
@@ -498,16 +523,18 @@ def on_created(rating_key, **kwargs):
if metadata:
notify = True
now = int(time.time())
if helpers.cast_to_int(metadata['updated_at']) < now - 86400: # Updated more than 24 hours ago
logger.debug(u"Tautulli TimelineHandler :: Library item %s updated more than 24 hours ago. Not notifying." % str(rating_key))
notify = False
# now = int(time.time())
#
# if helpers.cast_to_int(metadata['added_at']) < now - 86400: # Updated more than 24 hours ago
# logger.debug(u"Tautulli TimelineHandler :: Library item %s added more than 24 hours ago. Not notifying."
# % str(rating_key))
# notify = False
data_factory = datafactory.DataFactory()
if 'child_keys' not in kwargs:
if data_factory.get_recently_added_item(rating_key):
logger.debug(u"Tautulli TimelineHandler :: Library item %s added already. Not notifying again." % str(rating_key))
logger.debug(u"Tautulli TimelineHandler :: Library item %s added already. Not notifying again."
% str(rating_key))
notify = False
if notify:
@@ -530,7 +557,7 @@ def on_created(rating_key, **kwargs):
def delete_metadata_cache(session_key):
try:
os.remove(os.path.join(plexpy.CONFIG.CACHE_DIR, 'metadata-sessionKey-%s.json' % session_key))
os.remove(os.path.join(plexpy.CONFIG.CACHE_DIR, 'session_metadata/metadata-sessionKey-%s.json' % session_key))
except IOError as e:
logger.error(u"Tautulli ActivityHandler :: Failed to remove metadata cache file (sessionKey %s): %s"
% (session_key, e))

View File

@@ -61,12 +61,12 @@ def check_active_sessions(ws_request=False):
if session['state'] == 'paused':
logger.debug(u"Tautulli Monitor :: Session %s paused." % stream['session_key'])
plexpy.NOTIFY_QUEUE.put({'stream_data': stream, 'notify_action': 'on_pause'})
plexpy.NOTIFY_QUEUE.put({'stream_data': stream.copy(), 'notify_action': 'on_pause'})
if session['state'] == 'playing' and stream['state'] == 'paused':
logger.debug(u"Tautulli Monitor :: Session %s resumed." % stream['session_key'])
plexpy.NOTIFY_QUEUE.put({'stream_data': stream, 'notify_action': 'on_resume'})
plexpy.NOTIFY_QUEUE.put({'stream_data': stream.copy(), 'notify_action': 'on_resume'})
if stream['state'] == 'paused' and not ws_request:
# The stream is still paused so we need to increment the paused_counter
@@ -104,7 +104,7 @@ def check_active_sessions(ws_request=False):
'WHERE session_key = ? AND rating_key = ?',
[stream['session_key'], stream['rating_key']])
plexpy.NOTIFY_QUEUE.put({'stream_data': stream, 'notify_action': 'on_buffer'})
plexpy.NOTIFY_QUEUE.put({'stream_data': stream.copy(), 'notify_action': 'on_buffer'})
else:
# Subsequent buffer notifications after wait time
@@ -118,7 +118,7 @@ def check_active_sessions(ws_request=False):
'WHERE session_key = ? AND rating_key = ?',
[stream['session_key'], stream['rating_key']])
plexpy.NOTIFY_QUEUE.put({'stream_data': stream, 'notify_action': 'on_buffer'})
plexpy.NOTIFY_QUEUE.put({'stream_data': stream.copy(), 'notify_action': 'on_buffer'})
logger.debug(u"Tautulli Monitor :: Session %s is buffering. Count is now %s. Last triggered %s."
% (stream['session_key'],
@@ -135,7 +135,7 @@ def check_active_sessions(ws_request=False):
session['media_type'] == 'episode' and progress_percent >= plexpy.CONFIG.TV_WATCHED_PERCENT or
session['media_type'] == 'track' and progress_percent >= plexpy.CONFIG.MUSIC_WATCHED_PERCENT) \
and not any(d['notify_action'] == 'on_watched' for d in notify_states):
plexpy.NOTIFY_QUEUE.put({'stream_data': stream, 'notify_action': 'on_watched'})
plexpy.NOTIFY_QUEUE.put({'stream_data': stream.copy(), 'notify_action': 'on_watched'})
else:
# The user has stopped playing a stream
@@ -155,19 +155,18 @@ def check_active_sessions(ws_request=False):
stream['media_type'] == 'episode' and progress_percent >= plexpy.CONFIG.TV_WATCHED_PERCENT or
stream['media_type'] == 'track' and progress_percent >= plexpy.CONFIG.MUSIC_WATCHED_PERCENT) \
and not any(d['notify_action'] == 'on_watched' for d in notify_states):
plexpy.NOTIFY_QUEUE.put({'stream_data': stream, 'notify_action': 'on_watched'})
plexpy.NOTIFY_QUEUE.put({'stream_data': stream.copy(), 'notify_action': 'on_watched'})
plexpy.NOTIFY_QUEUE.put({'stream_data': stream, 'notify_action': 'on_stop'})
plexpy.NOTIFY_QUEUE.put({'stream_data': stream.copy(), 'notify_action': 'on_stop'})
# Write the item history on playback stop
success = monitor_process.write_session_history(session=stream)
if success:
row_id = monitor_process.write_session_history(session=stream)
if row_id:
# If session is written to the databaase successfully, remove the session from the session table
logger.debug(u"Tautulli Monitor :: Removing sessionKey %s ratingKey %s from session queue"
% (stream['session_key'], stream['rating_key']))
monitor_db.action('DELETE FROM sessions WHERE session_key = ? AND rating_key = ?',
[stream['session_key'], stream['rating_key']])
monitor_process.delete_session(row_id=row_id)
else:
stream['write_attempts'] += 1
@@ -175,18 +174,14 @@ def check_active_sessions(ws_request=False):
logger.warn(u"Tautulli Monitor :: Failed to write sessionKey %s ratingKey %s to the database. " \
"Will try again on the next pass. Write attempt %s."
% (stream['session_key'], stream['rating_key'], str(stream['write_attempts'])))
monitor_db.action('UPDATE sessions SET write_attempts = ? '
'WHERE session_key = ? AND rating_key = ?',
[stream['write_attempts'], stream['session_key'], stream['rating_key']])
monitor_process.increment_write_attempts(session_key=stream['session_key'])
else:
logger.warn(u"Tautulli Monitor :: Failed to write sessionKey %s ratingKey %s to the database. " \
"Removing session from the database. Write attempt %s."
% (stream['session_key'], stream['rating_key'], str(stream['write_attempts'])))
logger.debug(u"Tautulli Monitor :: Removing sessionKey %s ratingKey %s from session queue"
% (stream['session_key'], stream['rating_key']))
monitor_db.action('DELETE FROM sessions WHERE session_key = ? AND rating_key = ?',
[stream['session_key'], stream['rating_key']])
monitor_process.delete_session(session_key=stream['session_key'])
# Process the newly received session data
for session in media_container:
@@ -248,7 +243,7 @@ def check_recently_added():
if 0 < time_threshold - int(item['added_at']) <= time_interval:
logger.debug(u"Tautulli Monitor :: Library item %s added to Plex." % str(item['rating_key']))
plexpy.NOTIFY_QUEUE.put({'timeline_data': item, 'notify_action': 'on_created'})
plexpy.NOTIFY_QUEUE.put({'timeline_data': item.copy(), 'notify_action': 'on_created'})
else:
item = max(metadata, key=lambda x:x['added_at'])
@@ -266,15 +261,40 @@ def check_recently_added():
logger.debug(u"Tautulli Monitor :: Library item %s added to Plex." % str(item['rating_key']))
# Check if any notification agents have notifications enabled
plexpy.NOTIFY_QUEUE.put({'timeline_data': item, 'notify_action': 'on_created'})
plexpy.NOTIFY_QUEUE.put({'timeline_data': item.copy(), 'notify_action': 'on_created'})
def check_server_response():
logger.info(u"Tautulli Monitor :: Attempting to reconnect Plex server...")
try:
web_socket.start_thread()
except:
logger.warn(u"Websocket :: Unable to open connection.")
def connect_server(log=True, startup=False):
if plexpy.CONFIG.PMS_IS_CLOUD:
if log:
logger.info(u"Tautulli Monitor :: Checking for Plex Cloud server status...")
plex_tv = plextv.PlexTV()
status = plex_tv.get_cloud_server_status()
if status is True:
logger.info(u"Tautulli Monitor :: Plex Cloud server is active.")
elif status is False:
if log:
logger.info(u"Tautulli Monitor :: Plex Cloud server is sleeping.")
else:
if log:
logger.error(u"Tautulli Monitor :: Failed to retrieve Plex Cloud server status.")
if not status and startup:
web_socket.on_disconnect()
else:
status = True
if status:
if log and not startup:
logger.info(u"Tautulli Monitor :: Attempting to reconnect Plex server...")
try:
web_socket.start_thread()
except:
logger.error(u"Websocket :: Unable to open connection.")
def check_server_access():
@@ -330,4 +350,4 @@ def check_server_updates():
plexpy.NOTIFY_QUEUE.put({'notify_action': 'on_pmsupdate', 'pms_download_info': download_info})
else:
logger.info(u"Tautulli Monitor :: No PMS update available.")
logger.info(u"Tautulli Monitor :: No PMS update available.")

View File

@@ -127,7 +127,7 @@ class ActivityProcessor(object):
if result == 'insert':
# Check if any notification agents have notifications enabled
if notify:
plexpy.NOTIFY_QUEUE.put({'stream_data': values, 'notify_action': 'on_play'})
plexpy.NOTIFY_QUEUE.put({'stream_data': values.copy(), 'notify_action': 'on_play'})
# If it's our first write then time stamp it.
started = int(time.time())
@@ -155,7 +155,12 @@ class ActivityProcessor(object):
# Reload json from raw stream info
if session.get('raw_stream_info'):
session.update(json.loads(session['raw_stream_info']))
raw_stream_info = json.loads(session['raw_stream_info'])
# Don't overwrite id, session_key, stopped
raw_stream_info.pop('id', None)
raw_stream_info.pop('session_key', None)
raw_stream_info.pop('stopped', None)
session.update(raw_stream_info)
session = defaultdict(str, session)
@@ -177,6 +182,7 @@ class ActivityProcessor(object):
else:
logger.debug(u"Tautulli ActivityProcessor :: ratingKey %s not logged. Does not meet logging criteria. "
u"Media type is '%s'" % (session['rating_key'], session['media_type']))
return session['id']
if str(session['paused_counter']).isdigit():
real_play_time = stopped - session['started'] - int(session['paused_counter'])
@@ -229,7 +235,8 @@ class ActivityProcessor(object):
## TODO: Fix media info from imports. Temporary media info from import session.
media_info = session
# logger.debug(u"Tautulli ActivityProcessor :: Attempting to write to session_history table...")
# logger.debug(u"Tautulli ActivityProcessor :: Attempting to write sessionKey %s to session_history table..."
# % session['session_key'])
keys = {'id': None}
values = {'started': session['started'],
'stopped': stopped,
@@ -254,7 +261,8 @@ class ActivityProcessor(object):
'view_offset': session['view_offset']
}
# logger.debug(u"Tautulli ActivityProcessor :: Writing session_history transaction...")
# logger.debug(u"Tautulli ActivityProcessor :: Writing sessionKey %s session_history transaction..."
# % session['session_key'])
self.db.upsert(table_name='session_history', key_dict=keys, value_dict=values)
# Check if we should group the session, select the last two rows from the user
@@ -284,7 +292,7 @@ class ActivityProcessor(object):
query = 'UPDATE session_history SET reference_id = ? WHERE id = ? '
# If rating_key is the same in the previous session, then set the reference_id to the previous row, else set the reference_id to the new id
if prev_session == new_session == None:
if prev_session is None and new_session is None:
args = [last_id, last_id]
elif prev_session['rating_key'] == new_session['rating_key'] and prev_session['view_offset'] <= new_session['view_offset']:
args = [prev_session['reference_id'], new_session['id']]
@@ -298,7 +306,8 @@ class ActivityProcessor(object):
# Write the session_history_media_info table
# logger.debug(u"Tautulli ActivityProcessor :: Attempting to write to session_history_media_info table...")
# logger.debug(u"Tautulli ActivityProcessor :: Attempting to write to sessionKey %s session_history_media_info table..."
# % session['session_key'])
keys = {'id': last_id}
values = {'rating_key': session['rating_key'],
'video_decision': session['video_decision'],
@@ -365,7 +374,8 @@ class ActivityProcessor(object):
'optimized_version_title': session['optimized_version_title']
}
# logger.debug(u"Tautulli ActivityProcessor :: Writing session_history_media_info transaction...")
# logger.debug(u"Tautulli ActivityProcessor :: Writing sessionKey %s session_history_media_info transaction..."
# % session['session_key'])
self.db.upsert(table_name='session_history_media_info', key_dict=keys, value_dict=values)
# Write the session_history_metadata table
@@ -375,7 +385,8 @@ class ActivityProcessor(object):
genres = ";".join(metadata['genres'])
labels = ";".join(metadata['labels'])
# logger.debug(u"Tautulli ActivityProcessor :: Attempting to write to session_history_metadata table...")
# logger.debug(u"Tautulli ActivityProcessor :: Attempting to write to sessionKey %s session_history_metadata table..."
# % session['session_key'])
keys = {'id': last_id}
values = {'rating_key': session['rating_key'],
'parent_rating_key': session['parent_rating_key'],
@@ -411,11 +422,12 @@ class ActivityProcessor(object):
'labels': labels
}
# logger.debug(u"Tautulli ActivityProcessor :: Writing session_history_metadata transaction...")
# logger.debug(u"Tautulli ActivityProcessor :: Writing sessionKey %s session_history_metadata transaction..."
# % session['session_key'])
self.db.upsert(table_name='session_history_metadata', key_dict=keys, value_dict=values)
# Return true when the session is successfully written to the database
return True
# Return the session row id when the session is successfully written to the database
return session['id']
def get_sessions(self, user_id=None, ip_address=None):
query = 'SELECT * FROM sessions'
@@ -456,9 +468,11 @@ class ActivityProcessor(object):
return None
def delete_session(self, session_key=None):
def delete_session(self, session_key=None, row_id=None):
if str(session_key).isdigit():
self.db.action('DELETE FROM sessions WHERE session_key = ?', [session_key])
elif str(row_id).isdigit():
self.db.action('DELETE FROM sessions WHERE id = ?', [row_id])
def set_session_last_paused(self, session_key=None, timestamp=None):
if str(session_key).isdigit():

View File

@@ -167,8 +167,8 @@ class API2:
"""
logfile = os.path.join(plexpy.CONFIG.LOG_DIR, logger.FILENAME)
templog = []
start = int(kwargs.get('start', 0))
end = int(kwargs.get('end', 0))
start = int(start)
end = int(end)
if regex:
logger.api_debug(u'Tautulli APIv2 :: Filtering log using regex %s' % regex)
@@ -335,14 +335,14 @@ class API2:
""" Restart Tautulli."""
plexpy.SIGNAL = 'restart'
self._api_msg = 'Restarting plexpy'
self._api_msg = 'Restarting Tautulli'
self._api_result_type = 'success'
def update(self, **kwargs):
""" Check for Tautulli updates on Github."""
""" Update Tautulli."""
plexpy.SIGNAL = 'update'
self._api_msg = 'Updating plexpy'
self._api_msg = 'Updating Tautulli'
self._api_result_type = 'success'
def refresh_libraries_list(self, **kwargs):

View File

@@ -19,13 +19,12 @@ from collections import OrderedDict
import version
# Identify Our Application
USER_AGENT = 'Tautulli/-' + version.PLEXPY_BRANCH + ' v' + version.PLEXPY_RELEASE_VERSION + ' (' + platform.system() + \
' ' + platform.release() + ')'
PLATFORM = platform.system()
PLATFORM_VERSION = platform.release()
BRANCH = version.PLEXPY_BRANCH
VERSION_NUMBER = version.PLEXPY_RELEASE_VERSION
RELEASE = version.PLEXPY_RELEASE_VERSION
USER_AGENT = 'Tautulli/{} ({} {})'.format(RELEASE, PLATFORM, PLATFORM_VERSION)
DEFAULT_USER_THUMB = "interfaces/default/images/gravatar-default-80x80.png"
DEFAULT_POSTER_THUMB = "interfaces/default/images/poster.png"
@@ -174,11 +173,11 @@ HW_ENCODERS = [
SCHEDULER_LIST = [
'Check GitHub for updates',
'Check for server response',
'Check for active sessions',
'Check for recently added items',
'Check for Plex updates',
'Check for Plex remote access',
'Check server response',
'Refresh users list',
'Refresh libraries list',
'Refresh Plex server URLs',
@@ -279,16 +278,22 @@ NOTIFICATION_PARAMETERS = [
{
'category': 'Global',
'parameters': [
{'name': 'Tautulli Version', 'type': 'str', 'value': 'plexpy_version', 'description': 'The current version of Tautulli.'},
{'name': 'Tautulli Branch', 'type': 'str', 'value': 'plexpy_branch', 'description': 'The current git branch of Tautulli.'},
{'name': 'Tautulli Commit', 'type': 'str', 'value': 'plexpy_commit', 'description': 'The current git commit hash of Tautulli.'},
{'name': 'Tautulli Version', 'type': 'str', 'value': 'tautulli_version', 'description': 'The current version of Tautulli.'},
{'name': 'Tautulli Remote', 'type': 'str', 'value': 'tautulli_remote', 'description': 'The current git remote of Tautulli.'},
{'name': 'Tautulli Branch', 'type': 'str', 'value': 'tautulli_branch', 'description': 'The current git branch of Tautulli.'},
{'name': 'Tautulli Commit', 'type': 'str', 'value': 'tautulli_commit', 'description': 'The current git commit hash of Tautulli.'},
{'name': 'Server Name', 'type': 'str', 'value': 'server_name', 'description': 'The name of your Plex Server.'},
{'name': 'Server Uptime', 'type': 'str', 'value': 'server_uptime', 'description': 'The uptime (in days, hours, mins, secs) of your Plex Server.'},
{'name': 'Server IP', 'type': 'str', 'value': 'server_ip', 'description': 'The connection IP address for your Plex Server.'},
{'name': 'Server Port', 'type': 'int', 'value': 'server_port', 'description': 'The connection port for your Plex Server.'},
{'name': 'Server URL', 'type': 'str', 'value': 'server_url', 'description': 'The connection URL for your Plex Server.'},
{'name': 'Server Platform', 'type': 'str', 'value': 'server_platform', 'description': 'The platform of your Plex Server.'},
{'name': 'Server Version', 'type': 'str', 'value': 'server_version', 'description': 'The current version of your Plex Server.'},
{'name': 'Server ID', 'type': 'str', 'value': 'server_machine_id', 'description': 'The unique identifier for your Plex Server.'},
{'name': 'Action', 'type': 'str', 'value': 'action', 'description': 'The action that triggered the notification.'},
{'name': 'Datestamp', 'type': 'int', 'value': 'datestamp', 'description': 'The date (in date format) the notification was triggered.'},
{'name': 'Timestamp', 'type': 'int', 'value': 'timestamp', 'description': 'The time (in time format) the notification was triggered.'},
]
{'name': 'Datestamp', 'type': 'str', 'value': 'datestamp', 'description': 'The date (in date format) when the notification was triggered.'},
{'name': 'Timestamp', 'type': 'str', 'value': 'timestamp', 'description': 'The time (in time format) when the notification was triggered.'},
{'name': 'Unix Time', 'type': 'int', 'value': 'unixtime', 'description': 'The unix timestamp when the notification was triggered.'},
]
},
{
'category': 'Stream Details',
@@ -388,20 +393,22 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Track Number 00', 'type': 'int', 'value': 'track_num00', 'description': 'The two digit track number.', 'example': 'e.g. 04, or 04-10'},
{'name': 'Year', 'type': 'int', 'value': 'year', 'description': 'The release year for the item.'},
{'name': 'Release Date', 'type': 'int', 'value': 'release_date', 'description': 'The release date (in date format) for the item.'},
{'name': 'Air Date', 'type': 'int', 'value': 'air_date', 'description': 'The air date (in date format) for the item.'},
{'name': 'Added Date', 'type': 'int', 'value': 'added_date', 'description': 'The date (in date format) the item was added to Plex.'},
{'name': 'Updated Date', 'type': 'int', 'value': 'updated_date', 'description': 'The date (in date format) the item was updated on Plex.'},
{'name': 'Last Viewed Date', 'type': 'int', 'value': 'last_viewed_date', 'description': 'The date (in date format) the item was last viewed on Plex.'},
{'name': 'Air Date', 'type': 'str', 'value': 'air_date', 'description': 'The air date (in date format) for the item.'},
{'name': 'Added Date', 'type': 'str', 'value': 'added_date', 'description': 'The date (in date format) the item was added to Plex.'},
{'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': 'Director', 'type': 'str', 'value': 'directors', 'description': 'A list of directors for the item.'},
{'name': 'Writer', 'type': 'str', 'value': 'writers', 'description': 'A list of writers for the item.'},
{'name': 'Actor', 'type': 'str', 'value': 'actors', 'description': 'A list of actors for the item.'},
{'name': 'Genre', 'type': 'str', 'value': 'genres', 'description': 'A list of genres for the item.'},
{'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.'},
{'name': 'Genres', 'type': 'str', 'value': 'genres', 'description': 'A list of genres for the item.'},
{'name': 'Labels', 'type': 'str', 'value': 'labels', 'description': 'A list of labels for the item.'},
{'name': 'Collections', 'type': 'str', 'value': 'collections', 'description': 'A list of collections for the item.'},
{'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': 'int', 'value': 'rating', 'description': 'The rating (out of 10) for the item.'},
{'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': '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': '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.'},
@@ -461,28 +468,29 @@ NOTIFICATION_PARAMETERS = [
{
'category': 'Plex Update Available',
'parameters': [
{'name': 'Update Version', 'type': 'int', 'value': 'update_version', 'description': 'The available update version for your Plex Server.'},
{'name': 'Update Url', 'type': 'int', 'value': 'update_url', 'description': 'The download URL for the available update.'},
{'name': 'Update Release Date', 'type': 'int', 'value': 'update_release_date', 'description': 'The release date of the available update.'},
{'name': 'Update Channel', 'type': 'int', 'value': 'update_channel', 'description': 'The update channel.', 'example': 'Public or Plex Pass'},
{'name': 'Update Platform', 'type': 'int', 'value': 'update_platform', 'description': 'The platform of your Plex Server.'},
{'name': 'Update Distro', 'type': 'int', 'value': 'update_distro', 'description': 'The distro of your Plex Server.'},
{'name': 'Update Distro Build', 'type': 'int', 'value': 'update_distro_build', 'description': 'The distro build of your Plex Server.'},
{'name': 'Update Requirements', 'type': 'int', 'value': 'update_requirements', 'description': 'The requirements for the available update.'},
{'name': 'Update Extra Info', 'type': 'int', 'value': 'update_extra_info', 'description': 'Any extra info for the available update.'},
{'name': 'Update Changelog Added', 'type': 'int', 'value': 'update_changelog_added', 'description': 'The added changelog for the available update.'},
{'name': 'Update Changelog Fixed', 'type': 'int', 'value': 'update_changelog_fixed', 'description': 'The fixed changelog for the available update.'},
{'name': 'Update Version', 'type': 'str', 'value': 'update_version', 'description': 'The available update version for your Plex Server.'},
{'name': 'Update Url', 'type': 'str', 'value': 'update_url', 'description': 'The download URL for the available update.'},
{'name': 'Update Release Date', 'type': 'str', 'value': 'update_release_date', 'description': 'The release date of the available update.'},
{'name': 'Update Channel', 'type': 'str', 'value': 'update_channel', 'description': 'The update channel.', 'example': 'Public or Plex Pass'},
{'name': 'Update Platform', 'type': 'str', 'value': 'update_platform', 'description': 'The platform of your Plex Server.'},
{'name': 'Update Distro', 'type': 'str', 'value': 'update_distro', 'description': 'The distro of your Plex Server.'},
{'name': 'Update Distro Build', 'type': 'str', 'value': 'update_distro_build', 'description': 'The distro build of your Plex Server.'},
{'name': 'Update Requirements', 'type': 'str', 'value': 'update_requirements', 'description': 'The requirements for the available update.'},
{'name': 'Update Extra Info', 'type': 'str', 'value': 'update_extra_info', 'description': 'Any extra info for the available update.'},
{'name': 'Update Changelog Added', 'type': 'str', 'value': 'update_changelog_added', 'description': 'The added changelog for the available update.'},
{'name': 'Update Changelog Fixed', 'type': 'str', 'value': 'update_changelog_fixed', 'description': 'The fixed changelog for the available update.'},
]
},
{
'category': 'Tautulli Update Available',
'parameters': [
{'name': 'Plexpy Update Version', 'type': 'int', 'value': 'plexpy_update_version', 'description': 'The available update version for Tautulli.'},
{'name': 'Plexpy Update Tar', 'type': 'int', 'value': 'plexpy_update_tar', 'description': 'The tar download URL for the available update.'},
{'name': 'Plexpy Update Zip', 'type': 'int', 'value': 'plexpy_update_zip', 'description': 'The zip download URL for the available update.'},
{'name': 'Plexpy Update Commit', 'type': 'int', 'value': 'plexpy_update_commit', 'description': 'The commit hash for the available update.'},
{'name': 'Plexpy Update Behind', 'type': 'int', 'value': 'plexpy_update_behind', 'description': 'The number of commits behind for the available update.'},
{'name': 'Plexpy Update Changelog', 'type': 'int', 'value': 'plexpy_update_changelog', 'description': 'The changelog for the available update.'},
{'name': 'Tautulli Update Version', 'type': 'str', 'value': 'tautulli_update_version', 'description': 'The available update version for Tautulli.'},
{'name': 'Tautulli Update Release URL', 'type': 'str', 'value': 'tautulli_update_release_url', 'description': 'The release page URL on GitHub'},
{'name': 'Tautulli Update Tar', 'type': 'str', 'value': 'tautulli_update_tar', 'description': 'The tar download URL for the available update.'},
{'name': 'Tautulli Update Zip', 'type': 'str', 'value': 'tautulli_update_zip', 'description': 'The zip download URL for the available update.'},
{'name': 'Tautulli Update Commit', 'type': 'str', 'value': 'tautulli_update_commit', 'description': 'The commit hash for the available update.'},
{'name': 'Tautulli Update Behind', 'type': 'int', 'value': 'tautulli_update_behind', 'description': 'The number of commits behind for the available update.'},
{'name': 'Tautulli Update Changelog', 'type': 'str', 'value': 'tautulli_update_changelog', 'description': 'The changelog for the available update.'},
]
},
]

View File

@@ -61,7 +61,7 @@ _CONFIG_DEFINITIONS = {
'PMS_PLEXPASS': (int, 'PMS', 0),
'PMS_PLATFORM': (str, 'PMS', ''),
'PMS_VERSION': (str, 'PMS', ''),
'PMS_UPDATE_CHANNEL': (str, 'PMS', 'public'),
'PMS_UPDATE_CHANNEL': (str, 'PMS', 'plex'),
'PMS_UPDATE_DISTRO': (str, 'PMS', ''),
'PMS_UPDATE_DISTRO_BUILD': (str, 'PMS', ''),
'PMS_WEB_URL': (str, 'PMS', 'https://app.plex.tv/desktop'),
@@ -176,13 +176,13 @@ _CONFIG_DEFINITIONS = {
'GIT_PATH': (str, 'General', ''),
'GIT_REMOTE': (str, 'General', 'origin'),
'GIT_TOKEN': (str, 'General', ''),
'GIT_USER': (str, 'General', 'JonnyWong16'),
'GIT_REPO': (str, 'General', 'plexpy'),
'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', 0),
'GROUP_HISTORY_TABLES': (int, 'General', 1),
'GROWL_ENABLED': (int, 'Growl', 0),
'GROWL_HOST': (str, 'Growl', ''),
'GROWL_PASSWORD': (str, 'Growl', ''),
@@ -225,6 +225,7 @@ _CONFIG_DEFINITIONS = {
'HTTP_PROXY': (int, 'General', 0),
'HTTP_ROOT': (str, 'General', ''),
'HTTP_USERNAME': (str, 'General', ''),
'HTTP_PLEX_ADMIN': (int, 'General', 0),
'HIPCHAT_URL': (str, 'Hipchat', ''),
'HIPCHAT_COLOR': (str, 'Hipchat', ''),
'HIPCHAT_INCL_SUBJECT': (int, 'Hipchat', 1),
@@ -479,6 +480,7 @@ _CONFIG_DEFINITIONS = {
'REFRESH_USERS_ON_STARTUP': (int, 'Monitoring', 1),
'REMOTE_ACCESS_PING_THRESHOLD': (int, 'Advanced', 3),
'SESSION_DB_WRITE_ATTEMPTS': (int, 'Advanced', 5),
'SHOW_ADVANCED_SETTINGS': (int, 'General', 0),
'SLACK_ENABLED': (int, 'Slack', 0),
'SLACK_HOOK': (str, 'Slack', ''),
'SLACK_CHANNEL': (str, 'Slack', ''),
@@ -611,7 +613,9 @@ _CONFIG_DEFINITIONS = {
'XBMC_ON_INTUP': (int, 'XBMC', 0),
'XBMC_ON_PMSUPDATE': (int, 'XBMC', 0),
'XBMC_ON_CONCURRENT': (int, 'XBMC', 0),
'XBMC_ON_NEWDEVICE': (int, 'XBMC', 0)
'XBMC_ON_NEWDEVICE': (int, 'XBMC', 0),
'JWT_SECRET': (str, 'Advanced', ''),
'SYSTEM_ANALYTICS': (int, 'Advanced', 1)
}
_BLACKLIST_KEYS = ['_APITOKEN', '_TOKEN', '_KEY', '_SECRET', '_PASSWORD', '_APIKEY', '_ID', '_HOOK']
@@ -874,3 +878,15 @@ class Config(object):
self.MUSIC_WATCHED_PERCENT = self.NOTIFY_WATCHED_PERCENT
self.CONFIG_VERSION = 9
if self.CONFIG_VERSION == 9:
if self.PMS_UPDATE_CHANNEL == 'plexpass':
self.PMS_UPDATE_CHANNEL = 'beta'
self.CONFIG_VERSION = 10
if self.CONFIG_VERSION == 10:
self.GIT_USER = 'Tautulli'
self.GIT_REPO = 'Tautulli'
self.CONFIG_VERSION = 11

View File

@@ -23,7 +23,7 @@ import time
import plexpy
import logger
FILENAME = "plexpy.db"
FILENAME = "tautulli.db"
db_lock = threading.Lock()
@@ -63,9 +63,9 @@ def make_backup(cleanup=False, scheduler=False):
""" Makes a backup of db, removes all but the last 5 backups """
if scheduler:
backup_file = 'plexpy.backup-%s.sched.db' % arrow.now().format('YYYYMMDDHHmmss')
backup_file = 'tautulli.backup-%s.sched.db' % arrow.now().format('YYYYMMDDHHmmss')
else:
backup_file = 'plexpy.backup-%s.db' % arrow.now().format('YYYYMMDDHHmmss')
backup_file = 'tautulli.backup-%s.db' % arrow.now().format('YYYYMMDDHHmmss')
backup_folder = plexpy.CONFIG.BACKUP_DIR
backup_file_fp = os.path.join(backup_folder, backup_file)

View File

@@ -188,7 +188,7 @@ class DataFactory(object):
'episode': plexpy.CONFIG.TV_WATCHED_PERCENT,
'track': plexpy.CONFIG.MUSIC_WATCHED_PERCENT,
'photo': 0,
'clip': plexpy.CONFIG.MOVIE_WATCHED_PERCENT
'clip': plexpy.CONFIG.TV_WATCHED_PERCENT
}
rows = []
@@ -612,7 +612,6 @@ class DataFactory(object):
'total_plays': item['total_plays'],
'total_duration': item['total_duration'],
'last_play': item['last_watch'],
'thumb': user_thumb,
'user_thumb': user_thumb,
'grandparent_thumb': '',
'art': '',
@@ -827,6 +826,9 @@ class DataFactory(object):
if session.get_session_shared_libraries():
library_cards = session.get_session_shared_libraries()
if 'first_run_wizard' in library_cards:
return None
library_stats = []
try:
@@ -1107,6 +1109,7 @@ class DataFactory(object):
def get_poster_info(self, rating_key='', metadata=None):
monitor_db = database.MonitorDatabase()
poster_key = ''
if str(rating_key).isdigit():
poster_key = rating_key
elif metadata:
@@ -1118,6 +1121,7 @@ class DataFactory(object):
poster_key = metadata['parent_rating_key']
poster_info = {}
if poster_key:
try:
query = 'SELECT poster_title, poster_url FROM poster_urls ' \
@@ -1128,14 +1132,15 @@ class DataFactory(object):
return poster_info
def set_poster_url(self, rating_key='', poster_title='', poster_url=''):
def set_poster_url(self, rating_key='', poster_title='', poster_url='', delete_hash=''):
monitor_db = database.MonitorDatabase()
if str(rating_key).isdigit():
keys = {'rating_key': int(rating_key)}
values = {'poster_title': poster_title,
'poster_url': poster_url}
'poster_url': poster_url,
'delete_hash': delete_hash}
monitor_db.upsert(table_name='poster_urls', key_dict=keys, value_dict=values)
@@ -1143,10 +1148,62 @@ class DataFactory(object):
monitor_db = database.MonitorDatabase()
if rating_key:
logger.info(u"Tautulli DataFactory :: Deleting poster_url for rating_key %s from the database." % rating_key)
poster_info = monitor_db.select_single('SELECT poster_title, delete_hash '
'FROM poster_urls WHERE rating_key = ?',
[rating_key])
if poster_info['delete_hash']:
helpers.delete_from_imgur(poster_info['delete_hash'], poster_info['poster_title'])
logger.info(u"Tautulli DataFactory :: Deleting poster_url for '%s' (rating_key %s) from the database."
% (poster_info['poster_title'], rating_key))
result = monitor_db.action('DELETE FROM poster_urls WHERE rating_key = ?', [rating_key])
return True if result else False
def get_lookup_info(self, rating_key='', metadata=None):
monitor_db = database.MonitorDatabase()
lookup_key = ''
if str(rating_key).isdigit():
lookup_key = rating_key
elif metadata:
if metadata['media_type'] in ('movie', 'show', 'artist'):
lookup_key = metadata['rating_key']
elif metadata['media_type'] in ('season', 'album'):
lookup_key = metadata['parent_rating_key']
elif metadata['media_type'] in ('episode', 'track'):
lookup_key = metadata['grandparent_rating_key']
lookup_info = {'tvmaze_id': '',
'themoviedb_id': ''}
if lookup_key:
try:
query = 'SELECT tvmaze_id FROM tvmaze_lookup ' \
'WHERE rating_key = ?'
tvmaze_info = monitor_db.select_single(query, args=[lookup_key])
if tvmaze_info:
lookup_info['tvmaze_id'] = tvmaze_info['tvmaze_id']
query = 'SELECT themoviedb_id FROM themoviedb_lookup ' \
'WHERE rating_key = ?'
themoviedb_info = monitor_db.select_single(query, args=[lookup_key])
if themoviedb_info:
lookup_info['themoviedb_id'] = themoviedb_info['themoviedb_id']
except Exception as e:
logger.warn(u"Tautulli DataFactory :: Unable to execute database query for get_lookup_info: %s." % e)
return lookup_info
def delete_lookup_info(self, rating_key='', title=''):
monitor_db = database.MonitorDatabase()
if rating_key:
logger.info(u"Tautulli DataFactory :: Deleting lookup info for '%s' (rating_key %s) from the database."
% (title, rating_key))
result_tvmaze = monitor_db.action('DELETE FROM tvmaze_lookup WHERE rating_key = ?', [rating_key])
result_themoviedb = monitor_db.action('DELETE FROM themoviedb_lookup WHERE rating_key = ?', [rating_key])
return True if (result_tvmaze or result_themoviedb) else False
def get_search_query(self, rating_key=''):
monitor_db = database.MonitorDatabase()

View File

@@ -27,7 +27,7 @@ class Graphs(object):
def __init__(self):
pass
def get_total_plays_per_day(self, time_range='30', y_axis='plays', user_id=None):
def get_total_plays_per_day(self, time_range='30', y_axis='plays', user_id=None, grouping=None):
monitor_db = database.MonitorDatabase()
if not time_range.isdigit():
@@ -38,17 +38,22 @@ class Graphs(object):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
group_by = 'reference_id' if grouping else 'id'
try:
if y_axis == 'plays':
query = 'SELECT date(started, "unixepoch", "localtime") AS date_played, ' \
'SUM(CASE WHEN media_type = "episode" THEN 1 ELSE 0 END) AS tv_count, ' \
'SUM(CASE WHEN media_type = "movie" THEN 1 ELSE 0 END) AS movie_count, ' \
'SUM(CASE WHEN media_type = "track" THEN 1 ELSE 0 END) AS music_count ' \
'FROM session_history ' \
'WHERE datetime(stopped, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \
'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'GROUP BY date_played ' \
'ORDER BY started ASC' % (time_range, user_cond)
'ORDER BY started ASC' % (group_by, time_range, user_cond)
result = monitor_db.select(query)
else:
@@ -60,7 +65,7 @@ class Graphs(object):
'SUM(CASE WHEN media_type = "track" AND stopped > 0 THEN (stopped - started) ' \
' - (CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) ELSE 0 END) AS music_count ' \
'FROM session_history ' \
'WHERE datetime(stopped, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'GROUP BY date_played ' \
'ORDER BY started ASC' % (time_range, user_cond)
@@ -111,7 +116,7 @@ class Graphs(object):
'series': [series_1_output, series_2_output, series_3_output]}
return output
def get_total_plays_per_dayofweek(self, time_range='30', y_axis='plays', user_id=None):
def get_total_plays_per_dayofweek(self, time_range='30', y_axis='plays', user_id=None, grouping=None):
monitor_db = database.MonitorDatabase()
if not time_range.isdigit():
@@ -122,7 +127,12 @@ class Graphs(object):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
group_by = 'reference_id' if grouping else 'id'
try:
if y_axis == 'plays':
query = 'SELECT strftime("%%w", datetime(started, "unixepoch", "localtime")) AS daynumber, ' \
@@ -137,10 +147,10 @@ class Graphs(object):
'SUM(CASE WHEN media_type = "episode" THEN 1 ELSE 0 END) AS tv_count, ' \
'SUM(CASE WHEN media_type = "movie" THEN 1 ELSE 0 END) AS movie_count, ' \
'SUM(CASE WHEN media_type = "track" THEN 1 ELSE 0 END) AS music_count ' \
'FROM session_history ' \
'WHERE datetime(stopped, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \
'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'GROUP BY dayofweek ' \
'ORDER BY daynumber' % (time_range, user_cond)
'ORDER BY daynumber' % (group_by, time_range, user_cond)
result = monitor_db.select(query)
else:
@@ -160,7 +170,7 @@ class Graphs(object):
'SUM(CASE WHEN media_type = "track" AND stopped > 0 THEN (stopped - started) ' \
' - (CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) ELSE 0 END) AS music_count ' \
'FROM session_history ' \
'WHERE datetime(stopped, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'GROUP BY dayofweek ' \
'ORDER BY daynumber' % (time_range, user_cond)
@@ -212,7 +222,7 @@ class Graphs(object):
'series': [series_1_output, series_2_output, series_3_output]}
return output
def get_total_plays_per_hourofday(self, time_range='30', y_axis='plays', user_id=None):
def get_total_plays_per_hourofday(self, time_range='30', y_axis='plays', user_id=None, grouping=None):
monitor_db = database.MonitorDatabase()
if not time_range.isdigit():
@@ -223,17 +233,22 @@ class Graphs(object):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
group_by = 'reference_id' if grouping else 'id'
try:
if y_axis == 'plays':
query = 'SELECT strftime("%%H", datetime(started, "unixepoch", "localtime")) AS hourofday, ' \
'SUM(CASE WHEN media_type = "episode" THEN 1 ELSE 0 END) AS tv_count, ' \
'SUM(CASE WHEN media_type = "movie" THEN 1 ELSE 0 END) AS movie_count, ' \
'SUM(CASE WHEN media_type = "track" THEN 1 ELSE 0 END) AS music_count ' \
'FROM session_history ' \
'WHERE datetime(stopped, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \
'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'GROUP BY hourofday ' \
'ORDER BY hourofday' % (time_range, user_cond)
'ORDER BY hourofday' % (group_by, time_range, user_cond)
result = monitor_db.select(query)
else:
@@ -245,7 +260,7 @@ class Graphs(object):
'SUM(CASE WHEN media_type = "track" AND stopped > 0 THEN (stopped - started) ' \
' - (CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) ELSE 0 END) AS music_count ' \
'FROM session_history ' \
'WHERE datetime(stopped, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'GROUP BY hourofday ' \
'ORDER BY hourofday' % (time_range, user_cond)
@@ -295,9 +310,9 @@ class Graphs(object):
'series': [series_1_output, series_2_output, series_3_output]}
return output
def get_total_plays_per_month(self, time_range='12', y_axis='plays', user_id=None):
def get_total_plays_per_month(self, time_range='12', y_axis='plays', user_id=None, grouping=None):
import time as time
if not time_range.isdigit():
time_range = '12'
@@ -308,17 +323,22 @@ class Graphs(object):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
group_by = 'reference_id' if grouping else 'id'
try:
if y_axis == 'plays':
query = 'SELECT strftime("%%Y-%%m", datetime(started, "unixepoch", "localtime")) AS datestring, ' \
'SUM(CASE WHEN media_type = "episode" THEN 1 ELSE 0 END) AS tv_count, ' \
'SUM(CASE WHEN media_type = "movie" THEN 1 ELSE 0 END) AS movie_count, ' \
'SUM(CASE WHEN media_type = "track" THEN 1 ELSE 0 END) AS music_count ' \
'FROM session_history ' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \
'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s months", "localtime") %s' \
'GROUP BY strftime("%%Y-%%m", datetime(started, "unixepoch", "localtime")) ' \
'ORDER BY datestring DESC LIMIT %s' % (time_range, user_cond, time_range)
'ORDER BY datestring DESC LIMIT %s' % (group_by, time_range, user_cond, time_range)
result = monitor_db.select(query)
else:
@@ -384,7 +404,7 @@ class Graphs(object):
'series': [series_1_output, series_2_output, series_3_output]}
return output
def get_total_plays_by_top_10_platforms(self, time_range='30', y_axis='plays', user_id=None):
def get_total_plays_by_top_10_platforms(self, time_range='30', y_axis='plays', user_id=None, grouping=None):
monitor_db = database.MonitorDatabase()
if not time_range.isdigit():
@@ -395,7 +415,12 @@ class Graphs(object):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
group_by = 'reference_id' if grouping else 'id'
try:
if y_axis == 'plays':
query = 'SELECT platform, ' \
@@ -403,11 +428,11 @@ class Graphs(object):
'SUM(CASE WHEN media_type = "movie" THEN 1 ELSE 0 END) AS movie_count, ' \
'SUM(CASE WHEN media_type = "track" THEN 1 ELSE 0 END) AS music_count, ' \
'COUNT(id) AS total_count ' \
'FROM session_history ' \
'WHERE (datetime(stopped, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime")) %s' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \
'WHERE (datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime")) %s' \
'GROUP BY platform ' \
'ORDER BY total_count DESC ' \
'LIMIT 10' % (time_range, user_cond)
'LIMIT 10' % (group_by, time_range, user_cond)
result = monitor_db.select(query)
else:
@@ -421,7 +446,7 @@ class Graphs(object):
'SUM(CASE WHEN stopped > 0 THEN (stopped - started) ' \
' - (CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) ELSE 0 END) AS total_duration ' \
'FROM session_history ' \
'WHERE (datetime(stopped, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime")) %s' \
'WHERE (datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime")) %s' \
'GROUP BY platform ' \
'ORDER BY total_duration DESC ' \
'LIMIT 10' % (time_range, user_cond)
@@ -453,7 +478,7 @@ class Graphs(object):
'series': [series_1_output, series_2_output, series_3_output]}
return output
def get_total_plays_by_top_10_users(self, time_range='30', y_axis='plays', user_id=None):
def get_total_plays_by_top_10_users(self, time_range='30', y_axis='plays', user_id=None, grouping=None):
monitor_db = database.MonitorDatabase()
if not time_range.isdigit():
@@ -464,7 +489,12 @@ class Graphs(object):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
group_by = 'reference_id' if grouping else 'id'
try:
if y_axis == 'plays':
query = 'SELECT ' \
@@ -475,12 +505,12 @@ class Graphs(object):
'SUM(CASE WHEN media_type = "movie" THEN 1 ELSE 0 END) AS movie_count, ' \
'SUM(CASE WHEN media_type = "track" THEN 1 ELSE 0 END) AS music_count, ' \
'COUNT(session_history.id) AS total_count ' \
'FROM session_history ' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \
'JOIN users ON session_history.user_id = users.user_id ' \
'WHERE (datetime(stopped, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime")) %s' \
'WHERE (datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime")) %s' \
'GROUP BY session_history.user_id ' \
'ORDER BY total_count DESC ' \
'LIMIT 10' % (time_range, user_cond)
'LIMIT 10' % (group_by, time_range, user_cond)
result = monitor_db.select(query)
else:
@@ -498,7 +528,7 @@ class Graphs(object):
' - (CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) ELSE 0 END) AS total_duration ' \
'FROM session_history ' \
'JOIN users ON session_history.user_id = users.user_id ' \
'WHERE (datetime(stopped, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime")) %s' \
'WHERE (datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime")) %s' \
'GROUP BY session_history.user_id ' \
'ORDER BY total_duration DESC ' \
'LIMIT 10' % (time_range, user_cond)
@@ -535,7 +565,7 @@ class Graphs(object):
'series': [series_1_output, series_2_output, series_3_output]}
return output
def get_total_plays_per_stream_type(self, time_range='30', y_axis='plays', user_id=None):
def get_total_plays_per_stream_type(self, time_range='30', y_axis='plays', user_id=None, grouping=None):
monitor_db = database.MonitorDatabase()
if not time_range.isdigit():
@@ -546,7 +576,12 @@ class Graphs(object):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
group_by = 'reference_id' if grouping else 'id'
try:
if y_axis == 'plays':
query = 'SELECT date(session_history.started, "unixepoch", "localtime") AS date_played, ' \
@@ -556,14 +591,15 @@ class Graphs(object):
'THEN 1 ELSE 0 END) AS ds_count, ' \
'SUM(CASE WHEN session_history_media_info.transcode_decision = "transcode" ' \
'THEN 1 ELSE 0 END) AS tc_count ' \
'FROM session_history ' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \
'JOIN session_history_media_info ON session_history.id = session_history_media_info.id ' \
'WHERE (datetime(session_history.stopped, "unixepoch", "localtime") >= ' \
'WHERE (datetime(started, "unixepoch", "localtime") >= ' \
'datetime("now", "-%s days", "localtime")) AND ' \
'(session_history.media_type = "episode" OR session_history.media_type = "movie" OR ' \
'(session_history.media_type = "episode" OR ' \
'session_history.media_type = "movie" OR ' \
'session_history.media_type = "track") %s' \
'GROUP BY date_played ' \
'ORDER BY started ASC' % (time_range, user_cond)
'ORDER BY started ASC' % (group_by, time_range, user_cond)
result = monitor_db.select(query)
else:
@@ -579,7 +615,7 @@ class Graphs(object):
' - (CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) ELSE 0 END) AS tc_count ' \
'FROM session_history ' \
'JOIN session_history_media_info ON session_history.id = session_history_media_info.id ' \
'WHERE datetime(session_history.stopped, "unixepoch", "localtime") >= ' \
'WHERE datetime(started, "unixepoch", "localtime") >= ' \
'datetime("now", "-%s days", "localtime") AND ' \
'(session_history.media_type = "episode" OR session_history.media_type = "movie" OR ' \
'session_history.media_type = "track") %s' \
@@ -633,7 +669,7 @@ class Graphs(object):
'series': [series_1_output, series_2_output, series_3_output]}
return output
def get_total_plays_by_source_resolution(self, time_range='30', y_axis='plays', user_id=None):
def get_total_plays_by_source_resolution(self, time_range='30', y_axis='plays', user_id=None, grouping=None):
monitor_db = database.MonitorDatabase()
if not time_range.isdigit():
@@ -644,7 +680,12 @@ class Graphs(object):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
group_by = 'reference_id' if grouping else 'id'
try:
if y_axis == 'plays':
query = 'SELECT session_history_media_info.video_resolution AS resolution, ' \
@@ -655,14 +696,14 @@ class Graphs(object):
'SUM(CASE WHEN session_history_media_info.transcode_decision = "transcode" ' \
'THEN 1 ELSE 0 END) AS tc_count, ' \
'COUNT(session_history.id) AS total_count ' \
'FROM session_history ' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \
'JOIN session_history_media_info ON session_history.id = session_history_media_info.id ' \
'WHERE (datetime(session_history.stopped, "unixepoch", "localtime") >= ' \
'WHERE (datetime(started, "unixepoch", "localtime") >= ' \
'datetime("now", "-%s days", "localtime")) AND ' \
'(session_history.media_type = "episode" OR session_history.media_type = "movie") %s' \
'GROUP BY resolution ' \
'ORDER BY total_count DESC ' \
'LIMIT 10' % (time_range, user_cond)
'LIMIT 10' % (group_by, time_range, user_cond)
result = monitor_db.select(query)
else:
@@ -680,7 +721,7 @@ class Graphs(object):
' - (CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) ELSE 0 END) AS total_duration ' \
'FROM session_history ' \
'JOIN session_history_media_info ON session_history.id = session_history_media_info.id ' \
'WHERE (datetime(session_history.stopped, "unixepoch", "localtime") >= ' \
'WHERE (datetime(started, "unixepoch", "localtime") >= ' \
'datetime("now", "-%s days", "localtime")) AND ' \
'(session_history.media_type = "episode" OR session_history.media_type = "movie") %s' \
'GROUP BY resolution ' \
@@ -698,6 +739,10 @@ class Graphs(object):
series_3 = []
for item in result:
if item['resolution'] not in ('4k', 'unknown'):
item['resolution'] = item['resolution'].upper()
if item['resolution'].isdigit():
item['resolution'] += 'p'
categories.append(item['resolution'])
series_1.append(item['dp_count'])
series_2.append(item['ds_count'])
@@ -714,7 +759,7 @@ class Graphs(object):
'series': [series_1_output, series_2_output, series_3_output]}
return output
def get_total_plays_by_stream_resolution(self, time_range='30', y_axis='plays', user_id=None):
def get_total_plays_by_stream_resolution(self, time_range='30', y_axis='plays', user_id=None, grouping=None):
monitor_db = database.MonitorDatabase()
if not time_range.isdigit():
@@ -725,20 +770,27 @@ class Graphs(object):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
group_by = 'reference_id' if grouping else 'id'
try:
if y_axis == 'plays':
query = 'SELECT ' \
'(CASE WHEN session_history_media_info.stream_video_resolution IS NULL THEN ' \
'(CASE WHEN session_history_media_info.video_decision = "transcode" THEN ' \
'(CASE ' \
'WHEN session_history_media_info.transcode_height <= 360 THEN "sd" ' \
'WHEN session_history_media_info.transcode_height <= 360 THEN "SD" ' \
'WHEN session_history_media_info.transcode_height <= 480 THEN "480" ' \
'WHEN session_history_media_info.transcode_height <= 576 THEN "576" ' \
'WHEN session_history_media_info.transcode_height <= 720 THEN "720" ' \
'WHEN session_history_media_info.transcode_height <= 1080 THEN "1080" ' \
'WHEN session_history_media_info.transcode_height <= 1440 THEN "QHD" ' \
'WHEN session_history_media_info.transcode_height <= 2160 THEN "4K" ' \
'ELSE "unknown" END) ELSE session_history_media_info.video_resolution END) AS resolution, ' \
'WHEN session_history_media_info.transcode_height <= 2160 THEN "4k" ' \
'ELSE "unknown" END) ELSE session_history_media_info.video_resolution END) ' \
'ELSE session_history_media_info.stream_video_resolution END) AS resolution, ' \
'SUM(CASE WHEN session_history_media_info.transcode_decision = "direct play" ' \
'THEN 1 ELSE 0 END) AS dp_count, ' \
'SUM(CASE WHEN session_history_media_info.transcode_decision = "copy" ' \
@@ -746,28 +798,30 @@ class Graphs(object):
'SUM(CASE WHEN session_history_media_info.transcode_decision = "transcode" '\
'THEN 1 ELSE 0 END) AS tc_count, ' \
'COUNT(session_history.id) AS total_count ' \
'FROM session_history ' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \
'JOIN session_history_media_info ON session_history.id = session_history_media_info.id ' \
'WHERE (datetime(session_history.stopped, "unixepoch", "localtime") >= ' \
'WHERE (datetime(started, "unixepoch", "localtime") >= ' \
'datetime("now", "-%s days", "localtime")) AND ' \
'(session_history.media_type = "episode" OR session_history.media_type = "movie") %s' \
'GROUP BY resolution ' \
'ORDER BY total_count DESC ' \
'LIMIT 10' % (time_range, user_cond)
'LIMIT 10' % (group_by, time_range, user_cond)
result = monitor_db.select(query)
else:
query = 'SELECT ' \
'(CASE WHEN session_history_media_info.stream_video_resolution IS NULL THEN ' \
'(CASE WHEN session_history_media_info.video_decision = "transcode" THEN ' \
'(CASE ' \
'WHEN session_history_media_info.transcode_height <= 360 THEN "sd" ' \
'WHEN session_history_media_info.transcode_height <= 360 THEN "SD" ' \
'WHEN session_history_media_info.transcode_height <= 480 THEN "480" ' \
'WHEN session_history_media_info.transcode_height <= 576 THEN "576" ' \
'WHEN session_history_media_info.transcode_height <= 720 THEN "720" ' \
'WHEN session_history_media_info.transcode_height <= 1080 THEN "1080" ' \
'WHEN session_history_media_info.transcode_height <= 1440 THEN "QHD" ' \
'WHEN session_history_media_info.transcode_height <= 2160 THEN "4K" ' \
'ELSE "unknown" END) ELSE session_history_media_info.video_resolution END) AS resolution, ' \
'WHEN session_history_media_info.transcode_height <= 2160 THEN "4k" ' \
'ELSE "unknown" END) ELSE session_history_media_info.video_resolution END) ' \
'ELSE session_history_media_info.stream_video_resolution END) AS resolution, ' \
'SUM(CASE WHEN session_history_media_info.transcode_decision = "direct play" ' \
'AND session_history.stopped > 0 THEN (session_history.stopped - session_history.started) ' \
' - (CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) ELSE 0 END) AS dp_count, ' \
@@ -781,7 +835,7 @@ class Graphs(object):
' - (CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) ELSE 0 END) AS total_duration ' \
'FROM session_history ' \
'JOIN session_history_media_info ON session_history.id = session_history_media_info.id ' \
'WHERE (datetime(session_history.stopped, "unixepoch", "localtime") >= ' \
'WHERE (datetime(started, "unixepoch", "localtime") >= ' \
'datetime("now", "-%s days", "localtime")) AND ' \
'(session_history.media_type = "episode" OR session_history.media_type = "movie") %s' \
'GROUP BY resolution ' \
@@ -799,6 +853,10 @@ class Graphs(object):
series_3 = []
for item in result:
if item['resolution'] not in ('4k', 'unknown'):
item['resolution'] = item['resolution'].upper()
if item['resolution'].isdigit():
item['resolution'] += 'p'
categories.append(item['resolution'])
series_1.append(item['dp_count'])
series_2.append(item['ds_count'])
@@ -815,7 +873,7 @@ class Graphs(object):
'series': [series_1_output, series_2_output, series_3_output]}
return output
def get_stream_type_by_top_10_platforms(self, time_range='30', y_axis='plays', user_id=None):
def get_stream_type_by_top_10_platforms(self, time_range='30', y_axis='plays', user_id=None, grouping=None):
monitor_db = database.MonitorDatabase()
if not time_range.isdigit():
@@ -826,7 +884,12 @@ class Graphs(object):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
group_by = 'reference_id' if grouping else 'id'
try:
if y_axis == 'plays':
query = 'SELECT session_history.platform AS platform, ' \
@@ -837,13 +900,15 @@ class Graphs(object):
'SUM(CASE WHEN session_history_media_info.transcode_decision = "transcode" ' \
'THEN 1 ELSE 0 END) AS tc_count, ' \
'COUNT(session_history.id) AS total_count ' \
'FROM session_history ' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \
'JOIN session_history_media_info ON session_history.id = session_history_media_info.id ' \
'WHERE datetime(session_history.started, "unixepoch", "localtime") >= ' \
'WHERE datetime(started, "unixepoch", "localtime") >= ' \
'datetime("now", "-%s days", "localtime") AND ' \
'(session_history.media_type = "episode" OR session_history.media_type = "movie" OR session_history.media_type = "track") %s' \
'(session_history.media_type = "episode" OR ' \
'session_history.media_type = "movie" OR ' \
'session_history.media_type = "track") %s' \
'GROUP BY platform ' \
'ORDER BY total_count DESC LIMIT 10' % (time_range, user_cond)
'ORDER BY total_count DESC LIMIT 10' % (group_by, time_range, user_cond)
result = monitor_db.select(query)
else:
@@ -862,9 +927,11 @@ class Graphs(object):
' - (CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) ELSE 0 END) AS total_duration ' \
'FROM session_history ' \
'JOIN session_history_media_info ON session_history.id = session_history_media_info.id ' \
'WHERE datetime(session_history.started, "unixepoch", "localtime") >= ' \
'WHERE datetime(started, "unixepoch", "localtime") >= ' \
'datetime("now", "-%s days", "localtime") AND ' \
'(session_history.media_type = "episode" OR session_history.media_type = "movie" OR session_history.media_type = "track") %s' \
'(session_history.media_type = "episode" OR ' \
'session_history.media_type = "movie" OR ' \
'session_history.media_type = "track") %s' \
'GROUP BY platform ' \
'ORDER BY total_duration DESC LIMIT 10' % (time_range, user_cond)
@@ -896,7 +963,7 @@ class Graphs(object):
return output
def get_stream_type_by_top_10_users(self, time_range='30', y_axis='plays', user_id=None):
def get_stream_type_by_top_10_users(self, time_range='30', y_axis='plays', user_id=None, grouping=None):
monitor_db = database.MonitorDatabase()
if not time_range.isdigit():
@@ -907,7 +974,12 @@ class Graphs(object):
user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id()
elif user_id and user_id.isdigit():
user_cond = 'AND session_history.user_id = %s ' % user_id
if grouping is None:
grouping = plexpy.CONFIG.GROUP_HISTORY_TABLES
group_by = 'reference_id' if grouping else 'id'
try:
if y_axis == 'plays':
query = 'SELECT ' \
@@ -921,14 +993,16 @@ class Graphs(object):
'SUM(CASE WHEN session_history_media_info.transcode_decision = "transcode" ' \
'THEN 1 ELSE 0 END) AS tc_count, ' \
'COUNT(session_history.id) AS total_count ' \
'FROM session_history ' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \
'JOIN users ON session_history.user_id = users.user_id ' \
'JOIN session_history_media_info ON session_history.id = session_history_media_info.id ' \
'WHERE datetime(session_history.started, "unixepoch", "localtime") >= ' \
'WHERE datetime(started, "unixepoch", "localtime") >= ' \
'datetime("now", "-%s days", "localtime") AND ' \
'(session_history.media_type = "episode" OR session_history.media_type = "movie" OR session_history.media_type = "track") %s' \
'(session_history.media_type = "episode" OR ' \
'session_history.media_type = "movie" OR ' \
'session_history.media_type = "track") %s' \
'GROUP BY username ' \
'ORDER BY total_count DESC LIMIT 10' % (time_range, user_cond)
'ORDER BY total_count DESC LIMIT 10' % (group_by, time_range, user_cond)
result = monitor_db.select(query)
else:
@@ -951,9 +1025,11 @@ class Graphs(object):
'FROM session_history ' \
'JOIN users ON session_history.user_id = users.user_id ' \
'JOIN session_history_media_info ON session_history.id = session_history_media_info.id ' \
'WHERE datetime(session_history.started, "unixepoch", "localtime") >= ' \
'WHERE datetime(started, "unixepoch", "localtime") >= ' \
'datetime("now", "-%s days", "localtime") AND ' \
'(session_history.media_type = "episode" OR session_history.media_type = "movie" OR session_history.media_type = "track") %s' \
'(session_history.media_type = "episode" OR ' \
'session_history.media_type = "movie" OR ' \
'session_history.media_type = "track") %s' \
'GROUP BY username ' \
'ORDER BY total_duration DESC LIMIT 10' % (time_range, user_cond)

View File

@@ -646,7 +646,7 @@ def whois_lookup(ip_address):
countries = ipwhois.utils.get_countries()
nets = whois['nets']
for net in nets:
net['country'] = countries[net['country']]
net['country'] = countries.get(net['country'])
if net['postal_code']:
net['postal_code'] = net['postal_code'].replace('-', ' ')
except ValueError as e:
@@ -680,21 +680,21 @@ def anon_url(*url):
"""
return '' if None in url else '%s%s' % (plexpy.CONFIG.ANON_REDIRECT, ''.join(str(s) for s in url))
def uploadToImgur(imgPath, imgTitle=''):
def upload_to_imgur(imgPath, imgTitle=''):
""" Uploads an image to Imgur """
client_id = plexpy.CONFIG.IMGUR_CLIENT_ID
img_url = ''
img_url = delete_hash = ''
if not client_id:
logger.error(u"Tautulli Helpers :: Cannot upload poster to Imgur. No Imgur client id specified in the settings.")
return img_url
return img_url, delete_hash
try:
with open(imgPath, 'rb') as imgFile:
img = imgFile.read()
except IOError as e:
logger.error(u"Tautulli Helpers :: Unable to read image file for Imgur: %s" % e)
return img_url
return img_url, delete_hash
headers = {'Authorization': 'Client-ID %s' % client_id}
data = {'type': 'base64',
@@ -703,13 +703,15 @@ def uploadToImgur(imgPath, imgTitle=''):
data['title'] = imgTitle.encode('utf-8')
data['name'] = imgTitle.encode('utf-8') + '.jpg'
response, err_msg, req_msg = request.request_response2('https://api.imgur.com/3/image', 'POST', headers=headers, data=data)
response, err_msg, req_msg = request.request_response2('https://api.imgur.com/3/image', 'POST',
headers=headers, data=data)
if response and not err_msg:
t = '\'' + imgTitle + '\' ' if imgTitle else ''
logger.debug(u"Tautulli Helpers :: Image {}uploaded to Imgur.".format(t))
img_url = response.json().get('data').get('link', '').replace('http://', 'https://')
imgur_response_data = response.json().get('data')
img_url = imgur_response_data.get('link', '').replace('http://', 'https://')
delete_hash = imgur_response_data.get('deletehash', '')
else:
if err_msg:
logger.error(u"Tautulli Helpers :: Unable to upload image to Imgur: {}".format(err_msg))
@@ -719,7 +721,27 @@ def uploadToImgur(imgPath, imgTitle=''):
if req_msg:
logger.debug(u"Tautulli Helpers :: Request response: {}".format(req_msg))
return img_url
return img_url, delete_hash
def delete_from_imgur(delete_hash, imgTitle=''):
""" Deletes an image from Imgur """
client_id = plexpy.CONFIG.IMGUR_CLIENT_ID
headers = {'Authorization': 'Client-ID %s' % client_id}
response, err_msg, req_msg = request.request_response2('https://api.imgur.com/3/image/%s' % delete_hash, 'DELETE',
headers=headers)
if response and not err_msg:
t = '\'' + imgTitle + '\' ' if imgTitle else ''
logger.debug(u"Tautulli Helpers :: Image {}deleted from Imgur.".format(t))
return True
else:
if err_msg:
logger.error(u"Tautulli Helpers :: Unable to delete image from Imgur: {}".format(err_msg))
else:
logger.error(u"Tautulli Helpers :: Unable to delete image from Imgur.")
return False
def cache_image(url, image=None):
"""

View File

@@ -39,11 +39,17 @@ class HTTPHandler(object):
else:
self.urls = urls
self.headers = {'X-Plex-Device-Name': 'Tautulli',
'X-Plex-Product': 'Tautulli',
'X-Plex-Version': plexpy.common.RELEASE,
'X-Plex-Platform': plexpy.common.PLATFORM,
'X-Plex-Platform-Version': plexpy.common.PLATFORM_VERSION,
'X-Plex-Client-Identifier': plexpy.CONFIG.PMS_UUID,
}
self.token = token
if self.token:
self.headers = {'X-Plex-Token': self.token}
else:
self.headers = {}
self.headers['X-Plex-Token'] = self.token
self.timeout = timeout
self.ssl_verify = ssl_verify
@@ -65,7 +71,7 @@ class HTTPHandler(object):
Output: list
"""
self.uri = uri
self.uri = uri.encode('utf-8')
self.request_type = request_type.upper()
self.output_format = output_format.lower()
self.return_type = return_type
@@ -79,9 +85,9 @@ class HTTPHandler(object):
if uri:
request_urls = [urljoin(url, self.uri) for url in self.urls]
if no_token and headers:
self.headers = headers
elif headers:
if no_token:
self.headers.pop('X-Plex-Token', None)
if headers:
self.headers.update(headers)
responses = []

View File

@@ -544,19 +544,19 @@ class Libraries(object):
filtered_count = len(results)
# Sort results
results = sorted(results, key=lambda k: k['sort_title'])
results = sorted(results, key=lambda k: k['sort_title'].lower())
sort_order = json_data['order']
for order in reversed(sort_order):
sort_key = json_data['columns'][int(order['column'])]['data']
reverse = True if order['dir'] == 'desc' else False
if rating_key and sort_key == 'sort_title':
results = sorted(results, key=lambda k: helpers.cast_to_int(k['media_index']), reverse=reverse)
elif sort_key == 'file_size' or sort_key == 'bitrate':
elif sort_key in ('file_size', 'bitrate', 'added_at', 'last_played', 'play_count'):
results = sorted(results, key=lambda k: helpers.cast_to_int(k[sort_key]), reverse=reverse)
elif sort_key == 'video_resolution':
results = sorted(results, key=lambda k: helpers.cast_to_int(k[sort_key].replace('4k', '2160p').rstrip('p')), reverse=reverse)
else:
results = sorted(results, key=lambda k: k[sort_key], reverse=reverse)
results = sorted(results, key=lambda k: k[sort_key].lower(), reverse=reverse)
total_file_size = sum([helpers.cast_to_int(d['file_size']) for d in results])
@@ -744,7 +744,7 @@ class Libraries(object):
logger.warn(u"Tautulli Libraries :: Unable to retrieve library %s from database. Requesting library list refresh."
% section_id)
# Let's first refresh the libraries list to make sure the library isn't newly added and not in the db yet
pmsconnect.refresh_libraries()
refresh_libraries()
library_details = get_library_details(section_id=section_id)
@@ -1006,13 +1006,13 @@ class Libraries(object):
except Exception as e:
logger.warn(u"Tautulli Libraries :: Unable to execute database query for undelete: %s." % e)
def delete_datatable_media_info_cache(self, section_id=None):
def delete_media_info_cache(self, section_id=None):
import os
try:
if section_id.isdigit():
[os.remove(os.path.join(plexpy.CONFIG.CACHE_DIR, f)) for f in os.listdir(plexpy.CONFIG.CACHE_DIR)
if f.startswith('media_info-%s' % section_id) and f.endswith('.json')]
[os.remove(os.path.join(plexpy.CONFIG.CACHE_DIR, f)) for f in os.listdir(plexpy.CONFIG.CACHE_DIR)
if f.startswith('media_info_%s' % section_id) and f.endswith('.json')]
logger.debug(u"Tautulli Libraries :: Deleted media info table cache for section_id %s." % section_id)
return 'Deleted media info table cache for library with id %s.' % section_id

View File

@@ -30,8 +30,8 @@ import plexpy
import helpers
# These settings are for file logging only
FILENAME = "plexpy.log"
FILENAME_API = "plexpy_api.log"
FILENAME = "tautulli.log"
FILENAME_API = "tautulli_api.log"
FILENAME_PLEX_WEBSOCKET = "plex_websocket.log"
MAX_SIZE = 5000000 # 5 MB
MAX_FILES = 5
@@ -39,9 +39,9 @@ MAX_FILES = 5
_BLACKLIST_WORDS = set()
# Tautulli logger
logger = logging.getLogger("plexpy")
logger = logging.getLogger("tautulli")
# Tautulli API logger
logger_api = logging.getLogger("plexpy_api")
logger_api = logging.getLogger("tautulli_api")
# Tautulli websocket logger
logger_plex_websocket = logging.getLogger("plex_websocket")
@@ -178,9 +178,9 @@ def initMultiprocessing():
def initLogger(console=False, log_dir=False, verbose=False):
"""
Setup logging for Tautulli. It uses the logger instance with the name
'plexpy'. Three log handlers are added:
'tautulli'. Three log handlers are added:
* RotatingFileHandler: for the file plexpy.log
* RotatingFileHandler: for the file tautulli.log
* LogListHandler: for Web UI
* StreamHandler: for console (if console)
@@ -306,6 +306,11 @@ def initHooks(global_exceptions=True, thread_exceptions=True, pass_original=True
# Monkey patch the run() by monkey patching the __init__ method
threading.Thread.__init__ = new_init
def shutdown():
logging.shutdown()
# Expose logger methods
# Main Tautulli logger
info = logger.info

View File

@@ -87,6 +87,8 @@ def add_notifier_each(notifier_id=None, notify_action=None, stream_data=None, ti
conditions = notify_conditions(notify_action=notify_action,
stream_data=stream_data,
timeline_data=timeline_data)
else:
conditions = True
if notifiers_enabled and (manual_trigger or conditions):
if stream_data or timeline_data:
@@ -122,8 +124,8 @@ def add_notifier_each(notifier_id=None, notify_action=None, stream_data=None, ti
# Add on_concurrent and on_newdevice to queue if action is on_play
if notify_action == 'on_play':
plexpy.NOTIFY_QUEUE.put({'stream_data': stream_data, 'notify_action': 'on_concurrent'})
plexpy.NOTIFY_QUEUE.put({'stream_data': stream_data, 'notify_action': 'on_newdevice'})
plexpy.NOTIFY_QUEUE.put({'stream_data': stream_data.copy(), 'notify_action': 'on_concurrent'})
plexpy.NOTIFY_QUEUE.put({'stream_data': stream_data.copy(), 'notify_action': 'on_newdevice'})
def notify_conditions(notify_action=None, stream_data=None, timeline_data=None):
@@ -277,13 +279,13 @@ def notify_custom_conditions(notifier_id=None, parameters=None):
evaluated_conditions.append(any(c in parameter_value for c in values))
elif operator == 'does not contain':
evaluated_conditions.append(any(c not in parameter_value for c in values))
evaluated_conditions.append(all(c not in parameter_value for c in values))
elif operator == 'is':
evaluated_conditions.append(any(parameter_value == c for c in values))
elif operator == 'is not':
evaluated_conditions.append(any(parameter_value != c for c in values))
evaluated_conditions.append(all(parameter_value != c for c in values))
elif operator == 'begins with':
evaluated_conditions.append(parameter_value.startswith(tuple(values)))
@@ -318,13 +320,6 @@ def notify_custom_conditions(notifier_id=None, parameters=None):
def notify(notifier_id=None, notify_action=None, stream_data=None, timeline_data=None, parameters=None, **kwargs):
# Double check again if the notification has already been sent
if stream_data and \
any(d['notifier_id'] == notifier_id and d['notify_action'] == notify_action
for d in get_notify_state(session=stream_data)):
# Return if the notification has already been sent
return
logger.info(u"Tautulli NotificationHandler :: Preparing notifications for notifier_id %s." % notifier_id)
notifier_config = notifiers.get_notifier_config(notifier_id=notifier_id)
@@ -390,6 +385,28 @@ def get_notify_state(session):
return notify_states
def get_notify_state_enabled(session, notify_action, notified=True):
if notified:
timestamp_where = 'AND timestamp IS NOT NULL'
else:
timestamp_where = 'AND timestamp IS NULL'
monitor_db = database.MonitorDatabase()
result = monitor_db.select('SELECT id AS notifier_id, timestamp '
'FROM notifiers '
'LEFT OUTER JOIN ('
'SELECT timestamp, notifier_id '
'FROM notify_log '
'WHERE session_key = ? '
'AND rating_key = ? '
'AND user_id = ? '
'AND notify_action = ?) AS t ON notifiers.id = t.notifier_id '
'WHERE %s = 1 %s' % (notify_action, timestamp_where),
args=[session['session_key'], session['rating_key'], session['user_id'], notify_action])
return result
def set_notify_state(notifier, notify_action, subject='', body='', script_args='', session=None):
if notifier and notify_action:
@@ -435,20 +452,6 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
time_format = plexpy.CONFIG.TIME_FORMAT.replace('Do','')
duration_format = plexpy.CONFIG.TIME_FORMAT.replace('Do','').replace('a','').replace('A','')
# Get the server name
server_name = plexpy.CONFIG.PMS_NAME
# Get the server uptime
plex_tv = plextv.PlexTV()
server_times = plex_tv.get_server_times()
if server_times:
updated_at = server_times['updated_at']
server_uptime = helpers.human_duration(int(time.time() - helpers.cast_to_int(updated_at)))
else:
logger.error(u"Tautulli NotificationHandler :: Unable to retrieve server uptime.")
server_uptime = 'N/A'
# Get metadata for the item
if session:
rating_key = session['rating_key']
@@ -523,10 +526,15 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
remaining_duration = duration - view_offset
# Build Plex URL
if notify_params['media_type'] == 'track':
plex_web_rating_key = notify_params['parent_rating_key']
else:
plex_web_rating_key = notify_params['rating_key']
notify_params['plex_url'] = '{web_url}#!/server/{pms_identifier}/details?key=%2Flibrary%2Fmetadata%2F{rating_key}'.format(
web_url=plexpy.CONFIG.PMS_WEB_URL,
pms_identifier=plexpy.CONFIG.PMS_IDENTIFIER,
rating_key=rating_key)
rating_key=plex_web_rating_key)
# Get media IDs from guid and build URLs
if 'imdb://' in notify_params['guid']:
@@ -535,12 +543,12 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
notify_params['trakt_url'] = 'https://trakt.tv/search/imdb/' + notify_params['imdb_id']
if 'thetvdb://' in notify_params['guid']:
notify_params['thetvdb_id'] = notify_params['guid'].split('thetvdb://')[1].split('/')[0]
notify_params['thetvdb_id'] = notify_params['guid'].split('thetvdb://')[1].split('/')[0].split('?')[0]
notify_params['thetvdb_url'] = 'https://thetvdb.com/?tab=series&id=' + notify_params['thetvdb_id']
notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/' + notify_params['thetvdb_id'] + '?id_type=show'
elif 'thetvdbdvdorder://' in notify_params['guid']:
notify_params['thetvdb_id'] = notify_params['guid'].split('thetvdbdvdorder://')[1].split('/')[0]
notify_params['thetvdb_id'] = notify_params['guid'].split('thetvdbdvdorder://')[1].split('/')[0].split('?')[0]
notify_params['thetvdb_url'] = 'https://thetvdb.com/?tab=series&id=' + notify_params['thetvdb_id']
notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/' + notify_params['thetvdb_id'] + '?id_type=show'
@@ -551,7 +559,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
notify_params['trakt_url'] = 'https://trakt.tv/search/tmdb/' + notify_params['themoviedb_id'] + '?id_type=movie'
elif notify_params['media_type'] in ('show', 'season', 'episode'):
notify_params['themoviedb_id'] = notify_params['guid'].split('themoviedb://')[1].split('/')[0]
notify_params['themoviedb_id'] = notify_params['guid'].split('themoviedb://')[1].split('/')[0].split('?')[0]
notify_params['themoviedb_url'] = 'https://www.themoviedb.org/tv/' + notify_params['themoviedb_id']
notify_params['trakt_url'] = 'https://trakt.tv/search/tmdb/' + notify_params['themoviedb_id'] + '?id_type=show'
@@ -571,7 +579,14 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
notify_params['imdb_url'] = 'https://www.imdb.com/title/' + themoveidb_json['imdb_id']
elif notify_params.get('thetvdb_id') or notify_params.get('imdb_id'):
themoviedb_info = lookup_themoviedb_by_id(rating_key=rating_key,
if notify_params['media_type'] in ('episode', 'track'):
lookup_key = notify_params['grandparent_rating_key']
elif notify_params['media_type'] in ('season', 'album'):
lookup_key = notify_params['parent_rating_key']
else:
lookup_key = rating_key
themoviedb_info = lookup_themoviedb_by_id(rating_key=lookup_key,
thetvdb_id=notify_params.get('thetvdb_id'),
imdb_id=notify_params.get('imdb_id'))
notify_params.update(themoviedb_info)
@@ -579,7 +594,14 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
# Get TVmaze info (for tv shows only)
if plexpy.CONFIG.TVMAZE_LOOKUP:
if notify_params['media_type'] in ('show', 'season', 'episode') and (notify_params.get('thetvdb_id') or notify_params.get('imdb_id')):
tvmaze_info = lookup_tvmaze_by_id(rating_key=rating_key,
if notify_params['media_type'] in ('episode', 'track'):
lookup_key = notify_params['grandparent_rating_key']
elif notify_params['media_type'] in ('season', 'album'):
lookup_key = notify_params['parent_rating_key']
else:
lookup_key = rating_key
tvmaze_info = lookup_tvmaze_by_id(rating_key=lookup_key,
thetvdb_id=notify_params.get('thetvdb_id'),
imdb_id=notify_params.get('imdb_id'))
notify_params.update(tvmaze_info)
@@ -655,15 +677,21 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
available_params = {
# Global paramaters
'plexpy_version': common.VERSION_NUMBER,
'plexpy_branch': plexpy.CONFIG.GIT_BRANCH,
'plexpy_commit': plexpy.CURRENT_VERSION,
'server_name': server_name,
'server_uptime': server_uptime,
'server_version': server_times.get('version', ''),
'tautulli_version': common.RELEASE,
'tautulli_remote': plexpy.CONFIG.GIT_REMOTE,
'tautulli_branch': plexpy.CONFIG.GIT_BRANCH,
'tautulli_commit': plexpy.CURRENT_VERSION,
'server_name': plexpy.CONFIG.PMS_NAME,
'server_ip': plexpy.CONFIG.PMS_IP,
'server_port': plexpy.CONFIG.PMS_PORT,
'server_url': plexpy.CONFIG.PMS_URL,
'server_machine_id': plexpy.CONFIG.PMS_IDENTIFIER,
'server_platform': plexpy.CONFIG.PMS_PLATFORM,
'server_version': plexpy.CONFIG.PMS_VERSION,
'action': notify_action.lstrip('on_'),
'datestamp': arrow.now().format(date_format),
'timestamp': arrow.now().format(time_format),
'unixtime': int(time.time()),
# Stream parameters
'streams': stream_count,
'user_streams': user_stream_count,
@@ -772,6 +800,8 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'writers': ', '.join(notify_params['writers']),
'actors': ', '.join(notify_params['actors']),
'genres': ', '.join(notify_params['genres']),
'labels': ', '.join(notify_params['labels']),
'collections': ', '.join(notify_params['collections']),
'summary': notify_params['summary'],
'tagline': notify_params['tagline'],
'rating': notify_params['rating'],
@@ -840,40 +870,34 @@ def build_server_notify_params(notify_action=None, **kwargs):
date_format = plexpy.CONFIG.DATE_FORMAT.replace('Do','')
time_format = plexpy.CONFIG.TIME_FORMAT.replace('Do','')
# Get the server name
server_name = plexpy.CONFIG.PMS_NAME
# Get the server uptime
plex_tv = plextv.PlexTV()
server_times = plex_tv.get_server_times()
update_channel = pmsconnect.PmsConnect().get_server_update_channel()
pms_download_info = defaultdict(str, kwargs.pop('pms_download_info', {}))
plexpy_download_info = defaultdict(str, kwargs.pop('plexpy_download_info', {}))
if server_times:
updated_at = server_times['updated_at']
server_uptime = helpers.human_duration(int(time.time() - helpers.cast_to_int(updated_at)))
else:
logger.error(u"Tautulli NotificationHandler :: Unable to retrieve server uptime.")
server_uptime = 'N/A'
available_params = {
# Global paramaters
'plexpy_version': common.VERSION_NUMBER,
'plexpy_branch': plexpy.CONFIG.GIT_BRANCH,
'plexpy_commit': plexpy.CURRENT_VERSION,
'server_name': server_name,
'server_uptime': server_uptime,
'server_version': server_times.get('version', ''),
'tautulli_version': common.RELEASE,
'tautulli_remote': plexpy.CONFIG.GIT_REMOTE,
'tautulli_branch': plexpy.CONFIG.GIT_BRANCH,
'tautulli_commit': plexpy.CURRENT_VERSION,
'server_name': plexpy.CONFIG.PMS_NAME,
'server_ip': plexpy.CONFIG.PMS_IP,
'server_port': plexpy.CONFIG.PMS_PORT,
'server_url': plexpy.CONFIG.PMS_URL,
'server_platform': plexpy.CONFIG.PMS_PLATFORM,
'server_version': plexpy.CONFIG.PMS_VERSION,
'server_machine_id': plexpy.CONFIG.PMS_IDENTIFIER,
'action': notify_action.lstrip('on_'),
'datestamp': arrow.now().format(date_format),
'timestamp': arrow.now().format(time_format),
'unixtime': int(time.time()),
# Plex Media Server update parameters
'update_version': pms_download_info['version'],
'update_url': pms_download_info['download_url'],
'update_release_date': arrow.get(pms_download_info['release_date']).format(date_format)
if pms_download_info['release_date'] else '',
'update_channel': 'Beta' if plexpy.CONFIG.PMS_UPDATE_CHANNEL == 'plexpass' else 'Public',
'update_channel': 'Beta' if update_channel == 'beta' else 'Public',
'update_platform': pms_download_info['platform'],
'update_distro': pms_download_info['distro'],
'update_distro_build': pms_download_info['build'],
@@ -882,12 +906,13 @@ def build_server_notify_params(notify_action=None, **kwargs):
'update_changelog_added': pms_download_info['changelog_added'],
'update_changelog_fixed': pms_download_info['changelog_fixed'],
# Tautulli update parameters
'plexpy_update_version': plexpy_download_info['tag_name'],
'plexpy_update_tar': plexpy_download_info['tarball_url'],
'plexpy_update_zip': plexpy_download_info['zipball_url'],
'plexpy_update_commit': kwargs.pop('plexpy_update_commit', ''),
'plexpy_update_behind': kwargs.pop('plexpy_update_behind', ''),
'plexpy_update_changelog': plexpy_download_info['body']
'tautulli_update_version': plexpy_download_info['tag_name'],
'tautulli_update_release_url': plexpy_download_info['html_url'],
'tautulli_update_tar': plexpy_download_info['tarball_url'],
'tautulli_update_zip': plexpy_download_info['zipball_url'],
'tautulli_update_commit': kwargs.pop('plexpy_update_commit', ''),
'tautulli_update_behind': kwargs.pop('plexpy_update_behind', ''),
'tautulli_update_changelog': plexpy_download_info['body']
}
return available_params
@@ -987,8 +1012,8 @@ def strip_tag(data, agent_id=None):
'font': ['color']}
return bleach.clean(data, tags=whitelist.keys(), attributes=whitelist, strip=True)
elif agent_id == 10:
# Don't remove tags for email
elif agent_id in (10, 14, 20):
# Don't remove tags for Email, Slack, and Discord
return data
elif agent_id == 13:
@@ -1046,14 +1071,17 @@ def get_poster_info(poster_thumb, poster_key, poster_title):
raise Exception(u'PMS image request failed')
# Upload poster_thumb to Imgur and get link
poster_url = helpers.uploadToImgur(poster_file, poster_title)
poster_url, delete_hash = helpers.upload_to_imgur(poster_file, poster_title)
if poster_url:
# Create poster info
poster_info = {'poster_title': poster_title, 'poster_url': poster_url}
# Save the poster url in the database
data_factory.set_poster_url(rating_key=poster_key, poster_title=poster_title, poster_url=poster_url)
data_factory.set_poster_url(rating_key=poster_key,
poster_title=poster_title,
poster_url=poster_url,
delete_hash=delete_hash)
# Delete the cached poster
os.remove(poster_file)
@@ -1203,6 +1231,17 @@ def get_themoviedb_info(rating_key=None, media_type=None, themoviedb_id=None):
if response and not err_msg:
themoviedb_json = response.json()
themoviedb_id = themoviedb_json['id']
themoviedb_url = 'https://www.themoviedb.org/{}/{}'.format(media_type, themoviedb_id)
keys = {'themoviedb_id': themoviedb_id}
themoviedb_info = {'rating_key': rating_key,
'imdb_id': themoviedb_json.get('imdb_id'),
'themoviedb_url': themoviedb_url,
'themoviedb_json': json.dumps(themoviedb_json)
}
db.upsert(table_name='themoviedb_lookup', key_dict=keys, value_dict=themoviedb_info)
else:
if err_msg:

File diff suppressed because it is too large Load Diff

View File

@@ -144,14 +144,7 @@ class PlexTV(object):
uri = '/users/sign_in.xml'
base64string = base64.b64encode(('%s:%s' % (self.username, self.password)).encode('utf-8'))
headers = {'Content-Type': 'application/xml; charset=utf-8',
'X-Plex-Device-Name': 'Tautulli',
'X-Plex-Product': 'Tautulli',
'X-Plex-Version': plexpy.common.VERSION_NUMBER,
'X-Plex-Platform': plexpy.common.PLATFORM,
'X-Plex-Platform-Version': plexpy.common.PLATFORM_VERSION,
'X-Plex-Client-Identifier': plexpy.CONFIG.PMS_UUID,
'Authorization': 'Basic %s' % base64string
}
'Authorization': 'Basic %s' % base64string}
request = self.request_handler.make_request(uri=uri,
request_type='POST',
@@ -318,6 +311,14 @@ class PlexTV(object):
return request
def cloud_server_status(self, output_format=''):
uri = '/api/v2/cloud_server'
request = self.request_handler.make_request(uri=uri,
request_type='GET',
output_format=output_format)
return request
def get_full_users_list(self):
friends_list = self.get_plextv_friends(output_format='xml')
own_account = self.get_plextv_user_details(output_format='xml')
@@ -331,18 +332,19 @@ class PlexTV(object):
for a in xml_head:
own_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_allow_sync": None,
"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')
}
"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_admin": 1,
"is_allow_sync": None,
"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')
}
users_list.append(own_details)
@@ -354,18 +356,19 @@ class PlexTV(object):
for a in xml_head:
friend = {"user_id": helpers.get_xml_attr(a, 'id'),
"username": helpers.get_xml_attr(a, 'title'),
"thumb": helpers.get_xml_attr(a, 'thumb'),
"email": helpers.get_xml_attr(a, 'email'),
"is_home_user": helpers.get_xml_attr(a, 'home'),
"is_allow_sync": helpers.get_xml_attr(a, 'allowSync'),
"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')
}
"username": helpers.get_xml_attr(a, 'title'),
"thumb": helpers.get_xml_attr(a, 'thumb'),
"email": helpers.get_xml_attr(a, 'email'),
"is_admin": 0,
"is_home_user": helpers.get_xml_attr(a, 'home'),
"is_allow_sync": helpers.get_xml_attr(a, 'allowSync'),
"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')
}
users_list.append(friend)
@@ -374,9 +377,19 @@ class PlexTV(object):
def get_synced_items(self, machine_id=None, client_id_filter=None, user_id_filter=None,
rating_key_filter=None, sync_id_filter=None):
if machine_id is None:
if not machine_id:
machine_id = plexpy.CONFIG.PMS_IDENTIFIER
if isinstance(rating_key_filter, list):
rating_key_filter = [str(k) for k in rating_key_filter]
elif rating_key_filter:
rating_key_filter = [str(rating_key_filter)]
if isinstance(user_id_filter, list):
user_id_filter = [str(k) for k in user_id_filter]
elif user_id_filter:
user_id_filter = [str(user_id_filter)]
sync_list = self.get_plextv_sync_lists(machine_id, output_format='xml')
user_data = users.Users()
@@ -416,7 +429,7 @@ class PlexTV(object):
device_last_seen = helpers.get_xml_attr(device, 'lastSeenAt')
# Filter by user_id
if user_id_filter and str(user_id_filter) != device_user_id:
if user_id_filter and device_user_id not in user_id_filter:
continue
for synced in a.getElementsByTagName('SyncItems'):
@@ -430,7 +443,7 @@ class PlexTV(object):
for idx, item in enumerate(clean_uri) if item == 'metadata'), None)
# Filter by rating_key
if rating_key_filter and str(rating_key_filter) != rating_key:
if rating_key_filter and rating_key not in rating_key_filter:
continue
sync_id = helpers.get_xml_attr(item, 'id')
@@ -459,12 +472,13 @@ class PlexTV(object):
status_item_downloaded_count, status_item_count)
for settings in item.getElementsByTagName('MediaSettings'):
settings_audio_boost = helpers.get_xml_attr(settings, 'audioBoost')
settings_music_bitrate = helpers.get_xml_attr(settings, 'musicBitrate')
settings_photo_quality = helpers.get_xml_attr(settings, 'photoQuality')
settings_photo_resolution = helpers.get_xml_attr(settings, 'photoResolution')
settings_video_bitrate = helpers.get_xml_attr(settings, 'maxVideoBitrate')
settings_video_quality = helpers.get_xml_attr(settings, 'videoQuality')
settings_video_resolution = helpers.get_xml_attr(settings, 'videoResolution')
settings_audio_boost = helpers.get_xml_attr(settings, 'audioBoost')
settings_audio_bitrate = helpers.get_xml_attr(settings, 'musicBitrate')
settings_photo_quality = helpers.get_xml_attr(settings, 'photoQuality')
settings_photo_resolution = helpers.get_xml_attr(settings, 'photoResolution')
sync_details = {"device_name": helpers.sanitize(device_name),
"platform": helpers.sanitize(device_platform),
@@ -481,7 +495,8 @@ class PlexTV(object):
"item_complete_count": status_item_complete_count,
"item_downloaded_count": status_item_downloaded_count,
"item_downloaded_percent_complete": status_item_download_percent_complete,
"music_bitrate": settings_music_bitrate,
"video_bitrate": settings_video_bitrate,
"audio_bitrate": settings_audio_bitrate,
"photo_quality": settings_photo_quality,
"video_quality": settings_video_quality,
"total_size": status_total_size,
@@ -631,7 +646,8 @@ class PlexTV(object):
'ip': helpers.get_xml_attr(c, 'address'),
'port': helpers.get_xml_attr(c, 'port'),
'local': helpers.get_xml_attr(c, 'local'),
'value': helpers.get_xml_attr(c, 'address')
'value': helpers.get_xml_attr(c, 'address'),
'is_cloud': is_cloud
}
clean_servers.append(server)
@@ -639,10 +655,14 @@ class PlexTV(object):
def get_plex_downloads(self):
logger.debug(u"Tautulli PlexTV :: Retrieving current server version.")
pmsconnect.PmsConnect().set_server_version()
logger.debug(u"Tautulli PlexTV :: Plex update channel is %s." % plexpy.CONFIG.PMS_UPDATE_CHANNEL)
plex_downloads = self.get_plextv_downloads(plexpass=(plexpy.CONFIG.PMS_UPDATE_CHANNEL == 'plexpass'))
pms_connect = pmsconnect.PmsConnect()
pms_connect.set_server_version()
update_channel = pms_connect.get_server_update_channel()
logger.debug(u"Tautulli PlexTV :: Plex update channel is %s." % update_channel)
plex_downloads = self.get_plextv_downloads(plexpass=(update_channel == 'beta'))
try:
available_downloads = json.loads(plex_downloads)
@@ -735,3 +755,21 @@ class PlexTV(object):
devices_list.append(device)
return devices_list
def get_cloud_server_status(self):
cloud_status = self.cloud_server_status(output_format='xml')
try:
status_info = cloud_status.getElementsByTagName('info')
except Exception as e:
logger.warn(u"Tautulli PlexTV :: Unable to parse XML for get_cloud_server_status: %s." % e)
return False
for info in status_info:
servers = info.getElementsByTagName('server')
for s in servers:
if helpers.get_xml_attr(s, 'address') == plexpy.CONFIG.PMS_IP:
if helpers.get_xml_attr(info, 'running') == '1':
return True
else:
return False

View File

@@ -61,7 +61,7 @@ class PmsConnect(object):
self.url = plexpy.CONFIG.PMS_URL
elif not self.url:
self.url = 'http://{hostname}:{port}'.format(hostname=plexpy.CONFIG.PMS_IP,
port=plexpy.CONFIG.PMS_PORT)
port=plexpy.CONFIG.PMS_PORT)
self.timeout = plexpy.CONFIG.PMS_TIMEOUT
if not self.token:
@@ -533,11 +533,16 @@ class PmsConnect(object):
metadata = {}
if cache_key:
in_file_path = os.path.join(plexpy.CONFIG.CACHE_DIR, 'metadata-sessionKey-%s.json' % cache_key)
in_file_folder = os.path.join(plexpy.CONFIG.CACHE_DIR, 'session_metadata')
in_file_path = os.path.join(in_file_folder, 'metadata-sessionKey-%s.json' % cache_key)
if not os.path.exists(in_file_folder):
os.mkdir(in_file_folder)
try:
with open(in_file_path, 'r') as inFile:
metadata = json.load(inFile)
except IOError as e:
except (IOError, ValueError) as e:
pass
if metadata:
@@ -559,27 +564,32 @@ class PmsConnect(object):
for a in xml_head:
if a.getAttribute('size'):
if a.getAttribute('size') != '1':
if a.getAttribute('size') == '0':
return metadata
if a.getElementsByTagName('Directory'):
metadata_main = a.getElementsByTagName('Directory')[0]
metadata_type = helpers.get_xml_attr(metadata_main, 'type')
if metadata_type == 'photo':
metadata_type = 'photo_album'
metadata_main_list = a.getElementsByTagName('Directory')
elif a.getElementsByTagName('Video'):
metadata_main = a.getElementsByTagName('Video')[0]
metadata_type = helpers.get_xml_attr(metadata_main, 'type')
metadata_main_list = a.getElementsByTagName('Video')
elif a.getElementsByTagName('Track'):
metadata_main = a.getElementsByTagName('Track')[0]
metadata_type = helpers.get_xml_attr(metadata_main, 'type')
metadata_main_list = a.getElementsByTagName('Track')
elif a.getElementsByTagName('Photo'):
metadata_main = a.getElementsByTagName('Photo')[0]
metadata_type = helpers.get_xml_attr(metadata_main, 'type')
metadata_main_list = a.getElementsByTagName('Photo')
else:
logger.debug(u"Tautulli Pmsconnect :: Metadata failed")
return {}
if sync_id and len(metadata_main_list) > 1:
for metadata_main in metadata_main_list:
if helpers.get_xml_attr(metadata_main, 'ratingKey') == rating_key:
break
else:
metadata_main = metadata_main_list[0]
metadata_type = helpers.get_xml_attr(metadata_main, 'type')
if metadata_main.nodeName == 'Directory' and metadata_type == 'photo':
metadata_type = 'photo_album'
section_id = helpers.get_xml_attr(a, 'librarySectionID')
library_name = helpers.get_xml_attr(a, 'librarySectionTitle')
@@ -588,6 +598,7 @@ class PmsConnect(object):
actors = []
genres = []
labels = []
collections = []
if metadata_main.getElementsByTagName('Director'):
for director in metadata_main.getElementsByTagName('Director'):
@@ -609,6 +620,10 @@ class PmsConnect(object):
for label in metadata_main.getElementsByTagName('Label'):
labels.append(helpers.get_xml_attr(label, 'tag'))
if metadata_main.getElementsByTagName('Collection'):
for collection in metadata_main.getElementsByTagName('Collection'):
collections.append(helpers.get_xml_attr(collection, 'tag'))
if metadata_type == 'movie':
metadata = {'media_type': metadata_type,
'section_id': section_id,
@@ -646,10 +661,16 @@ class PmsConnect(object):
'actors': actors,
'genres': genres,
'labels': labels,
'collections': collections,
'full_title': helpers.get_xml_attr(metadata_main, 'title')
}
elif metadata_type == 'show':
# Workaround for for duration sometimes reported in minutes for a show
duration = helpers.get_xml_attr(metadata_main, 'duration')
if duration.isdigit() and int(duration) < 1000:
duration = unicode(int(duration) * 60 * 1000)
metadata = {'media_type': metadata_type,
'section_id': section_id,
'library_name': library_name,
@@ -669,7 +690,7 @@ class PmsConnect(object):
'rating': helpers.get_xml_attr(metadata_main, 'rating'),
'audience_rating': helpers.get_xml_attr(metadata_main, 'audienceRating'),
'user_rating': helpers.get_xml_attr(metadata_main, 'userRating'),
'duration': helpers.get_xml_attr(metadata_main, 'duration'),
'duration': 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'),
@@ -686,6 +707,7 @@ class PmsConnect(object):
'actors': actors,
'genres': genres,
'labels': labels,
'collections': collections,
'full_title': helpers.get_xml_attr(metadata_main, 'title')
}
@@ -728,6 +750,7 @@ class PmsConnect(object):
'actors': show_details['actors'],
'genres': show_details['genres'],
'labels': show_details['labels'],
'collections': show_details['collections'],
'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'parentTitle'),
helpers.get_xml_attr(metadata_main, 'title'))
}
@@ -771,6 +794,7 @@ class PmsConnect(object):
'actors': show_details['actors'],
'genres': show_details['genres'],
'labels': show_details['labels'],
'collections': show_details['collections'],
'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
helpers.get_xml_attr(metadata_main, 'title'))
}
@@ -812,6 +836,7 @@ class PmsConnect(object):
'actors': actors,
'genres': genres,
'labels': labels,
'collections': collections,
'full_title': helpers.get_xml_attr(metadata_main, 'title')
}
@@ -854,6 +879,7 @@ class PmsConnect(object):
'actors': actors,
'genres': genres,
'labels': labels,
'collections': collections,
'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'parentTitle'),
helpers.get_xml_attr(metadata_main, 'title'))
}
@@ -897,6 +923,7 @@ class PmsConnect(object):
'actors': actors,
'genres': album_details['genres'],
'labels': album_details['labels'],
'collections': album_details['collections'],
'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
helpers.get_xml_attr(metadata_main, 'title'))
}
@@ -938,6 +965,7 @@ class PmsConnect(object):
'actors': actors,
'genres': genres,
'labels': labels,
'collections': collections,
'full_title': helpers.get_xml_attr(metadata_main, 'title')
}
@@ -980,6 +1008,7 @@ class PmsConnect(object):
'actors': actors,
'genres': photo_album_details['genres'],
'labels': photo_album_details['labels'],
'collections': photo_album_details['collections'],
'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'parentTitle'),
helpers.get_xml_attr(metadata_main, 'title'))
}
@@ -1025,6 +1054,7 @@ class PmsConnect(object):
'actors': actors,
'genres': genres,
'labels': labels,
'collections': collections,
'full_title': helpers.get_xml_attr(metadata_main, 'title')
}
@@ -1065,6 +1095,7 @@ class PmsConnect(object):
'actors': actors,
'genres': genres,
'labels': labels,
'collections': collections,
'full_title': helpers.get_xml_attr(metadata_main, 'title')
}
@@ -1158,11 +1189,16 @@ class PmsConnect(object):
if cache_key:
metadata['_cache_time'] = int(time.time())
out_file_path = os.path.join(plexpy.CONFIG.CACHE_DIR, 'metadata-sessionKey-%s.json' % cache_key)
out_file_folder = os.path.join(plexpy.CONFIG.CACHE_DIR, 'session_metadata')
out_file_path = os.path.join(out_file_folder, 'metadata-sessionKey-%s.json' % cache_key)
if not os.path.exists(out_file_folder):
os.mkdir(out_file_folder)
try:
with open(out_file_path, 'w') as outFile:
json.dump(metadata, outFile)
except IOError as e:
except (IOError, ValueError) as e:
logger.error(u"Tautulli Pmsconnect :: Unable to create cache file for metadata (sessionKey %s): %s"
% (cache_key, e))
@@ -1370,9 +1406,13 @@ class PmsConnect(object):
else:
session_details = {'session_id': '',
'bandwidth': '',
'location': 'Unknown'
'location': 'wan' if player_details['local'] == '0' else 'lan'
}
# Check if using Plex Relay
session_details['relay'] = int(session_details['location'] != 'lan'
and player_details['ip_address_public'] == '127.0.0.1')
# Get the transcode details
if session.getElementsByTagName('TranscodeSession'):
transcode_info = session.getElementsByTagName('TranscodeSession')[0]
@@ -1443,16 +1483,24 @@ class PmsConnect(object):
if media_type not in ('photo', 'clip') and not session.getElementsByTagName('Session') \
and helpers.get_xml_attr(session, 'ratingKey').isdigit() and transcode_decision == 'direct play':
plex_tv = plextv.PlexTV()
parent_rating_key = helpers.get_xml_attr(session, 'parentRatingKey')
grandparent_rating_key = helpers.get_xml_attr(session, 'grandparentRatingKey')
synced_items = plex_tv.get_synced_items(client_id_filter=player_details['machine_id'],
rating_key_filter=rating_key)
rating_key_filter=[rating_key, parent_rating_key, grandparent_rating_key])
if synced_items:
sync_id = synced_items[0]['sync_id']
synced_item_details = synced_items[0]
sync_id = synced_item_details['sync_id']
synced_xml = self.get_sync_item(sync_id=sync_id, output_format='xml')
synced_xml_head = synced_xml.getElementsByTagName('MediaContainer')
if synced_xml_head[0].getElementsByTagName('Track'):
synced_session_data = synced_xml_head[0].getElementsByTagName('Track')[0]
synced_xml_items = synced_xml_head[0].getElementsByTagName('Track')
elif synced_xml_head[0].getElementsByTagName('Video'):
synced_session_data = synced_xml_head[0].getElementsByTagName('Video')[0]
synced_xml_items = synced_xml_head[0].getElementsByTagName('Video')
for synced_session_data in synced_xml_items:
if helpers.get_xml_attr(synced_session_data, 'ratingKey') == rating_key:
break
# Figure out which version is being played
if sync_id:
@@ -1586,6 +1634,7 @@ class PmsConnect(object):
channel_stream = 1
clip_media = session.getElementsByTagName('Media')[0]
clip_part = clip_media.getElementsByTagName('Part')[0]
audio_channels = helpers.get_xml_attr(clip_media, 'audioChannels')
metadata_details = {'media_type': media_type,
'section_id': helpers.get_xml_attr(session, 'librarySectionID'),
@@ -1624,7 +1673,8 @@ class PmsConnect(object):
'genres': [],
'labels': [],
'full_title': helpers.get_xml_attr(session, 'title'),
'container': helpers.get_xml_attr(clip_media, 'container'),
'container': helpers.get_xml_attr(clip_media, 'container') \
or helpers.get_xml_attr(clip_part, 'container'),
'height': helpers.get_xml_attr(clip_media, 'height'),
'width': helpers.get_xml_attr(clip_media, 'width'),
'video_codec': helpers.get_xml_attr(clip_media, 'videoCodec'),
@@ -1633,7 +1683,8 @@ class PmsConnect(object):
'audio_channels': audio_channels,
'audio_channel_layout': common.AUDIO_CHANNELS.get(audio_channels, audio_channels),
'channel_icon': helpers.get_xml_attr(session, 'sourceIcon'),
'channel_title': helpers.get_xml_attr(session, 'sourceTitle')
'channel_title': helpers.get_xml_attr(session, 'sourceTitle'),
'live': int(helpers.get_xml_attr(session, 'live') == '1')
}
else:
channel_stream = 0
@@ -1642,7 +1693,7 @@ class PmsConnect(object):
part_id = helpers.get_xml_attr(stream_media_parts_info, 'id')
if sync_id:
metadata_details = self.get_metadata_details(sync_id=sync_id, cache_key=session_key)
metadata_details = self.get_metadata_details(rating_key=rating_key, sync_id=sync_id, cache_key=session_key)
else:
metadata_details = self.get_metadata_details(rating_key=rating_key, cache_key=session_key)
@@ -1699,51 +1750,72 @@ class PmsConnect(object):
source_subtitle_details = next((p for p in source_media_part_streams if p['id'] == subtitle_id),
next((p for p in source_media_part_streams if p['type'] == '3'), source_subtitle_details))
# Overrides for live sessions
if metadata_details.get('live') and transcode_decision == 'transcode':
stream_details['stream_container_decision'] = 'transcode'
stream_details['stream_container'] = transcode_details['transcode_container']
video_details['stream_video_decision'] = transcode_details['video_decision']
stream_details['stream_video_codec'] = transcode_details['transcode_video_codec']
stream_details['stream_video_resolution'] = metadata_details['video_resolution']
audio_details['stream_audio_decision'] = transcode_details['audio_decision']
stream_details['stream_audio_codec'] = transcode_details['transcode_audio_codec']
stream_details['stream_audio_channels'] = transcode_details['transcode_audio_channels']
stream_details['stream_audio_channel_layout'] = common.AUDIO_CHANNELS.get(
transcode_details['transcode_audio_channels'], transcode_details['transcode_audio_channels'])
# Get the quality profile
if media_type in ('movie', 'episode', 'clip') and 'stream_bitrate' in stream_details:
stream_bitrate = helpers.cast_to_int(stream_details['stream_bitrate'])
source_bitrate = helpers.cast_to_int(source_media_details.get('bitrate'))
try:
quailtiy_bitrate = min(b for b in common.VIDEO_QUALITY_PROFILES if stream_bitrate <= b <= source_bitrate)
quality_profile = common.VIDEO_QUALITY_PROFILES[quailtiy_bitrate]
except ValueError:
if sync_id:
quality_profile = 'Original'
if sync_id:
synced_item_bitrate = helpers.cast_to_int(synced_item_details['video_bitrate'])
try:
synced_bitrate = min(b for b in common.VIDEO_QUALITY_PROFILES if source_bitrate <= b)
synced_bitrate = max(b for b in common.VIDEO_QUALITY_PROFILES if b <= synced_item_bitrate)
synced_version_profile = common.VIDEO_QUALITY_PROFILES[synced_bitrate]
except ValueError:
synced_version_profile = 'Original'
else:
synced_version_profile = ''
stream_bitrate = helpers.cast_to_int(stream_details['stream_bitrate'])
source_bitrate = helpers.cast_to_int(source_media_details.get('bitrate'))
try:
quailtiy_bitrate = min(
b for b in common.VIDEO_QUALITY_PROFILES if stream_bitrate <= b <= source_bitrate)
quality_profile = common.VIDEO_QUALITY_PROFILES[quailtiy_bitrate]
except ValueError:
quality_profile = 'Original'
if stream_details['optimized_version']:
optimized_version_profile = '{} Mbps {}'.format(round(source_bitrate / 1000.0, 1),
plexpy.common.VIDEO_RESOLUTION_OVERRIDES.get(source_media_details['video_resolution'], source_media_details['video_resolution']))
plexpy.common.VIDEO_RESOLUTION_OVERRIDES.get(source_media_details['video_resolution'],
source_media_details['video_resolution']))
else:
optimized_version_profile = ''
elif media_type == 'track' and 'stream_bitrate' in stream_details:
stream_bitrate = helpers.cast_to_int(stream_details['stream_bitrate'])
source_bitrate = helpers.cast_to_int(source_media_details.get('bitrate'))
try:
quailtiy_bitrate = min(b for b in common.AUDIO_QUALITY_PROFILES if stream_bitrate <= b <= source_bitrate)
quality_profile = common.AUDIO_QUALITY_PROFILES[quailtiy_bitrate]
except ValueError:
if sync_id:
quality_profile = 'Original'
if sync_id:
synced_item_bitrate = helpers.cast_to_int(synced_item_details['audio_bitrate'])
try:
synced_bitrate = min(b for b in common.AUDIO_QUALITY_PROFILES if source_bitrate <= b)
synced_bitrate = max(b for b in common.AUDIO_QUALITY_PROFILES if b <= synced_item_bitrate)
synced_version_profile = common.AUDIO_QUALITY_PROFILES[synced_bitrate]
except ValueError:
synced_version_profile = 'Original'
else:
synced_version_profile = ''
stream_bitrate = helpers.cast_to_int(stream_details['stream_bitrate'])
source_bitrate = helpers.cast_to_int(source_media_details.get('bitrate'))
try:
quailtiy_bitrate = min(b for b in common.AUDIO_QUALITY_PROFILES if stream_bitrate <= b <= source_bitrate)
quality_profile = common.AUDIO_QUALITY_PROFILES[quailtiy_bitrate]
except ValueError:
quality_profile = 'Original'
optimized_version_profile = ''
elif media_type == 'photo':
@@ -2071,10 +2143,12 @@ class PmsConnect(object):
sort_type = '&type=10'
elif section_type == 'photo':
sort_type = ''
elif section_type == 'photoAlbum':
elif section_type == 'photo_album':
sort_type = '&type=14'
elif section_type == 'picture':
sort_type = '&type=13'
sort_type = '&type=13&clusterZoomLevel=1'
elif section_type == 'clip':
sort_type = '&type=12&clusterZoomLevel=1'
else:
sort_type = ''
@@ -2122,8 +2196,12 @@ class PmsConnect(object):
item_main += a.getElementsByTagName('Photo')
for item in item_main:
media_type = helpers.get_xml_attr(item, 'type')
if item.nodeName == 'Directory' and media_type == 'photo':
media_type = 'photo_album'
item_info = {'section_id': helpers.get_xml_attr(a, 'librarySectionID'),
'media_type': helpers.get_xml_attr(item, 'type'),
'media_type': media_type,
'rating_key': helpers.get_xml_attr(item, 'ratingKey'),
'parent_rating_key': helpers.get_xml_attr(item, 'parentRatingKey'),
'grandparent_rating_key': helpers.get_xml_attr(item, 'grandparentRatingKey'),
@@ -2213,12 +2291,12 @@ class PmsConnect(object):
library_stats.update(child_stats)
if section_type == 'photo':
parent_list = self.get_library_children_details(section_id=section_id, section_type='photoAlbum', count='1')
parent_list = self.get_library_children_details(section_id=section_id, section_type='picture', count='1')
if parent_list:
parent_stats = {'parent_count': parent_list['library_count']}
library_stats.update(parent_stats)
child_list = self.get_library_children_details(section_id=section_id, section_type='picture', count='1')
child_list = self.get_library_children_details(section_id=section_id, section_type='clip', count='1')
if child_list:
child_stats = {'child_count': child_list['library_count']}
library_stats.update(child_stats)
@@ -2533,4 +2611,15 @@ class PmsConnect(object):
version = identity.get('version', plexpy.CONFIG.PMS_VERSION)
plexpy.CONFIG.__setattr__('PMS_VERSION', version)
plexpy.CONFIG.write()
plexpy.CONFIG.write()
def get_server_update_channel(self):
if plexpy.CONFIG.PMS_UPDATE_CHANNEL == 'plex':
update_channel_value = self.get_server_pref('ButlerUpdateChannel')
if update_channel_value == '8':
return 'beta'
else:
return 'public'
return plexpy.CONFIG.PMS_UPDATE_CHANNEL

View File

@@ -23,16 +23,15 @@ def get_session_info():
"""
Returns the session info for the user session
"""
from plexpy.webauth import SESSION_KEY
_session = {'user_id': None,
'user': None,
'user_group': 'admin',
'expiry': None}
try:
return cherrypy.session.get(SESSION_KEY, _session)
except AttributeError as e:
return _session
'exp': None}
if isinstance(cherrypy.request.login, dict):
return cherrypy.request.login
return _session
def get_session_user():
"""
@@ -80,7 +79,7 @@ def get_session_library_filters_type(filters, media_type=None):
filters = filters.get('filter_tv', ())
elif media_type == 'artist' or media_type == 'album' or media_type == 'track':
filters = filters.get('filter_music', ())
elif media_type == 'photo' or media_type == 'photoAlbum' or media_type == 'picture':
elif media_type == 'photo' or media_type == 'photo_album' or media_type == 'picture' or media_type == 'clip':
filters = filters.get('filter_photos', ())
else:
filters = filters.get('filter_all', ())
@@ -192,11 +191,7 @@ def mask_session_info(list_of_dicts, mask_metadata=True):
'user_thumb': common.DEFAULT_USER_THUMB,
'ip_address': 'N/A',
'machine_id': '',
'platform': 'Platform',
'player': 'Player',
'quality_profile': 'Unknown',
'bandwidth': '',
'location': ''
'player': 'Player'
}
metadata_to_mask = {'media_index': '0',

View File

@@ -52,6 +52,7 @@ def refresh_users():
new_value_dict = {"username": item['username'],
"thumb": item['thumb'],
"email": item['email'],
"is_admin": item['is_admin'],
"is_home_user": item['is_home_user'],
"is_allow_sync": item['is_allow_sync'],
"is_restricted": item['is_restricted'],
@@ -330,6 +331,7 @@ class Users(object):
'friendly_name': 'Local',
'user_thumb': common.DEFAULT_USER_THUMB,
'email': '',
'is_admin': '',
'is_home_user': 0,
'is_allow_sync': 0,
'is_restricted': 0,
@@ -349,21 +351,21 @@ class Users(object):
try:
if str(user_id).isdigit():
query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \
'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \
'email, is_admin, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \
'allow_guest, shared_libraries ' \
'FROM users ' \
'WHERE user_id = ? '
result = monitor_db.select(query, args=[user_id])
elif user:
query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \
'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \
'email, is_admin, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \
'allow_guest, shared_libraries ' \
'FROM users ' \
'WHERE username = ? COLLATE NOCASE '
result = monitor_db.select(query, args=[user])
elif email:
query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \
'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \
'email, is_admin, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \
'allow_guest, shared_libraries ' \
'FROM users ' \
'WHERE email = ? COLLATE NOCASE '
@@ -398,6 +400,7 @@ class Users(object):
'friendly_name': friendly_name,
'user_thumb': user_thumb,
'email': item['email'],
'is_admin': item['is_admin'],
'is_home_user': item['is_home_user'],
'is_allow_sync': item['is_allow_sync'],
'is_restricted': item['is_restricted'],
@@ -580,6 +583,27 @@ class Users(object):
return recently_watched
def get_users(self):
monitor_db = database.MonitorDatabase()
try:
query = 'SELECT user_id, username, friendly_name, email FROM users WHERE deleted_user = 0'
result = monitor_db.select(query=query)
except Exception as e:
logger.warn(u"Tautulli Users :: Unable to execute database query for get_users: %s." % e)
return None
users = []
for item in result:
user = {'user_id': item['user_id'],
'username': item['username'],
'friendly_name': item['friendly_name'] or item['username'],
'email': item['email']
}
users.append(user)
return users
def delete_all_history(self, user_id=None):
monitor_db = database.MonitorDatabase()

View File

@@ -1,2 +1,2 @@
PLEXPY_BRANCH = "beta"
PLEXPY_RELEASE_VERSION = "v2.0.12-beta"
PLEXPY_BRANCH = "master"
PLEXPY_RELEASE_VERSION = "v2.0.22"

View File

@@ -20,9 +20,9 @@ import subprocess
import tarfile
import plexpy
import common
import logger
import request
import version
def runGit(args):
@@ -65,7 +65,7 @@ def runGit(args):
def getVersion():
if version.PLEXPY_BRANCH.startswith('win32build'):
if common.BRANCH.startswith('win32build'):
plexpy.INSTALL_TYPE = 'win'
# Don't have a way to update exe yet, but don't want to set VERSION to None
@@ -120,15 +120,15 @@ def getVersion():
version_file = os.path.join(plexpy.PROG_DIR, 'version.txt')
if not os.path.isfile(version_file):
return None, 'origin', 'master'
return None, 'origin', common.BRANCH
with open(version_file, 'r') as f:
current_version = f.read().strip(' \n\r')
if current_version:
return current_version, plexpy.CONFIG.GIT_REMOTE, plexpy.CONFIG.GIT_BRANCH
return current_version, 'origin', common.BRANCH
else:
return None, 'origin', 'master'
return None, 'origin', common.BRANCH
def checkGithub(auto_update=False):
@@ -136,7 +136,9 @@ def checkGithub(auto_update=False):
# Get the latest version available from github
logger.info('Retrieving latest version information from GitHub')
url = 'https://api.github.com/repos/%s/plexpy/commits/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_BRANCH)
url = 'https://api.github.com/repos/%s/%s/commits/%s' % (plexpy.CONFIG.GIT_USER,
plexpy.CONFIG.GIT_REPO,
plexpy.CONFIG.GIT_BRANCH)
if plexpy.CONFIG.GIT_TOKEN: url = url + '?access_token=%s' % plexpy.CONFIG.GIT_TOKEN
version = request.request_json(url, timeout=20, validator=lambda x: type(x) == dict)
@@ -157,7 +159,10 @@ def checkGithub(auto_update=False):
return plexpy.LATEST_VERSION
logger.info('Comparing currently installed version with latest GitHub version')
url = 'https://api.github.com/repos/%s/plexpy/compare/%s...%s' % (plexpy.CONFIG.GIT_USER, plexpy.LATEST_VERSION, plexpy.CURRENT_VERSION)
url = 'https://api.github.com/repos/%s/%s/compare/%s...%s' % (plexpy.CONFIG.GIT_USER,
plexpy.CONFIG.GIT_REPO,
plexpy.LATEST_VERSION,
plexpy.CURRENT_VERSION)
if plexpy.CONFIG.GIT_TOKEN: url = url + '?access_token=%s' % plexpy.CONFIG.GIT_TOKEN
commits = request.request_json(url, timeout=20, whitelist_status_code=404, validator=lambda x: type(x) == dict)
@@ -175,7 +180,7 @@ def checkGithub(auto_update=False):
if plexpy.COMMITS_BEHIND > 0:
logger.info('New version is available. You are %s commits behind' % plexpy.COMMITS_BEHIND)
url = 'https://api.github.com/repos/%s/plexpy/releases' % plexpy.CONFIG.GIT_USER
url = 'https://api.github.com/repos/%s/%s/releases' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO)
releases = request.request_json(url, timeout=20, whitelist_status_code=404, validator=lambda x: type(x) == list)
if releases is None:
@@ -191,6 +196,8 @@ def checkGithub(auto_update=False):
else:
release = releases[0]
plexpy.LATEST_RELEASE = release['tag_name']
plexpy.NOTIFY_QUEUE.put({'notify_action': 'on_plexpyupdate', 'plexpy_download_info': release,
'plexpy_update_commit': plexpy.LATEST_VERSION, 'plexpy_update_behind': plexpy.COMMITS_BEHIND})
@@ -298,14 +305,14 @@ def checkout_git_branch():
logger.info('Output: ' + str(output))
def read_changelog(latest_only=False):
def read_changelog(latest_only=False, since_prev_release=False):
changelog_file = os.path.join(plexpy.PROG_DIR, 'CHANGELOG.md')
if not os.path.isfile(changelog_file):
return '<h4>Missing changelog file</h4>'
try:
output = ''
output = ['']
prev_level = 0
latest_version_found = False
@@ -329,27 +336,34 @@ def read_changelog(latest_only=False):
break
elif latest_only:
latest_version_found = True
# Add a space to the end of the release to match tags
elif since_prev_release and str(plexpy.PREV_RELEASE) + ' ' in header_text:
break
output += '<h' + header_level + '>' + header_text + '</h' + header_level + '>'
output[-1] += '<h' + header_level + '>' + header_text + '</h' + header_level + '>'
elif line_list_match:
line_level = len(line_list_match.group(1)) / 2
line_text = line_list_match.group(2)
if line_level > prev_level:
output += '<ul>' * (line_level - prev_level) + '<li>' + line_text + '</li>'
output[-1] += '<ul>' * (line_level - prev_level) + '<li>' + line_text + '</li>'
elif line_level < prev_level:
output += '</ul>' * (prev_level - line_level) + '<li>' + line_text + '</li>'
output[-1] += '</ul>' * (prev_level - line_level) + '<li>' + line_text + '</li>'
else:
output += '<li>' + line_text + '</li>'
output[-1] += '<li>' + line_text + '</li>'
prev_level = line_level
elif line.strip() == '' and prev_level:
output += '</ul>' * (prev_level)
output[-1] += '</ul>' * (prev_level)
output.append('')
prev_level = 0
return output
if since_prev_release:
output.reverse()
return ''.join(output)
except IOError as e:
logger.error('Tautulli Version Checker :: Unable to open changelog file. %s' % e)

View File

@@ -33,14 +33,33 @@ ws_reconnect = False
def start_thread():
if plexpy.CONFIG.FIRST_RUN_COMPLETE:
# Check for any existing sessions on start up
activity_pinger.check_active_sessions(ws_request=True)
# Start the websocket listener on it's own thread
threading.Thread(target=run).start()
# Check for any existing sessions on start up
activity_pinger.check_active_sessions(ws_request=True)
# Start the websocket listener on it's own thread
threading.Thread(target=run).start()
def on_connect():
if plexpy.PLEX_SERVER_UP is None:
plexpy.PLEX_SERVER_UP = True
if not plexpy.PLEX_SERVER_UP:
logger.info(u"Tautulli WebSocket :: The Plex Media Server is back up.")
plexpy.NOTIFY_QUEUE.put({'notify_action': 'on_intup'})
plexpy.PLEX_SERVER_UP = True
plexpy.initialize_scheduler()
def on_disconnect():
if plexpy.PLEX_SERVER_UP is None:
plexpy.PLEX_SERVER_UP = False
if plexpy.PLEX_SERVER_UP:
logger.info(u"Tautulli WebSocket :: Unable to get a response from the server, Plex server is down.")
plexpy.NOTIFY_QUEUE.put({'notify_action': 'on_intdown'})
plexpy.PLEX_SERVER_UP = False
activity_processor.ActivityProcessor().set_temp_stopped()
plexpy.initialize_scheduler()
@@ -55,7 +74,7 @@ def run():
if plexpy.CONFIG.PMS_SSL and plexpy.CONFIG.PMS_URL[:5] == 'https':
uri = plexpy.CONFIG.PMS_URL.replace('https://', 'wss://') + '/:/websockets/notifications'
secure = ' secure'
secure = 'secure '
else:
uri = 'ws://%s:%s/:/websockets/notifications' % (
plexpy.CONFIG.PMS_IP,
@@ -72,34 +91,29 @@ def run():
global ws_reconnect
ws_reconnect = False
reconnects = 0
ws_exception = False
# Try an open the websocket connection
while not plexpy.WS_CONNECTED and reconnects <= plexpy.CONFIG.WEBSOCKET_CONNECTION_ATTEMPTS:
try:
logger.info(u"Tautulli WebSocket :: Opening%s websocket, connection attempt %s." % (secure, str(reconnects + 1)))
ws = create_connection(uri, header=header)
reconnects = 0
logger.info(u"Tautulli WebSocket :: Ready")
plexpy.WS_CONNECTED = True
while not plexpy.WS_CONNECTED and reconnects < plexpy.CONFIG.WEBSOCKET_CONNECTION_ATTEMPTS:
if reconnects == 0:
logger.info(u"Tautulli WebSocket :: Opening %swebsocket." % secure)
if not plexpy.PLEX_SERVER_UP:
logger.info(u"Tautulli WebSocket :: The Plex Media Server is back up.")
plexpy.NOTIFY_QUEUE.put({'notify_action': 'on_intup'})
plexpy.PLEX_SERVER_UP = True
reconnects += 1
plexpy.initialize_scheduler()
except IOError as e:
logger.error(u"Tautulli WebSocket :: %s." % e)
reconnects += 1
# Sleep 5 between connection attempts
if reconnects > 1:
time.sleep(plexpy.CONFIG.WEBSOCKET_CONNECTION_TIMEOUT)
except (websocket.WebSocketException, Exception) as e:
logger.info(u"Tautulli WebSocket :: Connection attempt %s." % str(reconnects))
try:
ws = create_connection(uri, header=header)
logger.info(u"Tautulli WebSocket :: Ready")
plexpy.WS_CONNECTED = True
except (websocket.WebSocketException, IOError, Exception) as e:
logger.error(u"Tautulli WebSocket :: %s." % e)
plexpy.WS_CONNECTED = False
ws_exception = True
break
if plexpy.WS_CONNECTED:
on_connect()
while plexpy.WS_CONNECTED:
try:
@@ -109,20 +123,24 @@ def run():
reconnects = 0
except websocket.WebSocketConnectionClosedException:
if reconnects <= plexpy.CONFIG.WEBSOCKET_CONNECTION_ATTEMPTS:
if reconnects == 0:
logger.warn(u"Tautulli WebSocket :: Connection has closed.")
if not plexpy.CONFIG.PMS_IS_CLOUD and reconnects < plexpy.CONFIG.WEBSOCKET_CONNECTION_ATTEMPTS:
reconnects += 1
# Sleep 5 between connection attempts
if reconnects > 1:
time.sleep(plexpy.CONFIG.WEBSOCKET_CONNECTION_TIMEOUT)
logger.warn(u"Tautulli WebSocket :: Connection has closed, reconnection attempt %s." % reconnects)
logger.warn(u"Tautulli WebSocket :: Reconnection attempt %s." % str(reconnects))
try:
ws = create_connection(uri, header=header)
logger.info(u"Tautulli WebSocket :: Ready")
plexpy.WS_CONNECTED = True
except IOError as e:
logger.info(u"Tautulli WebSocket :: %s." % e)
except (websocket.WebSocketException, IOError, Exception) as e:
logger.error(u"Tautulli WebSocket :: %s." % e)
else:
ws.shutdown()
@@ -131,8 +149,8 @@ def run():
except (websocket.WebSocketException, Exception) as e:
logger.error(u"Tautulli WebSocket :: %s." % e)
ws.shutdown()
plexpy.WS_CONNECTED = False
ws_exception = True
break
# Check if we recieved a restart notification and close websocket connection cleanly
@@ -143,13 +161,6 @@ def run():
start_thread()
if not plexpy.WS_CONNECTED and not ws_reconnect:
logger.error(u"Tautulli WebSocket :: Connection unavailable.")
if not ws_exception and plexpy.PLEX_SERVER_UP:
logger.info(u"Tautulli WebSocket :: Unable to get an internal response from the server, Plex server is down.")
plexpy.NOTIFY_QUEUE.put({'notify_action': 'on_intdown'})
plexpy.PLEX_SERVER_UP = False
on_disconnect()
logger.debug(u"Tautulli WebSocket :: Leaving thread.")
@@ -180,7 +191,7 @@ def process(opcode, data):
info = json.loads(data)
except Exception as e:
logger.warn(u"Tautulli WebSocket :: Error decoding message from websocket: %s" % e)
logger.debug(data)
logger.websocket_error(data)
return False
info = info.get('NotificationContainer', info)

View File

@@ -18,12 +18,12 @@
# Form based authentication for CherryPy. Requires the
# Session tool to be loaded.
from cgi import escape
from datetime import datetime, timedelta
import re
import cherrypy
from hashing_passwords import check_hash
import jwt
import plexpy
import logger
@@ -32,7 +32,9 @@ from plexpy.users import Users, refresh_users
from plexpy.plextv import PlexTV
SESSION_KEY = '_cp_username'
JWT_ALGORITHM = 'HS256'
JWT_COOKIE_NAME = 'tautulli_token_'
def user_login(username=None, password=None):
if not username or not password:
@@ -52,10 +54,17 @@ def user_login(username=None, password=None):
if user_id != str(user_details['user_id']):
# The user is not in the database.
return None
elif plexpy.CONFIG.HTTP_PLEX_ADMIN and user_details['is_admin']:
# Plex admin login
return 'admin'
elif not user_details['allow_guest'] or user_details['deleted_user']:
# Guest access is disabled or the user is deleted.
return None
# Stop here if guest access is not enabled
if not plexpy.CONFIG.ALLOW_GUEST_ACCESS:
return 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)
@@ -73,7 +82,7 @@ def user_login(username=None, password=None):
# Refresh the users list to make sure we have all the correct permissions.
refresh_users()
# Successful login
return True
return 'guest'
else:
logger.warn(u"Tautulli WebAuth :: Unable to register user '%s' in database." % username)
return None
@@ -89,38 +98,62 @@ def user_login(username=None, password=None):
return None
def check_credentials(username, password, admin_login='0'):
"""Verifies credentials for username and password.
Returns True and the user group on success or False and no user group"""
if plexpy.CONFIG.HTTP_HASHED_PASSWORD and \
username == plexpy.CONFIG.HTTP_USERNAME and check_hash(password, plexpy.CONFIG.HTTP_PASSWORD):
return True, u'admin'
elif not plexpy.CONFIG.HTTP_HASHED_PASSWORD and \
username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD:
return True, u'admin'
elif not admin_login == '1' and plexpy.CONFIG.ALLOW_GUEST_ACCESS and user_login(username, password):
return True, u'guest'
else:
return False, None
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, 'admin'
elif not plexpy.CONFIG.HTTP_HASHED_PASSWORD and \
username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD:
return True, 'admin'
if plexpy.CONFIG.HTTP_PLEX_ADMIN or (not admin_login == '1' and plexpy.CONFIG.ALLOW_GUEST_ACCESS):
plex_login = user_login(username, password)
if plex_login is not None:
return True, plex_login
return False, None
def check_jwt_token():
jwt_cookie = JWT_COOKIE_NAME + plexpy.CONFIG.PMS_UUID
jwt_token = cherrypy.request.cookie.get(jwt_cookie)
if jwt_token:
try:
payload = jwt.decode(
jwt_token.value, plexpy.CONFIG.JWT_SECRET, leeway=timedelta(seconds=10), algorithms=[JWT_ALGORITHM]
)
except (jwt.DecodeError, jwt.ExpiredSignatureError):
return None
return payload
def check_auth(*args, **kwargs):
"""A tool that looks in config for 'auth.require'. If found and it
is not None, a login is required and the entry is evaluated as a list of
conditions that the user must fulfill"""
conditions = cherrypy.request.config.get('auth.require', None)
if conditions is not None:
_session = cherrypy.session.get(SESSION_KEY)
payload = check_jwt_token()
if payload:
cherrypy.request.login = payload
if _session and (_session['user'] and _session['expiry']) and _session['expiry'] > datetime.now():
cherrypy.request.login = _session['user']
for condition in conditions:
# A condition is just a callable that returns true or false
if not condition():
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
else:
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/logout")
def requireAuth(*conditions):
"""A decorator that appends conditions to the auth.require config
variable."""
@@ -141,14 +174,13 @@ def requireAuth(*conditions):
#
# Define those at will however suits the application.
def member_of(groupname):
def check():
# replace with actual check if <username> is in <groupname>
return cherrypy.request.login == plexpy.CONFIG.HTTP_USERNAME and groupname == 'admin'
return check
def member_of(user_group):
return lambda: cherrypy.request.login and cherrypy.request.login['user_group'] == user_group
def name_is(user_name):
return lambda: cherrypy.request.login and cherrypy.request.login['user'] == user_name
def name_is(reqd_username):
return lambda: reqd_username == cherrypy.request.login
# These might be handy
@@ -161,6 +193,7 @@ def any_of(*conditions):
return False
return check
# By default all conditions are required, but this might still be
# needed if you want to use it inside of an any_of(...) condition
def all_of(*conditions):
@@ -176,13 +209,18 @@ def all_of(*conditions):
# Controller to provide login and logout actions
class AuthController(object):
def check_auth_enabled(self):
if not plexpy.CONFIG.HTTP_BASIC_AUTH and plexpy.CONFIG.HTTP_PASSWORD:
return
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
def on_login(self, user_id, username, user_group):
"""Called on successful login"""
# Save login to the database
ip_address = cherrypy.request.headers.get('X-Forwarded-For', cherrypy.request.headers.get('Remote-Addr'))
host = cherrypy.request.headers.get('Origin')
host = cherrypy.request.headers.get('Host', cherrypy.request.headers.get('Origin'))
user_agent = cherrypy.request.headers.get('User-Agent')
Users().set_user_login(user_id=user_id,
@@ -197,7 +235,7 @@ class AuthController(object):
def on_logout(self, username, user_group):
"""Called on logout"""
logger.debug(u"Tautulli WebAuth :: %s User '%s' logged out of Tautulli." % (user_group.capitalize(), username))
logger.debug(u"Tautulli WebAuth :: %s user '%s' logged out of Tautulli." % (user_group.capitalize(), username))
def on_login_failed(self, username):
"""Called on failed login"""
@@ -213,25 +251,48 @@ class AuthController(object):
user_agent=user_agent,
success=0)
def get_loginform(self, username="", msg=""):
def get_loginform(self):
from plexpy.webserve import serve_template
return serve_template(templatename="login.html", title="Login", username=escape(username, True), msg=msg)
return serve_template(templatename="login.html", title="Login")
@cherrypy.expose
def index(self):
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login")
@cherrypy.expose
def login(self, username=None, password=None, remember_me='0', admin_login='0'):
if not cherrypy.config.get('tools.sessions.on'):
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
def login(self):
self.check_auth_enabled()
if not username and not password:
return self.get_loginform()
(vaild_login, user_group) = check_credentials(username, password, admin_login)
return self.get_loginform()
if vaild_login:
@cherrypy.expose
def logout(self):
self.check_auth_enabled()
payload = check_jwt_token()
if payload:
self.on_logout(payload['user'], payload['user_group'])
jwt_cookie = JWT_COOKIE_NAME + plexpy.CONFIG.PMS_UUID
cherrypy.response.cookie[jwt_cookie] = 'expire'
cherrypy.response.cookie[jwt_cookie]['expires'] = 0
cherrypy.response.cookie[jwt_cookie]['path'] = '/'
cherrypy.request.login = None
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login")
@cherrypy.expose
@cherrypy.tools.json_out()
def signin(self, username=None, password=None, remember_me='0', admin_login='0'):
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.'}
valid_login, user_group = check_credentials(username, password, admin_login)
if valid_login:
if user_group == 'guest':
if re.match(r"[^@]+@[^@]+\.[^@]+", username):
user_details = Users().get_details(email=username)
@@ -242,35 +303,37 @@ class AuthController(object):
else:
user_id = None
expiry = datetime.now() + (timedelta(days=30) if remember_me == '1' else timedelta(minutes=60))
time_delta = timedelta(days=30) if remember_me == '1' else timedelta(minutes=60)
expiry = datetime.utcnow() + time_delta
cherrypy.request.login = username
cherrypy.session[SESSION_KEY] = {'user_id': user_id,
'user': username,
'user_group': user_group,
'expiry': expiry}
payload = {
'user_id': user_id,
'user': username,
'user_group': user_group,
'exp': expiry
}
jwt_token = jwt.encode(payload, plexpy.CONFIG.JWT_SECRET, algorithm=JWT_ALGORITHM)
self.on_login(user_id, username, user_group)
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
jwt_cookie = JWT_COOKIE_NAME + plexpy.CONFIG.PMS_UUID
cherrypy.response.cookie[jwt_cookie] = jwt_token
cherrypy.response.cookie[jwt_cookie]['expires'] = int(time_delta.total_seconds())
cherrypy.response.cookie[jwt_cookie]['path'] = '/'
cherrypy.request.login = payload
cherrypy.response.status = 200
return {'status': 'success', 'token': jwt_token.decode('utf-8'), 'uuid': plexpy.CONFIG.PMS_UUID}
elif admin_login == '1':
self.on_login_failed(username)
logger.debug(u"Tautulli WebAuth :: Invalid admin login attempt from '%s'." % username)
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
cherrypy.response.status = 401
return error_message
else:
self.on_login_failed(username)
logger.debug(u"Tautulli WebAuth :: Invalid login attempt from '%s'." % username)
return self.get_loginform(username, u"Incorrect username/email or password.")
@cherrypy.expose
def logout(self):
if not cherrypy.config.get('tools.sessions.on'):
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
_session = cherrypy.session.get(SESSION_KEY)
cherrypy.session[SESSION_KEY] = None
if _session and _session['user']:
cherrypy.request.login = None
self.on_logout(_session['user'], _session['user_group'])
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login")
cherrypy.response.status = 401
return error_message

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,8 @@ def initialize(options):
if enable_https:
# If either the HTTPS certificate or key do not exist, try to make self-signed ones.
if plexpy.CONFIG.HTTPS_CREATE_CERT and \
(not (https_cert and os.path.exists(https_cert)) or not (https_key and os.path.exists(https_key))):
(not (https_cert and os.path.exists(https_cert)) or
not (https_key and os.path.exists(https_key))):
if not create_https_certificates(https_cert, https_key):
logger.warn(u"Tautulli WebStart :: Unable to create certificate and key. Disabling HTTPS")
enable_https = False
@@ -67,16 +68,21 @@ def initialize(options):
protocol = "http"
if options['http_password']:
logger.info(u"Tautulli WebStart :: Web server authentication is enabled, username is '%s'", options['http_username'])
login_allowed = ["Tautulli admin (username is '%s')" % options['http_username']]
if plexpy.CONFIG.HTTP_PLEX_ADMIN:
login_allowed.append("Plex admin")
logger.info(u"Tautulli WebStart :: Web server authentication is enabled: %s.", ' and '.join(login_allowed))
if options['http_basic_auth']:
session_enabled = auth_enabled = False
auth_enabled = False
basic_auth_enabled = True
else:
options_dict['tools.sessions.on'] = session_enabled = auth_enabled = True
auth_enabled = True
basic_auth_enabled = False
cherrypy.tools.auth = cherrypy.Tool('before_handler', webauth.check_auth)
else:
session_enabled = auth_enabled = basic_auth_enabled = False
auth_enabled = basic_auth_enabled = False
if options['http_root'].strip('/'):
plexpy.HTTP_ROOT = options['http_root'] = '/' + options['http_root'].strip('/') + '/'
@@ -93,11 +99,6 @@ def initialize(options):
'tools.gzip.mime_types': ['text/html', 'text/plain', 'text/css',
'text/javascript', 'application/json',
'application/javascript'],
'tools.sessions.on': session_enabled,
'tools.session.name': 'tautulli_session_id-' + plexpy.CONFIG.PMS_UUID,
'tools.sessions.storage_type': 'file',
'tools.sessions.storage_path': plexpy.CONFIG.CACHE_DIR,
'tools.sessions.timeout': 30 * 24 * 60, # 30 days
'tools.auth.on': auth_enabled,
'tools.auth_basic.on': basic_auth_enabled,
'tools.auth_basic.realm': 'Tautulli web server',
@@ -121,6 +122,7 @@ def initialize(options):
'/images': {
'tools.staticdir.on': True,
'tools.staticdir.dir': "interfaces/default/images",
'tools.staticdir.content_types': {'svg': 'image/svg+xml'},
'tools.caching.on': True,
'tools.caching.force': True,
'tools.caching.delay': 0,