Compare commits

...

243 Commits

Author SHA1 Message Date
JonnyWong16
e9676e3651 v2.1.2-beta 2018-05-01 08:47:32 -07:00
JonnyWong16
c16d3288d8 Update Imgur and Cloudinary help text 2018-04-29 21:18:01 -07:00
JonnyWong16
0d7ade8ca4 Transform images on Cloudinary 2018-04-29 18:46:46 -07:00
JonnyWong16
87b1118e98 Add delete from Cloudinary 2018-04-29 17:49:53 -07:00
JonnyWong16
9f6422cc8d Fix Imgur poster lookup on imfo pages 2018-04-29 16:04:46 -07:00
JonnyWong16
df1a42a4ee Fix update metadata z-index 2018-04-29 15:15:57 -07:00
JonnyWong16
6554136a8f Add Cloudinary image hosting option 2018-04-29 00:11:47 -07:00
JonnyWong16
81e04269fd Remove ratelimit library 2018-04-29 00:10:58 -07:00
JonnyWong16
b6c6590a12 Update six v1.11.0 2018-04-28 21:44:34 -07:00
JonnyWong16
136260a822 Add cloudinary v1.11.0 2018-04-28 21:44:19 -07:00
JonnyWong16
5710bcb43c Hardcode Pushover sounds list (Fixes Tautulli/Tautulli-Issues#65) 2018-04-28 20:07:36 -07:00
JonnyWong16
30bc3f8a66 Fix incorrect {action} for new device (Fixes Tautulli/Tautulli-Issues#63) 2018-04-28 19:59:23 -07:00
JonnyWong16
e0e7d68df2 API success result for empty response data (Fixes Tautulli/Tautulli-Issues#56) 2018-04-28 18:39:42 -07:00
JonnyWong16
cf73639281 Fix Twitter notification with self-hosted images (Fixes Tautulli/Tautulli-Issues#54) 2018-04-28 18:23:10 -07:00
JonnyWong16
008e04d5cf Re-factor script timeout code 2018-04-28 18:12:43 -07:00
JonnyWong16
5f7991665c Only notify Tautulli updates when checked as a scheduled task (Fixes Tautulli/Tautulli-Issues#46) 2018-04-28 17:59:53 -07:00
JonnyWong16
5e000162c6 Merge branch 'nightly' of https://github.com/Tautulli/Tautulli into nightly 2018-04-28 17:54:18 -07:00
JonnyWong16
ea1aba2c87 Merge pull request #1286 from Dam64/patch-1
add Message-ID on mails
2018-04-28 17:54:02 -07:00
JonnyWong16
f321bb869c Add Plex Cloud sleeping message 2018-04-28 17:51:52 -07:00
JonnyWong16
abe496668a Fix typo in Activity Refresh Interval setting 2018-04-28 17:48:55 -07:00
Dam64
9cefc7f701 add Message-ID on mails 2018-04-27 01:17:18 +02:00
JonnyWong16
1d3cd431eb v2.1.1-beta 2018-04-11 21:59:39 -07:00
JonnyWong16
8f8318da6d Fix Imgur fallback to cover on newsletters 2018-04-11 21:42:14 -07:00
JonnyWong16
36ce751875 Explicit white font colour on newsletter cards 2018-04-11 12:04:00 -07:00
JonnyWong16
858ea33680 Fix fallback to cover for albums on newsletter 2018-04-11 11:58:57 -07:00
JonnyWong16
eee759d0d0 Log newsletter start time and end time in database 2018-04-10 22:44:11 -07:00
JonnyWong16
dbe3b492fd Check if the newsletter has data before saving the html file 2018-04-10 22:28:48 -07:00
JonnyWong16
4e4fde2e9a Move time frame to global newsletter configs 2018-04-10 21:34:18 -07:00
JonnyWong16
5283126608 Week number of the year 2018-04-10 21:31:25 -07:00
JonnyWong16
df72ecebf5 Add hours as time frame for newsletters 2018-04-10 21:16:16 -07:00
JonnyWong16
d316aa34e2 Merge pull request #1283 from samip5/week_number-patch
Adding the week number parameter
2018-04-10 20:17:07 -07:00
JonnyWong16
405aec8bb8 User helper for casting condition values 2018-04-10 19:57:40 -07:00
samip5
4a62f8c395 Fixed a typo. 2018-04-10 20:15:26 +03:00
samip5
eabea2deeb Made the requested changes.
The requested changes by JonnyWong16 in the PR request were done in this
commit.
2018-04-10 20:07:56 +03:00
samip5
3742021dcc Removed the non-needed imports. 2018-04-10 13:20:30 +03:00
samip5
9c4219b42e Edited newsletters.py
It wouldn't want to work without the edit.
2018-04-10 13:19:29 +03:00
samip5
f624908302 Added a new branch, edited the code to include the week_number 2018-04-10 12:10:32 +03:00
samip5
ab9132cdd4 Made sure the syntax is understandable. 2018-04-09 17:27:13 +03:00
samip5
0186363753 Added the week number parameter. 2018-04-09 17:20:51 +03:00
JonnyWong16
653ad36f17 Move transcode decision after live session override 2018-04-08 15:14:35 -07:00
JonnyWong16
5073f82d53 Another fix for Live TV stream transcode decision 2018-04-08 15:09:23 -07:00
JonnyWong16
833937eced Fix Live TV transcode details (Fixes Tautulli/Tautulli-Issues#45) 2018-04-08 11:16:43 -07:00
JonnyWong16
32df79bb83 Only sanitize script output when viewing the logs in the UI 2018-04-08 10:44:47 -07:00
JonnyWong16
fabced9942 Add tqdm v4.21.0 2018-04-08 10:44:04 -07:00
JonnyWong16
8aa34321c9 Add plexapi v3.0.6 2018-04-08 10:43:33 -07:00
JonnyWong16
b144ded87b v2.1.0-beta 2018-04-07 15:23:03 -07:00
JonnyWong16
ef8c91ee56 Fix new email config showing when HTML formatted email is unchecked 2018-04-07 15:22:29 -07:00
JonnyWong16
d76ded3ebe Remove "Notify on" from notification trigger text 2018-04-07 15:13:20 -07:00
JonnyWong16
c4fc94ea34 Fix unicode log errors 2018-04-07 10:35:28 -07:00
JonnyWong16
ad61e23d92 Don't fallback to art on newsletters 2018-04-05 19:35:37 -07:00
JonnyWong16
fcd7593764 Add temporary watched state for sessions 2018-04-04 22:05:30 -07:00
JonnyWong16
8465df5095 Replace Imgur with Tautulli hosted newsletter assets 2018-04-04 21:37:50 -07:00
JonnyWong16
95697a3367 Fix homepage max 50 recently added items 2018-04-04 21:37:14 -07:00
JonnyWong16
978ae7d8cb Note that HTML support must be enabled for eamil 2018-04-04 21:01:29 -07:00
JonnyWong16
366e8514b6 Fix centering last item on newsletter 2018-04-04 20:45:34 -07:00
JonnyWong16
45c646c062 Fix test newsletter warning message 2018-04-02 15:27:27 -07:00
JonnyWong16
4b482938a1 Make sure simple cron is set when saving 2018-04-02 15:01:07 -07:00
JonnyWong16
9699129a38 Catch invalid cron entry and reset to default 2018-04-02 14:50:01 -07:00
JonnyWong16
5ef8947532 Merge v2.0.28 into v2-newsletters 2018-04-02 14:33:52 -07:00
JonnyWong16
f335ffa8d5 v2.0.28 2018-04-02 14:27:34 -07:00
JonnyWong16
793665d62a Revert home activity header 2018-04-02 14:25:54 -07:00
JonnyWong16
7da5730c73 Fix activity header text 2018-04-02 14:21:34 -07:00
JonnyWong16
1f587ed698 v2.0.27 2018-04-02 14:17:48 -07:00
JonnyWong16
1032fdfe7a Move refresh interval setting back to the settings page 2018-04-02 14:13:52 -07:00
JonnyWong16
35e3f7dccc Change crontab anchor text 2018-04-02 14:10:52 -07:00
JonnyWong16
909cbc90df Add toggle for simple/custom crontab 2018-04-02 13:10:29 -07:00
JonnyWong16
77ed94bbef Separate newsletter message and body text 2018-04-02 11:12:37 -07:00
JonnyWong16
c260543586 Add custom cron to newsletter schedule 2018-04-02 10:17:51 -07:00
JonnyWong16
a4de63095f Fix View on Plex overlay 2018-03-30 17:45:50 -07:00
JonnyWong16
817335b42e Separate values in img hash 2018-03-30 13:44:36 -07:00
JonnyWong16
818e7723ff v2.0.26-beta 2018-03-30 09:29:47 -07:00
JonnyWong16
a69008e179 Send Telegram notification separately if caption is longer than 200 characters (Closes Tautulli/Tautulli-Issues#20) 2018-03-30 09:23:38 -07:00
JonnyWong16
91c647f9ae Show extra type on activity cards 2018-03-29 19:54:43 -07:00
JonnyWong16
36b80aa6d3 Make sure all datatables are using POST 2018-03-28 18:08:57 -07:00
JonnyWong16
c35fcc727c Change default refresh to 10 seconds 2018-03-27 22:08:12 -07:00
JonnyWong16
749e1fcebe Move refresh interval setting to homepage 2018-03-26 08:53:40 -07:00
JonnyWong16
80506b8541 Add login redirect uri 2018-03-25 14:26:47 -07:00
JonnyWong16
80df2b0fad Add Imgur rate limiting 2018-03-25 13:47:49 -07:00
JonnyWong16
084732706d Add setting to change homepage refresh interval 2018-03-25 13:25:18 -07:00
JonnyWong16
2aff7713cd Fix invalid link to playlist in sync table (Fixes Tautulli/Tautulli-Issues#34) 2018-03-25 12:39:20 -07:00
JonnyWong16
683a782723 Fix typo (Closes Tautulli/Tautulli-Issues#35) 2018-03-25 11:58:57 -07:00
JonnyWong16
5108e1bb09 Add quick websocket test when verifying server 2018-03-25 11:38:35 -07:00
JonnyWong16
d8298a12eb Clear PMS selectize when dropdown opens 2018-03-25 11:00:58 -07:00
JonnyWong16
dec5931fd4 Fix some typos 2018-03-25 10:12:03 -07:00
JonnyWong16
71d79266f6 Offload homepage image processing to the Plex server 2018-03-25 00:31:04 -07:00
JonnyWong16
d3f6812178 Merge branch 'nightly' into v2-newsletter 2018-03-24 23:35:21 -07:00
JonnyWong16
042b48c1fd Fix repeating renaming notifiers on startup 2018-03-24 23:32:53 -07:00
JonnyWong16
38613f24fe Scroll to setting position 2018-03-24 23:09:30 -07:00
JonnyWong16
e23b1a0603 Add self-hosted notificaation images 2018-03-24 22:09:37 -07:00
JonnyWong16
90f3d597dc Reorganize all Imgur info in database 2018-03-24 20:02:23 -07:00
JonnyWong16
d166b77ea9 Link to season or episode 2018-03-24 18:09:36 -07:00
JonnyWong16
feb74b157f Move custom message into the body of the newsletter 2018-03-24 14:53:21 -07:00
JonnyWong16
4aeafdae2d Add custom body line to formatted email 2018-03-24 14:34:47 -07:00
JonnyWong16
f12de78370 Add warning about Imgur or self-hosting enabled 2018-03-24 13:46:15 -07:00
JonnyWong16
d2415c92ea Add option to send to a newsletter message to other notification agents 2018-03-24 13:45:59 -07:00
JonnyWong16
646ca1d9fa Add genre badges 2018-03-24 11:58:06 -07:00
JonnyWong16
c8c93c69ab Align text left on newsletter cards for Outlook 2018-03-24 11:17:50 -07:00
JonnyWong16
2c8c20af02 Add link to view full newsletter 2018-03-24 10:30:07 -07:00
JonnyWong16
a877da3de8 Fix newsletter header image sizes 2018-03-24 09:45:27 -07:00
JonnyWong16
1b7cfd7f8a Fix helper import 2018-03-24 09:05:20 -07:00
JonnyWong16
3f7edc3635 Redo newsletter template using tables 2018-03-24 00:02:31 -07:00
JonnyWong16
8fac54aa71 Typo 2018-03-22 22:11:11 -07:00
JonnyWong16
244008d539 v2.0.25 2018-03-22 22:06:01 -07:00
JonnyWong16
502b807e45 Fix websocket not scheduling reconnect 2018-03-22 21:03:11 -07:00
JonnyWong16
35914b9a48 Remove unicode from websocket logger error 2018-03-22 20:32:37 -07:00
JonnyWong16
24ac34d5e2 Make sure user has Plex Pass if checking for synced stream 2018-03-22 19:39:46 -07:00
JonnyWong16
e1035a49fd Move newsletter loader css to main file 2018-03-21 19:57:55 -07:00
JonnyWong16
511f4a916b Automatically append HTTP root to newsletter URL 2018-03-21 19:18:49 -07:00
JonnyWong16
1f10668838 Allow additional parameters for newsletter preview 2018-03-21 13:05:46 -07:00
JonnyWong16
a9a08a959c Don't return error messages if missing newsletter files 2018-03-21 12:14:18 -07:00
JonnyWong16
341f4040ff Hash image with UUID 2018-03-21 08:23:05 -07:00
JonnyWong16
e9a1b2ea38 Add self-hosted static images 2018-03-21 00:10:39 -07:00
JonnyWong16
7f67213ff7 Store image hash for self-hosted newsletters 2018-03-20 22:54:36 -07:00
JonnyWong16
e9bdbb863c Make self-hosted option global 2018-03-20 11:24:12 -07:00
JonnyWong16
04641c7c63 Encode UTF-8 when saving newsletter 2018-03-20 10:51:33 -07:00
JonnyWong16
15cc96a005 As self-hosted config option 2018-03-20 10:39:33 -07:00
JonnyWong16
b712874ed2 Fix email config for newsletters 2018-03-20 10:07:06 -07:00
JonnyWong16
5b1ff402bc Queue notification instead of waiting for send 2018-03-20 09:15:41 -07:00
JonnyWong16
eda0e73eb6 Queue newsletters instead of waiting for send 2018-03-20 09:09:49 -07:00
JonnyWong16
a5807f21b4 Flush temporary sessions automatically if failed to check sessions on startup 2018-03-19 23:24:09 -07:00
JonnyWong16
e3b71a729e Revert negative operator values to "OR" (UI change only) 2018-03-19 23:18:27 -07:00
JonnyWong16
f810f50ea9 Adjust newsletter log table widths 2018-03-19 23:10:25 -07:00
JonnyWong16
2b0f83e036 Newsletter body text for test 2018-03-19 23:07:19 -07:00
JonnyWong16
4977b3def1 Remove trailing slash 2018-03-19 23:04:09 -07:00
JonnyWong16
1cb5f0b635 Add newsletter base URL setting 2018-03-19 22:57:38 -07:00
JonnyWong16
7e11af1fd0 Add body text to newsletters 2018-03-19 20:20:57 -07:00
JonnyWong16
6f6fb485fe Log message no libraries selected for newsletter 2018-03-19 09:21:40 -07:00
JonnyWong16
964f24d6ab Fix music background on newsletter 2018-03-19 09:15:06 -07:00
JonnyWong16
1474f144fe View sent newsletters without authentication 2018-03-18 23:01:23 -07:00
JonnyWong16
8d25b0c973 Open Sans for newsletters 2018-03-18 21:56:44 -07:00
JonnyWong16
50b37d6b3a Add newsletter logs table 2018-03-18 21:40:57 -07:00
JonnyWong16
b9b82b23f7 Save newsletters to html file 2018-03-18 21:02:39 -07:00
JonnyWong16
b6bd305694 Merge branch 'nightly' into v2-newsletter 2018-03-18 17:49:14 -07:00
JonnyWong16
ebb287e1ee v2.0.24 2018-03-18 17:46:17 -07:00
JonnyWong16
bd3497b2bf Rename notifiers in database 2018-03-18 17:44:24 -07:00
JonnyWong16
034f3ee308 Anon URL to FAQ for pycryptodome does not work with anchors 2018-03-18 17:33:02 -07:00
JonnyWong16
a946879fc1 Better OSX register button 2018-03-18 17:22:44 -07:00
JonnyWong16
9f964b5a87 Move notification agent instructions to wiki 2018-03-18 17:05:30 -07:00
JonnyWong16
2245e38d40 Retrieve newsletter using uuid 2018-03-18 00:45:27 -07:00
JonnyWong16
c9618322c2 Log newsletter start/end date and uuid 2018-03-17 23:58:39 -07:00
JonnyWong16
960e147e10 Update Arrow to 0.10.0 2018-03-17 23:08:06 -07:00
JonnyWong16
bbca0b3b42 Add newsletter template folder config option 2018-03-17 20:32:55 -07:00
JonnyWong16
ed0b41cd19 Add punctuation to Arnold 2018-03-17 18:44:15 -07:00
JonnyWong16
dc87591992 Show historical stream data (Fixes Tautulli/Tautulli-Issues#27) 2018-03-17 16:36:24 -07:00
JonnyWong16
1f7be7a4d5 Offload image processing to the Plex server 2018-03-17 14:03:27 -07:00
JonnyWong16
d05e80e573 Make sure all exisiting environment variables are included for scripts 2018-03-17 13:30:12 -07:00
JonnyWong16
003e890844 Some small fixes after rebasing 2018-03-17 11:04:17 -07:00
JonnyWong16
afa16cd656 Newsletter preview by default 2018-03-17 10:42:00 -07:00
JonnyWong16
9aff61f670 Add wait message to newsletter loader 2018-03-17 10:42:00 -07:00
JonnyWong16
8b1c7df3ce Add generating newsletter loader 2018-03-17 10:42:00 -07:00
JonnyWong16
25355f29ce Add server name to newsletter 2018-03-17 10:41:18 -07:00
JonnyWong16
09ea81ccd2 Refactor some newsletter code 2018-03-17 10:41:18 -07:00
JonnyWong16
28efaf73c7 Patch apscheduler weekday (Sunday=0) 2018-03-17 10:41:18 -07:00
JonnyWong16
0057481efb Add funcsigs 1.0.2 2018-03-17 10:41:18 -07:00
JonnyWong16
827b012978 Fix float clear for odd newsletter card 2018-03-17 10:41:18 -07:00
JonnyWong16
0e419695cf Change newsletter job name 2018-03-17 10:41:18 -07:00
JonnyWong16
46f26cc307 Add message for missing Pillow library 2018-03-17 10:41:18 -07:00
JonnyWong16
46f7a92c97 Schedule newsletters 2018-03-17 10:39:30 -07:00
JonnyWong16
2a24ea4cdf Patch apscheduler to use datetime.isoweekday() 2018-03-17 10:39:30 -07:00
JonnyWong16
8e13bf4f93 Update apscheduler 3.5.0 2018-03-17 10:36:04 -07:00
JonnyWong16
aa844b76fc Sort newsletter libraries when rendered 2018-03-17 10:36:04 -07:00
JonnyWong16
0e5bb7b188 Add select/remove all for newsletter libraries 2018-03-17 10:36:04 -07:00
JonnyWong16
49a6cf8809 Add newsletter star rating on hover 2018-03-17 10:36:04 -07:00
JonnyWong16
2adad24684 Fix import on newsletter templates 2018-03-17 10:36:04 -07:00
JonnyWong16
d4d5ff9de7 Add newsletter handler 2018-03-17 10:36:04 -07:00
JonnyWong16
33c2315384 Add view on Plex overlay on newsletter 2018-03-17 10:34:08 -07:00
JonnyWong16
4577704f19 Redo newsletter CSS 2018-03-17 10:34:08 -07:00
JonnyWong16
a13d93f239 Add selectize input for email for newsletters 2018-03-17 10:31:20 -07:00
JonnyWong16
5ac5b3cd29 Add email subject line and sending newsletters 2018-03-17 10:22:04 -07:00
JonnyWong16
d104ec216c Fix newsletter save new email config 2018-03-17 10:22:04 -07:00
JonnyWong16
32645c374e Change to include libraries instead of exclude 2018-03-17 10:22:04 -07:00
JonnyWong16
d1f982847b Add exclude libraries option to newsletter
* Remove individual media type toggles
2018-03-17 10:22:04 -07:00
JonnyWong16
7770431b67 Add email config to newsletters 2018-03-17 10:22:04 -07:00
JonnyWong16
edeb6ae4e4 Add season count to newsletter tv cards 2018-03-17 10:19:28 -07:00
JonnyWong16
af3501a6a6 Add view on Plex to newsletter 2018-03-17 10:19:28 -07:00
JonnyWong16
0f39201774 Initial newsletter support 2018-03-17 10:17:39 -07:00
JonnyWong16
b73d2ff1f7 Add shared libraries for admin user 2018-03-17 10:11:49 -07:00
JonnyWong16
6009fb24b6 Add server token and shared libraries to user refresh 2018-03-17 10:11:49 -07:00
JonnyWong16
522684b2ab v2.0.23-beta 2018-03-16 19:59:06 -07:00
JonnyWong16
feab16b351 Update API docs for get_server_id 2018-03-16 19:47:49 -07:00
JonnyWong16
ee041db63d Pass common environment variable to scripts 2018-03-16 18:37:50 -07:00
JonnyWong16
2479533d07 Show Plex Server URL in settings 2018-03-16 17:43:32 -07:00
JonnyWong16
d045fd5834 Update Facebook Graph API version 2018-03-16 15:39:41 -07:00
JonnyWong16
8407f27fed Add value3 to IFTTT notifications (Closes #1279) 2018-03-16 09:45:30 -07:00
JonnyWong16
b505286caf Add season/episode/album/track count to notification parameters 2018-03-16 09:42:32 -07:00
JonnyWong16
feb762ce8b Beta/nightly update check to include non-beta releases 2018-03-16 08:37:50 -07:00
JonnyWong16
8acdb5af83 Use media stream info for transcode decision (Fixes Tautulli/Tautulli-Issues#24) 2018-03-14 19:45:47 -07:00
JonnyWong16
5af1294f71 Make websocket thread daemon 2018-03-14 16:19:22 -07:00
JonnyWong16
87d2d273d3 Attempt at fixing custom condition json error 2018-03-13 22:16:23 -07:00
JonnyWong16
b5c52ac71e Add logging for failed custom condition json 2018-03-13 20:45:41 -07:00
JonnyWong16
efe9a15f72 Cast Email username/password to string 2018-03-13 20:41:07 -07:00
JonnyWong16
525f1e4b0b Use cherrypy remote for login IP info 2018-03-13 10:00:08 -07:00
JonnyWong16
d18820b832 Use cherrypy base for login host info 2018-03-13 09:42:01 -07:00
JonnyWong16
7e024fd736 Remove test comment in c9c5989 2018-03-13 09:09:27 -07:00
JonnyWong16
c9c5989474 Fix login logs for Plex admin user 2018-03-13 09:08:09 -07:00
JonnyWong16
ce9f96d3be Exit if failed to move database instead of continuing 2018-03-12 19:43:46 -07:00
JonnyWong16
7362dd0bf4 Close websocket cleanly on shutdown 2018-03-12 19:38:19 -07:00
JonnyWong16
9905ebc144 Don't empty results if message in API response (Fixes Tautulli/Tautulli-Issues#13) 2018-03-12 08:56:43 -07:00
JonnyWong16
8f8010884b Add git pull after checkout from interface 2018-03-12 08:20:20 -07:00
JonnyWong16
37afd141be Catch invalid json for custom conditions 2018-03-11 20:59:18 -07:00
JonnyWong16
a3643b4302 Fix typos 2018-03-10 20:54:21 -08:00
JonnyWong16
02cfd8d9b7 Fix git branch select box height 2018-03-10 20:33:18 -08:00
JonnyWong16
941ce439b4 Update API message for remote app settings 2018-03-10 18:03:23 -08:00
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
197 changed files with 32311 additions and 2318 deletions

2
.gitignore vendored
View File

@@ -15,7 +15,9 @@
release.lock release.lock
version.lock version.lock
logs/* logs/*
backups/*
cache/* cache/*
newsletters/*
*.mmdb *.mmdb
# HTTPS Cert/Key # # HTTPS Cert/Key #

4
API.md
View File

@@ -401,6 +401,7 @@ Returns:
"quality_profile": "Original", "quality_profile": "Original",
"rating": "7.8", "rating": "7.8",
"rating_key": "153037", "rating_key": "153037",
"relay": 0,
"section_id": "2", "section_id": "2",
"session_id": "helf15l3rxgw01xxe0jf3l3d", "session_id": "helf15l3rxgw01xxe0jf3l3d",
"session_key": "27", "session_key": "27",
@@ -1673,7 +1674,8 @@ Optional parameters:
remote (int): 0 or 1 remote (int): 0 or 1
Returns: Returns:
string: The unique PMS identifier json:
{'identifier': '08u2phnlkdshf890bhdlksghnljsahgleikjfg9t'}
``` ```

View File

@@ -1,5 +1,140 @@
# Changelog # Changelog
## v2.1.2-beta (2018-05-01)
* Newsletters:
* New: Added Cloudinary option for image hosting.
* Notifications:
* New: Added Message-ID to Email header (Thanks @Dam64)
* Fix: Posters not showing up on Twitter with self-hosted images.
* Fix: Incorrect action parameter for new device notifications.
* Change: Hardcode Pushover sound list instead of fetching the list every time.
* API:
* Fix: Success result for empty response data.
* Change: Do not send notification when checking for Tautulli updates via the API.
## v2.1.1-beta (2018-04-11)
* Monitoring:
* Fix: Live TV transcoding showing incorrectly as direct play.
* Newsletters:
* New: Added week number as parameter. (Thanks @samip5)
* Fix: Fallback to cover art on the newsletter cards.
* Change: Option to set newsletter time frame by calendar days or hours.
* Notifications:
* New: Added week number as parameter. (Thanks @samip5)
* Other:
* New: Added plexapi library for custom scripts.
## v2.1.0-beta (2018-04-07)
* Newsletters:
* New: A completely new scheduled newsletter system.
* Beautiful HTML formatted newsletter for recently added movies, TV shows, or music.
* Send newsletters on a daily, weekly, or monthly schedule to your users.
* Customize the number of days of recently added content and the libraries to include on the newsletter.
* Add a custom message to be included on the newsletter.
* Option to either send an HTML formatted email, or a link to a self-hosted newsletter on your own domain to any notification agent.
* Notifications:
* New: Ability to use self-hosted images on your own domain instead of using Imgur.
## v2.0.28 (2018-04-02)
* Monitoring:
* Fix: Homepage activity header text.
## v2.0.27 (2018-04-02)
* Monitoring:
* Change: Move activity refresh interval setting to the settings page.
## v2.0.26-beta (2018-03-30)
* Monitoring:
* New: Setting to change the refresh interval on the homepage.
* Fix: Identify extras correctly on the activity cards.
* Notifications:
* Change: Send Telegram image and text separately if the caption is longer than 200 characters.
* UI:
* Fix: Error when clicking on synced playlist links.
## v2.0.25 (2018-03-22)
* Monitoring:
* Fix: Websocket not reconnecting causing activity monitoring and notifications to not work.
* Fix: Error checking for synced streams without Plex Pass.
## v2.0.24 (2018-03-18)
* Monitoring:
* Fix: Fix stream data not showing for history recorded before v2.
* Notifications:
* Fix: Set all environment variables for scripts.
* Change: Moved all notification agent instructions to the wiki.
* Change: XBMC notification agent renamed to Kodi.
* Change: OSX Notify notification agent renamed to macOS Notification Center.
## v2.0.23-beta (2018-03-16)
* Monitoring:
* Fix: Certain transcode stream showing incorrectly as direct play in history. Fix is not retroactive.
* Notifications:
* New: Added season/episode/album/track count to notification parameters.
* New: Added "Value 3" setting for IFTTT notifications.
* New: Set PLEX_URL, PLEX_TOKEN, TAUTULLI_URL, and TAUTULLI_APIKEY environment variables for scripts.
* Fix: Notifications failing to send with invalid custom conditions json.
* Fix: Email notifications failing with unicode username/passwords.
* Change: Facebook Graph API version updated to v2.12.
* UI:
* New: Show the Plex Server URL in the settings.
* Fix: Incorrect info displayed in the Tautulli login logs.
* API:
* Fix: API returning empty data if a message was in the original data.
* Change: get_server_id command returns json instead of string.
* Other:
* Fix: Forgot git pull when changing branches in the web UI.
## 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) ## v2.0.20-beta (2018-02-24)
* Notifications: * Notifications:

235
PlexPy.py
View File

@@ -21,239 +21,8 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>. # along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
import os from Tautulli import main
import sys
# Ensure lib added to path, before any other imports # Call main() from Tautulli.py
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)
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__": if __name__ == "__main__":
main() main()

267
Tautulli.py Executable file
View File

@@ -0,0 +1,267 @@
#!/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)):
try:
os.rename(os.path.join(plexpy.DATA_DIR, 'plexpy.db'), plexpy.DB_FILE)
except OSError as e:
raise SystemExit("Unable to rename plexpy.db to tautulli.db: %s", e)
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

@@ -15,7 +15,7 @@
<meta name="author" content=""> <meta name="author" content="">
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet"> <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/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/opensans.min.css" rel="stylesheet">
<link href="${http_root}css/font-awesome.min.css" rel="stylesheet"> <link href="${http_root}css/font-awesome.min.css" rel="stylesheet">
${next.headIncludes()} ${next.headIncludes()}
@@ -47,7 +47,7 @@
You are running an unknown version of Tautulli.<br /> You are running an unknown version of Tautulli.<br />
<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a> <a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>
</div> </div>
% elif plexpy.CONFIG.CHECK_GITHUB and plexpy.COMMITS_BEHIND > 0 and plexpy.common.BRANCH in ('master', 'beta') and plexpy.common.VERSION_NUMBER != plexpy.LATEST_RELEASE: % 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;"> <div id="updatebar" style="display: none;">
A <a href="${anon_url('https://github.com/%s/%s/releases/tag/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.LATEST_RELEASE))}" target="_blank"> 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 /> new release (${plexpy.LATEST_RELEASE})</a> of Tautulli is available!<br />
@@ -227,15 +227,23 @@ ${next.modalIncludes()}
</div> </div>
</div> </div>
<ul id="donation_type" class="nav nav-pills" role="tablist" style="display: flex; justify-content: center; margin: 10px 0;"> <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 class="active"><a href="#patreon-donation" role="tab" data-toggle="tab">Patreon</a></li>
<li><a href="#flattr-donation" role="tab" data-toggle="tab">Flattr</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="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="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="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> <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> </ul>
<div class="tab-content"> <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> <p>
Click the button below to continue to PayPal. Click the button below to continue to PayPal.
</p> </p>
@@ -243,14 +251,6 @@ ${next.modalIncludes()}
<img src="images/gold-rect-paypal-34px.png" alt="PayPal"> <img src="images/gold-rect-paypal-34px.png" alt="PayPal">
</a> </a>
</div> </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/%s/%s&title=Tautulli&language=en_GB&tags=github&category=software' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank">
<img src="images/flattr-badge-large.png" alt="Flattr">
</a>
</div>
<div role="tabpanel" class="tab-pane" id="crypto-donation"> <div role="tabpanel" class="tab-pane" id="crypto-donation">
<label>QR Code</label> <label>QR Code</label>
<pre id="crypto_qr_code" style="text-align: center"></pre> <pre id="crypto_qr_code" style="text-align: center"></pre>

View File

@@ -49,6 +49,10 @@ DOCUMENTATION :: END
<td>Cache Directory:</td> <td>Cache Directory:</td>
<td>${plexpy.CONFIG.CACHE_DIR}</td> <td>${plexpy.CONFIG.CACHE_DIR}</td>
</tr> </tr>
<tr>
<td>Newsletter Directory:</td>
<td>${plexpy.CONFIG.NEWSLETTER_DIR}</td>
</tr>
<tr> <tr>
<td>GeoLite2 Database:</td> <td>GeoLite2 Database:</td>
% if plexpy.CONFIG.GEOIP_DB: % if plexpy.CONFIG.GEOIP_DB:

View File

@@ -66,7 +66,6 @@ div.form-control .selectize-input {
color: #fff; color: #fff;
border: 0px solid #444; border: 0px solid #444;
background: #555; background: #555;
height: 32px;
padding: 6px 12px; padding: 6px 12px;
background-color: #555; background-color: #555;
border-radius: 3px; border-radius: 3px;
@@ -92,6 +91,7 @@ div.form-control .selectize-input {
border-top-left-radius: 3px; border-top-left-radius: 3px;
border-bottom-left-radius: 3px; border-bottom-left-radius: 3px;
min-height: 32px !important; min-height: 32px !important;
height: 32px !important;
} }
.input-group .selectize-control.form-control.selectize-pms-ip .selectize-input > div { .input-group .selectize-control.form-control.selectize-pms-ip .selectize-input > div {
max-width: 450px; max-width: 450px;
@@ -125,8 +125,10 @@ div.form-control .selectize-input {
padding-bottom: 2px !important; padding-bottom: 2px !important;
transition: background-color .3s; transition: background-color .3s;
} }
.react-selectize.root-node .simple-value span { .react-selectize.root-node .simple-value span,
.selectize-control.multi .selectize-input > div {
padding-bottom: 2px !important; padding-bottom: 2px !important;
padding-left: 5px !important;
} }
.react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .value-wrapper:not(:first-child):before { .react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .value-wrapper:not(:first-child):before {
content: "or"; content: "or";
@@ -134,9 +136,6 @@ div.form-control .selectize-input {
text-transform: uppercase; text-transform: uppercase;
font-size: 10px; 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 { .react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .resizable-input {
padding-top: 3px !important; padding-top: 3px !important;
padding-bottom: 3px !important; padding-bottom: 3px !important;
@@ -467,6 +466,18 @@ fieldset[disabled] .btn-bright.active {
.btn-group select { .btn-group select {
margin-top: 0; margin-top: 0;
} }
.input-group-addon-form {
display: inline-block;
line-height: 1.42857143;
color: #e5e5e5;
background-color: #3B3B3B;
border: 1px solid transparent;
border-top-right-radius: 3px !important;
border-bottom-right-radius: 3px !important;
height: 32px;
width: 100%;
margin-top: 5px;
}
#user-selection label { #user-selection label {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -745,7 +756,10 @@ a .users-poster-face:hover {
transition: all .2s ease-in-out; transition: all .2s ease-in-out;
overflow: hidden; overflow: hidden;
} }
.dashboard-activity-background-overlay { .dashboard-activity-background {
background-color: #282828;
background-position: center;
background-size: cover;
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
-webkit-flex-wrap: nowrap; -webkit-flex-wrap: nowrap;
@@ -754,30 +768,13 @@ a .users-poster-face:hover {
width: 100%; width: 100%;
padding: 5px; padding: 5px;
overflow: hidden; overflow: hidden;
-webkit-box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
-moz-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1);
box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
}
.dashboard-activity-background {
background-color: #282828;
background-position: center;
background-size: cover;
height: 235px;
width: 100%;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0.40;
-webkit-filter: blur(3px);
-moz-filter: blur(3px);
filter: blur(3px);
-webkit-transition: background 1s linear; -webkit-transition: background 1s linear;
transition: background 1s linear; transition: background 1s linear;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
backface-visibility: hidden; backface-visibility: hidden;
z-index: -1; -webkit-box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
-moz-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1);
box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
} }
.dashboard-activity-poster-container { .dashboard-activity-poster-container {
background-color: #282828; background-color: #282828;
@@ -808,14 +805,14 @@ a .users-poster-face:hover {
background-size: cover; background-size: cover;
height: 225px; height: 225px;
width: 150px; width: 150px;
-webkit-transition: background .2s ease-in-out;
transition: background .2s ease-in-out;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
opacity: 0.60; opacity: 0.60;
-webkit-filter: blur(3px); -webkit-filter: blur(3px);
-moz-filter: blur(3px); -moz-filter: blur(3px);
filter: blur(3px); filter: blur(3px);
-webkit-transition: background .2s ease-in-out;
transition: background .2s ease-in-out;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
z-index: 2; z-index: 2;
} }
.dashboard-activity-cover { .dashboard-activity-cover {
@@ -1162,7 +1159,10 @@ a .dashboard-activity-metadata-user-thumb:hover {
transition: all .2s ease-in-out; transition: all .2s ease-in-out;
overflow: hidden; overflow: hidden;
} }
.dashboard-stats-background-overlay { .dashboard-stats-background {
background-color: #282828;
background-position: center;
background-size: cover;
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
-webkit-flex-wrap: nowrap; -webkit-flex-wrap: nowrap;
@@ -1171,30 +1171,13 @@ a .dashboard-activity-metadata-user-thumb:hover {
width: 100%; width: 100%;
padding: 5px; padding: 5px;
overflow: hidden; overflow: hidden;
-webkit-box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
-moz-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1);
box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
}
.dashboard-stats-background {
background-color: #282828;
background-position: center;
background-size: cover;
height: 160px;
width: 100%;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0.40;
-webkit-filter: blur(3px);
-moz-filter: blur(3px);
filter: blur(3px);
-webkit-transition: background .2s ease-in-out; -webkit-transition: background .2s ease-in-out;
transition: background .2s ease-in-out; transition: background .2s ease-in-out;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
backface-visibility: hidden; backface-visibility: hidden;
z-index: -1; -webkit-box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
-moz-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1);
box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
} }
.dashboard-stats-background.flat { .dashboard-stats-background.flat {
opacity: 1; opacity: 1;
@@ -1214,17 +1197,6 @@ a .dashboard-activity-metadata-user-thumb:hover {
z-index: 1; z-index: 1;
} }
.dashboard-stats-poster { .dashboard-stats-poster {
background-position: center;
background-size: cover;
height: 150px;
width: 100px;
-webkit-transition: background .2s ease-in-out;
transition: background .2s ease-in-out;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
z-index: 2;
}
.dashboard-stats-poster-blur {
background-color: #282828; background-color: #282828;
background-position: center; background-position: center;
background-size: cover; background-size: cover;
@@ -1234,10 +1206,6 @@ a .dashboard-activity-metadata-user-thumb:hover {
transition: background .2s ease-in-out; transition: background .2s ease-in-out;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
backface-visibility: hidden; backface-visibility: hidden;
opacity: 0.60;
-webkit-filter: blur(3px);
-moz-filter: blur(3px);
filter: blur(3px);
z-index: 2; z-index: 2;
} }
.dashboard-stats-cover { .dashboard-stats-cover {
@@ -1419,7 +1387,7 @@ a .dashboard-activity-metadata-user-thumb:hover {
} }
.dashboard-stats-info-item .sub-count { .dashboard-stats-info-item .sub-count {
height: 100%; height: 100%;
margin-left: 10px; margin-left: 5px;
color: #f9be03; color: #f9be03;
font-size: 12px; font-size: 12px;
text-align: right; text-align: right;
@@ -1430,7 +1398,7 @@ a .dashboard-activity-metadata-user-thumb:hover {
} }
.dashboard-stats-info-item .sub-divider { .dashboard-stats-info-item .sub-divider {
height: 100%; height: 100%;
margin-left: 10px; margin-left: 5px;
color: #aaa; color: #aaa;
font-size: 12px; font-size: 12px;
text-align: left; text-align: left;
@@ -2160,6 +2128,12 @@ a:hover .item-children-poster {
top: 5px; top: 5px;
left: 12px; left: 12px;
} }
.settings-warning {
color: #eb8600;
}
span.settings-warning {
padding-left: 10px;
}
#menu_link_show_advanced_settings.active { #menu_link_show_advanced_settings.active {
color: #fff; color: #fff;
background-color: #cc7b19; background-color: #cc7b19;
@@ -2372,21 +2346,6 @@ a .library-user-instance-box:hover {
#watched-stats-days-selection label { #watched-stats-days-selection label {
margin-bottom: 0; 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 { .home-padded-header {
margin: 25px 0; margin: 25px 0;
height: 34px; height: 34px;
@@ -2988,6 +2947,9 @@ a .home-platforms-list-cover-face:hover
.stacked-configs > li.new-notification-agent, .stacked-configs > li.new-notification-agent,
.stacked-configs > li.notification-agent, .stacked-configs > li.notification-agent,
.stacked-configs > li.add-notification-agent, .stacked-configs > li.add-notification-agent,
.stacked-configs > li.new-newsletter-agent,
.stacked-configs > li.newsletter-agent,
.stacked-configs > li.add-newsletter-agent,
.stacked-configs > li.mobile-device, .stacked-configs > li.mobile-device,
.stacked-configs > li.add-mobile-device { .stacked-configs > li.add-mobile-device {
cursor: pointer; cursor: pointer;
@@ -3435,22 +3397,10 @@ pre::-webkit-scrollbar-thumb {
.notification-params tr:nth-child(even) td { .notification-params tr:nth-child(even) td {
background-color: rgba(255,255,255,0.010); background-color: rgba(255,255,255,0.010);
} }
#days-selection label { #days-selection label,
margin-bottom: 0;
}
#graph-days {
margin: 0;
width: 75px;
height: 34px;
}
#months-selection label { #months-selection label {
margin-bottom: 0; margin-bottom: 0;
} }
#graph-months {
margin: 0;
width: 75px;
height: 34px;
}
.card-sortable { .card-sortable {
height: 36px; height: 36px;
padding: 0 20px 0 0; padding: 0 20px 0 0;
@@ -3684,43 +3634,77 @@ a:hover .overlay-refresh-image:hover {
} }
#plexpy-notifiers-table .friendly_name, #plexpy-notifiers-table .friendly_name,
#notifier-config-modal span.notifier_id, #notifier-config-modal span.notifier_id,
#plexpy-newsletters-table .friendly_name,
#newsletter-config-modal span.newsletter_id,
#plexpy-mobile-devices-table .friendly_name, #plexpy-mobile-devices-table .friendly_name,
#mobile-device-config-modal span.notifier_id { #mobile-device-config-modal span.notifier_id {
color: #777; color: #777;
} }
#notifier-config-modal .nav-tabs { #notifier-config-modal .nav-tabs,
#newsletter-config-modal .nav-tabs {
margin-bottom: 10px; margin-bottom: 10px;
padding-left: 15px; padding-left: 15px;
border-bottom: 1px solid #444; border-bottom: 1px solid #444;
} }
#notifier-config-modal .nav-tabs > li { #notifier-config-modal .nav-tabs > li,
#newsletter-config-modal .nav-tabs > li {
margin: 0 0 -1px 0; margin: 0 0 -1px 0;
} }
#notifier-config-modal .nav-tabs > li > a { #notifier-config-modal .nav-tabs > li > a,
#newsletter-config-modal .nav-tabs > li > a {
padding: 5px 10px; padding: 5px 10px;
color: #737373; color: #737373;
} }
#notifier-config-modal .nav-tabs > li > a:hover { #notifier-config-modal .nav-tabs > li > a:hover,
#newsletter-config-modal .nav-tabs > li > a:hover {
border-color: #444; border-color: #444;
background: #222; background: #222;
} }
#notifier-config-modal .nav-tabs > li.active > a, #notifier-config-modal .nav-tabs > li.active > a,
#notifier-config-modal .nav-tabs > li.active > a:hover, #notifier-config-modal .nav-tabs > li.active > a:hover,
#notifier-config-modal .nav-tabs > li.active > a:focus { #notifier-config-modal .nav-tabs > li.active > a:focus,
#newsletter-config-modal .nav-tabs > li.active > a,
#newsletter-config-modal .nav-tabs > li.active > a:hover,
#newsletter-config-modal .nav-tabs > li.active > a:focus {
color: #fff; color: #fff;
background: #222; background: #222;
} }
#notifier-config-modal .nav-tabs > li.active > a, #notifier-config-modal .nav-tabs > li.active > a,
#notifier-config-modal .nav-tabs > li.active > a:hover, #notifier-config-modal .nav-tabs > li.active > a:hover,
#notifier-config-modal .nav-tabs > li.active > a:focus { #notifier-config-modal .nav-tabs > li.active > a:focus,
#newsletter-config-modal .nav-tabs > li.active > a,
#newsletter-config-modal .nav-tabs > li.active > a:hover,
#newsletter-config-modal .nav-tabs > li.active > a:focus {
border: 1px solid #444; border: 1px solid #444;
border-bottom-color: transparent; border-bottom-color: transparent;
} }
#newsletter-config-modal #custom_cron {
display: inline-block;
width: initial;
height: 32px;
margin-right: 5px;
margin-top: 4px;
}
#newsletter-config-modal #cron-widget {
display: inline-block;
margin-top: 1px;
}
#newsletter-config-modal #cron-widget select.cron-select {
width: initial;
display: inline;
height: 32px;
margin-top: 4px;
}
#newsletter-config-modal #cron-widget select.cron-select[name=cron-period] option[value=minute],
#newsletter-config-modal #cron-widget select.cron-select[name=cron-period] option[value=hour] {
display: none !important;
}
.git-group input.form-control { .git-group input.form-control {
width: 50%; width: 50%;
} }
.git-group select.form-control { .git-group select.form-control {
width: 50%; width: 50%;
height: 32px;
} }
#changelog-modal .modal-body > h2 { #changelog-modal .modal-body > h2 {
margin-bottom: 10px; margin-bottom: 10px;
@@ -3867,6 +3851,90 @@ a:hover .overlay-refresh-image:hover {
background-color: #107c10; background-color: #107c10;
background-image: url(../images/platforms/xbox.svg); background-image: url(../images/platforms/xbox.svg);
} }
.platform-android-rgba {
background-color: rgba(164, 202, 57, 0.40);
}
.platform-atv-rgba {
background-color: rgba(133, 132, 135, 0.40);
}
.platform-chrome-rgba {
background-color: rgba(237, 94, 80, 0.40);
}
.platform-chromecast-rgba {
background-color: rgba(16, 164, 232, 0.40);
}
.platform-default-rgba {
background-color: rgba(229, 160, 13, 0.40);
}
.platform-dlna-rgba {
background-color: rgba(12, 177, 75, 0.40);
}
.platform-firefox-rgba {
background-color: rgba(230, 120, 23, 0.40);
}
.platform-gtv-rgba {
background-color: rgba(0, 139, 207, 0.40);
}
.platform-ie-rgba {
background-color: rgba(0, 89, 158, 0.40);
}
.platform-ios-rgba {
background-color: rgba(133, 132, 135, 0.40);
}
.platform-kodi-rgba {
background-color: rgba(49, 175, 225, 0.40);
}
.platform-linux-rgba {
background-color: rgba(23, 147, 208, 0.40);
}
.platform-macos-rgba {
background-color: rgba(133, 132, 135, 0.40);
}
.platform-msedge-rgba {
background-color: rgba(0, 120, 215, 0.40);
}
.platform-opera-rgba {
background-color: rgba(255, 27, 45, 0.40);
}
.platform-playstation-rgba {
background-color: rgba(3, 77, 162, 0.40);
}
.platform-plex-rgba {
background-color: rgba(229, 160, 13, 0.40);
}
.platform-plexamp-rgba {
background-color: rgba(229, 160, 13, 0.40);
}
.platform-roku-rgba {
background-color: rgba(109, 60, 151, 0.40);
}
.platform-safari-rgba {
background-color: rgba(0, 169, 236, 0.40);
}
.platform-samsung-rgba {
background-color: rgba(3, 78, 162, 0.40);
}
.platform-synclounge-rgba {
background-color: rgba(21, 25, 36, 0.40);
}
.platform-tivo-rgba {
background-color: rgba(0, 167, 225, 0.40);
}
.platform-wiiu-rgba {
background-color: rgba(3, 169, 244, 0.40);
}
.platform-windows-rgba {
background-color: rgba(47, 192, 245, 0.40);
}
.platform-wp-rgba {
background-color: rgba(104, 33, 122, 0.40);
}
.platform-xbmc-rgba {
background-color: rgba(59, 72, 114, 0.40);
}
.platform-xbox-rgba {
background-color: rgba(16, 124, 16, 0.40);
}
.library-movie { .library-movie {
background-image: url(../images/libraries/movie.svg); background-image: url(../images/libraries/movie.svg);
} }
@@ -3967,3 +4035,61 @@ a:hover .overlay-refresh-image:hover {
.stream-info tr:nth-child(even) td { .stream-info tr:nth-child(even) td {
background-color: rgba(255,255,255,0.010); background-color: rgba(255,255,255,0.010);
} }
.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;
}
.newsletter-time_frame .input-group-addon {
height: 32px;
width: 52px;
margin-top: 5px;
line-height: 1.42857143;
}
.newsletter-time_frame input.form-control {
width: calc(50% - 37px);
}
.newsletter-time_frame select.form-control {
width: calc(50% - 15px);
height: 32px;
}
.newsletter-loader-container {
font-family: 'Open Sans', Arial, sans-serif;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.newsletter-loader-message {
color: #282A2D;
text-align: center;
position: absolute;
left: 50%;
top: 25%;
transform: translate(-50%, -50%);
}
.newsletter-loader {
border: 5px solid #ccc;
-webkit-animation: spin 1s linear infinite;
animation: spin 1s linear infinite;
border-top: 5px solid #282A2D;
border-radius: 50%;
width: 50px;
height: 50px;
position: relative;
left: calc(50% - 25px);
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
a[data-tab-destination] {
cursor: pointer;
}

View File

@@ -64,7 +64,7 @@ DOCUMENTATION :: END
from collections import defaultdict from collections import defaultdict
from urllib import quote from urllib import quote
from plexpy import helpers from plexpy import helpers
from plexpy.common import VIDEO_RESOLUTION_OVERRIDES, AUDIO_CODEC_OVERRIDES from plexpy.common import VIDEO_RESOLUTION_OVERRIDES, AUDIO_CODEC_OVERRIDES, EXTRA_TYPES
import plexpy import plexpy
%> %>
<% <%
@@ -79,20 +79,19 @@ DOCUMENTATION :: END
<div class="dashboard-activity-instance" id="activity-instance-${sk}" data-key="${sk}" data-id="${data['session_id']}" <div class="dashboard-activity-instance" id="activity-instance-${sk}" data-key="${sk}" data-id="${data['session_id']}"
data-rating_key="${data['rating_key']}" data-parent_rating_key="${data['parent_rating_key']}" data-grandparent_rating_key="${data['grandparent_rating_key']}"> 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"> <div class="dashboard-activity-container">
<div class="dashboard-activity-background-overlay"> <%
% if data['channel_stream'] == 0: if data['channel_stream'] == 0:
<div id="background-${sk}" class="dashboard-activity-background" style="background-image: url(pms_image_proxy?img=${data['art']}&width=500&height=280&fallback=art&refresh=true);"></div> background_url = 'pms_image_proxy?img=' + data['art'] + '&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art&refresh=true'
% else: else:
% if (data['art'] and data['art'].startswith('http')) or (data['thumb'] and data['thumb'].startswith('http')): if (data['art'] and data['art'].startswith('http')) or (data['thumb'] and data['thumb'].startswith('http')):
<div id="background-${sk}" class="dashboard-activity-background" style="background-image: url(${data['art']});"></div> background_url = data['art']
% else: else:
<!--Hacky solution to escape the image url until I come up with something better--> background_url = 'pms_image_proxy?img=' + quote(data['art'] or data['thumb']) + '&width=500&height=280&fallback=art&refresh=true&clip=true'
<div id="background-${sk}" class="dashboard-activity-background" style="background-image: url(pms_image_proxy?img=${quote(data['art'] or data['thumb'])}&width=500&height=280&fallback=art&refresh=true&clip=true);"></div> %>
% endif <div id="background-${sk}" class="dashboard-activity-background" style="background-image: url(${background_url});">
% endif
<div class="dashboard-activity-poster-container hidden-xs"> <div class="dashboard-activity-poster-container hidden-xs">
% if data['media_type'] == 'track': % if data['media_type'] == 'track':
<div id="poster-${sk}-bg" class="dashboard-activity-poster-blur" style="background-image: url(pms_image_proxy?img=${data['parent_thumb']}&width=300&height=300&fallback=cover&refresh=true);"></div> <div id="poster-${sk}-bg" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['parent_thumb']}&width=300&height=300&opacity=60&background=282828&blur=3&fallback=cover&refresh=true);"></div>
% endif % endif
% if data['channel_stream'] == 0: % if data['channel_stream'] == 0:
% if data['media_type'] == 'movie': % if data['media_type'] == 'movie':
@@ -108,7 +107,11 @@ DOCUMENTATION :: END
<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> <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> </a>
% elif data['media_type'] in ('photo', 'clip'): % elif data['media_type'] in ('photo', 'clip'):
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['parent_thumb']}&width=300&height=450&fallback=poster&refresh=true);"></div> % if data['extra_type']:
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['art'].replace('/art', '/thumb') or data['thumb']}&width=300&height=450&fallback=poster&refresh=true);"></div>
% else:
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['parent_thumb'] or data['thumb']}&width=300&height=450&fallback=poster&refresh=true);"></div>
% endif
% else: % else:
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(images/art.png);"></div> <div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(images/art.png);"></div>
% endif % endif
@@ -117,7 +120,7 @@ DOCUMENTATION :: END
<div id="poster-${sk}" class="dashboard-activity-poster-blur" style="background-image: url(${data['channel_icon']});"></div> <div id="poster-${sk}" class="dashboard-activity-poster-blur" style="background-image: url(${data['channel_icon']});"></div>
<div id="poster-${sk}" class="dashboard-activity-cover" style="background-image: url(${data['channel_icon']});"></div> <div id="poster-${sk}" class="dashboard-activity-cover" style="background-image: url(${data['channel_icon']});"></div>
% else: % else:
<div id="poster-${sk}" class="dashboard-activity-poster-blur" style="background-image: url(pms_image_proxy?img=${data['channel_icon']}&width=300&height=300&fallback=cover&refresh=true);"></div> <div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['channel_icon']}&width=300&height=300&opacity=60&background=282828&blur=3&fallback=cover&refresh=true);"></div>
<div id="poster-${sk}" class="dashboard-activity-cover" style="background-image: url(pms_image_proxy?img=${data['channel_icon']}&width=300&height=300&fallback=cover&refresh=true);"></div> <div id="poster-${sk}" class="dashboard-activity-cover" style="background-image: url(pms_image_proxy?img=${data['channel_icon']}&width=300&height=300&fallback=cover&refresh=true);"></div>
% endif % endif
% endif % endif
@@ -279,16 +282,20 @@ DOCUMENTATION :: END
<span id="location-${sk}">${data['location'].upper()}</span>: <span id="location-${sk}">${data['location'].upper()}</span>:
% if data['ip_address'] != 'N/A': % if data['ip_address'] != 'N/A':
<span class="ip-container"><span class="ip-address">${data['ip_address']}</span></span> <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']}"> % if data['relay']:
<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> <span data-toggle="tooltip" title="Plex Relay"><i class="fa fa-exclamation-circle"></i></span>
</a> % else:
<script> <a href="#" class="external_ip-modal" data-toggle="modal" data-target="#ip-info-modal" data-ip="${data['ip_address']}">
isPrivateIP("${data['ip_address']}").then(function () { <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>
$("#external_ip-${sk}").hide(); </a>
}, function () { <script>
$("#external_ip-${sk}").show(); isPrivateIP("${data['ip_address']}").then(function () {
}); $("#external_ip-${sk}").hide();
</script> }, function () {
$("#external_ip-${sk}").show();
});
</script>
% endif
% else: % else:
N/A N/A
% endif % endif
@@ -297,14 +304,13 @@ DOCUMENTATION :: END
<li class="dashboard-activity-info-item"> <li class="dashboard-activity-info-item">
<div class="sub-heading">Bandwidth</div> <div class="sub-heading">Bandwidth</div>
<div class="sub-value time-right"> <div class="sub-value time-right">
% if data['media_type'] != 'photo' and helpers.cast_to_int(data['bandwidth']): % if data['media_type'] != 'photo' and data['bandwidth'] != 'Unknown':
<% <%
bw = helpers.cast_to_int(data['bandwidth']) bw = helpers.cast_to_int(data['bandwidth'])
if bw != "Unknown": if bw > 1000:
if bw > 1000: bw = str(round(bw / 1000.0, 1)) + ' Mbps'
bw = str(round(bw / 1000.0, 1)) + ' Mbps' else:
else: bw = str(bw) + ' kbps'
bw = str(bw) + ' kbps'
%> %>
<span id="stream-bandwidth-${sk}">${bw}</span> <span id="stream-bandwidth-${sk}">${bw}</span>
<span id="streaming-brain-${sk}" data-toggle="tooltip" title="Streaming Brain Estimate (Required Bandwidth)"><i class="fa fa-info-circle"></i></span> <span id="streaming-brain-${sk}" data-toggle="tooltip" title="Streaming Brain Estimate (Required Bandwidth)"><i class="fa fa-info-circle"></i></span>
@@ -436,7 +442,12 @@ DOCUMENTATION :: END
% elif data['media_type'] == 'photo': % elif data['media_type'] == 'photo':
<span title="${data['title']}" class="sub-heading">${data['title']}</span> <span title="${data['title']}" class="sub-heading">${data['title']}</span>
% else: % else:
<span title="${data['year']}" class="sub-heading">${data['year']}</span> % if data['extra_type']:
<% extra_type = EXTRA_TYPES.get(data['extra_type'], data['sub_type'].capitalize()) %>
<span title="${data['year']} (${extra_type})" class="sub-heading">${data['year']} (${extra_type})</span>
% else:
<span title="${data['year']}" class="sub-heading">${data['year']}</span>
% endif
% endif % endif
% elif data['channel_title']: % elif data['channel_title']:
<span title="${data['channel_title']}" class="sub-heading">${data['channel_title']}</span> <span title="${data['channel_title']}" class="sub-heading">${data['channel_title']}</span>

View File

@@ -2,7 +2,7 @@
<%def name="headIncludes()"> <%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css"> <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>
<%def name="body()"> <%def name="body()">
@@ -39,12 +39,12 @@
</div> </div>
<div class="input-group pull-right" style="width: 1px;" id="days-selection"> <div class="input-group pull-right" style="width: 1px;" id="days-selection">
<span class="input-group-addon btn-dark inactive">Last</span> <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> <span class="input-group-addon btn-dark inactive">days</span>
</div> </div>
<div class="input-group pull-right" style="width: 1px;" id="months-selection"> <div class="input-group pull-right" style="width: 1px;" id="months-selection">
<span class="input-group-addon btn-dark inactive">Last</span> <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> <span class="input-group-addon btn-dark inactive">months</span>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,7 @@
<%def name="headIncludes()"> <%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css"> <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/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>
<%def name="body()"> <%def name="body()">
@@ -113,7 +113,7 @@
// Load user ids and names (for the selector) // Load user ids and names (for the selector)
$.ajax({ $.ajax({
url: 'get_user_names', url: 'get_user_names',
type: 'get', type: 'GET',
dataType: 'json', dataType: 'json',
success: function (data) { success: function (data) {
var select = $('#history-user'); var select = $('#history-user');
@@ -130,6 +130,7 @@
function loadHistoryTable(media_type, selected_user_id) { function loadHistoryTable(media_type, selected_user_id) {
history_table_options.ajax = { history_table_options.ajax = {
url: 'get_history', url: 'get_history',
type: 'POST',
data: function (d) { data: function (d) {
return { return {
json_data: JSON.stringify(d), json_data: JSON.stringify(d),
@@ -163,7 +164,7 @@
} }
var media_type = null; var media_type = null;
var selected_user_id = "${_session['user_id']}" == "None" ? null : "${_session['user_id']}"; var selected_user_id = "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}";
loadHistoryTable(media_type, selected_user_id); loadHistoryTable(media_type, selected_user_id);
% if _session['user_group'] == 'admin': % if _session['user_group'] == 'admin':

View File

@@ -71,22 +71,21 @@ DOCUMENTATION :: END
%> %>
<div class="dashboard-stats-instance" id="stats-instance-${stat_id}" data-stat_id="${stat_id}"> <div class="dashboard-stats-instance" id="stats-instance-${stat_id}" data-stat_id="${stat_id}">
<div class="dashboard-stats-container"> <div class="dashboard-stats-container">
<div class="dashboard-stats-background-overlay"> % if stat_id in ('top_movies', 'popular_movies', 'top_tv', 'popular_tv', 'top_music', 'popular_music', 'last_watched'):
% if stat_id in ('top_movies', 'popular_movies', 'top_tv', 'popular_tv', 'top_music', 'popular_music', 'last_watched'): % if row0['art']:
% if row0['art']: <div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(pms_image_proxy?img=${row0['art']}&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art);">
<div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(pms_image_proxy?img=${row0['art']}&width=500&height=280&fallback=art);"></div> % else:
% else: <div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(images/art.png);">
<div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(images/art.png);"></div> % endif
% endif % elif stat_id == 'top_platforms':
% elif stat_id == 'top_platforms': <div id="stats-background-${stat_id}" class="dashboard-stats-background platform-${row0['platform_name']}-rgba no-image">
<div id="stats-background-${stat_id}" class="dashboard-stats-background platform-${row0['platform_name']} no-image"></div> % else:
% else: <div id="stats-background-${stat_id}" class="dashboard-stats-background flat">
<div id="stats-background-${stat_id}" class="dashboard-stats-background flat"></div> % endif
% endif
% if stat_id in ('top_movies', 'popular_movies', 'top_tv', 'popular_tv', 'top_music', 'popular_music', 'last_watched'): % if stat_id in ('top_movies', 'popular_movies', 'top_tv', 'popular_tv', 'top_music', 'popular_music', 'last_watched'):
<div class="dashboard-stats-poster-container hidden-xs"> <div class="dashboard-stats-poster-container hidden-xs">
% if stat_id in ('top_music', 'popular_music'): % 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> <div id="stats-thumb-${stat_id}-bg" class="dashboard-stats-poster" style="background-image: url(pms_image_proxy?img=${row0['thumb']}&width=300&height=300&opacity=60&background=282828&blur=3&fallback=cover);"></div>
% endif % endif
<% height, type = ('300', 'cover') if stat_id in ('top_music', 'popular_music') else ('450', '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 '#' %> <% href = 'info?rating_key={}'.format(row0['rating_key']) if row0['rating_key'] else '#' %>
@@ -200,7 +199,7 @@ DOCUMENTATION :: END
}).addClass('platform-' + $(elem).data('platform')); }).addClass('platform-' + $(elem).data('platform'));
$('#stats-background-' + stat_id).removeClass(function (index, className) { $('#stats-background-' + stat_id).removeClass(function (index, className) {
return (className.match (/(^|\s)platform-\S+/g) || []).join(' '); return (className.match (/(^|\s)platform-\S+/g) || []).join(' ');
}).addClass('platform-' + $(elem).data('platform')); }).addClass('platform-' + $(elem).data('platform') + '-rgba');
} else { } else {
if (rating_key) { if (rating_key) {
href = 'info?rating_key=' + rating_key; href = 'info?rating_key=' + rating_key;
@@ -209,13 +208,13 @@ DOCUMENTATION :: END
} }
$('#stats-thumb-url-' + stat_id).attr('href', href).prop('title', $(elem).data('title')); $('#stats-thumb-url-' + stat_id).attr('href', href).prop('title', $(elem).data('title'));
if (art) { if (art) {
$('#stats-background-' + stat_id).css('background-image', 'url(pms_image_proxy?img=' + art + '&width=500&height=280&fallback=art)'); $('#stats-background-' + stat_id).css('background-image', 'url(pms_image_proxy?img=' + art + '&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art)');
} else { } else {
$('#stats-background-' + stat_id).css('background-image', 'url(images/art.png)'); $('#stats-background-' + stat_id).css('background-image', 'url(images/art.png)');
} }
if (thumb) { if (thumb) {
$('#stats-thumb-' + stat_id).css('background-image', 'url(pms_image_proxy?img=' + thumb + '&width=300&height=' + height + '&fallback=' + fallback + ')'); $('#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 + ')'); $('#stats-thumb-' + stat_id + '-bg').css('background-image', 'url(pms_image_proxy?img=' + thumb + '&width=300&height=' + height + '&opacity=60&background=282828&blur=3&fallback=' + fallback + ')');
} else { } else {
$('#stats-thumb-' + 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)'); $('#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.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -22,7 +22,18 @@
</h3> </h3>
</div> </div>
<div id="currentActivity"> <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> <div class="text-muted" id="dashboard-checking-activity"><i class="fa fa-refresh fa-spin"></i> Checking for activity...</div>
% elif config['pms_is_cloud']:
<div id="dashboard-no-activity" class="text-muted">Plex Cloud server is sleeping.</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> </div>
</div> </div>
@@ -51,7 +62,7 @@
</div> </div>
<div class="input-group pull-left" style="width: 1px; margin-right: 3px" id="watched-stats-days-selection"> <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> <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" /> <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> <span class="input-group-addon btn-dark inactive">days</span>
</div> </div>
</div> </div>
@@ -114,7 +125,7 @@
</label> </label>
</div> </div>
<div class="input-group pull-left" style="width: 1px;" id="recently-added-count-selection"> <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" /> <input type="number" class="form-control number-input" name="recently-added-count" id="recently-added-count" value="${config['home_stats_recently_added_count']}" min="1" max="50" data-default="50" data-toggle="tooltip" title="Min: 1 item<br>Max: 50 items" />
<span class="input-group-addon btn-dark inactive">items</span> <span class="input-group-addon btn-dark inactive">items</span>
</div> </div>
</div> </div>
@@ -137,13 +148,13 @@
<%def name="modalIncludes()"> <%def name="modalIncludes()">
% if _session['user_group'] == 'admin' and config['update_show_changelog']: % if _session['user_group'] == 'admin' and config['update_show_changelog']:
<% from plexpy.common import VERSION_NUMBER %> <% from plexpy.common import RELEASE %>
<div id="changelog-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="changelog-modal"> <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-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button> <button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
<h4 class="modal-title">Tautulli Updated to <strong>${VERSION_NUMBER}</strong></h4> <h4 class="modal-title">Tautulli Updated to <strong>${RELEASE}</strong></h4>
</div> </div>
<div class="modal-body"> <div class="modal-body">
</div> </div>
@@ -241,9 +252,10 @@
}); });
} }
}); });
}; }
</script> </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> <script>
var defaultHandler = { var defaultHandler = {
get: function(target, name) { get: function(target, name) {
@@ -266,6 +278,7 @@
async: true, async: true,
error: function (xhr, status, error) { error: function (xhr, status, error) {
console.log(status + ': ' + error); console.log(status + ': ' + error);
activity_ready = true;
}, },
complete: function (xhr, status) { complete: function (xhr, status) {
$('#dashboard-checking-activity').remove(); $('#dashboard-checking-activity').remove();
@@ -280,9 +293,9 @@
if (!(current_activity)) { if (!(current_activity)) {
% if _session['user_group'] == 'admin': % 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: % else:
var msg_settings = '' var msg_settings = '';
% endif % endif
$('#currentActivityHeader').hide(); $('#currentActivityHeader').hide();
$('#currentActivity').html('<div id="dashboard-no-activity" class="text-muted">There was an error communicating with your Plex Server.' + msg_settings + '</div>'); $('#currentActivity').html('<div id="dashboard-no-activity" class="text-muted">There was an error communicating with your Plex Server.' + msg_settings + '</div>');
@@ -364,7 +377,7 @@
if (s.media_type === 'track') { if (s.media_type === 'track') {
// Update if artist changed // Update if artist changed
if (s.grandparent_rating_key !== instance.data('grandparent_rating_key')) { if (s.grandparent_rating_key !== instance.data('grandparent_rating_key')) {
$('#background-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.art + '&width=500&height=280&fallback=art&refresh=true)'); $('#background-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.art + '&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art&refresh=true)');
$('#metadata-grandparent_title-' + key) $('#metadata-grandparent_title-' + key)
.attr('href', 'info?rating_key=' + s.grandparent_rating_key) .attr('href', 'info?rating_key=' + s.grandparent_rating_key)
.attr('title', s.grandparent_title) .attr('title', s.grandparent_title)
@@ -373,7 +386,7 @@
// Update cover if album changed // Update cover if album changed
if (s.parent_rating_key !== instance.data('parent_rating_key')) { if (s.parent_rating_key !== instance.data('parent_rating_key')) {
$('#poster-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&fallback=poster&refresh=true)'); $('#poster-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&fallback=poster&refresh=true)');
$('#poster-' + key + '-bg').css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&fallback=poster&refresh=true)'); $('#poster-' + key + '-bg').css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&opacity=60&background=282828&blur=3&fallback=poster&refresh=true)');
$('#poster-url-' + key) $('#poster-url-' + key)
.attr('href', 'info?rating_key=' + s.parent_rating_key) .attr('href', 'info?rating_key=' + s.parent_rating_key)
.attr('title', s.parent_title); .attr('title', s.parent_title);
@@ -496,17 +509,15 @@
$('#location-' + key).html(s.location.toUpperCase()); $('#location-' + key).html(s.location.toUpperCase());
if (s.media_type !== 'photo' && parseInt(s.bandwidth)) { if (s.media_type !== 'photo' && s.bandwidth !== 'Unknown') {
var bw = parseInt(s.bandwidth); var bw = parseInt(s.bandwidth) || 0;
if (bw !== "Unknown") { if (bw > 1000) {
if (bw > 1000) { bw = (bw / 1000).toFixed(1) + ' Mbps';
bw = (bw / 1000).toFixed(1) + ' Mbps'; } else {
} else { bw = bw + ' kbps'
bw = bw + ' kbps'
}
} }
$('#stream-bandwidth-' + key).html(bw); $('#stream-bandwidth-' + key).html(bw);
} };
// Update the stream progress times // Update the stream progress times
$('#stream-eta-' + key).html(moment().add(parseInt(s.duration) - parseInt(s.view_offset), 'milliseconds').format(time_format)); $('#stream-eta-' + key).html(moment().add(parseInt(s.duration) - parseInt(s.view_offset), 'milliseconds').format(time_format));
@@ -578,7 +589,7 @@
if (!(create_instances.length) && activity_ready) { if (!(create_instances.length) && activity_ready) {
getCurrentActivity(); getCurrentActivity();
} }
}, 2000); }, ${config['home_refresh_interval'] * 1000});
setInterval(function(){ setInterval(function(){
$('.progress_time_offset').each(function () { $('.progress_time_offset').each(function () {
@@ -593,7 +604,7 @@
if ($(this).data('state') === 'playing' && $(this).data('view_offset') >= 0) { if ($(this).data('state') === 'playing' && $(this).data('view_offset') >= 0) {
var view_offset = parseInt($(this).data('view_offset')); var view_offset = parseInt($(this).data('view_offset'));
var stream_duration = parseInt($(this).data('stream_duration')); var stream_duration = parseInt($(this).data('stream_duration'));
var progress_percent = Math.min(Math.trunc(view_offset / stream_duration * 100), 100) var progress_percent = Math.min(Math.trunc(view_offset / stream_duration * 100), 100);
$(this).width(progress_percent - 3 + '%').html(progress_percent + '%') $(this).width(progress_percent - 3 + '%').html(progress_percent + '%')
.attr('data-original-title', 'Stream Progress ' + progress_percent + '%') .attr('data-original-title', 'Stream Progress ' + progress_percent + '%')
.data('view_offset', Math.min(view_offset + 1000, stream_duration)); .data('view_offset', Math.min(view_offset + 1000, stream_duration));

View File

@@ -64,7 +64,7 @@ DOCUMENTATION :: END
<%def name="headIncludes()"> <%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css"> <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/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>
<%def name="body()"> <%def name="body()">
@@ -400,14 +400,14 @@ DOCUMENTATION :: END
% if data.get('poster_url'): % if data.get('poster_url'):
<div class="btn-group"> <div class="btn-group">
% if data['media_type'] == 'artist' or data['media_type'] == 'album' or data['media_type'] == 'track': % if data['media_type'] == 'artist' or data['media_type'] == 'album' or data['media_type'] == 'track':
<span class="imgur-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="80" data-width="80" style="display: inline-flex;"> <span class="hosted-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="80" data-width="80" style="display: inline-flex;">
% else: % else:
<span class="imgur-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="120" data-width="80" style="display: inline-flex;"> <span class="hosted-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="120" data-width="80" style="display: inline-flex;">
% endif % endif
<button class="btn btn-danger btn-edit" data-toggle="modal" aria-pressed="false" autocomplete="off" id="delete-imgur-poster" <button class="btn btn-danger btn-edit" data-toggle="modal" aria-pressed="false" autocomplete="off" id="delete-hosted-poster"
data-id="${data['parent_rating_key'] if data['media_type'] in ('episode', 'track') else data['rating_key']}" data-id="${data['parent_rating_key'] if data['media_type'] in ('episode', 'track') else data['rating_key']}"
data-title="${data["poster_title"]}"> data-title="${data["poster_title"]}">
<i class="fa fa-picture-o"></i> Delete Imgur Poster <i class="fa fa-picture-o"></i> Delete ${data['img_service']} Poster
</button> </button>
</span> </span>
</div> </div>
@@ -547,12 +547,12 @@ DOCUMENTATION :: END
function get_history() { function get_history() {
history_table_options.ajax = { history_table_options.ajax = {
url: 'get_history', url: 'get_history',
type: 'post', type: 'POST',
data: function ( d ) { data: function ( d ) {
return { return {
json_data: JSON.stringify( d ), json_data: JSON.stringify( d ),
grandparent_rating_key: "${data['rating_key']}", grandparent_rating_key: "${data['rating_key']}",
user_id: "${_session['user_id']}" == "None" ? null : "${_session['user_id']}" user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}"
}; };
} }
} }
@@ -563,12 +563,12 @@ DOCUMENTATION :: END
function get_history() { function get_history() {
history_table_options.ajax = { history_table_options.ajax = {
url: 'get_history', url: 'get_history',
type: 'post', type: 'POST',
data: function ( d ) { data: function ( d ) {
return { return {
json_data: JSON.stringify( d ), json_data: JSON.stringify( d ),
parent_rating_key: "${data['rating_key']}", parent_rating_key: "${data['rating_key']}",
user_id: "${_session['user_id']}" == "None" ? null : "${_session['user_id']}" user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}"
}; };
} }
} }
@@ -579,12 +579,12 @@ DOCUMENTATION :: END
function get_history() { function get_history() {
history_table_options.ajax = { history_table_options.ajax = {
url: 'get_history', url: 'get_history',
type: 'post', type: 'POST',
data: function ( d ) { data: function ( d ) {
return { return {
json_data: JSON.stringify( d ), json_data: JSON.stringify( d ),
rating_key: "${data['rating_key']}", rating_key: "${data['rating_key']}",
user_id: "${_session['user_id']}" == "None" ? null : "${_session['user_id']}" user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}"
}; };
} }
} }
@@ -705,7 +705,7 @@ DOCUMENTATION :: END
</script> </script>
% if data.get('poster_url'): % if data.get('poster_url'):
<script> <script>
$('.imgur-poster-tooltip').popover({ $('.hosted-poster-tooltip').popover({
html: true, html: true,
container: 'body', container: 'body',
trigger: 'hover', trigger: 'hover',
@@ -716,14 +716,14 @@ DOCUMENTATION :: END
} }
}); });
$('#delete-imgur-poster').on('click', function () { $('#delete-hosted-poster').on('click', function () {
var msg = 'Are you sure you want to delete the Imgur poster for <strong>' + $(this).data('title') + '</strong>?<br><br>' + var msg = 'Are you sure you want to delete the ${data['img_service']} poster for <strong>' + $(this).data('title') + '</strong>?<br><br>' +
'All previous links to this image will no longer work.'; 'All previous links to this image will no longer work.';
var url = 'delete_imgur_poster'; var url = 'delete_hosted_images';
var data = { rating_key: $(this).data('id') }; var data = { rating_key: $(this).data('id') };
var callback = function () { var callback = function () {
$('.imgur-poster-tooltip').popover('destroy'); $('.hosted-poster-tooltip').popover('destroy');
$('#delete-imgur-poster').closest('.btn-group').remove(); $('#delete-hosted-poster').closest('.btn-group').remove();
}; };
confirmAjaxCall(url, msg, data, false, callback); confirmAjaxCall(url, msg, data, false, callback);
}); });

File diff suppressed because one or more lines are too long

View File

@@ -292,7 +292,11 @@ function millisecondsToMinutes(ms, roundToMinute) {
if (ms > 0) { if (ms > 0) {
var minutes = Math.floor(ms / 60000); var minutes = Math.floor(ms / 60000);
var seconds = ((ms % 60000) / 1000).toFixed(0); var seconds = ((ms % 60000) / 1000).toFixed(0);
return (seconds == 60 ? (minutes+1) + ":00" : minutes + ":" + (seconds < 10 ? "0" : "") + seconds); if (roundToMinute) {
return (seconds >= 30 ? (minutes + 1) : minutes);
} else {
return (seconds == 60 ? (minutes + 1) + ":00" : minutes + ":" + (seconds < 10 ? "0" : "") + seconds);
}
} else { } else {
if (roundToMinute) { if (roundToMinute) {
return '0'; return '0';

View File

@@ -54,7 +54,7 @@ media_info_table_options = {
} else if (rowData['media_type'] === 'album') { } 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>'; 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>'); $(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>'; 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>'); $(td).html('<div><a href="#"><div style="float: left;">' + expand_details + '&nbsp;' + date + '</div></a></div>');
} else { } else {
@@ -77,32 +77,44 @@ media_info_table_options = {
if (rowData['media_type'] === 'movie') { if (rowData['media_type'] === 'movie') {
if (rowData['year']) { parent_info = ' (' + rowData['year'] + ')'; } 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>'; 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>'); $(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') { } 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>'; 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>'); $(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') { } 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>'; 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>'); $(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') { } 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>'; 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>'); $(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') { } 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>'; 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>'); $(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') { } 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>'; 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>'); $(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') { } 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>'; 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>'); $(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 { } else {
$(td).html(cellData); $(td).html(cellData);
} }
@@ -335,7 +347,7 @@ function childTableOptionsMedia(rowData) {
case 'album': case 'album':
section_type = 'track'; section_type = 'track';
break; break;
case 'photo': case 'photo_album':
section_type = 'picture'; section_type = 'picture';
break; break;
} }

View File

@@ -0,0 +1,146 @@
newsletter_log_table_options = {
"destroy": true,
"serverSide": true,
"processing": false,
"pagingType": "full_numbers",
"order": [ 0, 'desc'],
"pageLength": 50,
"stateSave": true,
"language": {
"search":"Search: ",
"lengthMenu": "Show _MENU_ lines per page",
"emptyTable": "No log information available",
"info" :"Showing _START_ to _END_ of _TOTAL_ lines",
"infoEmpty": "Showing 0 to 0 of 0 lines",
"infoFiltered": "(filtered from _MAX_ total lines)",
"loadingRecords": '<i class="fa fa-refresh fa-spin"></i> Loading items...</div>'
},
"autoWidth": false,
"scrollX": true,
"columnDefs": [
{
"targets": [0],
"data": "timestamp",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(moment(cellData, "X").format('YYYY-MM-DD HH:mm:ss'));
}
},
"width": "10%",
"className": "no-wrap"
},
{
"targets": [1],
"data": "newsletter_id",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
}
},
"width": "5%",
"className": "no-wrap"
},
{
"targets": [2],
"data": "agent_name",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
}
},
"width": "5%",
"className": "no-wrap"
},
{
"targets": [3],
"data": "notify_action",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
}
},
"width": "5%",
"className": "no-wrap"
},
{
"targets": [4],
"data": "subject_text",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
}
},
"width": "23%"
},
{
"targets": [5],
"data": "body_text",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
}
},
"width": "35%"
},
{
"targets": [6],
"data": "start_date",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
}
},
"width": "5%"
},
{
"targets": [7],
"data": "end_date",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
}
},
"width": "5%"
},
{
"targets": [8],
"data": "uuid",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html('<a href="newsletter/' + rowData['uuid'] + '" target="_blank">' + cellData + '</a>');
}
},
"width": "5%"
},
{
"targets": [9],
"data": "success",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData === 1) {
$(td).html('<span class="success-tooltip" data-toggle="tooltip" title="Newsletter Sent"><i class="fa fa-lg fa-fw fa-check"></i></span>');
} else {
$(td).html('<span class="success-tooltip" data-toggle="tooltip" title="Newsletter Failed"><i class="fa fa-lg fa-fw fa-times"></i></span>');
}
},
"searchable": false,
"orderable": false,
"className": "no-wrap",
"width": "2%"
},
],
"drawCallback": function (settings) {
// Jump to top of page
//$('html,body').scrollTop(0);
$('#ajaxMsg').fadeOut();
// Create the tooltips.
$('body').tooltip({
selector: '[data-toggle="tooltip"]',
container: 'body'
});
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
showMsg(msg, false, false, 0)
}
};

View File

@@ -86,7 +86,7 @@ notification_log_table_options = {
"targets": [6], "targets": [6],
"data": "success", "data": "success",
"createdCell": function (td, cellData, rowData, row, col) { "createdCell": function (td, cellData, rowData, row, col) {
if (cellData == 1) { if (cellData === 1) {
$(td).html('<span class="success-tooltip" data-toggle="tooltip" title="Notification Sent"><i class="fa fa-lg fa-fw fa-check"></i></span>'); $(td).html('<span class="success-tooltip" data-toggle="tooltip" title="Notification Sent"><i class="fa fa-lg fa-fw fa-check"></i></span>');
} else { } else {
$(td).html('<span class="success-tooltip" data-toggle="tooltip" title="Notification Failed"><i class="fa fa-lg fa-fw fa-times"></i></span>'); $(td).html('<span class="success-tooltip" data-toggle="tooltip" title="Notification Failed"><i class="fa fa-lg fa-fw fa-times"></i></span>');
@@ -113,4 +113,4 @@ notification_log_table_options = {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows..."; var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
showMsg(msg, false, false, 0) showMsg(msg, false, false, 0)
} }
} };

View File

@@ -37,7 +37,6 @@ sync_table_options = {
"data": "state", "data": "state",
"createdCell": function (td, cellData, rowData, row, col) { "createdCell": function (td, cellData, rowData, row, col) {
if (cellData === 'pending') { if (cellData === 'pending') {
$(td).addClass('currentlyWatching');
$(td).html('Pending...'); $(td).html('Pending...');
} else { } else {
$(td).html(cellData.toProperCase()); $(td).html(cellData.toProperCase());
@@ -66,7 +65,7 @@ sync_table_options = {
"data": "sync_title", "data": "sync_title",
"createdCell": function (td, cellData, rowData, row, col) { "createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') { if (cellData !== '') {
if (rowData['metadata_type'] !== '') { if (rowData['rating_key']) {
$(td).html('<a href="info?rating_key=' + rowData['rating_key'] + '">' + cellData + '</a>'); $(td).html('<a href="info?rating_key=' + rowData['rating_key'] + '">' + cellData + '</a>');
} else { } else {
$(td).html(cellData); $(td).html(cellData);
@@ -74,7 +73,7 @@ sync_table_options = {
} }
}, },
"className": "datatable-wrap" "className": "datatable-wrap"
}, },
{ {
"targets": [4], "targets": [4],
"data": "metadata_type", "data": "metadata_type",
@@ -150,6 +149,11 @@ sync_table_options = {
"preDrawCallback": function (settings) { "preDrawCallback": function (settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows..."; var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
showMsg(msg, false, false, 0) showMsg(msg, false, false, 0)
},
"rowCallback": function (row, rowData, rowIndex) {
if (rowData['state'] === 'pending') {
$(row).addClass('current-activity-row');
}
} }
}; };

View File

@@ -3,7 +3,7 @@
<%def name="headIncludes()"> <%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css"> <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/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>
<%def name="body()"> <%def name="body()">
@@ -91,7 +91,7 @@
json_data: JSON.stringify(d) json_data: JSON.stringify(d)
}; };
} }
} };
libraries_list_table = $('#libraries_list_table').DataTable(libraries_list_table_options); libraries_list_table = $('#libraries_list_table').DataTable(libraries_list_table_options);
var colvis = new $.fn.dataTable.ColVis(libraries_list_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 1] }); var colvis = new $.fn.dataTable.ColVis(libraries_list_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 1] });

View File

@@ -30,7 +30,7 @@ DOCUMENTATION :: END
<%def name="headIncludes()"> <%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css"> <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/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>
<%def name="body()"> <%def name="body()">
@@ -374,12 +374,12 @@ DOCUMENTATION :: END
// Build watch history table // Build watch history table
history_table_options.ajax = { history_table_options.ajax = {
url: 'get_history', url: 'get_history',
type: 'post', type: 'POST',
data: function ( d ) { data: function ( d ) {
return { return {
json_data: JSON.stringify( d ), json_data: JSON.stringify( d ),
section_id: section_id, section_id: section_id,
user_id: "${_session['user_id']}" == "None" ? null : "${_session['user_id']}" user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}"
}; };
} }
}; };
@@ -406,7 +406,7 @@ DOCUMENTATION :: END
// Build media info table // Build media info table
media_info_table_options.ajax = { media_info_table_options.ajax = {
url: 'get_library_media_info', url: 'get_library_media_info',
type: 'post', type: 'POST',
data: function ( d ) { data: function ( d ) {
return { return {
json_data: JSON.stringify( d ), json_data: JSON.stringify( d ),

View File

@@ -29,14 +29,13 @@ DOCUMENTATION :: END
headers = {'movie': ('Movie Libraries', ('Movies', '', '')), headers = {'movie': ('Movie Libraries', ('Movies', '', '')),
'show': ('TV Show Libraries', ('Shows', 'Seasons', 'Episodes')), 'show': ('TV Show Libraries', ('Shows', 'Seasons', 'Episodes')),
'artist': ('Music Libraries', ('Artists', 'Albums', 'Tracks')), 'artist': ('Music Libraries', ('Artists', 'Albums', 'Tracks')),
'photo': ('Photo Libraries', ('Albums', '', 'Photos'))} 'photo': ('Photo Libraries', ('Albums', 'Photos', 'Videos'))}
%> %>
% for section_type in types: % for section_type in types:
% if section_type in data: % if section_type in data:
<div class="dashboard-stats-instance" id="library-stats-instance-${section_type}" data-section_type="${section_type}"> <div class="dashboard-stats-instance" id="library-stats-instance-${section_type}" data-section_type="${section_type}">
<div class="dashboard-stats-container"> <div class="dashboard-stats-container">
<div class="dashboard-stats-background-overlay"> <div id="library-stats-background-${section_type}" class="dashboard-stats-background" style="background-image: url(pms_image_proxy?img=/:/resources/${section_type}-fanart.jpg&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art);">
<div id="library-stats-background-${section_type}" class="dashboard-stats-background" style="background-image: url(pms_image_proxy?img=/:/resources/${section_type}-fanart.jpg&width=500&height=280&fallback=art);"></div>
<div id="library-stats-thumb-${section_type}" class="dashboard-stats-flat svg-icon library-${section_type} hidden-xs"></div> <div id="library-stats-thumb-${section_type}" class="dashboard-stats-flat svg-icon library-${section_type} hidden-xs"></div>
<div class="dashboard-stats-info-container"> <div class="dashboard-stats-info-container">
<div id="library-stats-title-${section_type}" class="dashboard-stats-info-title"> <div id="library-stats-title-${section_type}" class="dashboard-stats-info-title">

View File

@@ -9,7 +9,7 @@
<meta name="author" content=""> <meta name="author" content="">
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet"> <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/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/opensans.min.css" rel="stylesheet">
<link href="${http_root}css/font-awesome.min.css" rel="stylesheet"> <link href="${http_root}css/font-awesome.min.css" rel="stylesheet">
@@ -85,7 +85,7 @@
dataType: 'json', dataType: 'json',
statusCode: { statusCode: {
200: function() { 200: function() {
window.location = "${http_root}"; window.location = "${redirect_uri or http_root}";
}, },
401: function() { 401: function() {
$('#incorrect-login').show(); $('#incorrect-login').show();

View File

@@ -5,7 +5,7 @@
<%def name="headIncludes()"> <%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css"> <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> <style>
td {word-break: break-all;} td {word-break: break-all;}
</style> </style>
@@ -21,9 +21,9 @@
<span><i class="fa fa-list-alt"></i> Logs</span> <span><i class="fa fa-list-alt"></i> Logs</span>
</div> </div>
<div class="button-bar"> <div class="button-bar">
<div class="btn-group" id="plexpy-log-levels"> <div class="btn-group" id="tautulli-log-levels">
<label> <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 value="">All log levels</option>
<option disabled>&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;</option> <option disabled>&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;</option>
<option value="DEBUG">Debug</option> <option value="DEBUG">Debug</option>
@@ -45,28 +45,30 @@
</select> </select>
</label> </label>
</div> </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-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="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> <button class="btn btn-dark" id="clear-logs"><i class="fa fa-trash-o"></i> Clear logs</button>
<button class="btn btn-dark" id="clear-notify-logs" style="display: none;"><i class="fa fa-trash-o"></i> Clear logs</button> <button class="btn btn-dark" id="clear-notify-logs" style="display: none;"><i class="fa fa-trash-o"></i> Clear logs</button>
<button class="btn btn-dark" id="clear-newsletter-logs" style="display: none;"><i class="fa fa-trash-o"></i> Clear logs</button>
<button class="btn btn-dark" id="clear-login-logs" style="display: none;"><i class="fa fa-trash-o"></i> Clear logs</button> <button class="btn btn-dark" id="clear-login-logs" style="display: none;"><i class="fa fa-trash-o"></i> Clear logs</button>
</div> </div>
</div> </div>
<div class='table-card-back'> <div class='table-card-back'>
<div> <div>
<ul id="log_tabs" class="nav nav-pills" role="tablist"> <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" 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="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"><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-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="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="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="newsletter-logs-btn" href="#tabs-newsletter_log" aria-controls="tabs-newsletter_log" role="tab" data-toggle="tab">Newsletter 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> <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> </ul>
<div class="tab-content"> <div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="tabs-plexpy_log" data-logfile="plexpy"> <div role="tabpanel" class="tab-pane active" id="tabs-tautulli_log" data-logfile="tautulli">
<table class="display" id="plexpy_log_table" width="100%"> <table class="display" id="tautulli_log_table" width="100%">
<thead> <thead>
<tr> <tr>
<th class="min-tablet" align="left" id="timestamp">Timestamp</th> <th class="min-tablet" align="left" id="timestamp">Timestamp</th>
@@ -77,8 +79,8 @@
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
<div role="tabpanel" class="tab-pane" id="tabs-plexpy_api_log" data-logfile="plexpy_api"> <div role="tabpanel" class="tab-pane" id="tabs-tautulli_api_log" data-logfile="tautulli_api">
<table class="display" id="plexpy_api_log_table" width="100%"> <table class="display" id="tautulli_api_log_table" width="100%">
<thead> <thead>
<tr> <tr>
<th class="min-tablet" align="left" id="timestamp">Timestamp</th> <th class="min-tablet" align="left" id="timestamp">Timestamp</th>
@@ -141,6 +143,25 @@
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
<div role="tabpanel" class="tab-pane" id="tabs-newsletter_log">
<table class="display" id="newsletter_log_table" width="100%">
<thead>
<tr>
<th align="left" id="newsletter_timestamp">Timestamp</th>
<th align="left" id="newsletter_newsletter_id">Newsletter ID</th>
<th align="left" id="newsletter_agent_name">Agent</th>
<th align="left" id="newsletter_notify_action">Action</th>
<th align="left" id="newsletter_subject_text">Subject Text</th>
<th align="left" id="newsletter_body_text">Body Text</th>
<th align="left" id="newsletter_start_date">Start Date</th>
<th align="left" id="newsletter_end_date">End Date</th>
<th align="left" id="newsletter_uuid">UUID</th>
<th align="left" id="newsletter_success"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-login_log"> <div role="tabpanel" class="tab-pane" id="tabs-login_log">
<table class="display login_log_table" id="login_log_table" width="100%"> <table class="display login_log_table" id="login_log_table" width="100%">
<thead> <thead>
@@ -191,12 +212,13 @@
<script src="${http_root}js/tables/logs.js${cache_param}"></script> <script src="${http_root}js/tables/logs.js${cache_param}"></script>
<script src="${http_root}js/tables/plex_logs.js${cache_param}"></script> <script src="${http_root}js/tables/plex_logs.js${cache_param}"></script>
<script src="${http_root}js/tables/notification_logs.js${cache_param}"></script> <script src="${http_root}js/tables/notification_logs.js${cache_param}"></script>
<script src="${http_root}js/tables/newsletter_logs.js${cache_param}"></script>
<script src="${http_root}js/tables/login_logs.js${cache_param}"></script> <script src="${http_root}js/tables/login_logs.js${cache_param}"></script>
<script> <script>
$(document).ready(function() { $(document).ready(function() {
loadPlexPyLogs('plexpy', selected_log_level); loadtautullilogs('tautulli', selected_log_level);
clearSearchButton('plexpy_log_table', log_table); clearSearchButton('tautulli_log_table', log_table);
}); });
var log_levels = ['DEBUG', 'INFO', 'WARN', 'ERROR']; var log_levels = ['DEBUG', 'INFO', 'WARN', 'ERROR'];
@@ -227,10 +249,10 @@
} }
var selected_log_level = null; var selected_log_level = null;
function loadPlexPyLogs(logfile, selected_log_level) { function loadtautullilogs(logfile, selected_log_level) {
log_table_options.ajax = { log_table_options.ajax = {
url: "get_log", url: 'get_log',
type: 'post', type: 'POST',
data: function (d) { data: function (d) {
return { return {
logfile: logfile, logfile: logfile,
@@ -238,10 +260,10 @@
log_level: selected_log_level log_level: selected_log_level
}; };
} }
} };
log_table = $('#' + logfile + '_log_table').DataTable(log_table_options); 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; selected_log_level = $(this).val() || null;
log_table.draw(); log_table.draw();
}); });
@@ -249,131 +271,168 @@
function loadPlexLogs() { function loadPlexLogs() {
plex_log_table_options.ajax = { plex_log_table_options.ajax = {
url: "get_plex_log?log_type=server" url: 'get_plex_log?log_type=server',
} type: 'POST'
};
plex_log_table_options.initComplete = bindLogLevelFilter; plex_log_table_options.initComplete = bindLogLevelFilter;
plex_log_table = $('#plex_log_table').DataTable(plex_log_table_options); plex_log_table = $('#plex_log_table').DataTable(plex_log_table_options);
} }
function loadPlexScannerLogs() { function loadPlexScannerLogs() {
plex_log_table_options.ajax = { plex_log_table_options.ajax = {
url: "get_plex_log?log_type=scanner" url: 'get_plex_log?log_type=scanner',
} type: 'POST'
};
plex_log_table_options.initComplete = bindLogLevelFilter; plex_log_table_options.initComplete = bindLogLevelFilter;
plex_scanner_log_table = $('#plex_scanner_log_table').DataTable(plex_log_table_options); plex_scanner_log_table = $('#plex_scanner_log_table').DataTable(plex_log_table_options);
} }
function loadNotificationLogs() { function loadNotificationLogs() {
notification_log_table_options.ajax = { notification_log_table_options.ajax = {
url: "get_notification_log", url: 'get_notification_log',
type: 'POST',
data: function (d) { data: function (d) {
return { return {
json_data: JSON.stringify(d) json_data: JSON.stringify(d)
}; };
} }
} };
notification_log_table = $('#notification_log_table').DataTable(notification_log_table_options); notification_log_table = $('#notification_log_table').DataTable(notification_log_table_options);
} }
function loadNewsletterLogs() {
newsletter_log_table_options.ajax = {
url: "get_newsletter_log",
data: function (d) {
return {
json_data: JSON.stringify(d)
};
}
};
newsletter_log_table = $('#newsletter_log_table').DataTable(newsletter_log_table_options);
}
function loadLoginLogs() { function loadLoginLogs() {
login_log_table_options.pageLength = 50; login_log_table_options.pageLength = 50;
login_log_table_options.ajax = { login_log_table_options.ajax = {
url: "get_user_logins", url: 'get_user_logins',
type: 'POST',
data: function (d) { data: function (d) {
return { return {
json_data: JSON.stringify(d) json_data: JSON.stringify(d)
}; };
} }
} };
login_log_table = $('#login_log_table').DataTable(login_log_table_options); login_log_table = $('#login_log_table').DataTable(login_log_table_options);
} }
$("#plexpy-logs-btn").click(function () { $("#tautulli-logs-btn").click(function () {
$("#plexpy-log-levels").show(); $("#tautulli-log-levels").show();
$("#plex-log-levels").hide(); $("#plex-log-levels").hide();
$("#clear-logs").show(); $("#clear-logs").show();
$("#download-plexpylog").show() $("#download-tautullilog").show();
$("#download-plexserverlog").hide() $("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide() $("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide(); $("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide(); $("#clear-login-logs").hide();
loadPlexPyLogs('plexpy', selected_log_level); loadtautullilogs('tautulli', selected_log_level);
clearSearchButton('plexpy_log_table', log_table); clearSearchButton('tautulli_log_table', log_table);
}); });
$("#plexpy-api-logs-btn").click(function () { $("#tautulli-api-logs-btn").click(function () {
$("#plexpy-log-levels").show(); $("#tautulli-log-levels").show();
$("#plex-log-levels").hide(); $("#plex-log-levels").hide();
$("#clear-logs").show(); $("#clear-logs").show();
$("#download-plexpylog").show() $("#download-tautullilog").show();
$("#download-plexserverlog").hide() $("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide() $("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide(); $("#clear-notify-logs").hide();
$("#clear-login-logs").hide(); $("#clear-newsletter-logs").hide();
loadPlexPyLogs('plexpy_api', selected_log_level); $("#clear-login-logs").hide();
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 () { $("#plex-websocket-logs-btn").click(function () {
$("#plexpy-log-levels").show(); $("#tautulli-log-levels").show();
$("#plex-log-levels").hide(); $("#plex-log-levels").hide();
$("#clear-logs").show(); $("#clear-logs").show();
$("#download-plexpylog").show() $("#download-tautullilog").show();
$("#download-plexserverlog").hide() $("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide() $("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide(); $("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-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); clearSearchButton('plex_websocket_log_table', log_table);
}); });
$("#plex-logs-btn").click(function () { $("#plex-logs-btn").click(function () {
$("#plexpy-log-levels").hide(); $("#tautulli-log-levels").hide();
$("#plex-log-levels").show(); $("#plex-log-levels").show();
$("#clear-logs").hide(); $("#clear-logs").hide();
$("#download-plexpylog").hide() $("#download-tautullilog").hide();
$("#download-plexserverlog").show() $("#download-plexserverlog").show();
$("#download-plexscannerlog").hide() $("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide(); $("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide(); $("#clear-login-logs").hide();
loadPlexLogs(); loadPlexLogs();
clearSearchButton('plex_log_table', plex_log_table); clearSearchButton('plex_log_table', plex_log_table);
}); });
$("#plex-scanner-logs-btn").click(function () { $("#plex-scanner-logs-btn").click(function () {
$("#plexpy-log-levels").hide(); $("#tautulli-log-levels").hide();
$("#plex-log-levels").show(); $("#plex-log-levels").show();
$("#clear-logs").hide(); $("#clear-logs").hide();
$("#download-plexpylog").hide() $("#download-tautullilog").hide();
$("#download-plexserverlog").hide() $("#download-plexserverlog").hide();
$("#download-plexscannerlog").show() $("#download-plexscannerlog").show();
$("#clear-notify-logs").hide(); $("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide(); $("#clear-login-logs").hide();
loadPlexScannerLogs(); loadPlexScannerLogs();
clearSearchButton('plex_scanner_log_table', plex_scanner_log_table); clearSearchButton('plex_scanner_log_table', plex_scanner_log_table);
}); });
$("#notification-logs-btn").click(function () { $("#notification-logs-btn").click(function () {
$("#plexpy-log-levels").hide(); $("#tautulli-log-levels").hide();
$("#plex-log-levels").hide(); $("#plex-log-levels").hide();
$("#clear-logs").hide(); $("#clear-logs").hide();
$("#download-plexpylog").hide() $("#download-tautullilog").hide();
$("#download-plexserverlog").hide() $("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide() $("#download-plexscannerlog").hide();
$("#clear-notify-logs").show(); $("#clear-notify-logs").show();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide(); $("#clear-login-logs").hide();
loadNotificationLogs(); loadNotificationLogs();
clearSearchButton('notification_log_table', notification_log_table); clearSearchButton('notification_log_table', notification_log_table);
}); });
$("#login-logs-btn").click(function () { $("#newsletter-logs-btn").click(function () {
$("#plexpy-log-levels").hide(); $("#tautulli-log-levels").hide();
$("#plex-log-levels").hide(); $("#plex-log-levels").hide();
$("#clear-logs").hide(); $("#clear-logs").hide();
$("#download-plexpylog").hide() $("#download-tautullilog").hide();
$("#download-plexserverlog").hide() $("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide() $("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide(); $("#clear-notify-logs").hide();
$("#clear-newsletter-logs").show();
$("#clear-login-logs").hide();
loadNewsletterLogs();
clearSearchButton('newsletter_log_table', newsletter_log_table);
});
$("#login-logs-btn").click(function () {
$("#tautulli-log-levels").hide();
$("#plex-log-levels").hide();
$("#clear-logs").hide();
$("#download-tautullilog").hide();
$("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").show(); $("#clear-login-logs").show();
loadLoginLogs(); loadLoginLogs();
clearSearchButton('login_log_table', notification_log_table); clearSearchButton('login_log_table', notification_log_table);
@@ -384,8 +443,8 @@
}); });
$("#clear-logs").click(function () { $("#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() var title = $("#log_tabs li.active a").text();
$("#confirm-message").text("Are you sure you want to clear the " + title + "?"); $("#confirm-message").text("Are you sure you want to clear the " + title + "?");
$('#confirm-modal').modal(); $('#confirm-modal').modal();
@@ -397,7 +456,7 @@
complete: function (xhr, status) { complete: function (xhr, status) {
result = $.parseJSON(xhr.responseText); result = $.parseJSON(xhr.responseText);
msg = result.message; msg = result.message;
if (result.result == 'success') { if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000) showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
} else { } else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true) showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
@@ -408,7 +467,7 @@
}); });
}); });
$("#download-plexpylog").click(function () { $("#download-tautullilog").click(function () {
var logfile = $(".tab-pane.active").data('logfile'); var logfile = $(".tab-pane.active").data('logfile');
window.location.href = "download_log?logfile=" + logfile; window.location.href = "download_log?logfile=" + logfile;
}); });
@@ -431,7 +490,7 @@
complete: function (xhr, status) { complete: function (xhr, status) {
result = $.parseJSON(xhr.responseText); result = $.parseJSON(xhr.responseText);
msg = result.message; msg = result.message;
if (result.result == 'success') { if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000) showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
} else { } else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true) showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
@@ -442,6 +501,27 @@
}); });
}); });
$("#clear-newsletter-logs").click(function () {
$("#confirm-message").text("Are you sure you want to clear the Tautulli Newsletter Logs?");
$('#confirm-modal').modal();
$('#confirm-modal').one('click', '#confirm-button', function () {
$.ajax({
url: 'delete_newsletter_log',
type: 'POST',
complete: function (xhr, status) {
result = $.parseJSON(xhr.responseText);
msg = result.message;
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)
}
newsletter_log_table.draw();
}
});
});
});
$("#clear-login-logs").click(function () { $("#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').modal();
@@ -452,7 +532,7 @@
complete: function (xhr, status) { complete: function (xhr, status) {
result = $.parseJSON(xhr.responseText); result = $.parseJSON(xhr.responseText);
msg = result.message; msg = result.message;
if (result.result == 'success') { if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000) showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
} else { } else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true) showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
@@ -473,10 +553,10 @@
{ {
clearInterval(timer); clearInterval(timer);
} }
if(refreshrate.value != 0) if(refreshrate.value !== 0)
{ {
timer = setInterval(function() { 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(); log_table.ajax.reload();
} else if ($("#tabs-plex_log").hasClass("active")) { } else if ($("#tabs-plex_log").hasClass("active")) {
plex_log_table.ajax.reload(); plex_log_table.ajax.reload();

View File

@@ -55,7 +55,7 @@ DOCUMENTATION :: END
}) })
} }
return deferred; return deferred;
} };
function checkQRAddress(url) { function checkQRAddress(url) {
var parser = document.createElement('a'); var parser = document.createElement('a');
@@ -82,7 +82,7 @@ DOCUMENTATION :: END
verifiedDevice = false; verifiedDevice = false;
getPlexPyURL().then(function (url) { getPlexPyURL().then(function (url) {
checkQRAddress(url) checkQRAddress(url);
$.get('generate_api_key', { device: true }).then(function (token) { $.get('generate_api_key', { device: true }).then(function (token) {
$('#api_qr_address').val(url); $('#api_qr_address').val(url);
@@ -120,7 +120,7 @@ DOCUMENTATION :: END
$('#api_qr_address').change(function () { $('#api_qr_address').change(function () {
var url = $(this).val(); var url = $(this).val();
checkQRAddress(url) checkQRAddress(url);
$('#api_qr_code').empty().qrcode({ $('#api_qr_code').empty().qrcode({
text: url + '|' + $('#api_qr_token').val() text: url + '|' + $('#api_qr_token').val()

View File

@@ -0,0 +1,739 @@
% if newsletter:
<%!
import json
from plexpy import notifiers
from plexpy.helpers import anon_url, checked
all_notifiers = sorted(notifiers.get_notifiers(), key=lambda k: (k['agent_label'].lower(), k['friendly_name'], k['id']))
email_notifiers = [n for n in all_notifiers if n['agent_name'] == 'email']
email_notifiers = [{'id': 0, 'agent_label': 'New Email Configuration', 'friendly_name': ''}] + email_notifiers
other_notifiers = [{'id': 0, 'agent_label': 'Select a Notification Agent', 'friendly_name': ''}] + all_notifiers
%>
<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" id="newsletter-config-modal-header">${newsletter['agent_label']} Newsletter Settings &nbsp;<small><span class="newsletter_id">(Newsletter ID: ${newsletter['id']})</span></small></h4>
</div>
<div class="modal-body">
<div class="container-fluid">
<div class="row">
<ul class="nav nav-tabs list-unstyled" role="tablist">
<li role="presentation" class="active"><a href="#tabs-newsletter_config" aria-controls="tabs-newsletter_config" role="tab" data-toggle="tab">Configuration</a></li>
<li role="presentation"><a href="#tabs-newsletter_agent" aria-controls="tabs-newsletter_agent" role="tab" data-toggle="tab">Notification Agent</a></li>
<li role="presentation"><a href="#tabs-newsletter_text" aria-controls="tabs-newsletter_text" role="tab" data-toggle="tab">Newsletter Text</a></li>
<li role="presentation"><a href="#tabs-test_newsletter" aria-controls="tabs-test_newsletter" role="tab" data-toggle="tab">Test Newsletter</a></li>
</ul>
</div>
<form action="set_newsletter_config" method="post" class="form" id="set_newsletter_config" data-parsley-validate>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="tabs-newsletter_config">
<div class="row">
<div class="col-md-12">
<div class="checkbox" style="margin-bottom: 20px;">
<label>
<input type="checkbox" data-id="active_value" class="checkboxes" value="1" ${checked(newsletter['active'])}> Enable the Newsletter
</label>
<input type="hidden" id="active_value" name="active" value="${newsletter['active']}">
</div>
<div class="form-group">
<label for="custom_cron">Schedule</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="custom_cron" name="newsletter_config_custom_cron">
<option value="0" ${'selected' if newsletter['config']['custom_cron'] == 0 else ''}>Simple</option>
<option value="1" ${'selected' if newsletter['config']['custom_cron'] == 1 else ''}>Custom</option>
</select>
<input type="text" id="cron_value" name="cron" value="${newsletter['cron']}" />
<div id="cron-widget"></div>
</div>
</div>
<p class="help-block">
<span id="simple_cron_message">Set the schedule for the newsletter.</span>
<span id="custom_cron_message">Set the schedule for the newsletter using a <a href="${anon_url('https://crontab.guru')}" target="_blank">custom crontab</a>. Only standard cron values are valid.</span>
</p>
</div>
<div class="form-group">
<label for="time_frame">Time Frame</label>
<div class="row">
<div class="col-md-4">
<div class="input-group newsletter-time_frame">
<span class="input-group-addon form-control btn-dark inactive">Last</span>
<input type="number" class="form-control" id="newsletter_config_time_frame" name="newsletter_config_time_frame" value="${newsletter['config']['time_frame']}">
<select class="form-control" id="newsletter_config_time_frame_units" name="newsletter_config_time_frame_units">
<option value="days" ${'selected' if newsletter['config']['time_frame_units'] == 'days' else ''}>days</option>
<option value="hours" ${'selected' if newsletter['config']['time_frame_units'] == 'hours' else ''}>hours</option>
</select>
</div>
</div>
</div>
<p class="help-block">Set the time frame to include in the newsletter. Note: Days uses calendar days (i.e. since midnight).</p>
</div>
</div>
<div class="col-md-12" style="padding-top: 10px; border-top: 1px solid #444;">
<input type="hidden" id="newsletter_id" name="newsletter_id" value="${newsletter['id']}" />
<input type="hidden" id="agent_id" name="agent_id" value="${newsletter['agent_id']}" />
% for item in newsletter['config_options']:
% if item['input_type'] == 'help':
<div class="form-group">
<label>${item['label']}</label>
<p class="help-block">${item['description'] | n}</p>
</div>
% elif item['input_type'] == 'text' or item['input_type'] == 'password':
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<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 ''}>
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
</div>
% elif item['input_type'] == 'number':
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-3">
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30">
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
</div>
% elif item['input_type'] == 'button':
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-12">
<input type="button" class="btn btn-bright" id="${item['name']}" name="${item['name']}" value="${item['value']}">
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
</div>
% elif item['input_type'] == 'checkbox':
<div class="checkbox">
<label>
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${checked(item['value'])}> ${item['label']}
</label>
<p class="help-block">${item['description'] | n}</p>
<input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}">
</div>
% elif item['input_type'] == 'select':
<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']}">
% for key, value in sorted(item['select_options'].iteritems()):
% if key == item['value']:
<option value="${key}" selected>${value}</option>
% else:
<option value="${key}">${value}</option>
% endif
% endfor
</select>
</div>
</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>
<div class="col-md-12" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #444;">
<div class="form-group">
<label for="friendly_name">Description</label>
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" id="friendly_name" name="friendly_name" value="${newsletter['friendly_name']}" size="30">
</div>
</div>
<p class="help-block">Optional: Enter a description to help identify this newsletter in the newsletters list.</p>
</div>
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-newsletter_agent">
<div class="row">
<div class="col-md-12">
<div class="checkbox">
<label>
<input type="checkbox" id="newsletter_config_formatted_checkbox" data-id="newsletter_config_formatted" class="checkboxes" value="1" ${checked(newsletter['config']['formatted'])}> Send newsletter as an HTML formatted Email
</label>
<p class="help-block">Enable to send the newsletter as an HTML formatted Email. Disable to only send a subject and body message to a different notification agent.</p>
<input type="hidden" id="newsletter_config_formatted" name="newsletter_config_formatted" value="${newsletter['config']['formatted']}">
</div>
<div class="form-group" id="email_notifier_select">
<label for="newsletter_email_notifier_id">Email Notification Agent</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="newsletter_email_notifier_id" name="newsletter_email_notifier_id">
% for notifier in email_notifiers:
<% selected = 'selected' if notifier['id'] == newsletter['email_config']['notifier_id'] else '' %>
% if notifier['friendly_name']:
<option value="${notifier['id']}" ${selected}>${notifier['agent_label']} (${notifier['id']} - ${notifier['friendly_name']})</option>
% elif notifier['id']:
<option value="${notifier['id']}" ${selected}>${notifier['agent_label']} (${notifier['id']})</option>
% else:
<option value="${notifier['id']}" ${selected}>${notifier['agent_label']}</option>
% endif
% endfor
</select>
</div>
</div>
<p class="help-block">
Select an existing Email notification agent or enter a new configuration below.<br>
Note: Make sure HTML support is enabled for the Email notification agent.
</p>
</div>
<div class="form-group" id="other_notifier_select">
<label for="newsletter_config_notifier_id">Notification Agent</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="newsletter_config_notifier_id" name="newsletter_config_notifier_id">
% for notifier in other_notifiers:
<% selected = 'selected' if notifier['id'] == newsletter['config']['notifier_id'] else '' %>
% if notifier['friendly_name']:
<option value="${notifier['id']}" ${selected}>${notifier['agent_label']} (${notifier['id']} - ${notifier['friendly_name']})</option>
% elif notifier['id']:
<option value="${notifier['id']}" ${selected}>${notifier['agent_label']} (${notifier['id']})</option>
% else:
<option value="${notifier['id']}" ${selected}>${notifier['agent_label']}</option>
% endif
% endfor
</select>
</div>
</div>
<p class="help-block">
Select an existing notification agent where the subject and body text will be sent.<br>
Note: Self-hosted newsletters must be enabled under <a data-tab-destination="tabs-notifications" data-dismiss="modal" data-target="#newsletter_self_hosted">Newsletters</a> to include a link to the newsletter.
</p>
</div>
</div>
<div id="newsletter-email-config" class="col-md-12" style="padding-top: 10px; border-top: 1px solid #444;">
% for item in newsletter['email_config_options']:
% if item['input_type'] == 'help':
<div class="form-group">
<label>${item['label']}</label>
<p class="help-block">${item['description'] | n}</p>
</div>
% elif item['input_type'] == 'text' or item['input_type'] == 'password':
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<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 ''}>
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
</div>
% elif item['input_type'] == 'number':
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-3">
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30">
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
</div>
% elif item['input_type'] == 'button':
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-12">
<input type="button" class="btn btn-bright" id="${item['name']}" name="${item['name']}" value="${item['value']}">
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
</div>
% elif item['input_type'] == 'checkbox' and item['name'] != 'newsletter_email_html_support':
<div class="checkbox">
<label>
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${checked(item['value'])}> ${item['label']}
</label>
<p class="help-block">${item['description'] | n}</p>
<input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}">
</div>
% elif item['input_type'] == 'select':
<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']}">
% for key, value in sorted(item['select_options'].iteritems()):
% if key == item['value']:
<option value="${key}" selected>${value}</option>
% else:
<option value="${key}">${value}</option>
% endif
% endfor
</select>
</div>
</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
<input type="hidden" id="newsletter_email_html_support" name="newsletter_email_html_support" value="1">
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-newsletter_text">
<label>Newsletter Text</label>
<p class="help-block">
Set the custom formatted text for each type of notification.
<a href="#newsletter-text-sub-modal" data-toggle="modal">Click here</a> for a list of available parameters which can be used.
</p>
<p class="help-block">
You can also add text modifiers to change the case or slice parameters with a list of items.
<a href="#notify-text-modifiers-modal" data-toggle="modal">Click here</a> to view usage information.
</p>
<div class="row">
<div class="col-md-12">
<div class="form-group">
<label for="subject">Subject</label>
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" id="subject" name="subject" value="${newsletter['subject']}" size="30">
</div>
</div>
<p class="help-block">
Enter a custom subject line for the newsletter. Leave blank for default.
</p>
</div>
<div class="form-group" id="newsletter_body">
<label for="body">Body</label>
<div class="row">
<div class="col-md-12">
<textarea class="form-control" id="body" name="body" data-autoresize>${newsletter['body']}</textarea>
</div>
</div>
<p class="help-block">
Enter a custom body line for the newsletter notification. Leave blank for default.
</p>
</div>
<div class="form-group">
<label for="message">Message</label>
<div class="row">
<div class="col-md-12">
<textarea class="form-control" id="message" name="message" data-autoresize>${newsletter['message']}</textarea>
</div>
</div>
<p class="help-block">
Enter a custom message to include on the newsletter.
</p>
</div>
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-test_newsletter">
<label>Preview Newsletter</label>
<p class="help-block">
Preview the ${newsletter['agent_label']} newsletter.
</p>
<div class="form-group">
<div class="row">
<div class="col-md-12">
<input type="button" class="btn btn-bright" id="preview_newsletter" name="preview_newsletter" value="Preview ${newsletter['agent_label']} Newsletter">
</div>
</div>
</div>
<label>Test Newsletter</label>
<p class="help-block">
Test if the ${newsletter['agent_label']} newsletter is working. Check the <a href="logs">logs</a> for troubleshooting.
</p>
<p class="help-block">
Warning: This will send an actual newsletter to your notification agent!
</p>
<div class="form-group">
<div class="row">
<div class="col-md-12">
<input type="button" class="btn btn-bright" id="test_newsletter" name="test_newsletter" value="Test ${newsletter['agent_label']} Newsletter">
</div>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
<div class="modal-footer">
<input type="button" id="delete-newsletter-item" class="btn btn-danger btn-edit" style="float:left;" value="Delete">
<input type="button" id="duplicate-newsletter-item" class="btn btn-dark btn-edit" style="float:left;" value="Duplicate">
<input type="button" id="save-newsletter-item" class="btn btn-bright" value="Save">
</div>
</div>
</div>
<script src="${http_root}js/jquery-cron-min.js"></script>
<script>
$('#newsletter-config-modal').unbind('hidden.bs.modal');
var cron_widget = $('#cron-widget').cron({
initial: '0 0 * * 0',
classes: 'form-control cron-select',
onChange: function() {
$("#cron_value").val($(this).cron('value'));
}
});
if (${newsletter['config']['custom_cron']}) {
$('#cron_value').val('${newsletter['cron']}');
} else {
try {
cron_widget.cron('value', '${newsletter['cron']}');
} catch (e) {}
}
function toggleCustomCron() {
if ($('#custom_cron').val() === '1'){
$('#cron-widget').hide();
$('#cron_value').show();
$('#simple_cron_message').hide();
$('#custom_cron_message').show();
} else {
$('#cron-widget').show();
$('#cron_value').hide();
$('#simple_cron_message').show();
$('#custom_cron_message').hide();
}
}
toggleCustomCron();
$('#custom_cron').change(function () {
toggleCustomCron();
});
var $incl_libraries = $('#newsletter_config_incl_libraries').selectize({
plugins: ['remove_button'],
maxItems: null,
render: {
option: function(item) {
if (item.value.endsWith('-all')) {
return '<div class="' + item.value + '">' + item.text + '</div>'
}
return '<div>' + item.text + '</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();
}
}
});
var incl_libraries = $incl_libraries[0].selectize;
incl_libraries.setValue(${json.dumps(next((c['value'] for c in newsletter['config_options'] if c['name'] == 'newsletter_config_incl_libraries'), [])) | n});
function toggleEmailSelect () {
if ($('#newsletter_config_formatted_checkbox').is(':checked')) {
$('#newsletter_body').hide();
$('#email_notifier_select').show();
$('#other_notifier_select').hide();
toggleNewEmailConfig();
} else {
$('#newsletter_body').show();
$('#email_notifier_select').hide();
$('#other_notifier_select').show();
$('#newsletter-email-config').hide();
}
}
toggleEmailSelect();
$('#newsletter_config_formatted_checkbox').change(function () {
toggleEmailSelect();
});
function toggleNewEmailConfig () {
if ($('#newsletter_config_formatted_checkbox').is(':checked') && $('#newsletter_email_notifier_id').val() === '0') {
$('#newsletter-email-config').show();
} else {
$('#newsletter-email-config').hide();
}
}
toggleNewEmailConfig();
$('#newsletter_email_notifier_id').change(function () {
toggleNewEmailConfig();
});
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 = $('#newsletter_email_to, #newsletter_email_cc, #newsletter_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 newsletter['email_config_options'] if c['name'] == 'newsletter_email_to'), [])) | n});
email_cc.setValue(${json.dumps(next((c['value'] for c in newsletter['email_config_options'] if c['name'] == 'newsletter_email_cc'), [])) | n});
email_bcc.setValue(${json.dumps(next((c['value'] for c in newsletter['email_config_options'] if c['name'] == 'newsletter_email_bcc'), [])) | n});
function reloadModal() {
$.ajax({
url: 'get_newsletter_config_modal',
data: { newsletter_id: '${newsletter["id"]}' },
cache: false,
async: true,
complete: function (xhr, status) {
$('#newsletter-config-modal').html(xhr.responseText);
}
});
}
function saveCallback(jqXHR) {
if (jqXHR) {
var result = $.parseJSON(jqXHR.responseText);
var msg = result.message;
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)
}
}
getNewslettersTable();
}
function deleteCallback() {
$('#newsletter-config-modal').modal('hide');
getNewslettersTable();
}
function duplicateCallback(result) {
// Set new newsletter id
$('#newsletter_id').val(result.newsletter_id);
// Clear friendly name
$('#friendly_name').val("");
saveNewsletter();
$('#newsletter-config-modal').on('hidden.bs.modal', function () {
loadNewsletterConfig(result.newsletter_id);
});
$('#newsletter-config-modal').modal('hide');
}
function saveNewsletter() {
// Trim all text inputs before saving
$('input[type=text]').val(function(_, value) {
return $.trim(value);
});
// Make sure simple cron value is set
if ($('#custom_cron').val() === '0'){
$("#cron_value").val(cron_widget.cron('value'));
}
doAjaxCall('set_newsletter_config', $(this), 'tabs', true, true, saveCallback);
}
$('#delete-newsletter-item').click(function () {
var msg = 'Are you sure you want to delete this <strong>${newsletter["agent_label"]}</strong> newsletter?';
var url = 'delete_newsletter';
confirmAjaxCall(url, msg, { newsletter_id: '${newsletter["id"]}' }, null, deleteCallback);
});
$('#duplicate-newsletter-item').click(function() {
var msg = 'Are you sure you want to duplicate this <strong>${newsletter["agent_label"]}</strong> newsletter?';
var url = 'add_newsletter_config';
confirmAjaxCall(url, msg, { agent_id: '${newsletter["agent_id"]}' }, null, duplicateCallback);
});
$('#save-newsletter-item').click(function () {
saveNewsletter();
});
$('#preview_newsletter').click(function () {
doAjaxCall('set_newsletter_config', $(this), 'tabs', true, false, previewNewsletter);
});
$('#test_newsletter').click(function () {
doAjaxCall('set_newsletter_config', $(this), 'tabs', true, false, sendTestNewsletter);
});
function previewNewsletter() {
showMsg('<i class="fa fa-check"></i>&nbsp; Check pop-up blocker if no response.', false, true, 2000);
window.open('newsletter_preview?newsletter_id=' + $('#newsletter_id').val());
}
function sendTestNewsletter() {
showMsg('<i class="fa fa-refresh fa-spin"></i>&nbsp; Sending Newsletter', false);
$.ajax({
url: 'send_newsletter',
data: {
newsletter_id: $('#newsletter_id').val(),
notify_action: 'test'
},
cache: false,
async: true,
success: function (data) {
if (data.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + data.message, false, true, 5000);
} else {
showMsg('<i class="fa fa-exclamation-circle"></i> ' + data.message, false, true, 5000, true);
}
}
});
}
$("${', '.join(['#' + c['name'] for c in newsletter['config_options'] if c.get('refresh')])}").on('change', function () {
// Reload modal to update certain fields
doAjaxCall('set_newsletter_config', $(this), 'tabs', true, false, reloadModal);
return false;
});
// Never send checkbox values directly, always substitute value in hidden input.
$('.checkboxes').click(function () {
var configToggle = $(this).data('id');
if ($(this).is(':checked')) {
$('#'+configToggle).val(1);
} else {
$('#'+configToggle).val(0);
}
});
// auto resizing textarea for custom notification message body
$('textarea[data-autoresize]').each(function () {
var offset = this.offsetHeight - this.clientHeight;
var resizeTextarea = function (el) {
$(el).css('height', 'auto').css('height', el.scrollHeight + offset);
};
$(this).on('focus keyup input', function () { resizeTextarea(this); }).removeAttr('data-autoresize');
});
</script>
% else:
<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" id="newsletter-config-modal-header">Error</h4>
</div>
<div class="modal-body" style="text-align: center">
<strong>
<i class="fa fa-exclamation-circle"></i> Failed to retrieve newsletter configuration. Check the <a href="logs">logs</a> for more info.
</strong>
</div>
<div class="modal-footer">
</div>
</div>
</div>
% endif

View File

@@ -0,0 +1,48 @@
<%
import urllib
%>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Tautulli - ${title} | ${server_name}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
<style>
* {
padding: 0 !important;
margin: 0 !important;
}
</style>
</head>
<body>
<div id="loader" class="newsletter-loader-container">
<div class="newsletter-loader-message">
<div class="newsletter-loader"></div>
<br>
Generating Newsletter
<br>
Please wait, this may take a few minutes...
</div>
</div>
<script src="${http_root}js/jquery-2.1.4.min.js"></script>
<script>
$(document).ready(function () {
var frame = $('<iframe></iframe>', {
src: '${http_root}real_newsletter?${urllib.urlencode(kwargs) | n}',
frameborder: '0',
style: 'display: none; height: 100vh; width: 100vw;'
});
frame.on('load', function (e) {
$(e.target).fadeIn();
$('#loader').fadeOut();
});
$('body').append(frame);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,51 @@
<%doc>
USAGE DOCUMENTATION :: PLEASE LEAVE THIS AT THE TOP OF THIS FILE
For Mako templating syntax documentation please visit: http://docs.makotemplates.org/en/latest/
Filename: newsletters_table.html
Version: 0.1
DOCUMENTATION :: END
</%doc>
<% from plexpy.newsletter_handler import NEWSLETTER_SCHED %>
<ul class="stacked-configs list-unstyled">
% for newsletter in sorted(newsletters_list, key=lambda k: (k['agent_label'], k['friendly_name'], k['id'])):
<li class="newsletter-agent" data-id="${newsletter['id']}">
<span>
<span class="toggle-left trigger-tooltip ${'active' if newsletter['active'] else ''}" data-toggle="tooltip" data-placement="top" title="Newsletter ${'active' if newsletter['active'] else 'inactive'}"><i class="fa fa-lg fa-newspaper-o"></i></span>
% if newsletter['friendly_name']:
${newsletter['agent_label']} &nbsp;<span class="friendly_name">(${newsletter['id']} - ${newsletter['friendly_name']})</span>
% else:
${newsletter['agent_label']} &nbsp;<span class="friendly_name">(${newsletter['id']})</span>
% endif
<span class="toggle-right"><i class="fa fa-lg fa-cog"></i></span>
<span class="toggle-right friendly_name" id="newsletter-next_run-${newsletter['id']}">
% if NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])):
<% job = NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])) %>
<script>
$("#newsletter-next_run-${newsletter['id']}").text(moment("${job.next_run_time}", "YYYY-MM-DD HH:mm:ssZ").fromNow())
</script>
% endif
</span>
</span>
</li>
% endfor
<li class="add-newsletter-agent" id="add-newsletter-agent" data-target="#add-newsletter-modal" data-toggle="modal">
<span>
<span class="toggle-left"><i class="fa fa-lg fa-newspaper-o"></i></span> Add a new newsletter agent
<span class="toggle-right"><i class="fa fa-lg fa-plus"></i></span>
</span>
</li>
</ul>
<script>
// Load newsletter config modal
$(".newsletter-agent").click(function () {
var newsletter_id = $(this).data('id');
loadNewsletterConfig(newsletter_id);
});
$('.trigger-tooltip').tooltip();
</script>

View File

@@ -1,3 +1,4 @@
% if notifier:
<%! <%!
import json import json
from plexpy import helpers, notifiers, users from plexpy import helpers, notifiers, users
@@ -6,9 +7,6 @@
user_emails = [{'user': u['friendly_name'] or u['username'], 'email': u['email']} for u in users.Users().get_users() if u['email']] 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']) sorted(user_emails, key=lambda u: u['user'])
%> %>
% if notifier:
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
<link href="${http_root}css/selectize.min.css" rel="stylesheet" />
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -19,7 +17,7 @@
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<ul class="nav nav-tabs list-unstyled" role="tablist"> <ul class="nav nav-tabs list-unstyled" role="tablist">
<li role="presentation" class="active"><a href="#tabs-config" aria-controls="tabs-config" role="tab" data-toggle="tab">Configuration</a></li> <li role="presentation" class="active"><a href="#tabs-notifier_config" aria-controls="tabs-notifier_config" role="tab" data-toggle="tab">Configuration</a></li>
<li role="presentation"><a href="#tabs-notify_triggers" aria-controls="tabs-notify_triggers" role="tab" data-toggle="tab">Triggers</a></li> <li role="presentation"><a href="#tabs-notify_triggers" aria-controls="tabs-notify_triggers" role="tab" data-toggle="tab">Triggers</a></li>
<li role="presentation"><a href="#tabs-notify_conditions" aria-controls="tabs-notify_conditions" role="tab" data-toggle="tab">Conditions</a></li> <li role="presentation"><a href="#tabs-notify_conditions" aria-controls="tabs-notify_conditions" role="tab" data-toggle="tab">Conditions</a></li>
<li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">${'Arguments' if notifier['agent_name'] == 'scripts' else 'Text'}</a></li> <li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">${'Arguments' if notifier['agent_name'] == 'scripts' else 'Text'}</a></li>
@@ -28,7 +26,7 @@
</div> </div>
<form action="set_notifier_config" method="post" class="form" id="set_notifier_config" data-parsley-validate> <form action="set_notifier_config" method="post" class="form" id="set_notifier_config" data-parsley-validate>
<div class="tab-content"> <div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="tabs-config"> <div role="tabpanel" class="tab-pane active" id="tabs-notifier_config">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<input type="hidden" id="notifier_id" name="notifier_id" value="${notifier['id']}" /> <input type="hidden" id="notifier_id" name="notifier_id" value="${notifier['id']}" />
@@ -45,9 +43,6 @@
<div class="row"> <div class="row">
<div class="col-md-12"> <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 ''}> <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>
% endif
</div> </div>
</div> </div>
<p class="help-block">${item['description'] | n}</p> <p class="help-block">${item['description'] | n}</p>
@@ -151,7 +146,7 @@
% for action in available_notification_actions: % for action in available_notification_actions:
<div class="checkbox"> <div class="checkbox">
<label> <label>
<input type="checkbox" data-id="${action['name']}" class="checkboxes" value="1" ${helpers.checked(notifier['actions'][action['name']])}> Notify on ${action['label']} <input type="checkbox" data-id="${action['name']}" class="checkboxes" value="1" ${helpers.checked(notifier['actions'][action['name']])}> ${action['label']}
</label> </label>
<p class="help-block">${action['description'] | n}</p> <p class="help-block">${action['description'] | n}</p>
<input type="hidden" id="${action['name']}" name="${action['name']}" value="${notifier['actions'][action['name']]}"> <input type="hidden" id="${action['name']}" name="${action['name']}" value="${notifier['actions'][action['name']]}">
@@ -167,11 +162,11 @@
<a href="#notify-text-sub-modal" data-toggle="modal">Click here</a> for a description of all the parameters. <a href="#notify-text-sub-modal" data-toggle="modal">Click here</a> for a description of all the parameters.
</p> </p>
<div id="condition-widget"></div> <div id="condition-widget"></div>
<input type="hidden" name="custom_conditions" id="custom_conditions" /> <input type="hidden" id="custom_conditions" name="custom_conditions" />
<div class="form-group"> <div class="form-group">
<label for="custom_conditions_logic">Condition Logic</label> <label for="custom_conditions_logic">Condition Logic</label>
<input type="text" class="form-control" name="custom_conditions_logic" id="custom_conditions_logic" value="${notifier['custom_conditions_logic']}" required /> <input type="text" class="form-control" name="custom_conditions_logic" id="custom_conditions_logic" value="${notifier['custom_conditions_logic']}" />
<div id="custom_conditions_logic_error" class="alert alert-danger" role="alert" style="padding-top: 5px; padding-bottom: 5px; margin: 0; display: none;"><i class="fa fa-exclamation-triangle" style="color: #a94442;"></i> <span></span></div> <div id="custom_conditions_logic_error" class="alert alert-danger" role="alert" style="padding-top: 5px; padding-bottom: 5px; margin: 0; display: none;"><i class="fa fa-exclamation-triangle" style="color: #a94442;"></i> <span></span></div>
<p class="help-block"> <p class="help-block">
Optional: Enter custom logic to use when evaluating the conditions (e.g. <span class="inline-pre">{1} and ({2} or {3})</span>). Optional: Enter custom logic to use when evaluating the conditions (e.g. <span class="inline-pre">{1} and ({2} or {3})</span>).
@@ -333,31 +328,16 @@
$('#notifier-config-modal').unbind('hidden.bs.modal'); $('#notifier-config-modal').unbind('hidden.bs.modal');
// Need this for setting conditions since conditions contain the character " // Need this for setting conditions since conditions contain the character "
$('#custom_conditions').val(${json.dumps(notifier["custom_conditions"]) | n}); $('#custom_conditions').val(JSON.stringify(${json.dumps(notifier["custom_conditions"]) | n}));
$('#condition-widget').filterer({ $('#condition-widget').filterer({
parameters: ${parameters | n}, parameters: ${json.dumps(parameters) | n},
conditions: ${notifier["custom_conditions"] | n}, conditions: ${json.dumps(notifier["custom_conditions"]) | n},
updateConditions: function(newConditions){ updateConditions: function(newConditions){
$('#custom_conditions').val(JSON.stringify(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() { function reloadModal() {
$.ajax({ $.ajax({
url: 'get_notifier_config_modal', url: 'get_notifier_config_modal',
@@ -425,7 +405,7 @@
$('#duplicate-notifier-item').click(function() { $('#duplicate-notifier-item').click(function() {
var msg = 'Are you sure you want to duplicate this <strong>${notifier["agent_label"]}</strong> notification agent?'; var msg = 'Are you sure you want to duplicate this <strong>${notifier["agent_label"]}</strong> notification agent?';
var url = 'add_notifier_config'; var url = 'add_notifier_config';
confirmAjaxCall(url, msg, { agent_id: "${notifier['agent_id']}" }, null, duplicateCallback); confirmAjaxCall(url, msg, { agent_id: '${notifier["agent_id"]}' }, null, duplicateCallback);
}); });
$('#save-notifier-item').click(function () { $('#save-notifier-item').click(function () {
@@ -433,16 +413,30 @@
}); });
% if notifier['agent_name'] == 'facebook': % if notifier['agent_name'] == 'facebook':
if (location.protocol !== 'https:') {
$('#tabs-config .form-group:first').prepend(
'<div class="form-group">' +
'<label>Warning</label>' +
'<p class="help-block" style="color: #eb8600;">Facebook requires HTTPS for authorization. ' +
'Please enable HTTPS for Tautulli under <a data-tab-destination="tabs-web_interface" data-dismiss="modal" data-target="#enable_https">Web Interface</a>.</p>' +
'</div>'
);
$('#facebook_redirect_uri').val('HTTPS not enabled');
} else {
$('#facebook_redirect_uri').val(location.href.split('/settings')[0] + '/facebook_redirect');
}
function disableFacebookRequest() { 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_facebook_auth').prop('disabled', false); }
else { $('#facebook_facebookStep1').prop('disabled', true); } else { $('#facebook_facebook_auth').prop('disabled', true); }
} }
disableFacebookRequest(); disableFacebookRequest();
$('#facebook_app_id, #facebook_app_secret').on('change', function () { $('#facebook_app_id, #facebook_app_secret').on('change', function () {
disableFacebookRequest(); disableFacebookRequest();
}); });
$('#facebook_facebookStep1').click(function () { $('#facebook_facebook_auth').click(function () {
// Remove trailing '/' from Facebook redirect URI // Remove trailing '/' from Facebook redirect URI
if ($('#facebook_redirect_uri') && $('#facebook_redirect_uri').val().endsWith('/')) { if ($('#facebook_redirect_uri') && $('#facebook_redirect_uri').val().endsWith('/')) {
$('#facebook_redirect_uri').val($('#facebook_redirect_uri').val().slice(0, -1)); $('#facebook_redirect_uri').val($('#facebook_redirect_uri').val().slice(0, -1));
@@ -450,7 +444,7 @@
var facebook_token; var facebook_token;
$.ajax({ $.ajax({
url: 'facebookStep1', url: 'facebook_auth',
data: { data: {
app_id: $('#facebook_app_id').val(), app_id: $('#facebook_app_id').val(),
app_secret: $('#facebook_app_secret').val(), app_secret: $('#facebook_app_secret').val(),
@@ -508,7 +502,7 @@
}); });
% elif notifier['agent_name'] == 'osx': % elif notifier['agent_name'] == 'osx':
$('#osxnotifyregister').click(function () { $('#osx_notify_register').click(function () {
var osx_notify_app = $('#osx_notify_app').val(); 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); }); $.get('osxnotifyregister', { 'app': osx_notify_app }, function (data) { showMsg('<i class="fa fa-check"></i> ' + data, false, true, 3000); });
}); });
@@ -606,6 +600,22 @@
}); });
}); });
% 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 % endif
function validateLogic() { function validateLogic() {
@@ -736,11 +746,12 @@
}); });
function sendTestNotification() { function sendTestNotification() {
showMsg('<i class="fa fa-refresh fa-spin"></i>&nbsp; Sending Notification', false);
if ('${notifier["agent_name"]}' !== 'browser') { if ('${notifier["agent_name"]}' !== 'browser') {
$.ajax({ $.ajax({
url: 'send_notification', url: 'send_notification',
data: { data: {
notifier_id: '${notifier["id"]}', notifier_id: $('#notifier_id').val(),
subject: $('#test_subject').val(), subject: $('#test_subject').val(),
body: $('#test_body').val(), body: $('#test_body').val(),
script: $('#test_script').val(), script: $('#test_script').val(),
@@ -749,13 +760,11 @@
}, },
cache: false, cache: false,
async: true, async: true,
complete: function (xhr, status) { success: function (data) {
if (xhr.responseText.indexOf('sent') > -1) { if (data.result === 'success') {
msg = '<i class="fa fa-check"></i>&nbsp; ' + xhr.responseText; showMsg('<i class="fa fa-check"></i> ' + data.message, false, true, 5000);
showMsg(msg, false, true, 2000);
} else { } else {
msg = '<i class="fa fa-times"></i>&nbsp; ' + xhr.responseText; showMsg('<i class="fa fa-exclamation-circle"></i> ' + data.message, false, true, 5000, true);
showMsg(msg, false, true, 2000, true);
} }
} }
}); });

View File

@@ -10,7 +10,7 @@ DOCUMENTATION :: END
</%doc> </%doc>
<ul class="stacked-configs list-unstyled"> <ul class="stacked-configs list-unstyled">
% for notifier in sorted(notifiers_list, key=lambda k: (k['agent_label'], k['friendly_name'], k['id'])): % for notifier in sorted(notifiers_list, key=lambda k: (k['agent_label'].lower(), k['friendly_name'], k['id'])):
<li class="notification-agent" data-id="${notifier['id']}"> <li class="notification-agent" data-id="${notifier['id']}">
<span> <span>
<span class="toggle-left trigger-tooltip ${'active' if notifier['active'] else ''}" data-toggle="tooltip" data-placement="top" title="Triggers ${'active' if notifier['active'] else 'inactive'}"><i class="fa fa-lg fa-bell"></i></span> <span class="toggle-left trigger-tooltip ${'active' if notifier['active'] else ''}" data-toggle="tooltip" data-placement="top" title="Triggers ${'active' if notifier['active'] else 'inactive'}"><i class="fa fa-lg fa-bell"></i></span>

View File

@@ -4,10 +4,11 @@
import sys import sys
import plexpy import plexpy
from plexpy import common, notifiers from plexpy import common, notifiers, newsletters
from plexpy.helpers import anon_url, checked from plexpy.helpers import anon_url, checked
available_notification_agents = sorted(notifiers.available_notification_agents(), key=lambda k: k['label']) available_notification_agents = sorted(notifiers.available_notification_agents(), key=lambda k: k['label'].lower())
available_newsletter_agents = sorted(newsletters.available_newsletter_agents(), key=lambda k: k['label'].lower())
%> %>
<%def name="headIncludes()"> <%def name="headIncludes()">
</%def> </%def>
@@ -49,8 +50,9 @@
<li role="presentation"><a href="#tabs-homepage" aria-controls="tabs-homepage" role="tab" data-toggle="tab">Homepage</a></li> <li role="presentation"><a href="#tabs-homepage" aria-controls="tabs-homepage" role="tab" data-toggle="tab">Homepage</a></li>
<li role="presentation"><a href="#tabs-web_interface" aria-controls="tabs-web_interface" role="tab" data-toggle="tab">Web Interface</a></li> <li role="presentation"><a href="#tabs-web_interface" aria-controls="tabs-web_interface" role="tab" data-toggle="tab">Web Interface</a></li>
<li role="presentation"><a href="#tabs-plex_media_server" aria-controls="tabs-plex_media_server" role="tab" data-toggle="tab">Plex Media Server</a></li> <li role="presentation"><a href="#tabs-plex_media_server" aria-controls="tabs-plex_media_server" role="tab" data-toggle="tab">Plex Media Server</a></li>
<li role="presentation"><a href="#tabs-notifications" aria-controls="tabs-notifications" role="tab" data-toggle="tab">Notifications</a></li> <li role="presentation"><a href="#tabs-notifications" aria-controls="tabs-notifications" role="tab" data-toggle="tab">Notifications & Newsletters</a></li>
<li role="presentation"><a href="#tabs-notification_agents" aria-controls="tabs-notification_agents" role="tab" data-toggle="tab">Notification Agents</a></li> <li role="presentation"><a href="#tabs-notification_agents" aria-controls="tabs-notification_agents" role="tab" data-toggle="tab">Notification Agents</a></li>
<li role="presentation"><a href="#tabs-newsletter_agents" aria-controls="tabs-newsletter_agents" role="tab" data-toggle="tab">Newsletter Agents</a></li>
<li role="presentation"><a href="#tabs-import_backups" aria-controls="tabs-import_backups" role="tab" data-toggle="tab">Import & Backups</a></li> <li role="presentation"><a href="#tabs-import_backups" aria-controls="tabs-import_backups" role="tab" data-toggle="tab">Import & Backups</a></li>
<li role="presentation"><a href="#tabs-android_app" aria-controls="tabs-android_app" role="tab" data-toggle="tab">Tautulli Remote Android App <sup><small>beta</small></sup></a></li> <li role="presentation"><a href="#tabs-android_app" aria-controls="tabs-android_app" role="tab" data-toggle="tab">Tautulli Remote Android App <sup><small>beta</small></sup></a></li>
</ul> </ul>
@@ -60,9 +62,9 @@
<input type="hidden" id="show_advanced_settings" name="show_advanced_settings" value="${config['show_advanced_settings']}" required> <input type="hidden" id="show_advanced_settings" name="show_advanced_settings" value="${config['show_advanced_settings']}" required>
<div class="tab-content"> <div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="tabs-help_info"> <div role="tabpanel" class="tab-pane active" id="tabs-help_info">
% if common.VERSION_NUMBER: % if common.RELEASE:
<div class="padded-header"> <div class="padded-header">
<h3>Version ${common.VERSION_NUMBER} <small><a id="changelog-modal-link" href="#"><i class="fa fa-info-circle"></i> Changelog</a></small></h3> <h3>Version ${common.RELEASE} <small><a id="changelog-modal-link" href="#"><i class="fa fa-info-circle"></i> Changelog</a></small></h3>
</div> </div>
% endif % endif
<div class="padded-header"> <div class="padded-header">
@@ -267,6 +269,21 @@
<div role="tabpanel" class="tab-pane" id="tabs-homepage"> <div role="tabpanel" class="tab-pane" id="tabs-homepage">
<div class="padded-header">
<h3>Activity</h3>
</div>
<div class="form-group">
<label for="home_refresh_interval">Activity Refresh Interval</label>
<div class="row">
<div class="col-md-2">
<input type="text" class="form-control" data-parsley-type="integer" id="home_refresh_interval" name="home_refresh_interval" value="${config['home_refresh_interval']}" size="5" data-parsley-min="2" data-parsley-trigger="change" data-parsley-errors-container="#home_refresh_interval_error" required>
</div>
<div id="home_refresh_interval_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
<p class="help-block">Set the interval (in seconds) to refresh the current activity on the homepage. Minimum 2.</p>
</div>
<div class="padded-header"> <div class="padded-header">
<h3>Sections</h3> <h3>Sections</h3>
</div> </div>
@@ -438,6 +455,18 @@
</div> </div>
<p class="help-block">Port to bind web server to. Note that ports below 1024 may require root.</p> <p class="help-block">Port to bind web server to. Note that ports below 1024 may require root.</p>
</div> </div>
<div class="form-group advanced-setting">
<label for="http_base_url">Public Tautulli Domain</label>
<div class="row">
<div class="col-md-8">
<input type="text" class="form-control" id="http_base_url" name="http_base_url" value="${config['http_base_url']}" placeholder="http://mydomain.com" data-parsley-trigger="change" data-parsley-pattern="^https?:\/\/\S+$" data-parsley-errors-container="#http_base_url_error" data-parsley-error-message="Invalid URL">
</div>
<div id=http_base_url_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
<p class="help-block">
Set your public Tautulli domain for self-hosted notification images and newsletters. (e.g. http://mydomain.com)
</p>
</div>
<div class="form-group advanced-setting"> <div class="form-group advanced-setting">
<label for="http_root">HTTP Root</label> <label for="http_root">HTTP Root</label>
<div class="row"> <div class="row">
@@ -554,7 +583,7 @@
<label> <label>
<input type="checkbox" name="http_hash_password" id="http_hash_password" value="1" ${config['http_hash_password']} data-parsley-trigger="change"> Hash Password in the Config File <input type="checkbox" name="http_hash_password" id="http_hash_password" value="1" ${config['http_hash_password']} data-parsley-trigger="change"> Hash Password in the Config File
</label> </label>
<span id="hashPasswordCheck" style="color: #eb8600; padding-left: 10px;"></span> <span id="hashPasswordCheck" class="settings-warning"></span>
<p class="help-block">Store a hashed password in the config file.<br />Warning: Your password cannot be recovered if forgotten!</p> <p class="help-block">Store a hashed password in the config file.<br />Warning: Your password cannot be recovered if forgotten!</p>
</div> </div>
<input type="text" id="http_hashed_password" name="http_hashed_password" value="${config['http_hashed_password']}" style="display: none;" data-parsley-trigger="change" data-parsley-type="integer" data-parsley-range="[0, 1]" <input type="text" id="http_hashed_password" name="http_hashed_password" value="${config['http_hashed_password']}" style="display: none;" data-parsley-trigger="change" data-parsley-type="integer" data-parsley-range="[0, 1]"
@@ -572,14 +601,14 @@
<label> <label>
<input type="checkbox" class="auth-settings" name="http_plex_admin" id="http_plex_admin" value="1" ${config['http_plex_admin']} data-parsley-trigger="change"> Allow Plex Admin <input type="checkbox" class="auth-settings" name="http_plex_admin" id="http_plex_admin" value="1" ${config['http_plex_admin']} data-parsley-trigger="change"> Allow Plex Admin
</label> </label>
<span id="allowPlexCheck" style="color: #eb8600; padding-left: 10px;"></span> <span id="allowPlexCheck" class="settings-warning"></span>
<p class="help-block">Allow the Plex server admin to login as a Tautulli admin using their Plex.tv account.</p> <p class="help-block">Allow the Plex server admin to login as a Tautulli admin using their Plex.tv account.</p>
</div> </div>
<div class="checkbox"> <div class="checkbox">
<label> <label>
<input type="checkbox" id="allow_guest_access" name="allow_guest_access" value="1" ${config['allow_guest_access']}> Allow Guest Access to Tautulli <input type="checkbox" id="allow_guest_access" name="allow_guest_access" value="1" ${config['allow_guest_access']}> Allow Guest Access to Tautulli
</label> </label>
<span id="allowGuestCheck" style="color: #eb8600; padding-left: 10px;"></span> <span id="allowGuestCheck" class="settings-warning"></span>
<p class="help-block">Allow shared users to login to Tautulli using their Plex.tv account. Individual user access needs to be enabled from Users > Edit Mode.</p> <p class="help-block">Allow shared users to login to Tautulli using their Plex.tv account. Individual user access needs to be enabled from Users > Edit Mode.</p>
</div> </div>
@@ -642,7 +671,7 @@
<label for="pms_port">Plex Port</label> <label for="pms_port">Plex Port</label>
<div class="row"> <div class="row">
<div class="col-md-2"> <div class="col-md-2">
<input data-parsley-type="integer" class="pms-settings form-control" type="text" id="pms_port" name="pms_port" value="${config['pms_port']}" size="30" data-parsley-trigger="change" data-parsley-errors-container="#pms_port_error" required> <input data-parsley-type="integer" class="form-control pms-settings" type="text" id="pms_port" name="pms_port" value="${config['pms_port']}" size="30" data-parsley-trigger="change" data-parsley-errors-container="#pms_port_error" required>
</div> </div>
<div id="pms_port_error" class="alert alert-danger settings-alert" role="alert"></div> <div id="pms_port_error" class="alert alert-danger settings-alert" role="alert"></div>
</div> </div>
@@ -650,31 +679,42 @@
</div> </div>
<div class="checkbox"> <div class="checkbox">
<label> <label>
<input type="checkbox" id="pms_is_remote_checkbox" class="checkbox-toggle" data-id="pms_is_remote" value="1" ${checked(config['pms_is_remote'])}> Remote Server <input type="checkbox" id="pms_is_remote_checkbox" class="checkbox-toggle pms-settings" data-id="pms_is_remote" value="1" ${checked(config['pms_is_remote'])}> Remote Server
<input type="hidden" id="pms_is_remote" name="pms_is_remote" value="${config['pms_is_remote']}"> <input type="hidden" id="pms_is_remote" name="pms_is_remote" value="${config['pms_is_remote']}">
</label> </label>
<p class="help-block">Check this if your Plex Server is not on the same local network as Tautulli.</p> <p class="help-block">Check this if your Plex Server is not on the same local network as Tautulli.</p>
</div> </div>
<div class="checkbox"> <div class="checkbox">
<label> <label>
<input type="checkbox" id="pms_ssl_checkbox" class="checkbox-toggle" data-id="pms_ssl" value="1" ${checked(config['pms_ssl'])}> Use SSL <input type="checkbox" id="pms_ssl_checkbox" class="checkbox-toggle pms-settings" data-id="pms_ssl" value="1" ${checked(config['pms_ssl'])}> Use SSL
<input type="hidden" id="pms_ssl" name="pms_ssl" value="${config['pms_ssl']}"> <input type="hidden" id="pms_ssl" name="pms_ssl" value="${config['pms_ssl']}">
</label> </label>
<p class="help-block">If you have secure connections enabled on your Plex Server, communicate with it securely.</p> <p class="help-block">If you have secure connections enabled on your Plex Server, communicate with it securely.</p>
</div> </div>
<div class="form-group">
<label for="pms_url">Plex Server URL</label>
<div class="row">
<div class="col-md-9">
<input type="text" class="form-control" id="pms_url" name="pms_url" value="${config['pms_url']}" size="30" readonly>
</div>
</div>
<p class="help-block">
The server URL that Tautulli will use to connect to your Plex server. Retrieved automatically.
</p>
</div>
<div class="checkbox advanced-setting"> <div class="checkbox advanced-setting">
<label> <label>
<input type="checkbox" id="pms_url_manual" name="pms_url_manual" value="1" ${config['pms_url_manual']}> Manual Connection <input type="checkbox" class="pms-settings" id="pms_url_manual" name="pms_url_manual" value="1" ${config['pms_url_manual']}> Manual Connection
</label> </label>
<span id="cloudManualConnection" style="display: none; color: #eb8600; padding-left: 10px;"> Not available for Plex Cloud servers.</span> <span id="cloudManualConnection" style="display: none; color: #eb8600; padding-left: 10px;"> Not available for Plex Cloud servers.</span>
<p class="help-block">Use the user defined connection details. Do not retrieve the server connection URL automatically.</p> <p class="help-block">Use the user defined connection details. Do not retrieve the server connection URL automatically.</p>
</div> </div>
<div class="form-group advanced-setting"> <div class="form-group advanced-setting">
<label for="pms_logs_folder">Plex Web URL</label> <label for="pms_web_url">Plex Web URL</label>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-9">
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" id="pms_web_url" name="pms_web_url" value="${config['pms_web_url']}" size="30" data-parsley-trigger="change" data-parsley-pattern="^https?:\/\/\S+$|^https:\/\/app.plex.tv\/desktop$" data-parsley-errors-container="#pms_web_url_error" data-parsley-error-message="Invalid Plex Web URL."> <input type="text" class="form-control" id="pms_web_url" name="pms_web_url" value="${config['pms_web_url']}" size="30" data-parsley-trigger="change" data-parsley-pattern="^https?:\/\/\S+$|^https:\/\/app.plex.tv\/desktop$" data-parsley-errors-container="#pms_web_url_error" data-parsley-error-message="Invalid Plex Web URL">
<span class="input-group-btn"> <span class="input-group-btn">
<button class="btn btn-form" type="button" id="test_pms_web_button">Test URL</button> <button class="btn btn-form" type="button" id="test_pms_web_button">Test URL</button>
</span> </span>
@@ -750,7 +790,7 @@
<input type="checkbox" id="monitor_remote_access" name="monitor_remote_access" value="1" ${config['monitor_remote_access']}> Monitor Plex Remote Access <input type="checkbox" id="monitor_remote_access" name="monitor_remote_access" value="1" ${config['monitor_remote_access']}> Monitor Plex Remote Access
</label> </label>
<span id="cloudMonitorRemoteAccess" style="display: none; color: #eb8600; padding-left: 10px;"> Not available for Plex Cloud servers.</span> <span id="cloudMonitorRemoteAccess" style="display: none; color: #eb8600; padding-left: 10px;"> Not available for Plex Cloud servers.</span>
<span id="remoteAccessCheck" style="color: #eb8600; padding-left: 10px;"></span> <span id="remoteAccessCheck" class="settings-warning"></span>
<p class="help-block">Enable to have Tautulli check if remote access to the Plex Media Server goes down.</p> <p class="help-block">Enable to have Tautulli check if remote access to the Plex Media Server goes down.</p>
</div> </div>
@@ -896,7 +936,7 @@
</div> </div>
<!--<div class="checkbox"> <!--<div class="checkbox">
<label> <label>
<input type="checkbox" name="notify_recently_added_upgrade" id="notify_recently_added_upgrade" value="1" ${config['notify_recently_added_upgrade']}> Send a Notification for New Versions <span style="color: #eb8600; padding-left: 10px;">[Not working]</span> <input type="checkbox" name="notify_recently_added_upgrade" id="notify_recently_added_upgrade" value="1" ${config['notify_recently_added_upgrade']}> Send a Notification for New Versions <span class="settings-warning">[Not working]</span>
</label> </label>
<p class="help-block"> <p class="help-block">
Enable to send another recently added notification when adding a new version of existing media.<br /> Enable to send another recently added notification when adding a new version of existing media.<br />
@@ -905,16 +945,46 @@
</div>--> </div>-->
<div class="padded-header"> <div class="padded-header">
<h3>3rd Party APIs</h3> <h3>Newsletters</h3>
</div> </div>
<div class="checkbox"> <div class="checkbox">
<label> <label>
<input type="checkbox" name="notify_upload_posters" id="notify_upload_posters" value="1" ${config['notify_upload_posters']}> Upload Posters to Imgur for Notifications <input type="checkbox" id="newsletter_self_hosted" name="newsletter_self_hosted" value="1" ${config['newsletter_self_hosted']}> Self-Hosted Newsletters
</label> </label>
<p class="help-block">Enable to upload Plex posters to Imgur for notifications. Disable if posters are not being used to save bandwidth.</p> <p class="help-block">Enable to host newsletters on your own domain. This will generate a link to an HTML page where you can view the newsletter.</p>
</div> </div>
<div id="imgur_upload_options"> <div id="self_host_newsletter_options" style="overlfow: hidden; display: ${'block' if config['newsletter_self_hosted'] == 'checked' else 'none'}">
<p class="help-block" id="self_host_newsletter_message">
Note: The <span class="inline-pre">${http_root}newsletter</span> endpoint on your domain must be publicly accessible from the internet.<br>
Note: Newsletter images will be self-hosted regardless of the Image Hosting setting below.<br>
</p>
<p class="help-block settings-warning base-url-warning">Warning: Public Tautulli domain not set under <a data-tab-destination="tabs-web_interface" data-target="#http_base_url">Web Interface</a>.</p>
</div>
<div class="padded-header">
<h3>3rd Party APIs</h3>
</div>
<div class="form-group">
<label for="notify_upload_posters">Image Hosting</label>
<div class="row">
<div class="col-md-6">
<select class="form-control" id="notify_upload_posters" name="notify_upload_posters">
<option value="0" ${'selected' if config['notify_upload_posters'] == 0 else ''}>Disabled</option>
<option value="1" ${'selected' if config['notify_upload_posters'] == 1 else ''}>Imgur</option>
<option value="3" ${'selected' if config['notify_upload_posters'] == 3 else ''}>Cloudinary</option>
<option value="2" ${'selected' if config['notify_upload_posters'] == 2 else ''}>Self-hosted on public Tautulli domain</option>
</select>
</div>
</div>
<p class="help-block">Select where to host Plex images for notifications and newsletters.</p>
</div>
<div id="imgur_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 1 else 'block'}">
<p class="help-block" id="imgur_upload_message">
You can register a new Imgur application <a href="${anon_url('https://api.imgur.com/oauth2/addclient')}" target="_blank">here</a>.<br>
Warning: Imgur uploads are rate-limited and newsletters may exceed the limit. Please use Cloudinary for newsletters instead.
</p>
<div class="form-group"> <div class="form-group">
<label for="imgur_client_id">Imgur Client ID</label> <label for="imgur_client_id">Imgur Client ID</label>
<div class="row"> <div class="row">
@@ -922,9 +992,49 @@
<input type="text" class="form-control" id="imgur_client_id" name="imgur_client_id" value="${config['imgur_client_id']}" data-parsley-trigger="change"> <input type="text" class="form-control" id="imgur_client_id" name="imgur_client_id" value="${config['imgur_client_id']}" data-parsley-trigger="change">
</div> </div>
</div> </div>
<p class="help-block">Enter your Imgur API Client ID.</p>
</div>
</div>
<div id="self_host_image_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 2 else 'block'}">
<p class="help-block" id="self_host_image_message">Note: The <span class="inline-pre">${http_root}image</span> endpoint on your domain must be publicly accessible from the internet.</p>
<p class="help-block settings-warning base-url-warning">Warning: Public Tautulli domain not set under <a data-tab-destination="tabs-web_interface" data-target="#http_base_url">Web Interface</a>.</p>
</div>
<div id="cloudinary_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 3 else 'block'}">
<p class="help-block" id="imgur_upload_message">
You can sign up for Cloudinary <a href="${anon_url('https://cloudinary.com')}" target="_blank">here</a>.<br>
</p>
<div class="form-group">
<label for="cloudinary_cloud_name">Cloudinary Cloud Name</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="cloudinary_cloud_name" name="cloudinary_cloud_name" value="${config['cloudinary_cloud_name']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block"> <p class="help-block">
Enter your Imgur API client ID in order to upload posters. Enter your Cloudinary Cloud Name.
You can register a new application <a href="${anon_url('https://api.imgur.com/oauth2/addclient')}" target="_blank">here</a>.<br /> </p>
</div>
<div class="form-group">
<label for="cloudinary_api_key">Cloudinary API Key</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="cloudinary_api_key" name="cloudinary_api_key" value="${config['cloudinary_api_key']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your Cloudinary API Key.
</p>
</div>
<div class="form-group">
<label for="cloudinary_api_secret">Cloudinary API Secret</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="cloudinary_api_secret" name="cloudinary_api_secret" value="${config['cloudinary_api_secret']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your Cloudinary API Secret.
</p>
</div> </div>
</div> </div>
<div class="checkbox"> <div class="checkbox">
@@ -953,6 +1063,9 @@
<p class="help-block"> <p class="help-block">
Add a new notification agent, or configure an existing notification agent by clicking the settings icon on the right. Add a new notification agent, or configure an existing notification agent by clicking the settings icon on the right.
</p> </p>
<p class="help-block">
Please see the <a target='_blank' href='${anon_url('https://github.com/%s/%s-Wiki/wiki/Notification-Agents-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}'>Notification Agents Guide</a> for instructions on setting up each notification agent.
</p>
<br /> <br />
<div id="plexpy-notifiers-table"> <div id="plexpy-notifiers-table">
<div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Loading notification agents...</div> <div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Loading notification agents...</div>
@@ -961,13 +1074,33 @@
</div> </div>
<div role="tabpanel" class="tab-pane" id="tabs-newsletter_agents">
<div class="padded-header">
<h3>Newsletter Agents</h3>
</div>
<p class="help-block">
Add a new newsletter agent, or configure an existing newsletter agent by clicking the settings icon on the right.
</p>
<p class="help-block settings-warning" id="newsletter_upload_warning">
Note: Either <a data-tab-destination="tabs-notifications" data-target="#notify_upload_posters">Image Hosting</a> or <a data-tab-destination="tabs-notifications" data-target="#newsletter_self_hosted">Self-Hosted Newsletters</a> must be enabled.</span>
</p>
<br/>
<div id="plexpy-newsletters-table">
<div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Loading newsletter agents...</div>
<br>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-import_backups"> <div role="tabpanel" class="tab-pane" id="tabs-import_backups">
<div class="padded-header"> <div class="padded-header">
<h3>Database Import</h3> <h3>Database Import</h3>
</div> </div>
<p class="help-block">Click a button below to import an exisiting database from another app.</p> <p class="help-block">Click a button below to import an existing database from another app.</p>
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-form toggle-app-import-modal" type="button" data-target="#app-import-modal" data-toggle="modal" data-app="plexwatch">PlexWatch</button> <button class="btn btn-form toggle-app-import-modal" type="button" data-target="#app-import-modal" data-toggle="modal" data-app="plexwatch">PlexWatch</button>
<button class="btn btn-form toggle-app-import-modal" type="button" data-target="#app-import-modal" data-toggle="modal" data-app="plexivity">Plexivity</button> <button class="btn btn-form toggle-app-import-modal" type="button" data-target="#app-import-modal" data-toggle="modal" data-app="plexivity">Plexivity</button>
@@ -1005,6 +1138,17 @@
<h3>Directories</h3> <h3>Directories</h3>
</div> </div>
<div class="form-group">
<label for="log_dir">Log Directory</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control directory-settings" id="log_dir" name="log_dir" value="${config['log_dir']}">
<div class="btn-group">
<button class="btn btn-form" type="button" id="clear_logs">Clear Logs</button>
</div>
</div>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="backup_dir">Backup Directory</label> <label for="backup_dir">Backup Directory</label>
<div class="row"> <div class="row">
@@ -1030,13 +1174,10 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="log_dir">Log Directory</label> <label for="newsletter_dir">Newsletter Directory</label>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<input type="text" class="form-control directory-settings" id="log_dir" name="log_dir" value="${config['log_dir']}"> <input type="text" class="form-control directory-settings" id="newsletter_dir" name="newsletter_dir" value="${config['newsletter_dir']}">
<div class="btn-group">
<button class="btn btn-form" type="button" id="clear_logs">Clear Logs</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1062,8 +1203,8 @@
</div> </div>
<p class="form-group"> <p class="form-group">
<label>Registered Devices</label> <label>Registered Devices</label>
<p class="help-block">Register a new device, or configure an existing device by clicking the settings icon on the right.</p> <p class="help-block">Register a new device using a QR code, or configure an existing device by clicking the settings icon on the right.</p>
<p id="app_api_msg" style="color: #eb8600;">The API must be enabled under <a data-tab-destination="tabs-access_control" style="cursor: pointer;">Access Control</a> to use the app.</p> <p id="app_api_msg" style="color: #eb8600;">The API must be enabled under <a data-tab-destination="tabs-web_interface" data-target="#api_enabled">Web Interface</a> to use the app.</p>
<div class="row"> <div class="row">
<div id="plexpy-mobile-devices-table" class="col-md-12"> <div id="plexpy-mobile-devices-table" class="col-md-12">
<div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Loading registered devices...</div> <div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Loading registered devices...</div>
@@ -1214,7 +1355,7 @@
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<ul class="stacked-configs list-unstyled"> <ul class="stacked-configs list-unstyled">
% for agent in available_notification_agents: % for agent in sorted(available_notification_agents, key=lambda k: k['label'].lower()):
<li class="new-notification-agent" data-id="${agent['id']}"> <li class="new-notification-agent" data-id="${agent['id']}">
<span>${agent['label']}</span> <span>${agent['label']}</span>
</li> </li>
@@ -1230,7 +1371,36 @@
</div> </div>
</div> </div>
</div> </div>
<div id="add-newsletter-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="add-newsletter-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">Add a Newsletter Agent</h4>
</div>
<div class="modal-body">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<ul class="stacked-configs list-unstyled">
% for agent in available_newsletter_agents:
<li class="new-newsletter-agent" data-id="${agent['id']}">
<span>${agent['label']}</span>
</li>
% endfor
</ul>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<input type="button" class="btn btn-bright" data-dismiss="modal" value="Cancel">
</div>
</div>
</div>
</div>
<div id="notifier-config-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="notifier-config-modal"></div> <div id="notifier-config-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="notifier-config-modal"></div>
<div id="newsletter-config-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="newsletter-config-modal"></div>
<div id="notify-text-sub-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="notify-text-sub-modal"> <div id="notify-text-sub-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="notify-text-sub-modal">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -1386,6 +1556,53 @@
</div> </div>
<div id="notifier-text-preview-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="notifier-text-preview-modal"> <div id="notifier-text-preview-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="notifier-text-preview-modal">
</div> </div>
<div id="newsletter-text-sub-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="newsletter-text-sub-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">Newsletter Parameters</h4>
</div>
<div class="modal-body">
<div>
<p class="help-block">
If the value for a selected parameter cannot be provided, it will display as blank.
</p>
% for category in common.NEWSLETTER_PARAMETERS:
<table class="notification-params">
<thead>
<tr>
<th colspan="2">
${category['category']}
</th>
</tr>
</thead>
<tbody>
% for parameter in category['parameters']:
<tr>
<td><strong>{${parameter['value']}}</strong></td>
<td>
${parameter['description']}
% if parameter.get('example'):
<span class="small-muted">(${parameter['example']})</span>
% endif
% if parameter.get('help_text'):
<p class="small-muted">(${parameter['help_text']})</p>
% endif
</td>
</tr>
% endfor
</tbody>
</table>
% endfor
</div>
</div>
<div class="modal-footer"></div>
</div>
</div>
</div>
<div id="changelog-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="changelog-modal"> <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-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -1520,6 +1737,29 @@
}); });
} }
function getNewslettersTable() {
$.ajax({
url: 'get_newsletters_table',
cache: false,
async: true,
complete: function(xhr, status) {
$("#plexpy-newsletters-table").html(xhr.responseText);
}
});
}
function loadNewsletterConfig(newsletter_id) {
$.ajax({
url: 'get_newsletter_config_modal',
data: { newsletter_id: newsletter_id },
cache: false,
async: true,
complete: function (xhr, status) {
$("#newsletter-config-modal").html(xhr.responseText).modal('show');
}
});
}
function getMobileDevicesTable() { function getMobileDevicesTable() {
$.ajax({ $.ajax({
url: 'get_mobile_devices_table', url: 'get_mobile_devices_table',
@@ -1573,7 +1813,7 @@ $(document).ready(function() {
} }
function preSaveChecks(_callback) { function preSaveChecks(_callback) {
if ($("#pms_identifier").val() == "") { if (serverChanged) {
verifyServer(); verifyServer();
} }
verifyPMSWebURL(); verifyPMSWebURL();
@@ -1585,13 +1825,14 @@ $(document).ready(function() {
// Alert the user that their changes require a restart. // Alert the user that their changes require a restart.
function postSaveChecks() { function postSaveChecks() {
if (serverChanged || authChanged || httpChanged || directoryChanged) { if (authChanged || httpChanged || directoryChanged) {
$('#restart-modal').modal('show'); $('#restart-modal').modal('show');
} }
$("#http_hashed_password").val($("#http_hash_password").is(":checked") ? 1 : 0); $("#http_hashed_password").val($("#http_hash_password").is(":checked") ? 1 : 0);
getConfigurationTable(); getConfigurationTable();
getSchedulerTable(); getSchedulerTable();
getNotifiersTable(); getNotifiersTable();
getNewslettersTable();
getMobileDevicesTable(); getMobileDevicesTable();
loadUpdateDistros(); loadUpdateDistros();
settingsChanged = false; settingsChanged = false;
@@ -1628,9 +1869,9 @@ $(document).ready(function() {
initConfigCheckbox('#enable_https'); initConfigCheckbox('#enable_https');
initConfigCheckbox('#https_create_cert'); initConfigCheckbox('#https_create_cert');
initConfigCheckbox('#check_github'); initConfigCheckbox('#check_github');
initConfigCheckbox('#notify_upload_posters');
initConfigCheckbox('#monitor_pms_updates'); initConfigCheckbox('#monitor_pms_updates');
initConfigCheckbox('#newsletter_self_hosted');
$('#menu_link_shutdown').click(function() { $('#menu_link_shutdown').click(function() {
$('#confirm-message').text("Are you sure you want to shutdown Tautulli?"); $('#confirm-message').text("Are you sure you want to shutdown Tautulli?");
$('#confirm-modal').modal(); $('#confirm-modal').modal();
@@ -1675,6 +1916,7 @@ $(document).ready(function() {
getConfigurationTable(); getConfigurationTable();
getSchedulerTable(); getSchedulerTable();
getNotifiersTable(); getNotifiersTable();
getNewslettersTable();
getMobileDevicesTable(); getMobileDevicesTable();
$('#changelog-modal-link').on('click', function (e) { $('#changelog-modal-link').on('click', function (e) {
@@ -1769,9 +2011,8 @@ $(document).ready(function() {
$( ".pms-settings" ).change(function() { $( ".pms-settings" ).change(function() {
serverChanged = true; serverChanged = true;
$("#pms_identifier").val("");
$("#server_changed").prop('checked', true); $("#server_changed").prop('checked', true);
verifyServer(); $("#pms_verify").hide();
}); });
$('.checkbox-toggle').click(function () { $('.checkbox-toggle').click(function () {
@@ -1841,7 +2082,11 @@ $(document).ready(function() {
$('#pms_ssl').val(ssl !== 'undefined' && ssl === 1 ? 1 : 0); $('#pms_ssl').val(ssl !== 'undefined' && ssl === 1 ? 1 : 0);
$('#pms_is_cloud').val(is_cloud !== 'undefined' && is_cloud === true ? 1 : 0); $('#pms_is_cloud').val(is_cloud !== 'undefined' && is_cloud === true ? 1 : 0);
$('#pms_url_manual').prop('checked', false); $('#pms_url_manual').prop('checked', false);
$('#pms_url').val('Please verify your server above to retrieve the URL');
PMSCloudCheck(); PMSCloudCheck();
},
onDropdownOpen: function() {
this.clear();
} }
}); });
var select_pms = $select_pms[0].selectize; var select_pms = $select_pms[0].selectize;
@@ -1906,6 +2151,7 @@ $(document).ready(function() {
var pms_identifier = $("#pms_identifier").val(); var pms_identifier = $("#pms_identifier").val();
var pms_ssl = $("#pms_ssl").val(); var pms_ssl = $("#pms_ssl").val();
var pms_is_remote = $("#pms_is_remote").val(); var pms_is_remote = $("#pms_is_remote").val();
var pms_url_manual = $("#pms_url_manual").is(':checked') ? 1 : 0;
if (($("#pms_ip").val() !== '') || ($("#pms_port").val() !== '')) { if (($("#pms_ip").val() !== '') || ($("#pms_port").val() !== '')) {
$("#pms_verify").html('<i class="fa fa-refresh fa-spin"></i>').fadeIn('fast'); $("#pms_verify").html('<i class="fa fa-refresh fa-spin"></i>').fadeIn('fast');
@@ -1914,9 +2160,11 @@ $(document).ready(function() {
data: { data: {
hostname: pms_ip, hostname: pms_ip,
port: pms_port, port: pms_port,
identifier: pms_identifier,
ssl: pms_ssl, ssl: pms_ssl,
remote: pms_is_remote remote: pms_is_remote,
manual: pms_url_manual,
get_url: true,
test_websocket: true
}, },
cache: true, cache: true,
async: true, async: true,
@@ -1925,12 +2173,27 @@ $(document).ready(function() {
$("#pms_verify").html('<i class="fa fa-close"></i>').fadeIn('fast'); $("#pms_verify").html('<i class="fa fa-close"></i>').fadeIn('fast');
$("#pms_ip_group").addClass("has-error"); $("#pms_ip_group").addClass("has-error");
}, },
success: function (json) { success: function(xhr, status) {
var machine_identifier = json; var result = xhr;
if (machine_identifier) { var identifier = result.identifier;
$("#pms_identifier").val(machine_identifier); var url = result.url;
$("#pms_verify").html('<i class="fa fa-check"></i>').fadeIn('fast'); var ws = result.ws;
$("#pms_ip_group").removeClass("has-error"); if (identifier) {
$("#pms_identifier").val(identifier);
if (url) {
$("#pms_url").val(url);
}
if (ws === false) {
$("#pms_verify").html('<i class="fa fa-close"></i>').fadeIn('fast');
$("#pms_ip_group").addClass("has-error");
showMsg('<i class="fa fa-exclamation-circle"></i> Server found but unable to connect websocket.<br>Check the <a href="logs">logs</a> for errors.', false, true, 5000, true)
} else {
$("#pms_verify").html('<i class="fa fa-check"></i>').fadeIn('fast');
$("#pms_ip_group").removeClass("has-error");
serverChanged = false;
}
if (_callback) { if (_callback) {
_callback(); _callback();
@@ -1950,7 +2213,6 @@ $(document).ready(function() {
} }
$('#verify_server_button').on('click', function(){ $('#verify_server_button').on('click', function(){
$("#pms_identifier").val("");
verifyServer(); verifyServer();
}); });
@@ -2272,6 +2534,32 @@ $(document).ready(function() {
}); });
}); });
// Add a new newsletter agent
$('.new-newsletter-agent').click(function () {
$.ajax({
url: 'add_newsletter_config',
data: { agent_id: $(this).data('id') },
cache: false,
async: true,
complete: function (xhr, status) {
var result = $.parseJSON(xhr.responseText);
var msg = result.message;
$('#add-newsletter-modal').modal('hide');
if (result.result == 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
loadNewsletterConfig(result.newsletter_id);
} else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true);
}
getNewslettersTable();
}
});
});
$('#http_base_url').change(function () {
$(this).val($(this).val().replace(/\/*$/, ''));
});
function apiEnabled() { function apiEnabled() {
var api_enabled = $('#api_enabled').prop('checked'); var api_enabled = $('#api_enabled').prop('checked');
$('#app_api_msg').toggle(!(api_enabled)); $('#app_api_msg').toggle(!(api_enabled));
@@ -2281,9 +2569,67 @@ $(document).ready(function() {
apiEnabled(); apiEnabled();
}); });
function imageUpload() {
var upload_val = $('#notify_upload_posters').val();
if (upload_val === '1') {
$('#imgur_upload_options').slideDown();
} else {
$('#imgur_upload_options').slideUp();
}
if (upload_val === '2') {
$('#self_host_image_options').slideDown();
} else {
$('#self_host_image_options').slideUp();
}
if (upload_val === '3') {
$('#cloudinary_upload_options').slideDown();
} else {
$('#cloudinary_upload_options').slideUp();
}
}
$('#notify_upload_posters').change(function () {
imageUpload();
});
function baseURLSet() {
if ($('#http_base_url').val()) {
$('.base-url-warning').hide();
} else {
$('.base-url-warning').show();
}
}
baseURLSet();
$('#http_base_url').change(function () {
baseURLSet();
});
function newsletterUploadEnabled() {
if ($('#notify_upload_posters').val() !== '2' || $('#newsletter_self_hosted').is(':checked')) {
$('#newsletter_upload_warning').hide();
} else {
$('#newsletter_upload_warning').show();
}
}
newsletterUploadEnabled();
$('#notify_upload_posters, #newsletter_self_hosted').change(function () {
baseURLSet();
newsletterUploadEnabled();
});
$('body').on('click', 'a[data-tab-destination]', function () { $('body').on('click', 'a[data-tab-destination]', function () {
var tab = $(this).data('tab-destination'); var tab = $(this).data('tab-destination');
$("a[href=#" + tab + "]").click(); $("a[href=#" + tab + "]").click();
var scroll_destination = $(this).data('target');
if (scroll_destination) {
if ($(scroll_destination).closest('.advanced-setting').length && !$('#menu_link_show_advanced_settings').hasClass('active')) {
$('#menu_link_show_advanced_settings').click()
}
var body_container = $('.body-container')
var scroll_pos = scroll_destination ? body_container.scrollTop() + $(scroll_destination).offset().top - 100 : 0;
body_container.animate({scrollTop: scroll_pos});
}
}); });
}); });
</script> </script>

View File

@@ -58,6 +58,10 @@ DOCUMENTATION :: END
<div class="col-sm-12 text-muted stream-info-current"> <div class="col-sm-12 text-muted stream-info-current">
<i class="fa fa-exclamation-circle"></i> Current session. Updated stream details below may be delayed. <i class="fa fa-exclamation-circle"></i> Current session. Updated stream details below may be delayed.
</div> </div>
% elif data['pre_tautulli']:
<div class="col-sm-12 text-muted stream-info-current">
<i class="fa fa-exclamation-circle"></i> Pre-Tautulli history. Stream details below may be incorrect.
</div>
% endif % endif
<table class="stream-info" style="margin-top: 0;"> <table class="stream-info" style="margin-top: 0;">
<thead> <thead>
@@ -84,8 +88,8 @@ DOCUMENTATION :: END
<tbody> <tbody>
<tr> <tr>
<td>Bitrate</td> <td>Bitrate</td>
<td>${data['stream_bitrate']} kbps</td> <td>${data['stream_bitrate']} ${'kbps' if data['stream_bitrate'] else ''}</td>
<td>${data['bitrate']} kbps</td> <td>${data['bitrate']} ${'kbps' if data['bitrate'] else ''}</td>
</tr> </tr>
% if data['media_type'] != 'track': % if data['media_type'] != 'track':
<tr> <tr>
@@ -154,8 +158,8 @@ DOCUMENTATION :: END
</tr> </tr>
<tr> <tr>
<td>Bitrate</td> <td>Bitrate</td>
<td>${data['stream_video_bitrate']} kbps</td> <td>${data['stream_video_bitrate']} ${'kbps' if data['stream_video_bitrate'] else ''}</td>
<td>${data['video_bitrate']} kbps</td> <td>${data['video_bitrate']} ${'kbps' if data['video_bitrate'] else ''}</td>
</tr> </tr>
<tr> <tr>
<td>Width</td> <td>Width</td>
@@ -199,8 +203,8 @@ DOCUMENTATION :: END
</tr> </tr>
<tr> <tr>
<td>Bitrate</td> <td>Bitrate</td>
<td>${data['stream_audio_bitrate']} kbps</td> <td>${data['stream_audio_bitrate']} ${'kbps' if data['stream_audio_bitrate'] else ''}</td>
<td>${data['audio_bitrate']} kbps</td> <td>${data['audio_bitrate']} ${'kbps' if data['audio_bitrate'] else ''}</td>
</tr> </tr>
<tr> <tr>
<td>Channels</td> <td>Channels</td>

View File

@@ -2,7 +2,7 @@
<%def name="headIncludes()"> <%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css"> <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"> <link rel="stylesheet" href="${http_root}css/dataTables.colVis.css">
<style> <style>
td {word-wrap: break-word} td {word-wrap: break-word}
@@ -100,7 +100,7 @@
// Load user ids and names (for the selector) // Load user ids and names (for the selector)
$.ajax({ $.ajax({
url: 'get_user_names', url: 'get_user_names',
type: 'get', type: 'GET',
dataType: 'json', dataType: 'json',
success: function (data) { success: function (data) {
var select = $('#sync-user'); var select = $('#sync-user');
@@ -116,7 +116,8 @@
function loadSyncTable(selected_user_id) { function loadSyncTable(selected_user_id) {
sync_table_options.ajax = { sync_table_options.ajax = {
url: 'get_sync?user_id=' + selected_user_id url: 'get_sync?user_id=' + selected_user_id,
type: 'POST'
}; };
sync_table = $('#sync_table').DataTable(sync_table_options); sync_table = $('#sync_table').DataTable(sync_table_options);
var colvis = new $.fn.dataTable.ColVis(sync_table, { var colvis = new $.fn.dataTable.ColVis(sync_table, {
@@ -134,7 +135,7 @@
}); });
} }
var selected_user_id = "${_session['user_id']}" == "None" ? null : "${_session['user_id']}"; var selected_user_id = "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}";
loadSyncTable(selected_user_id); loadSyncTable(selected_user_id);
% if _session['user_group'] == 'admin': % if _session['user_group'] == 'admin':

View File

@@ -96,7 +96,7 @@ DOCUMENTATION :: END
</div> </div>
</div> </div>
<div class='table-card-back'> <div class='table-card-back'>
<div id="search-results-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading search results...</div> <div id="search-results-list" class="children-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading search results...</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -32,7 +32,7 @@ DOCUMENTATION :: END
<%def name="headIncludes()"> <%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css"> <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/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>
<%def name="body()"> <%def name="body()">
@@ -413,7 +413,7 @@ DOCUMENTATION :: END
// Build watch history table // Build watch history table
history_table_options.ajax = { history_table_options.ajax = {
url: 'get_history', url: 'get_history',
type: 'post', type: 'POST',
data: function ( d ) { data: function ( d ) {
return { return {
json_data: JSON.stringify( d ), json_data: JSON.stringify( d ),
@@ -442,7 +442,8 @@ DOCUMENTATION :: END
function loadSyncTable() { function loadSyncTable() {
// Build user sync table // Build user sync table
sync_table_options.ajax = { sync_table_options.ajax = {
url: 'get_sync?user_id=' + user_id url: 'get_sync?user_id=' + user_id,
type: 'POST'
}; };
sync_table = $('#sync_table-UID-${data["user_id"]}').DataTable(sync_table_options); sync_table = $('#sync_table-UID-${data["user_id"]}').DataTable(sync_table_options);
sync_table.column(2).visible(false); sync_table.column(2).visible(false);
@@ -457,7 +458,7 @@ DOCUMENTATION :: END
// Build user IP table // Build user IP table
user_ip_table_options.ajax = { user_ip_table_options.ajax = {
url: 'get_user_ips', url: 'get_user_ips',
type: 'post', type: 'POST',
data: function ( d ) { data: function ( d ) {
return { return {
json_data: JSON.stringify( d ), json_data: JSON.stringify( d ),
@@ -474,6 +475,7 @@ DOCUMENTATION :: END
// Build user login table // Build user login table
login_log_table_options.ajax = { login_log_table_options.ajax = {
url: 'get_user_logins', url: 'get_user_logins',
type: 'POST',
data: function(d) { data: function(d) {
return { return {
json_data: JSON.stringify(d), json_data: JSON.stringify(d),

View File

@@ -3,7 +3,7 @@
<%def name="headIncludes()"> <%def name="headIncludes()">
<link rel="stylesheet" href="${http_root}css/dataTables.bootstrap.css"> <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/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>
<%def name="body()"> <%def name="body()">
@@ -94,7 +94,7 @@
json_data: JSON.stringify(d) json_data: JSON.stringify(d)
}; };
} }
} };
users_list_table = $('#users_list_table').DataTable(users_list_table_options); users_list_table = $('#users_list_table').DataTable(users_list_table_options);
var colvis = new $.fn.dataTable.ColVis(users_list_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 1] }); var colvis = new $.fn.dataTable.ColVis(users_list_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 1] });

View File

@@ -14,7 +14,7 @@
<meta name="author" content=""> <meta name="author" content="">
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet"> <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/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/selectize.bootstrap3.css" rel="stylesheet">
<link href="${http_root}css/opensans.min.css" rel="stylesheet"> <link href="${http_root}css/opensans.min.css" rel="stylesheet">
<link href="${http_root}css/font-awesome.min.css" rel="stylesheet"> <link href="${http_root}css/font-awesome.min.css" rel="stylesheet">
@@ -94,7 +94,7 @@
<label for="pms_ip">Plex IP or Hostname</label> <label for="pms_ip">Plex IP or Hostname</label>
<div class="row"> <div class="row">
<div class="col-xs-12"> <div class="col-xs-12">
<select class="form-control selectize-pms-ip" id="pms_ip" name="pms_ip"> <select class="form-control pms-settings selectize-pms-ip" id="pms_ip" name="pms_ip">
<option value="${config['pms_ip']}" selected>${config['pms_ip']}</option> <option value="${config['pms_ip']}" selected>${config['pms_ip']}</option>
</select> </select>
</div> </div>
@@ -104,12 +104,12 @@
<label for="pms_port">Plex Port</label> <label for="pms_port">Plex Port</label>
<div class="row"> <div class="row">
<div class="col-xs-3"> <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> <input type="text" class="form-control pms-settings" name="pms_port" id="pms_port" placeholder="32400" value="${config['pms_port']}" required>
</div> </div>
<div class="col-xs-4"> <div class="col-xs-4">
<div class="checkbox"> <div class="checkbox">
<label> <label>
<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="checkbox" id="pms_ssl_checkbox" class="checkbox-toggle pms-settings" 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']}"> <input type="hidden" id="pms_ssl" name="pms_ssl" value="${config['pms_ssl']}">
</label> </label>
</div> </div>
@@ -117,16 +117,16 @@
<div class="col-xs-4"> <div class="col-xs-4">
<div class="checkbox"> <div class="checkbox">
<label> <label>
<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="checkbox" id="pms_is_remote_checkbox" class="checkbox-toggle pms-settings" 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']}"> <input type="hidden" id="pms_is_remote" name="pms_is_remote" value="${config['pms_is_remote']}">
</label> </label>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<input type="hidden" class="form-control pms-settings" id="pms_valid" data-validate="validatePMSip" value=""> <input type="hidden" 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" 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']}"> <input type="hidden" id="pms_identifier" name="pms_identifier" value="${config['pms_identifier']}">
<a class="btn btn-dark" id="verify-plex-server" href="#" role="button">Verify</a><span style="margin-left: 10px; display: none;" id="pms-verify-status"></span> <a class="btn btn-dark" id="verify-plex-server" href="#" role="button">Verify</a><span style="margin-left: 10px; display: none;" id="pms-verify-status"></span>
</div> </div>
@@ -374,6 +374,9 @@ $(document).ready(function() {
$('#pms_is_remote_checkbox').prop('disabled', false); $('#pms_is_remote_checkbox').prop('disabled', false);
$('#pms_ssl_checkbox').prop('disabled', false); $('#pms_ssl_checkbox').prop('disabled', false);
} }
},
onDropdownOpen: function() {
this.clear();
} }
}); });
var select_pms = $select_pms[0].selectize; var select_pms = $select_pms[0].selectize;
@@ -419,7 +422,8 @@ $(document).ready(function() {
port: pms_port, port: pms_port,
identifier: pms_identifier, identifier: pms_identifier,
ssl: pms_ssl, ssl: pms_ssl,
remote: pms_is_remote }, remote: pms_is_remote
},
cache: true, cache: true,
async: true, async: true,
timeout: 5000, timeout: 5000,
@@ -427,10 +431,11 @@ $(document).ready(function() {
$("#pms-verify-status").html('<i class="fa fa-exclamation-circle"></i> This is not a Plex Server!'); $("#pms-verify-status").html('<i class="fa fa-exclamation-circle"></i> This is not a Plex Server!');
$('#pms-verify-status').fadeIn('fast'); $('#pms-verify-status').fadeIn('fast');
}, },
success: function (json) { success: function(xhr, status) {
var machine_identifier = json; var result = xhr;
if (machine_identifier) { var identifier = result.identifier;
$("#pms_identifier").val(machine_identifier); if (identifier) {
$("#pms_identifier").val(identifier);
$("#pms-verify-status").html('<i class="fa fa-check"></i> Server found!'); $("#pms-verify-status").html('<i class="fa fa-check"></i> Server found!');
$('#pms-verify-status').fadeIn('fast'); $('#pms-verify-status').fadeIn('fast');
pms_verified = true; pms_verified = true;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,12 +3,12 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>Label</key> <key>Label</key>
<string>plexpy</string> <string>tautulli</string>
<key>ProgramArguments</key> <key>ProgramArguments</key>
<array> <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>/usr/bin/python</string>
<string>/Applications/PlexPy/PlexPy.py</string> <string>/Applications/Tautulli/Tautulli.py</string>
</array> </array>
<key>RunAtLoad</key> <key>RunAtLoad</key>
<true/> <true/>

View File

@@ -2,9 +2,9 @@
<!DOCTYPE service_bundle SYSTEM "/usr/share/lib/xml/dtd/service_bundle.dtd.1"> <!DOCTYPE service_bundle SYSTEM "/usr/share/lib/xml/dtd/service_bundle.dtd.1">
<!-- <!--
Created by Manifold 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"/> <create_default_instance enabled="true"/>
@@ -19,10 +19,10 @@
</dependency> </dependency>
<method_context> <method_context>
<method_credential user="plexpy" group="nogroup"/> <method_credential user="tautulli" group="nogroup"/>
</method_context> </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"/> <exec_method type="method" name="stop" exec=":kill" timeout_seconds="60"/>
@@ -37,7 +37,7 @@
<template> <template>
<common_name> <common_name>
<loctext xml:lang="C"> <loctext xml:lang="C">
PlexPy Tautulli
</loctext> </loctext>
</common_name> </common_name>
</template> </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 # Service Unit file for systemd system manager
# #
# INSTALLATION NOTES # INSTALLATION NOTES
# #
# 1. Rename this file as you want, ensuring that it ends in .service # 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 # 2. Adjust configuration settings as required. More details in the
# "CONFIGURATION NOTES" section shown below. # "CONFIGURATION NOTES" section shown below.
@@ -15,39 +15,39 @@
# #
# 4. Enable boot-time autostart with the following commands: # 4. Enable boot-time autostart with the following commands:
# systemctl daemon-reload # systemctl daemon-reload
# systemctl enable plexpy.service # systemctl enable tautulli.service
# #
# 5. Start now with the following command: # 5. Start now with the following command:
# systemctl start plexpy.service # systemctl start tautulli.service
# #
# CONFIGURATION NOTES # CONFIGURATION NOTES
# #
# - The example settings in this file assume that you will run PlexPy as user: 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 plexpy directory: # - To create this user and give it ownership of the tautulli directory:
# sudo adduser --system --no-create-home plexpy # sudo adduser --system --no-create-home tautulli
# sudo chown plexpy:nogroup -R /opt/plexpy # sudo chown tautulli:nogroup -R /opt/Tautulli
# #
# - Option names (e.g. ExecStart=, Type=) appear to be case-sensitive) # - Option names (e.g. ExecStart=, Type=) appear to be case-sensitive)
# #
# - Adjust ExecStart= to point to: # - 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) # 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) # multi-user.target equates to runlevel 3 (multi-user text mode)
# graphical.target equates to runlevel 5 (multi-user X11 graphical mode) # graphical.target equates to runlevel 5 (multi-user X11 graphical mode)
[Unit] [Unit]
Description=PlexPy - Stats for Plex Media Server usage Description=Tautulli - Stats for Plex Media Server usage
[Service] [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 GuessMainPID=no
Type=forking Type=forking
User=plexpy User=tautulli
Group=nogroup Group=nogroup
[Install] [Install]

View File

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

View File

@@ -1,5 +1,10 @@
version_info = (3, 0, 1) from pkg_resources import get_distribution, DistributionNotFound
version = '3.0.1'
release = '3.0.1'
__version__ = release # PEP 396 try:
release = get_distribution('APScheduler').version.split('-')[0]
except DistributionNotFound:
release = '3.5.0'
version_info = tuple(int(x) if x.isdigit() else x for x in release.split('.'))
version = __version__ = '.'.join(str(x) for x in version_info[:3])
del get_distribution, DistributionNotFound

View File

@@ -1,25 +1,33 @@
__all__ = ('EVENT_SCHEDULER_START', 'EVENT_SCHEDULER_SHUTDOWN', 'EVENT_EXECUTOR_ADDED', 'EVENT_EXECUTOR_REMOVED', __all__ = ('EVENT_SCHEDULER_STARTED', 'EVENT_SCHEDULER_SHUTDOWN', 'EVENT_SCHEDULER_PAUSED',
'EVENT_JOBSTORE_ADDED', 'EVENT_JOBSTORE_REMOVED', 'EVENT_ALL_JOBS_REMOVED', 'EVENT_JOB_ADDED', 'EVENT_SCHEDULER_RESUMED', 'EVENT_EXECUTOR_ADDED', 'EVENT_EXECUTOR_REMOVED',
'EVENT_JOB_REMOVED', 'EVENT_JOB_MODIFIED', 'EVENT_JOB_EXECUTED', 'EVENT_JOB_ERROR', 'EVENT_JOB_MISSED', 'EVENT_JOBSTORE_ADDED', 'EVENT_JOBSTORE_REMOVED', 'EVENT_ALL_JOBS_REMOVED',
'EVENT_JOB_ADDED', 'EVENT_JOB_REMOVED', 'EVENT_JOB_MODIFIED', 'EVENT_JOB_EXECUTED',
'EVENT_JOB_ERROR', 'EVENT_JOB_MISSED', 'EVENT_JOB_SUBMITTED', 'EVENT_JOB_MAX_INSTANCES',
'SchedulerEvent', 'JobEvent', 'JobExecutionEvent') 'SchedulerEvent', 'JobEvent', 'JobExecutionEvent')
EVENT_SCHEDULER_START = 1 EVENT_SCHEDULER_STARTED = EVENT_SCHEDULER_START = 2 ** 0
EVENT_SCHEDULER_SHUTDOWN = 2 EVENT_SCHEDULER_SHUTDOWN = 2 ** 1
EVENT_EXECUTOR_ADDED = 4 EVENT_SCHEDULER_PAUSED = 2 ** 2
EVENT_EXECUTOR_REMOVED = 8 EVENT_SCHEDULER_RESUMED = 2 ** 3
EVENT_JOBSTORE_ADDED = 16 EVENT_EXECUTOR_ADDED = 2 ** 4
EVENT_JOBSTORE_REMOVED = 32 EVENT_EXECUTOR_REMOVED = 2 ** 5
EVENT_ALL_JOBS_REMOVED = 64 EVENT_JOBSTORE_ADDED = 2 ** 6
EVENT_JOB_ADDED = 128 EVENT_JOBSTORE_REMOVED = 2 ** 7
EVENT_JOB_REMOVED = 256 EVENT_ALL_JOBS_REMOVED = 2 ** 8
EVENT_JOB_MODIFIED = 512 EVENT_JOB_ADDED = 2 ** 9
EVENT_JOB_EXECUTED = 1024 EVENT_JOB_REMOVED = 2 ** 10
EVENT_JOB_ERROR = 2048 EVENT_JOB_MODIFIED = 2 ** 11
EVENT_JOB_MISSED = 4096 EVENT_JOB_EXECUTED = 2 ** 12
EVENT_ALL = (EVENT_SCHEDULER_START | EVENT_SCHEDULER_SHUTDOWN | EVENT_JOBSTORE_ADDED | EVENT_JOBSTORE_REMOVED | EVENT_JOB_ERROR = 2 ** 13
EVENT_JOB_MISSED = 2 ** 14
EVENT_JOB_SUBMITTED = 2 ** 15
EVENT_JOB_MAX_INSTANCES = 2 ** 16
EVENT_ALL = (EVENT_SCHEDULER_STARTED | EVENT_SCHEDULER_SHUTDOWN | EVENT_SCHEDULER_PAUSED |
EVENT_SCHEDULER_RESUMED | EVENT_EXECUTOR_ADDED | EVENT_EXECUTOR_REMOVED |
EVENT_JOBSTORE_ADDED | EVENT_JOBSTORE_REMOVED | EVENT_ALL_JOBS_REMOVED |
EVENT_JOB_ADDED | EVENT_JOB_REMOVED | EVENT_JOB_MODIFIED | EVENT_JOB_EXECUTED | EVENT_JOB_ADDED | EVENT_JOB_REMOVED | EVENT_JOB_MODIFIED | EVENT_JOB_EXECUTED |
EVENT_JOB_ERROR | EVENT_JOB_MISSED) EVENT_JOB_ERROR | EVENT_JOB_MISSED | EVENT_JOB_SUBMITTED | EVENT_JOB_MAX_INSTANCES)
class SchedulerEvent(object): class SchedulerEvent(object):
@@ -55,9 +63,21 @@ class JobEvent(SchedulerEvent):
self.jobstore = jobstore self.jobstore = jobstore
class JobSubmissionEvent(JobEvent):
"""
An event that concerns the submission of a job to its executor.
:ivar scheduled_run_times: a list of datetimes when the job was intended to run
"""
def __init__(self, code, job_id, jobstore, scheduled_run_times):
super(JobSubmissionEvent, self).__init__(code, job_id, jobstore)
self.scheduled_run_times = scheduled_run_times
class JobExecutionEvent(JobEvent): class JobExecutionEvent(JobEvent):
""" """
An event that concerns the execution of individual jobs. An event that concerns the running of a job within its executor.
:ivar scheduled_run_time: the time when the job was scheduled to be run :ivar scheduled_run_time: the time when the job was scheduled to be run
:ivar retval: the return value of the successfully executed job :ivar retval: the return value of the successfully executed job
@@ -65,7 +85,8 @@ class JobExecutionEvent(JobEvent):
:ivar traceback: a formatted traceback for the exception :ivar traceback: a formatted traceback for the exception
""" """
def __init__(self, code, job_id, jobstore, scheduled_run_time, retval=None, exception=None, traceback=None): def __init__(self, code, job_id, jobstore, scheduled_run_time, retval=None, exception=None,
traceback=None):
super(JobExecutionEvent, self).__init__(code, job_id, jobstore) super(JobExecutionEvent, self).__init__(code, job_id, jobstore)
self.scheduled_run_time = scheduled_run_time self.scheduled_run_time = scheduled_run_time
self.retval = retval self.retval = retval

View File

@@ -1,28 +1,60 @@
from __future__ import absolute_import from __future__ import absolute_import
import sys import sys
from apscheduler.executors.base import BaseExecutor, run_job from apscheduler.executors.base import BaseExecutor, run_job
try:
from asyncio import iscoroutinefunction
from apscheduler.executors.base_py3 import run_coroutine_job
except ImportError:
from trollius import iscoroutinefunction
run_coroutine_job = None
class AsyncIOExecutor(BaseExecutor): class AsyncIOExecutor(BaseExecutor):
""" """
Runs jobs in the default executor of the event loop. Runs jobs in the default executor of the event loop.
If the job function is a native coroutine function, it is scheduled to be run directly in the
event loop as soon as possible. All other functions are run in the event loop's default
executor which is usually a thread pool.
Plugin alias: ``asyncio`` Plugin alias: ``asyncio``
""" """
def start(self, scheduler, alias): def start(self, scheduler, alias):
super(AsyncIOExecutor, self).start(scheduler, alias) super(AsyncIOExecutor, self).start(scheduler, alias)
self._eventloop = scheduler._eventloop self._eventloop = scheduler._eventloop
self._pending_futures = set()
def shutdown(self, wait=True):
# There is no way to honor wait=True without converting this method into a coroutine method
for f in self._pending_futures:
if not f.done():
f.cancel()
self._pending_futures.clear()
def _do_submit_job(self, job, run_times): def _do_submit_job(self, job, run_times):
def callback(f): def callback(f):
self._pending_futures.discard(f)
try: try:
events = f.result() events = f.result()
except: except BaseException:
self._run_job_error(job.id, *sys.exc_info()[1:]) self._run_job_error(job.id, *sys.exc_info()[1:])
else: else:
self._run_job_success(job.id, events) self._run_job_success(job.id, events)
f = self._eventloop.run_in_executor(None, run_job, job, job._jobstore_alias, run_times, self._logger.name) if iscoroutinefunction(job.func):
if run_coroutine_job is not None:
coro = run_coroutine_job(job, job._jobstore_alias, run_times, self._logger.name)
f = self._eventloop.create_task(coro)
else:
raise Exception('Executing coroutine based jobs is not supported with Trollius')
else:
f = self._eventloop.run_in_executor(None, run_job, job, job._jobstore_alias, run_times,
self._logger.name)
f.add_done_callback(callback) f.add_done_callback(callback)
self._pending_futures.add(f)

View File

@@ -8,13 +8,15 @@ import sys
from pytz import utc from pytz import utc
import six import six
from apscheduler.events import JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED from apscheduler.events import (
JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED)
class MaxInstancesReachedError(Exception): class MaxInstancesReachedError(Exception):
def __init__(self, job): def __init__(self, job):
super(MaxInstancesReachedError, self).__init__( super(MaxInstancesReachedError, self).__init__(
'Job "%s" has already reached its maximum number of instances (%d)' % (job.id, job.max_instances)) 'Job "%s" has already reached its maximum number of instances (%d)' %
(job.id, job.max_instances))
class BaseExecutor(six.with_metaclass(ABCMeta, object)): class BaseExecutor(six.with_metaclass(ABCMeta, object)):
@@ -30,13 +32,14 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
def start(self, scheduler, alias): def start(self, scheduler, alias):
""" """
Called by the scheduler when the scheduler is being started or when the executor is being added to an already Called by the scheduler when the scheduler is being started or when the executor is being
running scheduler. added to an already running scheduler.
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting this executor :param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting
this executor
:param str|unicode alias: alias of this executor as it was assigned to the scheduler :param str|unicode alias: alias of this executor as it was assigned to the scheduler
"""
"""
self._scheduler = scheduler self._scheduler = scheduler
self._lock = scheduler._create_lock() self._lock = scheduler._create_lock()
self._logger = logging.getLogger('apscheduler.executors.%s' % alias) self._logger = logging.getLogger('apscheduler.executors.%s' % alias)
@@ -45,7 +48,8 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
""" """
Shuts down this executor. Shuts down this executor.
:param bool wait: ``True`` to wait until all submitted jobs have been executed :param bool wait: ``True`` to wait until all submitted jobs
have been executed
""" """
def submit_job(self, job, run_times): def submit_job(self, job, run_times):
@@ -53,10 +57,12 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
Submits job for execution. Submits job for execution.
:param Job job: job to execute :param Job job: job to execute
:param list[datetime] run_times: list of datetimes specifying when the job should have been run :param list[datetime] run_times: list of datetimes specifying
:raises MaxInstancesReachedError: if the maximum number of allowed instances for this job has been reached when the job should have been run
""" :raises MaxInstancesReachedError: if the maximum number of
allowed instances for this job has been reached
"""
assert self._lock is not None, 'This executor has not been started yet' assert self._lock is not None, 'This executor has not been started yet'
with self._lock: with self._lock:
if self._instances[job.id] >= job.max_instances: if self._instances[job.id] >= job.max_instances:
@@ -70,50 +76,71 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
"""Performs the actual task of scheduling `run_job` to be called.""" """Performs the actual task of scheduling `run_job` to be called."""
def _run_job_success(self, job_id, events): def _run_job_success(self, job_id, events):
"""Called by the executor with the list of generated events when `run_job` has been successfully called.""" """
Called by the executor with the list of generated events when :func:`run_job` has been
successfully called.
"""
with self._lock: with self._lock:
self._instances[job_id] -= 1 self._instances[job_id] -= 1
if self._instances[job_id] == 0:
del self._instances[job_id]
for event in events: for event in events:
self._scheduler._dispatch_event(event) self._scheduler._dispatch_event(event)
def _run_job_error(self, job_id, exc, traceback=None): def _run_job_error(self, job_id, exc, traceback=None):
"""Called by the executor with the exception if there is an error calling `run_job`.""" """Called by the executor with the exception if there is an error calling `run_job`."""
with self._lock: with self._lock:
self._instances[job_id] -= 1 self._instances[job_id] -= 1
if self._instances[job_id] == 0:
del self._instances[job_id]
exc_info = (exc.__class__, exc, traceback) exc_info = (exc.__class__, exc, traceback)
self._logger.error('Error running job %s', job_id, exc_info=exc_info) self._logger.error('Error running job %s', job_id, exc_info=exc_info)
def run_job(job, jobstore_alias, run_times, logger_name): def run_job(job, jobstore_alias, run_times, logger_name):
"""Called by executors to run the job. Returns a list of scheduler events to be dispatched by the scheduler.""" """
Called by executors to run the job. Returns a list of scheduler events to be dispatched by the
scheduler.
"""
events = [] events = []
logger = logging.getLogger(logger_name) logger = logging.getLogger(logger_name)
for run_time in run_times: for run_time in run_times:
# See if the job missed its run time window, and handle possible misfires accordingly # See if the job missed its run time window, and handle
# possible misfires accordingly
if job.misfire_grace_time is not None: if job.misfire_grace_time is not None:
difference = datetime.now(utc) - run_time difference = datetime.now(utc) - run_time
grace_time = timedelta(seconds=job.misfire_grace_time) grace_time = timedelta(seconds=job.misfire_grace_time)
if difference > grace_time: if difference > grace_time:
events.append(JobExecutionEvent(EVENT_JOB_MISSED, job.id, jobstore_alias, run_time)) events.append(JobExecutionEvent(EVENT_JOB_MISSED, job.id, jobstore_alias,
run_time))
logger.warning('Run time of job "%s" was missed by %s', job, difference) logger.warning('Run time of job "%s" was missed by %s', job, difference)
continue continue
logger.info('Running job "%s" (scheduled at %s)', job, run_time) logger.info('Running job "%s" (scheduled at %s)', job, run_time)
try: try:
retval = job.func(*job.args, **job.kwargs) retval = job.func(*job.args, **job.kwargs)
except: except BaseException:
exc, tb = sys.exc_info()[1:] exc, tb = sys.exc_info()[1:]
formatted_tb = ''.join(format_tb(tb)) formatted_tb = ''.join(format_tb(tb))
events.append(JobExecutionEvent(EVENT_JOB_ERROR, job.id, jobstore_alias, run_time, exception=exc, events.append(JobExecutionEvent(EVENT_JOB_ERROR, job.id, jobstore_alias, run_time,
traceback=formatted_tb)) exception=exc, traceback=formatted_tb))
logger.exception('Job "%s" raised an exception', job) logger.exception('Job "%s" raised an exception', job)
# This is to prevent cyclic references that would lead to memory leaks
if six.PY2:
sys.exc_clear()
del tb
else:
import traceback
traceback.clear_frames(tb)
del tb
else: else:
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time, retval=retval)) events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time,
retval=retval))
logger.info('Job "%s" executed successfully', job) logger.info('Job "%s" executed successfully', job)
return events return events

View File

@@ -0,0 +1,41 @@
import logging
import sys
from datetime import datetime, timedelta
from traceback import format_tb
from pytz import utc
from apscheduler.events import (
JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED)
async def run_coroutine_job(job, jobstore_alias, run_times, logger_name):
"""Coroutine version of run_job()."""
events = []
logger = logging.getLogger(logger_name)
for run_time in run_times:
# See if the job missed its run time window, and handle possible misfires accordingly
if job.misfire_grace_time is not None:
difference = datetime.now(utc) - run_time
grace_time = timedelta(seconds=job.misfire_grace_time)
if difference > grace_time:
events.append(JobExecutionEvent(EVENT_JOB_MISSED, job.id, jobstore_alias,
run_time))
logger.warning('Run time of job "%s" was missed by %s', job, difference)
continue
logger.info('Running job "%s" (scheduled at %s)', job, run_time)
try:
retval = await job.func(*job.args, **job.kwargs)
except BaseException:
exc, tb = sys.exc_info()[1:]
formatted_tb = ''.join(format_tb(tb))
events.append(JobExecutionEvent(EVENT_JOB_ERROR, job.id, jobstore_alias, run_time,
exception=exc, traceback=formatted_tb))
logger.exception('Job "%s" raised an exception', job)
else:
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time,
retval=retval))
logger.info('Job "%s" executed successfully', job)
return events

View File

@@ -5,7 +5,8 @@ from apscheduler.executors.base import BaseExecutor, run_job
class DebugExecutor(BaseExecutor): class DebugExecutor(BaseExecutor):
""" """
A special executor that executes the target callable directly instead of deferring it to a thread or process. A special executor that executes the target callable directly instead of deferring it to a
thread or process.
Plugin alias: ``debug`` Plugin alias: ``debug``
""" """
@@ -13,7 +14,7 @@ class DebugExecutor(BaseExecutor):
def _do_submit_job(self, job, run_times): def _do_submit_job(self, job, run_times):
try: try:
events = run_job(job, job._jobstore_alias, run_times, self._logger.name) events = run_job(job, job._jobstore_alias, run_times, self._logger.name)
except: except BaseException:
self._run_job_error(job.id, *sys.exc_info()[1:]) self._run_job_error(job.id, *sys.exc_info()[1:])
else: else:
self._run_job_success(job.id, events) self._run_job_success(job.id, events)

View File

@@ -21,9 +21,10 @@ class GeventExecutor(BaseExecutor):
def callback(greenlet): def callback(greenlet):
try: try:
events = greenlet.get() events = greenlet.get()
except: except BaseException:
self._run_job_error(job.id, *sys.exc_info()[1:]) self._run_job_error(job.id, *sys.exc_info()[1:])
else: else:
self._run_job_success(job.id, events) self._run_job_success(job.id, events)
gevent.spawn(run_job, job, job._jobstore_alias, run_times, self._logger.name).link(callback) gevent.spawn(run_job, job, job._jobstore_alias, run_times, self._logger.name).\
link(callback)

View File

@@ -0,0 +1,54 @@
from __future__ import absolute_import
import sys
from concurrent.futures import ThreadPoolExecutor
from tornado.gen import convert_yielded
from apscheduler.executors.base import BaseExecutor, run_job
try:
from inspect import iscoroutinefunction
from apscheduler.executors.base_py3 import run_coroutine_job
except ImportError:
def iscoroutinefunction(func):
return False
class TornadoExecutor(BaseExecutor):
"""
Runs jobs either in a thread pool or directly on the I/O loop.
If the job function is a native coroutine function, it is scheduled to be run directly in the
I/O loop as soon as possible. All other functions are run in a thread pool.
Plugin alias: ``tornado``
:param int max_workers: maximum number of worker threads in the thread pool
"""
def __init__(self, max_workers=10):
super(TornadoExecutor, self).__init__()
self.executor = ThreadPoolExecutor(max_workers)
def start(self, scheduler, alias):
super(TornadoExecutor, self).start(scheduler, alias)
self._ioloop = scheduler._ioloop
def _do_submit_job(self, job, run_times):
def callback(f):
try:
events = f.result()
except BaseException:
self._run_job_error(job.id, *sys.exc_info()[1:])
else:
self._run_job_success(job.id, events)
if iscoroutinefunction(job.func):
f = run_coroutine_job(job, job._jobstore_alias, run_times, self._logger.name)
else:
f = self.executor.submit(run_job, job, job._jobstore_alias, run_times,
self._logger.name)
f = convert_yielded(f)
f.add_done_callback(callback)

View File

@@ -21,5 +21,5 @@ class TwistedExecutor(BaseExecutor):
else: else:
self._run_job_error(job.id, result.value, result.tb) self._run_job_error(job.id, result.value, result.tb)
self._reactor.getThreadPool().callInThreadWithCallback(callback, run_job, job, job._jobstore_alias, run_times, self._reactor.getThreadPool().callInThreadWithCallback(
self._logger.name) callback, run_job, job, job._jobstore_alias, run_times, self._logger.name)

View File

@@ -4,8 +4,9 @@ from uuid import uuid4
import six import six
from apscheduler.triggers.base import BaseTrigger from apscheduler.triggers.base import BaseTrigger
from apscheduler.util import ref_to_obj, obj_to_ref, datetime_repr, repr_escape, get_callable_name, check_callable_args, \ from apscheduler.util import (
convert_to_datetime ref_to_obj, obj_to_ref, datetime_repr, repr_escape, get_callable_name, check_callable_args,
convert_to_datetime)
class Job(object): class Job(object):
@@ -21,13 +22,20 @@ class Job(object):
:var bool coalesce: whether to only run the job once when several run times are due :var bool coalesce: whether to only run the job once when several run times are due
:var trigger: the trigger object that controls the schedule of this job :var trigger: the trigger object that controls the schedule of this job
:var str executor: the name of the executor that will run this job :var str executor: the name of the executor that will run this job
:var int misfire_grace_time: the time (in seconds) how much this job's execution is allowed to be late :var int misfire_grace_time: the time (in seconds) how much this job's execution is allowed to
:var int max_instances: the maximum number of concurrently executing instances allowed for this job be late
:var int max_instances: the maximum number of concurrently executing instances allowed for this
job
:var datetime.datetime next_run_time: the next scheduled run time of this job :var datetime.datetime next_run_time: the next scheduled run time of this job
.. note::
The ``misfire_grace_time`` has some non-obvious effects on job execution. See the
:ref:`missed-job-executions` section in the documentation for an in-depth explanation.
""" """
__slots__ = ('_scheduler', '_jobstore_alias', 'id', 'trigger', 'executor', 'func', 'func_ref', 'args', 'kwargs', __slots__ = ('_scheduler', '_jobstore_alias', 'id', 'trigger', 'executor', 'func', 'func_ref',
'name', 'misfire_grace_time', 'coalesce', 'max_instances', 'next_run_time') 'args', 'kwargs', 'name', 'misfire_grace_time', 'coalesce', 'max_instances',
'next_run_time')
def __init__(self, scheduler, id=None, **kwargs): def __init__(self, scheduler, id=None, **kwargs):
super(Job, self).__init__() super(Job, self).__init__()
@@ -38,53 +46,69 @@ class Job(object):
def modify(self, **changes): def modify(self, **changes):
""" """
Makes the given changes to this job and saves it in the associated job store. Makes the given changes to this job and saves it in the associated job store.
Accepted keyword arguments are the same as the variables on this class. Accepted keyword arguments are the same as the variables on this class.
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.modify_job` .. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.modify_job`
"""
:return Job: this job instance
"""
self._scheduler.modify_job(self.id, self._jobstore_alias, **changes) self._scheduler.modify_job(self.id, self._jobstore_alias, **changes)
return self
def reschedule(self, trigger, **trigger_args): def reschedule(self, trigger, **trigger_args):
""" """
Shortcut for switching the trigger on this job. Shortcut for switching the trigger on this job.
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.reschedule_job` .. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.reschedule_job`
"""
:return Job: this job instance
"""
self._scheduler.reschedule_job(self.id, self._jobstore_alias, trigger, **trigger_args) self._scheduler.reschedule_job(self.id, self._jobstore_alias, trigger, **trigger_args)
return self
def pause(self): def pause(self):
""" """
Temporarily suspend the execution of this job. Temporarily suspend the execution of this job.
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.pause_job` .. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.pause_job`
"""
:return Job: this job instance
"""
self._scheduler.pause_job(self.id, self._jobstore_alias) self._scheduler.pause_job(self.id, self._jobstore_alias)
return self
def resume(self): def resume(self):
""" """
Resume the schedule of this job if previously paused. Resume the schedule of this job if previously paused.
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.resume_job` .. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.resume_job`
"""
:return Job: this job instance
"""
self._scheduler.resume_job(self.id, self._jobstore_alias) self._scheduler.resume_job(self.id, self._jobstore_alias)
return self
def remove(self): def remove(self):
""" """
Unschedules this job and removes it from its associated job store. Unschedules this job and removes it from its associated job store.
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.remove_job` .. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.remove_job`
"""
"""
self._scheduler.remove_job(self.id, self._jobstore_alias) self._scheduler.remove_job(self.id, self._jobstore_alias)
@property @property
def pending(self): def pending(self):
"""Returns ``True`` if the referenced job is still waiting to be added to its designated job store.""" """
Returns ``True`` if the referenced job is still waiting to be added to its designated job
store.
"""
return self._jobstore_alias is None return self._jobstore_alias is None
# #
@@ -97,8 +121,8 @@ class Job(object):
:type now: datetime.datetime :type now: datetime.datetime
:rtype: list[datetime.datetime] :rtype: list[datetime.datetime]
"""
"""
run_times = [] run_times = []
next_run_time = self.next_run_time next_run_time = self.next_run_time
while next_run_time and next_run_time <= now: while next_run_time and next_run_time <= now:
@@ -108,8 +132,11 @@ class Job(object):
return run_times return run_times
def _modify(self, **changes): def _modify(self, **changes):
"""Validates the changes to the Job and makes the modifications if and only if all of them validate.""" """
Validates the changes to the Job and makes the modifications if and only if all of them
validate.
"""
approved = {} approved = {}
if 'id' in changes: if 'id' in changes:
@@ -125,7 +152,7 @@ class Job(object):
args = changes.pop('args') if 'args' in changes else self.args args = changes.pop('args') if 'args' in changes else self.args
kwargs = changes.pop('kwargs') if 'kwargs' in changes else self.kwargs kwargs = changes.pop('kwargs') if 'kwargs' in changes else self.kwargs
if isinstance(func, str): if isinstance(func, six.string_types):
func_ref = func func_ref = func
func = ref_to_obj(func) func = ref_to_obj(func)
elif callable(func): elif callable(func):
@@ -177,7 +204,8 @@ class Job(object):
if 'trigger' in changes: if 'trigger' in changes:
trigger = changes.pop('trigger') trigger = changes.pop('trigger')
if not isinstance(trigger, BaseTrigger): if not isinstance(trigger, BaseTrigger):
raise TypeError('Expected a trigger instance, got %s instead' % trigger.__class__.__name__) raise TypeError('Expected a trigger instance, got %s instead' %
trigger.__class__.__name__)
approved['trigger'] = trigger approved['trigger'] = trigger
@@ -189,10 +217,12 @@ class Job(object):
if 'next_run_time' in changes: if 'next_run_time' in changes:
value = changes.pop('next_run_time') value = changes.pop('next_run_time')
approved['next_run_time'] = convert_to_datetime(value, self._scheduler.timezone, 'next_run_time') approved['next_run_time'] = convert_to_datetime(value, self._scheduler.timezone,
'next_run_time')
if changes: if changes:
raise AttributeError('The following are not modifiable attributes of Job: %s' % ', '.join(changes)) raise AttributeError('The following are not modifiable attributes of Job: %s' %
', '.join(changes))
for key, value in six.iteritems(approved): for key, value in six.iteritems(approved):
setattr(self, key, value) setattr(self, key, value)
@@ -200,9 +230,10 @@ class Job(object):
def __getstate__(self): def __getstate__(self):
# Don't allow this Job to be serialized if the function reference could not be determined # Don't allow this Job to be serialized if the function reference could not be determined
if not self.func_ref: if not self.func_ref:
raise ValueError('This Job cannot be serialized since the reference to its callable (%r) could not be ' raise ValueError(
'determined. Consider giving a textual reference (module:function name) instead.' % 'This Job cannot be serialized since the reference to its callable (%r) could not '
(self.func,)) 'be determined. Consider giving a textual reference (module:function name) '
'instead.' % (self.func,))
return { return {
'version': 1, 'version': 1,
@@ -221,7 +252,8 @@ class Job(object):
def __setstate__(self, state): def __setstate__(self, state):
if state.get('version', 1) > 1: if state.get('version', 1) > 1:
raise ValueError('Job has version %s, but only version 1 can be handled' % state['version']) raise ValueError('Job has version %s, but only version 1 can be handled' %
state['version'])
self.id = state['id'] self.id = state['id']
self.func_ref = state['func'] self.func_ref = state['func']
@@ -245,8 +277,13 @@ class Job(object):
return '<Job (id=%s name=%s)>' % (repr_escape(self.id), repr_escape(self.name)) return '<Job (id=%s name=%s)>' % (repr_escape(self.id), repr_escape(self.name))
def __str__(self): def __str__(self):
return '%s (trigger: %s, next run at: %s)' % (repr_escape(self.name), repr_escape(str(self.trigger)), return repr_escape(self.__unicode__())
datetime_repr(self.next_run_time))
def __unicode__(self): def __unicode__(self):
return six.u('%s (trigger: %s, next run at: %s)') % (self.name, self.trigger, datetime_repr(self.next_run_time)) if hasattr(self, 'next_run_time'):
status = ('next run at: ' + datetime_repr(self.next_run_time) if
self.next_run_time else 'paused')
else:
status = 'pending'
return u'%s (trigger: %s, %s)' % (self.name, self.trigger, status)

View File

@@ -8,23 +8,27 @@ class JobLookupError(KeyError):
"""Raised when the job store cannot find a job for update or removal.""" """Raised when the job store cannot find a job for update or removal."""
def __init__(self, job_id): def __init__(self, job_id):
super(JobLookupError, self).__init__(six.u('No job by the id of %s was found') % job_id) super(JobLookupError, self).__init__(u'No job by the id of %s was found' % job_id)
class ConflictingIdError(KeyError): class ConflictingIdError(KeyError):
"""Raised when the uniqueness of job IDs is being violated.""" """Raised when the uniqueness of job IDs is being violated."""
def __init__(self, job_id): def __init__(self, job_id):
super(ConflictingIdError, self).__init__(six.u('Job identifier (%s) conflicts with an existing job') % job_id) super(ConflictingIdError, self).__init__(
u'Job identifier (%s) conflicts with an existing job' % job_id)
class TransientJobError(ValueError): class TransientJobError(ValueError):
"""Raised when an attempt to add transient (with no func_ref) job to a persistent job store is detected.""" """
Raised when an attempt to add transient (with no func_ref) job to a persistent job store is
detected.
"""
def __init__(self, job_id): def __init__(self, job_id):
super(TransientJobError, self).__init__( super(TransientJobError, self).__init__(
six.u('Job (%s) cannot be added to this job store because a reference to the callable could not be ' u'Job (%s) cannot be added to this job store because a reference to the callable '
'determined.') % job_id) u'could not be determined.' % job_id)
class BaseJobStore(six.with_metaclass(ABCMeta)): class BaseJobStore(six.with_metaclass(ABCMeta)):
@@ -36,10 +40,11 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
def start(self, scheduler, alias): def start(self, scheduler, alias):
""" """
Called by the scheduler when the scheduler is being started or when the job store is being added to an already Called by the scheduler when the scheduler is being started or when the job store is being
running scheduler. added to an already running scheduler.
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting this job store :param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting
this job store
:param str|unicode alias: alias of this job store as it was assigned to the scheduler :param str|unicode alias: alias of this job store as it was assigned to the scheduler
""" """
@@ -50,13 +55,22 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
def shutdown(self): def shutdown(self):
"""Frees any resources still bound to this job store.""" """Frees any resources still bound to this job store."""
def _fix_paused_jobs_sorting(self, jobs):
for i, job in enumerate(jobs):
if job.next_run_time is not None:
if i > 0:
paused_jobs = jobs[:i]
del jobs[:i]
jobs.extend(paused_jobs)
break
@abstractmethod @abstractmethod
def lookup_job(self, job_id): def lookup_job(self, job_id):
""" """
Returns a specific job, or ``None`` if it isn't found.. Returns a specific job, or ``None`` if it isn't found..
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of the returned job to The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of
point to the scheduler and itself, respectively. the returned job to point to the scheduler and itself, respectively.
:param str|unicode job_id: identifier of the job :param str|unicode job_id: identifier of the job
:rtype: Job :rtype: Job
@@ -75,7 +89,8 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
@abstractmethod @abstractmethod
def get_next_run_time(self): def get_next_run_time(self):
""" """
Returns the earliest run time of all the jobs stored in this job store, or ``None`` if there are no active jobs. Returns the earliest run time of all the jobs stored in this job store, or ``None`` if
there are no active jobs.
:rtype: datetime.datetime :rtype: datetime.datetime
""" """
@@ -83,11 +98,12 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
@abstractmethod @abstractmethod
def get_all_jobs(self): def get_all_jobs(self):
""" """
Returns a list of all jobs in this job store. The returned jobs should be sorted by next run time (ascending). Returns a list of all jobs in this job store.
Paused jobs (next_run_time is None) should be sorted last. The returned jobs should be sorted by next run time (ascending).
Paused jobs (next_run_time == None) should be sorted last.
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of the returned jobs to The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of
point to the scheduler and itself, respectively. the returned jobs to point to the scheduler and itself, respectively.
:rtype: list[Job] :rtype: list[Job]
""" """

View File

@@ -13,7 +13,8 @@ class MemoryJobStore(BaseJobStore):
def __init__(self): def __init__(self):
super(MemoryJobStore, self).__init__() super(MemoryJobStore, self).__init__()
self._jobs = [] # list of (job, timestamp), sorted by next_run_time and job id (ascending) # list of (job, timestamp), sorted by next_run_time and job id (ascending)
self._jobs = []
self._jobs_index = {} # id -> (job, timestamp) lookup table self._jobs_index = {} # id -> (job, timestamp) lookup table
def lookup_job(self, job_id): def lookup_job(self, job_id):
@@ -80,13 +81,13 @@ class MemoryJobStore(BaseJobStore):
def _get_job_index(self, timestamp, job_id): def _get_job_index(self, timestamp, job_id):
""" """
Returns the index of the given job, or if it's not found, the index where the job should be inserted based on Returns the index of the given job, or if it's not found, the index where the job should be
the given timestamp. inserted based on the given timestamp.
:type timestamp: int :type timestamp: int
:type job_id: str :type job_id: str
"""
"""
lo, hi = 0, len(self._jobs) lo, hi = 0, len(self._jobs)
timestamp = float('inf') if timestamp is None else timestamp timestamp = float('inf') if timestamp is None else timestamp
while lo < hi: while lo < hi:

View File

@@ -1,4 +1,5 @@
from __future__ import absolute_import from __future__ import absolute_import
import warnings
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
@@ -19,16 +20,18 @@ except ImportError: # pragma: nocover
class MongoDBJobStore(BaseJobStore): class MongoDBJobStore(BaseJobStore):
""" """
Stores jobs in a MongoDB database. Any leftover keyword arguments are directly passed to pymongo's `MongoClient Stores jobs in a MongoDB database. Any leftover keyword arguments are directly passed to
pymongo's `MongoClient
<http://api.mongodb.org/python/current/api/pymongo/mongo_client.html#pymongo.mongo_client.MongoClient>`_. <http://api.mongodb.org/python/current/api/pymongo/mongo_client.html#pymongo.mongo_client.MongoClient>`_.
Plugin alias: ``mongodb`` Plugin alias: ``mongodb``
:param str database: database to store jobs in :param str database: database to store jobs in
:param str collection: collection to store jobs in :param str collection: collection to store jobs in
:param client: a :class:`~pymongo.mongo_client.MongoClient` instance to use instead of providing connection :param client: a :class:`~pymongo.mongo_client.MongoClient` instance to use instead of
arguments providing connection arguments
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available :param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
highest available
""" """
def __init__(self, database='apscheduler', collection='jobs', client=None, def __init__(self, database='apscheduler', collection='jobs', client=None,
@@ -42,14 +45,23 @@ class MongoDBJobStore(BaseJobStore):
raise ValueError('The "collection" parameter must not be empty') raise ValueError('The "collection" parameter must not be empty')
if client: if client:
self.connection = maybe_ref(client) self.client = maybe_ref(client)
else: else:
connect_args.setdefault('w', 1) connect_args.setdefault('w', 1)
self.connection = MongoClient(**connect_args) self.client = MongoClient(**connect_args)
self.collection = self.connection[database][collection] self.collection = self.client[database][collection]
def start(self, scheduler, alias):
super(MongoDBJobStore, self).start(scheduler, alias)
self.collection.ensure_index('next_run_time', sparse=True) self.collection.ensure_index('next_run_time', sparse=True)
@property
def connection(self):
warnings.warn('The "connection" member is deprecated -- use "client" instead',
DeprecationWarning)
return self.client
def lookup_job(self, job_id): def lookup_job(self, job_id):
document = self.collection.find_one(job_id, ['job_state']) document = self.collection.find_one(job_id, ['job_state'])
return self._reconstitute_job(document['job_state']) if document else None return self._reconstitute_job(document['job_state']) if document else None
@@ -59,12 +71,15 @@ class MongoDBJobStore(BaseJobStore):
return self._get_jobs({'next_run_time': {'$lte': timestamp}}) return self._get_jobs({'next_run_time': {'$lte': timestamp}})
def get_next_run_time(self): def get_next_run_time(self):
document = self.collection.find_one({'next_run_time': {'$ne': None}}, fields=['next_run_time'], document = self.collection.find_one({'next_run_time': {'$ne': None}},
projection=['next_run_time'],
sort=[('next_run_time', ASCENDING)]) sort=[('next_run_time', ASCENDING)])
return utc_timestamp_to_datetime(document['next_run_time']) if document else None return utc_timestamp_to_datetime(document['next_run_time']) if document else None
def get_all_jobs(self): def get_all_jobs(self):
return self._get_jobs({}) jobs = self._get_jobs({})
self._fix_paused_jobs_sorting(jobs)
return jobs
def add_job(self, job): def add_job(self, job):
try: try:
@@ -83,7 +98,7 @@ class MongoDBJobStore(BaseJobStore):
} }
result = self.collection.update({'_id': job.id}, {'$set': changes}) result = self.collection.update({'_id': job.id}, {'$set': changes})
if result and result['n'] == 0: if result and result['n'] == 0:
raise JobLookupError(id) raise JobLookupError(job.id)
def remove_job(self, job_id): def remove_job(self, job_id):
result = self.collection.remove(job_id) result = self.collection.remove(job_id)
@@ -94,7 +109,7 @@ class MongoDBJobStore(BaseJobStore):
self.collection.remove() self.collection.remove()
def shutdown(self): def shutdown(self):
self.connection.disconnect() self.client.close()
def _reconstitute_job(self, job_state): def _reconstitute_job(self, job_state):
job_state = pickle.loads(job_state) job_state = pickle.loads(job_state)
@@ -107,11 +122,13 @@ class MongoDBJobStore(BaseJobStore):
def _get_jobs(self, conditions): def _get_jobs(self, conditions):
jobs = [] jobs = []
failed_job_ids = [] failed_job_ids = []
for document in self.collection.find(conditions, ['_id', 'job_state'], sort=[('next_run_time', ASCENDING)]): for document in self.collection.find(conditions, ['_id', 'job_state'],
sort=[('next_run_time', ASCENDING)]):
try: try:
jobs.append(self._reconstitute_job(document['job_state'])) jobs.append(self._reconstitute_job(document['job_state']))
except: except BaseException:
self._logger.exception('Unable to restore job "%s" -- removing it', document['_id']) self._logger.exception('Unable to restore job "%s" -- removing it',
document['_id'])
failed_job_ids.append(document['_id']) failed_job_ids.append(document['_id'])
# Remove all the jobs we failed to restore # Remove all the jobs we failed to restore
@@ -121,4 +138,4 @@ class MongoDBJobStore(BaseJobStore):
return jobs return jobs
def __repr__(self): def __repr__(self):
return '<%s (client=%s)>' % (self.__class__.__name__, self.connection) return '<%s (client=%s)>' % (self.__class__.__name__, self.client)

View File

@@ -1,5 +1,7 @@
from __future__ import absolute_import from __future__ import absolute_import
from datetime import datetime
from pytz import utc
import six import six
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
@@ -19,14 +21,16 @@ except ImportError: # pragma: nocover
class RedisJobStore(BaseJobStore): class RedisJobStore(BaseJobStore):
""" """
Stores jobs in a Redis database. Any leftover keyword arguments are directly passed to redis's StrictRedis. Stores jobs in a Redis database. Any leftover keyword arguments are directly passed to redis's
:class:`~redis.StrictRedis`.
Plugin alias: ``redis`` Plugin alias: ``redis``
:param int db: the database number to store jobs in :param int db: the database number to store jobs in
:param str jobs_key: key to store jobs in :param str jobs_key: key to store jobs in
:param str run_times_key: key to store the jobs' run times in :param str run_times_key: key to store the jobs' run times in
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available :param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
highest available
""" """
def __init__(self, db=0, jobs_key='apscheduler.jobs', run_times_key='apscheduler.run_times', def __init__(self, db=0, jobs_key='apscheduler.jobs', run_times_key='apscheduler.run_times',
@@ -65,7 +69,8 @@ class RedisJobStore(BaseJobStore):
def get_all_jobs(self): def get_all_jobs(self):
job_states = self.redis.hgetall(self.jobs_key) job_states = self.redis.hgetall(self.jobs_key)
jobs = self._reconstitute_jobs(six.iteritems(job_states)) jobs = self._reconstitute_jobs(six.iteritems(job_states))
return sorted(jobs, key=lambda job: job.next_run_time) paused_sort_key = datetime(9999, 12, 31, tzinfo=utc)
return sorted(jobs, key=lambda job: job.next_run_time or paused_sort_key)
def add_job(self, job): def add_job(self, job):
if self.redis.hexists(self.jobs_key, job.id): if self.redis.hexists(self.jobs_key, job.id):
@@ -73,8 +78,10 @@ class RedisJobStore(BaseJobStore):
with self.redis.pipeline() as pipe: with self.redis.pipeline() as pipe:
pipe.multi() pipe.multi()
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol)) pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(),
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id) self.pickle_protocol))
if job.next_run_time:
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
pipe.execute() pipe.execute()
def update_job(self, job): def update_job(self, job):
@@ -82,7 +89,8 @@ class RedisJobStore(BaseJobStore):
raise JobLookupError(job.id) raise JobLookupError(job.id)
with self.redis.pipeline() as pipe: with self.redis.pipeline() as pipe:
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol)) pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(),
self.pickle_protocol))
if job.next_run_time: if job.next_run_time:
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id) pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
else: else:
@@ -121,7 +129,7 @@ class RedisJobStore(BaseJobStore):
for job_id, job_state in job_states: for job_id, job_state in job_states:
try: try:
jobs.append(self._reconstitute_job(job_state)) jobs.append(self._reconstitute_job(job_state))
except: except BaseException:
self._logger.exception('Unable to restore job "%s" -- removing it', job_id) self._logger.exception('Unable to restore job "%s" -- removing it', job_id)
failed_job_ids.append(job_id) failed_job_ids.append(job_id)

View File

@@ -0,0 +1,153 @@
from __future__ import absolute_import
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
from apscheduler.job import Job
try:
import cPickle as pickle
except ImportError: # pragma: nocover
import pickle
try:
import rethinkdb as r
except ImportError: # pragma: nocover
raise ImportError('RethinkDBJobStore requires rethinkdb installed')
class RethinkDBJobStore(BaseJobStore):
"""
Stores jobs in a RethinkDB database. Any leftover keyword arguments are directly passed to
rethinkdb's `RethinkdbClient <http://www.rethinkdb.com/api/#connect>`_.
Plugin alias: ``rethinkdb``
:param str database: database to store jobs in
:param str collection: collection to store jobs in
:param client: a :class:`rethinkdb.net.Connection` instance to use instead of providing
connection arguments
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
highest available
"""
def __init__(self, database='apscheduler', table='jobs', client=None,
pickle_protocol=pickle.HIGHEST_PROTOCOL, **connect_args):
super(RethinkDBJobStore, self).__init__()
if not database:
raise ValueError('The "database" parameter must not be empty')
if not table:
raise ValueError('The "table" parameter must not be empty')
self.database = database
self.table = table
self.client = client
self.pickle_protocol = pickle_protocol
self.connect_args = connect_args
self.conn = None
def start(self, scheduler, alias):
super(RethinkDBJobStore, self).start(scheduler, alias)
if self.client:
self.conn = maybe_ref(self.client)
else:
self.conn = r.connect(db=self.database, **self.connect_args)
if self.database not in r.db_list().run(self.conn):
r.db_create(self.database).run(self.conn)
if self.table not in r.table_list().run(self.conn):
r.table_create(self.table).run(self.conn)
if 'next_run_time' not in r.table(self.table).index_list().run(self.conn):
r.table(self.table).index_create('next_run_time').run(self.conn)
self.table = r.db(self.database).table(self.table)
def lookup_job(self, job_id):
results = list(self.table.get_all(job_id).pluck('job_state').run(self.conn))
return self._reconstitute_job(results[0]['job_state']) if results else None
def get_due_jobs(self, now):
return self._get_jobs(r.row['next_run_time'] <= datetime_to_utc_timestamp(now))
def get_next_run_time(self):
results = list(
self.table
.filter(r.row['next_run_time'] != None) # flake8: noqa
.order_by(r.asc('next_run_time'))
.map(lambda x: x['next_run_time'])
.limit(1)
.run(self.conn)
)
return utc_timestamp_to_datetime(results[0]) if results else None
def get_all_jobs(self):
jobs = self._get_jobs()
self._fix_paused_jobs_sorting(jobs)
return jobs
def add_job(self, job):
job_dict = {
'id': job.id,
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
'job_state': r.binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
}
results = self.table.insert(job_dict).run(self.conn)
if results['errors'] > 0:
raise ConflictingIdError(job.id)
def update_job(self, job):
changes = {
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
'job_state': r.binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
}
results = self.table.get_all(job.id).update(changes).run(self.conn)
skipped = False in map(lambda x: results[x] == 0, results.keys())
if results['skipped'] > 0 or results['errors'] > 0 or not skipped:
raise JobLookupError(job.id)
def remove_job(self, job_id):
results = self.table.get_all(job_id).delete().run(self.conn)
if results['deleted'] + results['skipped'] != 1:
raise JobLookupError(job_id)
def remove_all_jobs(self):
self.table.delete().run(self.conn)
def shutdown(self):
self.conn.close()
def _reconstitute_job(self, job_state):
job_state = pickle.loads(job_state)
job = Job.__new__(Job)
job.__setstate__(job_state)
job._scheduler = self._scheduler
job._jobstore_alias = self._alias
return job
def _get_jobs(self, predicate=None):
jobs = []
failed_job_ids = []
query = (self.table.filter(r.row['next_run_time'] != None).filter(predicate) if
predicate else self.table)
query = query.order_by('next_run_time', 'id').pluck('id', 'job_state')
for document in query.run(self.conn):
try:
jobs.append(self._reconstitute_job(document['job_state']))
except:
self._logger.exception('Unable to restore job "%s" -- removing it', document['id'])
failed_job_ids.append(document['id'])
# Remove all the jobs we failed to restore
if failed_job_ids:
r.expr(failed_job_ids).for_each(
lambda job_id: self.table.get_all(job_id).delete()).run(self.conn)
return jobs
def __repr__(self):
connection = self.conn
return '<%s (connection=%s)>' % (self.__class__.__name__, connection)

View File

@@ -10,29 +10,38 @@ except ImportError: # pragma: nocover
import pickle import pickle
try: try:
from sqlalchemy import create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select from sqlalchemy import (
create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select)
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.sql.expression import null
except ImportError: # pragma: nocover except ImportError: # pragma: nocover
raise ImportError('SQLAlchemyJobStore requires SQLAlchemy installed') raise ImportError('SQLAlchemyJobStore requires SQLAlchemy installed')
class SQLAlchemyJobStore(BaseJobStore): class SQLAlchemyJobStore(BaseJobStore):
""" """
Stores jobs in a database table using SQLAlchemy. The table will be created if it doesn't exist in the database. Stores jobs in a database table using SQLAlchemy.
The table will be created if it doesn't exist in the database.
Plugin alias: ``sqlalchemy`` Plugin alias: ``sqlalchemy``
:param str url: connection string (see `SQLAlchemy documentation :param str url: connection string (see
<http://docs.sqlalchemy.org/en/latest/core/engines.html?highlight=create_engine#database-urls>`_ :ref:`SQLAlchemy documentation <sqlalchemy:database_urls>` on this)
on this) :param engine: an SQLAlchemy :class:`~sqlalchemy.engine.Engine` to use instead of creating a
:param engine: an SQLAlchemy Engine to use instead of creating a new one based on ``url`` new one based on ``url``
:param str tablename: name of the table to store jobs in :param str tablename: name of the table to store jobs in
:param metadata: a :class:`~sqlalchemy.MetaData` instance to use instead of creating a new one :param metadata: a :class:`~sqlalchemy.schema.MetaData` instance to use instead of creating a
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available new one
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
highest available
:param str tableschema: name of the (existing) schema in the target database where the table
should be
:param dict engine_options: keyword arguments to :func:`~sqlalchemy.create_engine`
(ignored if ``engine`` is given)
""" """
def __init__(self, url=None, engine=None, tablename='apscheduler_jobs', metadata=None, def __init__(self, url=None, engine=None, tablename='apscheduler_jobs', metadata=None,
pickle_protocol=pickle.HIGHEST_PROTOCOL): pickle_protocol=pickle.HIGHEST_PROTOCOL, tableschema=None, engine_options=None):
super(SQLAlchemyJobStore, self).__init__() super(SQLAlchemyJobStore, self).__init__()
self.pickle_protocol = pickle_protocol self.pickle_protocol = pickle_protocol
metadata = maybe_ref(metadata) or MetaData() metadata = maybe_ref(metadata) or MetaData()
@@ -40,18 +49,22 @@ class SQLAlchemyJobStore(BaseJobStore):
if engine: if engine:
self.engine = maybe_ref(engine) self.engine = maybe_ref(engine)
elif url: elif url:
self.engine = create_engine(url) self.engine = create_engine(url, **(engine_options or {}))
else: else:
raise ValueError('Need either "engine" or "url" defined') raise ValueError('Need either "engine" or "url" defined')
# 191 = max key length in MySQL for InnoDB/utf8mb4 tables, 25 = precision that translates to an 8-byte float # 191 = max key length in MySQL for InnoDB/utf8mb4 tables,
# 25 = precision that translates to an 8-byte float
self.jobs_t = Table( self.jobs_t = Table(
tablename, metadata, tablename, metadata,
Column('id', Unicode(191, _warn_on_bytestring=False), primary_key=True), Column('id', Unicode(191, _warn_on_bytestring=False), primary_key=True),
Column('next_run_time', Float(25), index=True), Column('next_run_time', Float(25), index=True),
Column('job_state', LargeBinary, nullable=False) Column('job_state', LargeBinary, nullable=False),
schema=tableschema
) )
def start(self, scheduler, alias):
super(SQLAlchemyJobStore, self).start(scheduler, alias)
self.jobs_t.create(self.engine, True) self.jobs_t.create(self.engine, True)
def lookup_job(self, job_id): def lookup_job(self, job_id):
@@ -64,13 +77,16 @@ class SQLAlchemyJobStore(BaseJobStore):
return self._get_jobs(self.jobs_t.c.next_run_time <= timestamp) return self._get_jobs(self.jobs_t.c.next_run_time <= timestamp)
def get_next_run_time(self): def get_next_run_time(self):
selectable = select([self.jobs_t.c.next_run_time]).where(self.jobs_t.c.next_run_time != None).\ selectable = select([self.jobs_t.c.next_run_time]).\
where(self.jobs_t.c.next_run_time != null()).\
order_by(self.jobs_t.c.next_run_time).limit(1) order_by(self.jobs_t.c.next_run_time).limit(1)
next_run_time = self.engine.execute(selectable).scalar() next_run_time = self.engine.execute(selectable).scalar()
return utc_timestamp_to_datetime(next_run_time) return utc_timestamp_to_datetime(next_run_time)
def get_all_jobs(self): def get_all_jobs(self):
return self._get_jobs() jobs = self._get_jobs()
self._fix_paused_jobs_sorting(jobs)
return jobs
def add_job(self, job): def add_job(self, job):
insert = self.jobs_t.insert().values(**{ insert = self.jobs_t.insert().values(**{
@@ -116,13 +132,14 @@ class SQLAlchemyJobStore(BaseJobStore):
def _get_jobs(self, *conditions): def _get_jobs(self, *conditions):
jobs = [] jobs = []
selectable = select([self.jobs_t.c.id, self.jobs_t.c.job_state]).order_by(self.jobs_t.c.next_run_time) selectable = select([self.jobs_t.c.id, self.jobs_t.c.job_state]).\
order_by(self.jobs_t.c.next_run_time)
selectable = selectable.where(*conditions) if conditions else selectable selectable = selectable.where(*conditions) if conditions else selectable
failed_job_ids = set() failed_job_ids = set()
for row in self.engine.execute(selectable): for row in self.engine.execute(selectable):
try: try:
jobs.append(self._reconstitute_job(row.job_state)) jobs.append(self._reconstitute_job(row.job_state))
except: except BaseException:
self._logger.exception('Unable to restore job "%s" -- removing it', row.id) self._logger.exception('Unable to restore job "%s" -- removing it', row.id)
failed_job_ids.add(row.id) failed_job_ids.add(row.id)

View File

@@ -0,0 +1,179 @@
from __future__ import absolute_import
import os
from datetime import datetime
from pytz import utc
from kazoo.exceptions import NoNodeError, NodeExistsError
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
from apscheduler.job import Job
try:
import cPickle as pickle
except ImportError: # pragma: nocover
import pickle
try:
from kazoo.client import KazooClient
except ImportError: # pragma: nocover
raise ImportError('ZooKeeperJobStore requires Kazoo installed')
class ZooKeeperJobStore(BaseJobStore):
"""
Stores jobs in a ZooKeeper tree. Any leftover keyword arguments are directly passed to
kazoo's `KazooClient
<http://kazoo.readthedocs.io/en/latest/api/client.html>`_.
Plugin alias: ``zookeeper``
:param str path: path to store jobs in
:param client: a :class:`~kazoo.client.KazooClient` instance to use instead of
providing connection arguments
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
highest available
"""
def __init__(self, path='/apscheduler', client=None, close_connection_on_exit=False,
pickle_protocol=pickle.HIGHEST_PROTOCOL, **connect_args):
super(ZooKeeperJobStore, self).__init__()
self.pickle_protocol = pickle_protocol
self.close_connection_on_exit = close_connection_on_exit
if not path:
raise ValueError('The "path" parameter must not be empty')
self.path = path
if client:
self.client = maybe_ref(client)
else:
self.client = KazooClient(**connect_args)
self._ensured_path = False
def _ensure_paths(self):
if not self._ensured_path:
self.client.ensure_path(self.path)
self._ensured_path = True
def start(self, scheduler, alias):
super(ZooKeeperJobStore, self).start(scheduler, alias)
if not self.client.connected:
self.client.start()
def lookup_job(self, job_id):
self._ensure_paths()
node_path = os.path.join(self.path, job_id)
try:
content, _ = self.client.get(node_path)
doc = pickle.loads(content)
job = self._reconstitute_job(doc['job_state'])
return job
except BaseException:
return None
def get_due_jobs(self, now):
timestamp = datetime_to_utc_timestamp(now)
jobs = [job_def['job'] for job_def in self._get_jobs()
if job_def['next_run_time'] is not None and job_def['next_run_time'] <= timestamp]
return jobs
def get_next_run_time(self):
next_runs = [job_def['next_run_time'] for job_def in self._get_jobs()
if job_def['next_run_time'] is not None]
return utc_timestamp_to_datetime(min(next_runs)) if len(next_runs) > 0 else None
def get_all_jobs(self):
jobs = [job_def['job'] for job_def in self._get_jobs()]
self._fix_paused_jobs_sorting(jobs)
return jobs
def add_job(self, job):
self._ensure_paths()
node_path = os.path.join(self.path, str(job.id))
value = {
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
'job_state': job.__getstate__()
}
data = pickle.dumps(value, self.pickle_protocol)
try:
self.client.create(node_path, value=data)
except NodeExistsError:
raise ConflictingIdError(job.id)
def update_job(self, job):
self._ensure_paths()
node_path = os.path.join(self.path, str(job.id))
changes = {
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
'job_state': job.__getstate__()
}
data = pickle.dumps(changes, self.pickle_protocol)
try:
self.client.set(node_path, value=data)
except NoNodeError:
raise JobLookupError(job.id)
def remove_job(self, job_id):
self._ensure_paths()
node_path = os.path.join(self.path, str(job_id))
try:
self.client.delete(node_path)
except NoNodeError:
raise JobLookupError(job_id)
def remove_all_jobs(self):
try:
self.client.delete(self.path, recursive=True)
except NoNodeError:
pass
self._ensured_path = False
def shutdown(self):
if self.close_connection_on_exit:
self.client.stop()
self.client.close()
def _reconstitute_job(self, job_state):
job_state = job_state
job = Job.__new__(Job)
job.__setstate__(job_state)
job._scheduler = self._scheduler
job._jobstore_alias = self._alias
return job
def _get_jobs(self):
self._ensure_paths()
jobs = []
failed_job_ids = []
all_ids = self.client.get_children(self.path)
for node_name in all_ids:
try:
node_path = os.path.join(self.path, node_name)
content, _ = self.client.get(node_path)
doc = pickle.loads(content)
job_def = {
'job_id': node_name,
'next_run_time': doc['next_run_time'] if doc['next_run_time'] else None,
'job_state': doc['job_state'],
'job': self._reconstitute_job(doc['job_state']),
'creation_time': _.ctime
}
jobs.append(job_def)
except BaseException:
self._logger.exception('Unable to restore job "%s" -- removing it' % node_name)
failed_job_ids.append(node_name)
# Remove all the jobs we failed to restore
if failed_job_ids:
for failed_id in failed_job_ids:
self.remove_job(failed_id)
paused_sort_key = datetime(9999, 12, 31, tzinfo=utc)
return sorted(jobs, key=lambda job_def: (job_def['job'].next_run_time or paused_sort_key,
job_def['creation_time']))
def __repr__(self):
self._logger.exception('<%s (client=%s)>' % (self.__class__.__name__, self.client))
return '<%s (client=%s)>' % (self.__class__.__name__, self.client)

View File

@@ -1,5 +1,5 @@
from __future__ import absolute_import from __future__ import absolute_import
from functools import wraps from functools import wraps, partial
from apscheduler.schedulers.base import BaseScheduler from apscheduler.schedulers.base import BaseScheduler
from apscheduler.util import maybe_ref from apscheduler.util import maybe_ref
@@ -10,13 +10,15 @@ except ImportError: # pragma: nocover
try: try:
import trollius as asyncio import trollius as asyncio
except ImportError: except ImportError:
raise ImportError('AsyncIOScheduler requires either Python 3.4 or the asyncio package installed') raise ImportError(
'AsyncIOScheduler requires either Python 3.4 or the asyncio package installed')
def run_in_event_loop(func): def run_in_event_loop(func):
@wraps(func) @wraps(func)
def wrapper(self, *args, **kwargs): def wrapper(self, *args, **kwargs):
self._eventloop.call_soon_threadsafe(func, self, *args, **kwargs) wrapped = partial(func, self, *args, **kwargs)
self._eventloop.call_soon_threadsafe(wrapped)
return wrapper return wrapper
@@ -24,6 +26,8 @@ class AsyncIOScheduler(BaseScheduler):
""" """
A scheduler that runs on an asyncio (:pep:`3156`) event loop. A scheduler that runs on an asyncio (:pep:`3156`) event loop.
The default executor can run jobs based on native coroutines (``async def``).
Extra options: Extra options:
============== ============================================================= ============== =============================================================
@@ -34,10 +38,6 @@ class AsyncIOScheduler(BaseScheduler):
_eventloop = None _eventloop = None
_timeout = None _timeout = None
def start(self):
super(AsyncIOScheduler, self).start()
self.wakeup()
@run_in_event_loop @run_in_event_loop
def shutdown(self, wait=True): def shutdown(self, wait=True):
super(AsyncIOScheduler, self).shutdown(wait) super(AsyncIOScheduler, self).shutdown(wait)

View File

@@ -1,4 +1,5 @@
from __future__ import absolute_import from __future__ import absolute_import
from threading import Thread, Event from threading import Thread, Event
from apscheduler.schedulers.base import BaseScheduler from apscheduler.schedulers.base import BaseScheduler
@@ -13,11 +14,12 @@ class BackgroundScheduler(BlockingScheduler):
Extra options: Extra options:
========== ============================================================================================ ========== =============================================================================
``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``, ``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``, see
see `the documentation <https://docs.python.org/3.4/library/threading.html#thread-objects>`_ `the documentation
<https://docs.python.org/3.4/library/threading.html#thread-objects>`_
for further details) for further details)
========== ============================================================================================ ========== =============================================================================
""" """
_thread = None _thread = None
@@ -26,14 +28,14 @@ class BackgroundScheduler(BlockingScheduler):
self._daemon = asbool(config.pop('daemon', True)) self._daemon = asbool(config.pop('daemon', True))
super(BackgroundScheduler, self)._configure(config) super(BackgroundScheduler, self)._configure(config)
def start(self): def start(self, *args, **kwargs):
BaseScheduler.start(self)
self._event = Event() self._event = Event()
BaseScheduler.start(self, *args, **kwargs)
self._thread = Thread(target=self._main_loop, name='APScheduler') self._thread = Thread(target=self._main_loop, name='APScheduler')
self._thread.daemon = self._daemon self._thread.daemon = self._daemon
self._thread.start() self._thread.start()
def shutdown(self, wait=True): def shutdown(self, *args, **kwargs):
super(BackgroundScheduler, self).shutdown(wait) super(BackgroundScheduler, self).shutdown(*args, **kwargs)
self._thread.join() self._thread.join()
del self._thread del self._thread

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,21 @@
from __future__ import absolute_import from __future__ import absolute_import
from threading import Event from threading import Event
from apscheduler.schedulers.base import BaseScheduler from apscheduler.schedulers.base import BaseScheduler, STATE_STOPPED
from apscheduler.util import TIMEOUT_MAX
class BlockingScheduler(BaseScheduler): class BlockingScheduler(BaseScheduler):
""" """
A scheduler that runs in the foreground (:meth:`~apscheduler.schedulers.base.BaseScheduler.start` will block). A scheduler that runs in the foreground
(:meth:`~apscheduler.schedulers.base.BaseScheduler.start` will block).
""" """
MAX_WAIT_TIME = 4294967 # Maximum value accepted by Event.wait() on Windows
_event = None _event = None
def start(self): def start(self, *args, **kwargs):
super(BlockingScheduler, self).start()
self._event = Event() self._event = Event()
super(BlockingScheduler, self).start(*args, **kwargs)
self._main_loop() self._main_loop()
def shutdown(self, wait=True): def shutdown(self, wait=True):
@@ -23,10 +23,11 @@ class BlockingScheduler(BaseScheduler):
self._event.set() self._event.set()
def _main_loop(self): def _main_loop(self):
while self.running: wait_seconds = TIMEOUT_MAX
wait_seconds = self._process_jobs() while self.state != STATE_STOPPED:
self._event.wait(wait_seconds if wait_seconds is not None else self.MAX_WAIT_TIME) self._event.wait(wait_seconds)
self._event.clear() self._event.clear()
wait_seconds = self._process_jobs()
def wakeup(self): def wakeup(self):
self._event.set() self._event.set()

View File

@@ -16,14 +16,14 @@ class GeventScheduler(BlockingScheduler):
_greenlet = None _greenlet = None
def start(self): def start(self, *args, **kwargs):
BaseScheduler.start(self)
self._event = Event() self._event = Event()
BaseScheduler.start(self, *args, **kwargs)
self._greenlet = gevent.spawn(self._main_loop) self._greenlet = gevent.spawn(self._main_loop)
return self._greenlet return self._greenlet
def shutdown(self, wait=True): def shutdown(self, *args, **kwargs):
super(GeventScheduler, self).shutdown(wait) super(GeventScheduler, self).shutdown(*args, **kwargs)
self._greenlet.join() self._greenlet.join()
del self._greenlet del self._greenlet

View File

@@ -4,7 +4,7 @@ from apscheduler.schedulers.base import BaseScheduler
try: try:
from PyQt5.QtCore import QObject, QTimer from PyQt5.QtCore import QObject, QTimer
except ImportError: # pragma: nocover except (ImportError, RuntimeError): # pragma: nocover
try: try:
from PyQt4.QtCore import QObject, QTimer from PyQt4.QtCore import QObject, QTimer
except ImportError: except ImportError:
@@ -19,12 +19,8 @@ class QtScheduler(BaseScheduler):
_timer = None _timer = None
def start(self): def shutdown(self, *args, **kwargs):
super(QtScheduler, self).start() super(QtScheduler, self).shutdown(*args, **kwargs)
self.wakeup()
def shutdown(self, wait=True):
super(QtScheduler, self).shutdown(wait)
self._stop_timer() self._stop_timer()
def _start_timer(self, wait_seconds): def _start_timer(self, wait_seconds):

View File

@@ -1,4 +1,5 @@
from __future__ import absolute_import from __future__ import absolute_import
from datetime import timedelta from datetime import timedelta
from functools import wraps from functools import wraps
@@ -22,6 +23,8 @@ class TornadoScheduler(BaseScheduler):
""" """
A scheduler that runs on a Tornado IOLoop. A scheduler that runs on a Tornado IOLoop.
The default executor can run jobs based on native coroutines (``async def``).
=========== =============================================================== =========== ===============================================================
``io_loop`` Tornado IOLoop instance to use (defaults to the global IO loop) ``io_loop`` Tornado IOLoop instance to use (defaults to the global IO loop)
=========== =============================================================== =========== ===============================================================
@@ -30,10 +33,6 @@ class TornadoScheduler(BaseScheduler):
_ioloop = None _ioloop = None
_timeout = None _timeout = None
def start(self):
super(TornadoScheduler, self).start()
self.wakeup()
@run_in_ioloop @run_in_ioloop
def shutdown(self, wait=True): def shutdown(self, wait=True):
super(TornadoScheduler, self).shutdown(wait) super(TornadoScheduler, self).shutdown(wait)
@@ -53,6 +52,10 @@ class TornadoScheduler(BaseScheduler):
self._ioloop.remove_timeout(self._timeout) self._ioloop.remove_timeout(self._timeout)
del self._timeout del self._timeout
def _create_default_executor(self):
from apscheduler.executors.tornado import TornadoExecutor
return TornadoExecutor()
@run_in_ioloop @run_in_ioloop
def wakeup(self): def wakeup(self):
self._stop_timer() self._stop_timer()

View File

@@ -1,4 +1,5 @@
from __future__ import absolute_import from __future__ import absolute_import
from functools import wraps from functools import wraps
from apscheduler.schedulers.base import BaseScheduler from apscheduler.schedulers.base import BaseScheduler
@@ -35,10 +36,6 @@ class TwistedScheduler(BaseScheduler):
self._reactor = maybe_ref(config.pop('reactor', default_reactor)) self._reactor = maybe_ref(config.pop('reactor', default_reactor))
super(TwistedScheduler, self)._configure(config) super(TwistedScheduler, self)._configure(config)
def start(self):
super(TwistedScheduler, self).start()
self.wakeup()
@run_in_reactor @run_in_reactor
def shutdown(self, wait=True): def shutdown(self, wait=True):
super(TwistedScheduler, self).shutdown(wait) super(TwistedScheduler, self).shutdown(wait)

View File

@@ -1,4 +1,6 @@
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from datetime import timedelta
import random
import six import six
@@ -6,11 +8,41 @@ import six
class BaseTrigger(six.with_metaclass(ABCMeta)): class BaseTrigger(six.with_metaclass(ABCMeta)):
"""Abstract base class that defines the interface that every trigger must implement.""" """Abstract base class that defines the interface that every trigger must implement."""
__slots__ = ()
@abstractmethod @abstractmethod
def get_next_fire_time(self, previous_fire_time, now): def get_next_fire_time(self, previous_fire_time, now):
""" """
Returns the next datetime to fire on, If no such datetime can be calculated, returns ``None``. Returns the next datetime to fire on, If no such datetime can be calculated, returns
``None``.
:param datetime.datetime previous_fire_time: the previous time the trigger was fired :param datetime.datetime previous_fire_time: the previous time the trigger was fired
:param datetime.datetime now: current datetime :param datetime.datetime now: current datetime
""" """
def _apply_jitter(self, next_fire_time, jitter, now):
"""
Randomize ``next_fire_time`` by adding or subtracting a random value (the jitter). If the
resulting datetime is in the past, returns the initial ``next_fire_time`` without jitter.
``next_fire_time - jitter <= result <= next_fire_time + jitter``
:param datetime.datetime|None next_fire_time: next fire time without jitter applied. If
``None``, returns ``None``.
:param int|None jitter: maximum number of seconds to add or subtract to
``next_fire_time``. If ``None`` or ``0``, returns ``next_fire_time``
:param datetime.datetime now: current datetime
:return datetime.datetime|None: next fire time with a jitter.
"""
if next_fire_time is None or not jitter:
return next_fire_time
next_fire_time_with_jitter = next_fire_time + timedelta(
seconds=random.uniform(-jitter, jitter))
if next_fire_time_with_jitter < now:
# Next fire time with jitter is in the past.
# Ignore jitter to avoid false misfire.
return next_fire_time
return next_fire_time_with_jitter

View File

@@ -0,0 +1,95 @@
from apscheduler.triggers.base import BaseTrigger
from apscheduler.util import obj_to_ref, ref_to_obj
class BaseCombiningTrigger(BaseTrigger):
__slots__ = ('triggers', 'jitter')
def __init__(self, triggers, jitter=None):
self.triggers = triggers
self.jitter = jitter
def __getstate__(self):
return {
'version': 1,
'triggers': [(obj_to_ref(trigger.__class__), trigger.__getstate__())
for trigger in self.triggers],
'jitter': self.jitter
}
def __setstate__(self, state):
if state.get('version', 1) > 1:
raise ValueError(
'Got serialized data for version %s of %s, but only versions up to 1 can be '
'handled' % (state['version'], self.__class__.__name__))
self.jitter = state['jitter']
self.triggers = []
for clsref, state in state['triggers']:
cls = ref_to_obj(clsref)
trigger = cls.__new__(cls)
trigger.__setstate__(state)
self.triggers.append(trigger)
def __repr__(self):
return '<{}({}{})>'.format(self.__class__.__name__, self.triggers,
', jitter={}'.format(self.jitter) if self.jitter else '')
class AndTrigger(BaseCombiningTrigger):
"""
Always returns the earliest next fire time that all the given triggers can agree on.
The trigger is considered to be finished when any of the given triggers has finished its
schedule.
Trigger alias: ``and``
:param list triggers: triggers to combine
:param int|None jitter: advance or delay the job execution by ``jitter`` seconds at most.
"""
__slots__ = ()
def get_next_fire_time(self, previous_fire_time, now):
while True:
fire_times = [trigger.get_next_fire_time(previous_fire_time, now)
for trigger in self.triggers]
if None in fire_times:
return None
elif min(fire_times) == max(fire_times):
return self._apply_jitter(fire_times[0], self.jitter, now)
else:
now = max(fire_times)
def __str__(self):
return 'and[{}]'.format(', '.join(str(trigger) for trigger in self.triggers))
class OrTrigger(BaseCombiningTrigger):
"""
Always returns the earliest next fire time produced by any of the given triggers.
The trigger is considered finished when all the given triggers have finished their schedules.
Trigger alias: ``or``
:param list triggers: triggers to combine
:param int|None jitter: advance or delay the job execution by ``jitter`` seconds at most.
.. note:: Triggers that depends on the previous fire time, such as the interval trigger, may
seem to behave strangely since they are always passed the previous fire time produced by
any of the given triggers.
"""
__slots__ = ()
def get_next_fire_time(self, previous_fire_time, now):
fire_times = [trigger.get_next_fire_time(previous_fire_time, now)
for trigger in self.triggers]
fire_times = [fire_time for fire_time in fire_times if fire_time is not None]
if fire_times:
return self._apply_jitter(min(fire_times), self.jitter, now)
else:
return None
def __str__(self):
return 'or[{}]'.format(', '.join(str(trigger) for trigger in self.triggers))

View File

@@ -4,13 +4,15 @@ from tzlocal import get_localzone
import six import six
from apscheduler.triggers.base import BaseTrigger from apscheduler.triggers.base import BaseTrigger
from apscheduler.triggers.cron.fields import BaseField, WeekField, DayOfMonthField, DayOfWeekField, DEFAULT_VALUES from apscheduler.triggers.cron.fields import (
BaseField, MonthField, WeekField, DayOfMonthField, DayOfWeekField, DEFAULT_VALUES)
from apscheduler.util import datetime_ceil, convert_to_datetime, datetime_repr, astimezone from apscheduler.util import datetime_ceil, convert_to_datetime, datetime_repr, astimezone
class CronTrigger(BaseTrigger): class CronTrigger(BaseTrigger):
""" """
Triggers when current time matches all specified time constraints, similarly to how the UNIX cron scheduler works. Triggers when current time matches all specified time constraints,
similarly to how the UNIX cron scheduler works.
:param int|str year: 4-digit year :param int|str year: 4-digit year
:param int|str month: month (1-12) :param int|str month: month (1-12)
@@ -22,8 +24,9 @@ class CronTrigger(BaseTrigger):
:param int|str second: second (0-59) :param int|str second: second (0-59)
:param datetime|str start_date: earliest possible date/time to trigger on (inclusive) :param datetime|str start_date: earliest possible date/time to trigger on (inclusive)
:param datetime|str end_date: latest possible date/time to trigger on (inclusive) :param datetime|str end_date: latest possible date/time to trigger on (inclusive)
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations :param datetime.tzinfo|str timezone: time zone to use for the date/time calculations (defaults
(defaults to scheduler timezone) to scheduler timezone)
:param int|None jitter: advance or delay the job execution by ``jitter`` seconds at most.
.. note:: The first weekday is always **monday**. .. note:: The first weekday is always **monday**.
""" """
@@ -31,7 +34,7 @@ class CronTrigger(BaseTrigger):
FIELD_NAMES = ('year', 'month', 'day', 'week', 'day_of_week', 'hour', 'minute', 'second') FIELD_NAMES = ('year', 'month', 'day', 'week', 'day_of_week', 'hour', 'minute', 'second')
FIELDS_MAP = { FIELDS_MAP = {
'year': BaseField, 'year': BaseField,
'month': BaseField, 'month': MonthField,
'week': WeekField, 'week': WeekField,
'day': DayOfMonthField, 'day': DayOfMonthField,
'day_of_week': DayOfWeekField, 'day_of_week': DayOfWeekField,
@@ -40,15 +43,16 @@ class CronTrigger(BaseTrigger):
'second': BaseField 'second': BaseField
} }
__slots__ = 'timezone', 'start_date', 'end_date', 'fields' __slots__ = 'timezone', 'start_date', 'end_date', 'fields', 'jitter'
def __init__(self, year=None, month=None, day=None, week=None, day_of_week=None, hour=None, minute=None, def __init__(self, year=None, month=None, day=None, week=None, day_of_week=None, hour=None,
second=None, start_date=None, end_date=None, timezone=None): minute=None, second=None, start_date=None, end_date=None, timezone=None,
jitter=None):
if timezone: if timezone:
self.timezone = astimezone(timezone) self.timezone = astimezone(timezone)
elif start_date and start_date.tzinfo: elif isinstance(start_date, datetime) and start_date.tzinfo:
self.timezone = start_date.tzinfo self.timezone = start_date.tzinfo
elif end_date and end_date.tzinfo: elif isinstance(end_date, datetime) and end_date.tzinfo:
self.timezone = end_date.tzinfo self.timezone = end_date.tzinfo
else: else:
self.timezone = get_localzone() self.timezone = get_localzone()
@@ -56,6 +60,8 @@ class CronTrigger(BaseTrigger):
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date') self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date') self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
self.jitter = jitter
values = dict((key, value) for (key, value) in six.iteritems(locals()) values = dict((key, value) for (key, value) in six.iteritems(locals())
if key in self.FIELD_NAMES and value is not None) if key in self.FIELD_NAMES and value is not None)
self.fields = [] self.fields = []
@@ -76,13 +82,35 @@ class CronTrigger(BaseTrigger):
field = field_class(field_name, exprs, is_default) field = field_class(field_name, exprs, is_default)
self.fields.append(field) self.fields.append(field)
@classmethod
def from_crontab(cls, expr, timezone=None):
"""
Create a :class:`~CronTrigger` from a standard crontab expression.
See https://en.wikipedia.org/wiki/Cron for more information on the format accepted here.
:param expr: minute, hour, day of month, month, day of week
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations (
defaults to scheduler timezone)
:return: a :class:`~CronTrigger` instance
"""
values = expr.split()
if len(values) != 5:
raise ValueError('Wrong number of fields; got {}, expected 5'.format(len(values)))
return cls(minute=values[0], hour=values[1], day=values[2], month=values[3],
day_of_week=values[4], timezone=timezone)
def _increment_field_value(self, dateval, fieldnum): def _increment_field_value(self, dateval, fieldnum):
""" """
Increments the designated field and resets all less significant fields to their minimum values. Increments the designated field and resets all less significant fields to their minimum
values.
:type dateval: datetime :type dateval: datetime
:type fieldnum: int :type fieldnum: int
:return: a tuple containing the new date, and the number of the field that was actually incremented :return: a tuple containing the new date, and the number of the field that was actually
incremented
:rtype: tuple :rtype: tuple
""" """
@@ -128,12 +156,13 @@ class CronTrigger(BaseTrigger):
else: else:
values[field.name] = new_value values[field.name] = new_value
difference = datetime(**values) - dateval.replace(tzinfo=None) return self.timezone.localize(datetime(**values))
return self.timezone.normalize(dateval + difference)
def get_next_fire_time(self, previous_fire_time, now): def get_next_fire_time(self, previous_fire_time, now):
if previous_fire_time: if previous_fire_time:
start_date = max(now, previous_fire_time + timedelta(microseconds=1)) start_date = min(now, previous_fire_time + timedelta(microseconds=1))
if start_date == previous_fire_time:
start_date += timedelta(microseconds=1)
else: else:
start_date = max(now, self.start_date) if self.start_date else now start_date = max(now, self.start_date) if self.start_date else now
@@ -163,8 +192,36 @@ class CronTrigger(BaseTrigger):
return None return None
if fieldnum >= 0: if fieldnum >= 0:
if self.jitter is not None:
next_date = self._apply_jitter(next_date, self.jitter, now)
return next_date return next_date
def __getstate__(self):
return {
'version': 2,
'timezone': self.timezone,
'start_date': self.start_date,
'end_date': self.end_date,
'fields': self.fields,
'jitter': self.jitter,
}
def __setstate__(self, state):
# This is for compatibility with APScheduler 3.0.x
if isinstance(state, tuple):
state = state[1]
if state.get('version', 1) > 2:
raise ValueError(
'Got serialized data for version %s of %s, but only versions up to 2 can be '
'handled' % (state['version'], self.__class__.__name__))
self.timezone = state['timezone']
self.start_date = state['start_date']
self.end_date = state['end_date']
self.fields = state['fields']
self.jitter = state.get('jitter')
def __str__(self): def __str__(self):
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default] options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
return 'cron[%s]' % (', '.join(options)) return 'cron[%s]' % (', '.join(options))
@@ -172,5 +229,11 @@ class CronTrigger(BaseTrigger):
def __repr__(self): def __repr__(self):
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default] options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
if self.start_date: if self.start_date:
options.append("start_date='%s'" % datetime_repr(self.start_date)) options.append("start_date=%r" % datetime_repr(self.start_date))
return '<%s (%s)>' % (self.__class__.__name__, ', '.join(options)) if self.end_date:
options.append("end_date=%r" % datetime_repr(self.end_date))
if self.jitter:
options.append('jitter=%s' % self.jitter)
return "<%s (%s, timezone='%s')>" % (
self.__class__.__name__, ', '.join(options), self.timezone)

View File

@@ -1,17 +1,16 @@
""" """This module contains the expressions applicable for CronTrigger's fields."""
This module contains the expressions applicable for CronTrigger's fields.
"""
from calendar import monthrange from calendar import monthrange
import re import re
from apscheduler.util import asint from apscheduler.util import asint
__all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression', 'WeekdayPositionExpression', __all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression',
'LastDayOfMonthExpression') 'WeekdayPositionExpression', 'LastDayOfMonthExpression')
WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
class AllExpression(object): class AllExpression(object):
@@ -22,6 +21,14 @@ class AllExpression(object):
if self.step == 0: if self.step == 0:
raise ValueError('Increment must be higher than 0') raise ValueError('Increment must be higher than 0')
def validate_range(self, field_name):
from apscheduler.triggers.cron.fields import MIN_VALUES, MAX_VALUES
value_range = MAX_VALUES[field_name] - MIN_VALUES[field_name]
if self.step and self.step > value_range:
raise ValueError('the step value ({}) is higher than the total range of the '
'expression ({})'.format(self.step, value_range))
def get_next_value(self, date, field): def get_next_value(self, date, field):
start = field.get_value(date) start = field.get_value(date)
minval = field.get_min(date) minval = field.get_min(date)
@@ -37,6 +44,9 @@ class AllExpression(object):
if next <= maxval: if next <= maxval:
return next return next
def __eq__(self, other):
return isinstance(other, self.__class__) and self.step == other.step
def __str__(self): def __str__(self):
if self.step: if self.step:
return '*/%d' % self.step return '*/%d' % self.step
@@ -51,7 +61,7 @@ class RangeExpression(AllExpression):
r'(?P<first>\d+)(?:-(?P<last>\d+))?(?:/(?P<step>\d+))?$') r'(?P<first>\d+)(?:-(?P<last>\d+))?(?:/(?P<step>\d+))?$')
def __init__(self, first, last=None, step=None): def __init__(self, first, last=None, step=None):
AllExpression.__init__(self, step) super(RangeExpression, self).__init__(step)
first = asint(first) first = asint(first)
last = asint(last) last = asint(last)
if last is None and step is None: if last is None and step is None:
@@ -61,25 +71,41 @@ class RangeExpression(AllExpression):
self.first = first self.first = first
self.last = last self.last = last
def validate_range(self, field_name):
from apscheduler.triggers.cron.fields import MIN_VALUES, MAX_VALUES
super(RangeExpression, self).validate_range(field_name)
if self.first < MIN_VALUES[field_name]:
raise ValueError('the first value ({}) is lower than the minimum value ({})'
.format(self.first, MIN_VALUES[field_name]))
if self.last is not None and self.last > MAX_VALUES[field_name]:
raise ValueError('the last value ({}) is higher than the maximum value ({})'
.format(self.last, MAX_VALUES[field_name]))
value_range = (self.last or MAX_VALUES[field_name]) - self.first
if self.step and self.step > value_range:
raise ValueError('the step value ({}) is higher than the total range of the '
'expression ({})'.format(self.step, value_range))
def get_next_value(self, date, field): def get_next_value(self, date, field):
start = field.get_value(date) startval = field.get_value(date)
minval = field.get_min(date) minval = field.get_min(date)
maxval = field.get_max(date) maxval = field.get_max(date)
# Apply range limits # Apply range limits
minval = max(minval, self.first) minval = max(minval, self.first)
if self.last is not None: maxval = min(maxval, self.last) if self.last is not None else maxval
maxval = min(maxval, self.last) nextval = max(minval, startval)
start = max(start, minval)
if not self.step: # Apply the step if defined
next = start if self.step:
else: distance_to_next = (self.step - (nextval - minval)) % self.step
distance_to_next = (self.step - (start - minval)) % self.step nextval += distance_to_next
next = start + distance_to_next
if next <= maxval: return nextval if nextval <= maxval else None
return next
def __eq__(self, other):
return (isinstance(other, self.__class__) and self.first == other.first and
self.last == other.last)
def __str__(self): def __str__(self):
if self.last != self.first and self.last is not None: if self.last != self.first and self.last is not None:
@@ -100,6 +126,37 @@ class RangeExpression(AllExpression):
return "%s(%s)" % (self.__class__.__name__, ', '.join(args)) return "%s(%s)" % (self.__class__.__name__, ', '.join(args))
class MonthRangeExpression(RangeExpression):
value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE)
def __init__(self, first, last=None):
try:
first_num = MONTHS.index(first.lower()) + 1
except ValueError:
raise ValueError('Invalid month name "%s"' % first)
if last:
try:
last_num = MONTHS.index(last.lower()) + 1
except ValueError:
raise ValueError('Invalid month name "%s"' % last)
else:
last_num = None
super(MonthRangeExpression, self).__init__(first_num, last_num)
def __str__(self):
if self.last != self.first and self.last is not None:
return '%s-%s' % (MONTHS[self.first - 1], MONTHS[self.last - 1])
return MONTHS[self.first - 1]
def __repr__(self):
args = ["'%s'" % MONTHS[self.first]]
if self.last != self.first and self.last is not None:
args.append("'%s'" % MONTHS[self.last - 1])
return "%s(%s)" % (self.__class__.__name__, ', '.join(args))
class WeekdayRangeExpression(RangeExpression): class WeekdayRangeExpression(RangeExpression):
value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE) value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE)
@@ -117,7 +174,7 @@ class WeekdayRangeExpression(RangeExpression):
else: else:
last_num = None last_num = None
RangeExpression.__init__(self, first_num, last_num) super(WeekdayRangeExpression, self).__init__(first_num, last_num)
def __str__(self): def __str__(self):
if self.last != self.first and self.last is not None: if self.last != self.first and self.last is not None:
@@ -133,9 +190,11 @@ class WeekdayRangeExpression(RangeExpression):
class WeekdayPositionExpression(AllExpression): class WeekdayPositionExpression(AllExpression):
options = ['1st', '2nd', '3rd', '4th', '5th', 'last'] options = ['1st', '2nd', '3rd', '4th', '5th', 'last']
value_re = re.compile(r'(?P<option_name>%s) +(?P<weekday_name>(?:\d+|\w+))' % '|'.join(options), re.IGNORECASE) value_re = re.compile(r'(?P<option_name>%s) +(?P<weekday_name>(?:\d+|\w+))' %
'|'.join(options), re.IGNORECASE)
def __init__(self, option_name, weekday_name): def __init__(self, option_name, weekday_name):
super(WeekdayPositionExpression, self).__init__(None)
try: try:
self.option_num = self.options.index(option_name.lower()) self.option_num = self.options.index(option_name.lower())
except ValueError: except ValueError:
@@ -147,8 +206,7 @@ class WeekdayPositionExpression(AllExpression):
raise ValueError('Invalid weekday name "%s"' % weekday_name) raise ValueError('Invalid weekday name "%s"' % weekday_name)
def get_next_value(self, date, field): def get_next_value(self, date, field):
# Figure out the weekday of the month's first day and the number # Figure out the weekday of the month's first day and the number of days in that month
# of days in that month
first_day_wday, last_day = monthrange(date.year, date.month) first_day_wday, last_day = monthrange(date.year, date.month)
# Calculate which day of the month is the first of the target weekdays # Calculate which day of the month is the first of the target weekdays
@@ -160,23 +218,28 @@ class WeekdayPositionExpression(AllExpression):
if self.option_num < 5: if self.option_num < 5:
target_day = first_hit_day + self.option_num * 7 target_day = first_hit_day + self.option_num * 7
else: else:
target_day = first_hit_day + ((last_day - first_hit_day) / 7) * 7 target_day = first_hit_day + ((last_day - first_hit_day) // 7) * 7
if target_day <= last_day and target_day >= date.day: if target_day <= last_day and target_day >= date.day:
return target_day return target_day
def __eq__(self, other):
return (super(WeekdayPositionExpression, self).__eq__(other) and
self.option_num == other.option_num and self.weekday == other.weekday)
def __str__(self): def __str__(self):
return '%s %s' % (self.options[self.option_num], WEEKDAYS[self.weekday]) return '%s %s' % (self.options[self.option_num], WEEKDAYS[self.weekday])
def __repr__(self): def __repr__(self):
return "%s('%s', '%s')" % (self.__class__.__name__, self.options[self.option_num], WEEKDAYS[self.weekday]) return "%s('%s', '%s')" % (self.__class__.__name__, self.options[self.option_num],
WEEKDAYS[self.weekday])
class LastDayOfMonthExpression(AllExpression): class LastDayOfMonthExpression(AllExpression):
value_re = re.compile(r'last', re.IGNORECASE) value_re = re.compile(r'last', re.IGNORECASE)
def __init__(self): def __init__(self):
pass super(LastDayOfMonthExpression, self).__init__(None)
def get_next_value(self, date, field): def get_next_value(self, date, field):
return monthrange(date.year, date.month)[1] return monthrange(date.year, date.month)[1]

View File

@@ -1,22 +1,26 @@
""" """Fields represent CronTrigger options which map to :class:`~datetime.datetime` fields."""
Fields represent CronTrigger options which map to :class:`~datetime.datetime`
fields.
"""
from calendar import monthrange from calendar import monthrange
import re
import six
from apscheduler.triggers.cron.expressions import ( from apscheduler.triggers.cron.expressions import (
AllExpression, RangeExpression, WeekdayPositionExpression, LastDayOfMonthExpression, WeekdayRangeExpression) AllExpression, RangeExpression, WeekdayPositionExpression, LastDayOfMonthExpression,
WeekdayRangeExpression, MonthRangeExpression)
__all__ = ('MIN_VALUES', 'MAX_VALUES', 'DEFAULT_VALUES', 'BaseField', 'WeekField', 'DayOfMonthField', 'DayOfWeekField') __all__ = ('MIN_VALUES', 'MAX_VALUES', 'DEFAULT_VALUES', 'BaseField', 'WeekField',
'DayOfMonthField', 'DayOfWeekField')
MIN_VALUES = {'year': 1970, 'month': 1, 'day': 1, 'week': 1, 'day_of_week': 0, 'hour': 0, 'minute': 0, 'second': 0} MIN_VALUES = {'year': 1970, 'month': 1, 'day': 1, 'week': 1, 'day_of_week': 0, 'hour': 0,
MAX_VALUES = {'year': 2 ** 63, 'month': 12, 'day:': 31, 'week': 53, 'day_of_week': 6, 'hour': 23, 'minute': 59, 'minute': 0, 'second': 0}
'second': 59} MAX_VALUES = {'year': 9999, 'month': 12, 'day': 31, 'week': 53, 'day_of_week': 6, 'hour': 23,
DEFAULT_VALUES = {'year': '*', 'month': 1, 'day': 1, 'week': '*', 'day_of_week': '*', 'hour': 0, 'minute': 0, 'minute': 59, 'second': 59}
'second': 0} DEFAULT_VALUES = {'year': '*', 'month': 1, 'day': 1, 'week': '*', 'day_of_week': '*', 'hour': 0,
'minute': 0, 'second': 0}
SEPARATOR = re.compile(' *, *')
class BaseField(object): class BaseField(object):
@@ -50,23 +54,29 @@ class BaseField(object):
self.expressions = [] self.expressions = []
# Split a comma-separated expression list, if any # Split a comma-separated expression list, if any
exprs = str(exprs).strip() for expr in SEPARATOR.split(str(exprs).strip()):
if ',' in exprs: self.compile_expression(expr)
for expr in exprs.split(','):
self.compile_expression(expr)
else:
self.compile_expression(exprs)
def compile_expression(self, expr): def compile_expression(self, expr):
for compiler in self.COMPILERS: for compiler in self.COMPILERS:
match = compiler.value_re.match(expr) match = compiler.value_re.match(expr)
if match: if match:
compiled_expr = compiler(**match.groupdict()) compiled_expr = compiler(**match.groupdict())
try:
compiled_expr.validate_range(self.name)
except ValueError as e:
exc = ValueError('Error validating expression {!r}: {}'.format(expr, e))
six.raise_from(exc, None)
self.expressions.append(compiled_expr) self.expressions.append(compiled_expr)
return return
raise ValueError('Unrecognized expression "%s" for field "%s"' % (expr, self.name)) raise ValueError('Unrecognized expression "%s" for field "%s"' % (expr, self.name))
def __eq__(self, other):
return isinstance(self, self.__class__) and self.expressions == other.expressions
def __str__(self): def __str__(self):
expr_strings = (str(e) for e in self.expressions) expr_strings = (str(e) for e in self.expressions)
return ','.join(expr_strings) return ','.join(expr_strings)
@@ -94,4 +104,8 @@ class DayOfWeekField(BaseField):
COMPILERS = BaseField.COMPILERS + [WeekdayRangeExpression] COMPILERS = BaseField.COMPILERS + [WeekdayRangeExpression]
def get_value(self, dateval): def get_value(self, dateval):
return dateval.weekday() return dateval.isoweekday() % 7
class MonthField(BaseField):
COMPILERS = BaseField.COMPILERS + [MonthRangeExpression]

View File

@@ -14,15 +14,36 @@ class DateTrigger(BaseTrigger):
:param datetime.tzinfo|str timezone: time zone for ``run_date`` if it doesn't have one already :param datetime.tzinfo|str timezone: time zone for ``run_date`` if it doesn't have one already
""" """
__slots__ = 'timezone', 'run_date' __slots__ = 'run_date'
def __init__(self, run_date=None, timezone=None): def __init__(self, run_date=None, timezone=None):
timezone = astimezone(timezone) or get_localzone() timezone = astimezone(timezone) or get_localzone()
self.run_date = convert_to_datetime(run_date or datetime.now(), timezone, 'run_date') if run_date is not None:
self.run_date = convert_to_datetime(run_date, timezone, 'run_date')
else:
self.run_date = datetime.now(timezone)
def get_next_fire_time(self, previous_fire_time, now): def get_next_fire_time(self, previous_fire_time, now):
return self.run_date if previous_fire_time is None else None return self.run_date if previous_fire_time is None else None
def __getstate__(self):
return {
'version': 1,
'run_date': self.run_date
}
def __setstate__(self, state):
# This is for compatibility with APScheduler 3.0.x
if isinstance(state, tuple):
state = state[1]
if state.get('version', 1) > 1:
raise ValueError(
'Got serialized data for version %s of %s, but only version 1 can be handled' %
(state['version'], self.__class__.__name__))
self.run_date = state['run_date']
def __str__(self): def __str__(self):
return 'date[%s]' % datetime_repr(self.run_date) return 'date[%s]' % datetime_repr(self.run_date)

View File

@@ -9,8 +9,8 @@ from apscheduler.util import convert_to_datetime, timedelta_seconds, datetime_re
class IntervalTrigger(BaseTrigger): class IntervalTrigger(BaseTrigger):
""" """
Triggers on specified intervals, starting on ``start_date`` if specified, ``datetime.now()`` + interval Triggers on specified intervals, starting on ``start_date`` if specified, ``datetime.now()`` +
otherwise. interval otherwise.
:param int weeks: number of weeks to wait :param int weeks: number of weeks to wait
:param int days: number of days to wait :param int days: number of days to wait
@@ -20,12 +20,15 @@ class IntervalTrigger(BaseTrigger):
:param datetime|str start_date: starting point for the interval calculation :param datetime|str start_date: starting point for the interval calculation
:param datetime|str end_date: latest possible date/time to trigger on :param datetime|str end_date: latest possible date/time to trigger on
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations :param datetime.tzinfo|str timezone: time zone to use for the date/time calculations
:param int|None jitter: advance or delay the job execution by ``jitter`` seconds at most.
""" """
__slots__ = 'timezone', 'start_date', 'end_date', 'interval' __slots__ = 'timezone', 'start_date', 'end_date', 'interval', 'interval_length', 'jitter'
def __init__(self, weeks=0, days=0, hours=0, minutes=0, seconds=0, start_date=None, end_date=None, timezone=None): def __init__(self, weeks=0, days=0, hours=0, minutes=0, seconds=0, start_date=None,
self.interval = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds) end_date=None, timezone=None, jitter=None):
self.interval = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes,
seconds=seconds)
self.interval_length = timedelta_seconds(self.interval) self.interval_length = timedelta_seconds(self.interval)
if self.interval_length == 0: if self.interval_length == 0:
self.interval = timedelta(seconds=1) self.interval = timedelta(seconds=1)
@@ -33,9 +36,9 @@ class IntervalTrigger(BaseTrigger):
if timezone: if timezone:
self.timezone = astimezone(timezone) self.timezone = astimezone(timezone)
elif start_date and start_date.tzinfo: elif isinstance(start_date, datetime) and start_date.tzinfo:
self.timezone = start_date.tzinfo self.timezone = start_date.tzinfo
elif end_date and end_date.tzinfo: elif isinstance(end_date, datetime) and end_date.tzinfo:
self.timezone = end_date.tzinfo self.timezone = end_date.tzinfo
else: else:
self.timezone = get_localzone() self.timezone = get_localzone()
@@ -44,6 +47,8 @@ class IntervalTrigger(BaseTrigger):
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date') self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date') self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
self.jitter = jitter
def get_next_fire_time(self, previous_fire_time, now): def get_next_fire_time(self, previous_fire_time, now):
if previous_fire_time: if previous_fire_time:
next_fire_time = previous_fire_time + self.interval next_fire_time = previous_fire_time + self.interval
@@ -54,12 +59,48 @@ class IntervalTrigger(BaseTrigger):
next_interval_num = int(ceil(timediff_seconds / self.interval_length)) next_interval_num = int(ceil(timediff_seconds / self.interval_length))
next_fire_time = self.start_date + self.interval * next_interval_num next_fire_time = self.start_date + self.interval * next_interval_num
if self.jitter is not None:
next_fire_time = self._apply_jitter(next_fire_time, self.jitter, now)
if not self.end_date or next_fire_time <= self.end_date: if not self.end_date or next_fire_time <= self.end_date:
return self.timezone.normalize(next_fire_time) return self.timezone.normalize(next_fire_time)
def __getstate__(self):
return {
'version': 2,
'timezone': self.timezone,
'start_date': self.start_date,
'end_date': self.end_date,
'interval': self.interval,
'jitter': self.jitter,
}
def __setstate__(self, state):
# This is for compatibility with APScheduler 3.0.x
if isinstance(state, tuple):
state = state[1]
if state.get('version', 1) > 2:
raise ValueError(
'Got serialized data for version %s of %s, but only versions up to 2 can be '
'handled' % (state['version'], self.__class__.__name__))
self.timezone = state['timezone']
self.start_date = state['start_date']
self.end_date = state['end_date']
self.interval = state['interval']
self.interval_length = timedelta_seconds(self.interval)
self.jitter = state.get('jitter')
def __str__(self): def __str__(self):
return 'interval[%s]' % str(self.interval) return 'interval[%s]' % str(self.interval)
def __repr__(self): def __repr__(self):
return "<%s (interval=%r, start_date='%s')>" % (self.__class__.__name__, self.interval, options = ['interval=%r' % self.interval, 'start_date=%r' % datetime_repr(self.start_date)]
datetime_repr(self.start_date)) if self.end_date:
options.append("end_date=%r" % datetime_repr(self.end_date))
if self.jitter:
options.append('jitter=%s' % self.jitter)
return "<%s (%s, timezone='%s')>" % (
self.__class__.__name__, ', '.join(options), self.timezone)

View File

@@ -2,9 +2,9 @@
from __future__ import division from __future__ import division
from datetime import date, datetime, time, timedelta, tzinfo from datetime import date, datetime, time, timedelta, tzinfo
from inspect import isfunction, ismethod, getargspec
from calendar import timegm from calendar import timegm
import re import re
from functools import partial
from pytz import timezone, utc from pytz import timezone, utc
import six import six
@@ -12,14 +12,16 @@ import six
try: try:
from inspect import signature from inspect import signature
except ImportError: # pragma: nocover except ImportError: # pragma: nocover
try: from funcsigs import signature
from funcsigs import signature
except ImportError: try:
signature = None from threading import TIMEOUT_MAX
except ImportError:
TIMEOUT_MAX = 4294967 # Maximum value accepted by Event.wait() on Windows
__all__ = ('asint', 'asbool', 'astimezone', 'convert_to_datetime', 'datetime_to_utc_timestamp', __all__ = ('asint', 'asbool', 'astimezone', 'convert_to_datetime', 'datetime_to_utc_timestamp',
'utc_timestamp_to_datetime', 'timedelta_seconds', 'datetime_ceil', 'get_callable_name', 'obj_to_ref', 'utc_timestamp_to_datetime', 'timedelta_seconds', 'datetime_ceil', 'get_callable_name',
'ref_to_obj', 'maybe_ref', 'repr_escape', 'check_callable_args') 'obj_to_ref', 'ref_to_obj', 'maybe_ref', 'repr_escape', 'check_callable_args')
class _Undefined(object): class _Undefined(object):
@@ -32,17 +34,18 @@ class _Undefined(object):
def __repr__(self): def __repr__(self):
return '<undefined>' return '<undefined>'
undefined = _Undefined() #: a unique object that only signifies that no value is defined undefined = _Undefined() #: a unique object that only signifies that no value is defined
def asint(text): def asint(text):
""" """
Safely converts a string to an integer, returning None if the string is None. Safely converts a string to an integer, returning ``None`` if the string is ``None``.
:type text: str :type text: str
:rtype: int :rtype: int
"""
"""
if text is not None: if text is not None:
return int(text) return int(text)
@@ -52,8 +55,8 @@ def asbool(obj):
Interprets an object as a boolean value. Interprets an object as a boolean value.
:rtype: bool :rtype: bool
"""
"""
if isinstance(obj, str): if isinstance(obj, str):
obj = obj.strip().lower() obj = obj.strip().lower()
if obj in ('true', 'yes', 'on', 'y', 't', '1'): if obj in ('true', 'yes', 'on', 'y', 't', '1'):
@@ -69,15 +72,19 @@ def astimezone(obj):
Interprets an object as a timezone. Interprets an object as a timezone.
:rtype: tzinfo :rtype: tzinfo
"""
"""
if isinstance(obj, six.string_types): if isinstance(obj, six.string_types):
return timezone(obj) return timezone(obj)
if isinstance(obj, tzinfo): if isinstance(obj, tzinfo):
if not hasattr(obj, 'localize') or not hasattr(obj, 'normalize'): if not hasattr(obj, 'localize') or not hasattr(obj, 'normalize'):
raise TypeError('Only timezones from the pytz library are supported') raise TypeError('Only timezones from the pytz library are supported')
if obj.zone == 'local': if obj.zone == 'local':
raise ValueError('Unable to determine the name of the local timezone -- use an explicit timezone instead') raise ValueError(
'Unable to determine the name of the local timezone -- you must explicitly '
'specify the name of the local timezone. Please refrain from using timezones like '
'EST to prevent problems with daylight saving time. Instead, use a locale based '
'timezone name (such as Europe/Helsinki).')
return obj return obj
if obj is not None: if obj is not None:
raise TypeError('Expected tzinfo, got %s instead' % obj.__class__.__name__) raise TypeError('Expected tzinfo, got %s instead' % obj.__class__.__name__)
@@ -92,20 +99,20 @@ _DATE_REGEX = re.compile(
def convert_to_datetime(input, tz, arg_name): def convert_to_datetime(input, tz, arg_name):
""" """
Converts the given object to a timezone aware datetime object. Converts the given object to a timezone aware datetime object.
If a timezone aware datetime object is passed, it is returned unmodified. If a timezone aware datetime object is passed, it is returned unmodified.
If a native datetime object is passed, it is given the specified timezone. If a native datetime object is passed, it is given the specified timezone.
If the input is a string, it is parsed as a datetime with the given timezone. If the input is a string, it is parsed as a datetime with the given timezone.
Date strings are accepted in three different forms: date only (Y-m-d), Date strings are accepted in three different forms: date only (Y-m-d), date with time
date with time (Y-m-d H:M:S) or with date+time with microseconds (Y-m-d H:M:S) or with date+time with microseconds (Y-m-d H:M:S.micro).
(Y-m-d H:M:S.micro).
:param str|datetime input: the datetime or string to convert to a timezone aware datetime :param str|datetime input: the datetime or string to convert to a timezone aware datetime
:param datetime.tzinfo tz: timezone to interpret ``input`` in :param datetime.tzinfo tz: timezone to interpret ``input`` in
:param str arg_name: the name of the argument (used in an error message) :param str arg_name: the name of the argument (used in an error message)
:rtype: datetime :rtype: datetime
"""
"""
if input is None: if input is None:
return return
elif isinstance(input, datetime): elif isinstance(input, datetime):
@@ -125,14 +132,16 @@ def convert_to_datetime(input, tz, arg_name):
if datetime_.tzinfo is not None: if datetime_.tzinfo is not None:
return datetime_ return datetime_
if tz is None: if tz is None:
raise ValueError('The "tz" argument must be specified if %s has no timezone information' % arg_name) raise ValueError(
'The "tz" argument must be specified if %s has no timezone information' % arg_name)
if isinstance(tz, six.string_types): if isinstance(tz, six.string_types):
tz = timezone(tz) tz = timezone(tz)
try: try:
return tz.localize(datetime_, is_dst=None) return tz.localize(datetime_, is_dst=None)
except AttributeError: except AttributeError:
raise TypeError('Only pytz timezones are supported (need the localize() and normalize() methods)') raise TypeError(
'Only pytz timezones are supported (need the localize() and normalize() methods)')
def datetime_to_utc_timestamp(timeval): def datetime_to_utc_timestamp(timeval):
@@ -141,8 +150,8 @@ def datetime_to_utc_timestamp(timeval):
:type timeval: datetime :type timeval: datetime
:rtype: float :rtype: float
"""
"""
if timeval is not None: if timeval is not None:
return timegm(timeval.utctimetuple()) + timeval.microsecond / 1000000 return timegm(timeval.utctimetuple()) + timeval.microsecond / 1000000
@@ -153,8 +162,8 @@ def utc_timestamp_to_datetime(timestamp):
:type timestamp: float :type timestamp: float
:rtype: datetime :rtype: datetime
"""
"""
if timestamp is not None: if timestamp is not None:
return datetime.fromtimestamp(timestamp, utc) return datetime.fromtimestamp(timestamp, utc)
@@ -165,8 +174,8 @@ def timedelta_seconds(delta):
:type delta: timedelta :type delta: timedelta
:rtype: float :rtype: float
"""
"""
return delta.days * 24 * 60 * 60 + delta.seconds + \ return delta.days * 24 * 60 * 60 + delta.seconds + \
delta.microseconds / 1000000.0 delta.microseconds / 1000000.0
@@ -176,8 +185,8 @@ def datetime_ceil(dateval):
Rounds the given datetime object upwards. Rounds the given datetime object upwards.
:type dateval: datetime :type dateval: datetime
"""
"""
if dateval.microsecond > 0: if dateval.microsecond > 0:
return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond) return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond)
return dateval return dateval
@@ -192,8 +201,8 @@ def get_callable_name(func):
Returns the best available display name for the given function/callable. Returns the best available display name for the given function/callable.
:rtype: str :rtype: str
"""
"""
# the easy case (on Python 3.3+) # the easy case (on Python 3.3+)
if hasattr(func, '__qualname__'): if hasattr(func, '__qualname__'):
return func.__qualname__ return func.__qualname__
@@ -222,20 +231,24 @@ def get_callable_name(func):
def obj_to_ref(obj): def obj_to_ref(obj):
""" """
Returns the path to the given object. Returns the path to the given callable.
:rtype: str :rtype: str
:raises TypeError: if the given object is not callable
:raises ValueError: if the given object is a :class:`~functools.partial`, lambda or a nested
function
""" """
if isinstance(obj, partial):
raise ValueError('Cannot create a reference to a partial()')
try: name = get_callable_name(obj)
ref = '%s:%s' % (obj.__module__, get_callable_name(obj)) if '<lambda>' in name:
obj2 = ref_to_obj(ref) raise ValueError('Cannot create a reference to a lambda')
if obj != obj2: if '<locals>' in name:
raise ValueError raise ValueError('Cannot create a reference to a nested function')
except Exception:
raise ValueError('Cannot determine the reference to %r' % obj)
return ref return '%s:%s' % (obj.__module__, name)
def ref_to_obj(ref): def ref_to_obj(ref):
@@ -243,8 +256,8 @@ def ref_to_obj(ref):
Returns the object pointed to by ``ref``. Returns the object pointed to by ``ref``.
:type ref: str :type ref: str
"""
"""
if not isinstance(ref, six.string_types): if not isinstance(ref, six.string_types):
raise TypeError('References must be strings') raise TypeError('References must be strings')
if ':' not in ref: if ':' not in ref:
@@ -252,12 +265,12 @@ def ref_to_obj(ref):
modulename, rest = ref.split(':', 1) modulename, rest = ref.split(':', 1)
try: try:
obj = __import__(modulename) obj = __import__(modulename, fromlist=[rest])
except ImportError: except ImportError:
raise LookupError('Error resolving reference %s: could not import module' % ref) raise LookupError('Error resolving reference %s: could not import module' % ref)
try: try:
for name in modulename.split('.')[1:] + rest.split('.'): for name in rest.split('.'):
obj = getattr(obj, name) obj = getattr(obj, name)
return obj return obj
except Exception: except Exception:
@@ -268,8 +281,8 @@ def maybe_ref(ref):
""" """
Returns the object that the given reference points to, if it is indeed a reference. Returns the object that the given reference points to, if it is indeed a reference.
If it is not a reference, the object is returned as-is. If it is not a reference, the object is returned as-is.
"""
"""
if not isinstance(ref, str): if not isinstance(ref, str):
return ref return ref
return ref_to_obj(ref) return ref_to_obj(ref)
@@ -281,7 +294,8 @@ if six.PY2:
return string.encode('ascii', 'backslashreplace') return string.encode('ascii', 'backslashreplace')
return string return string
else: else:
repr_escape = lambda string: string def repr_escape(string):
return string
def check_callable_args(func, args, kwargs): def check_callable_args(func, args, kwargs):
@@ -290,70 +304,51 @@ def check_callable_args(func, args, kwargs):
:type args: tuple :type args: tuple
:type kwargs: dict :type kwargs: dict
"""
"""
pos_kwargs_conflicts = [] # parameters that have a match in both args and kwargs pos_kwargs_conflicts = [] # parameters that have a match in both args and kwargs
positional_only_kwargs = [] # positional-only parameters that have a match in kwargs positional_only_kwargs = [] # positional-only parameters that have a match in kwargs
unsatisfied_args = [] # parameters in signature that don't have a match in args or kwargs unsatisfied_args = [] # parameters in signature that don't have a match in args or kwargs
unsatisfied_kwargs = [] # keyword-only arguments that don't have a match in kwargs unsatisfied_kwargs = [] # keyword-only arguments that don't have a match in kwargs
unmatched_args = list(args) # args that didn't match any of the parameters in the signature unmatched_args = list(args) # args that didn't match any of the parameters in the signature
unmatched_kwargs = list(kwargs) # kwargs that didn't match any of the parameters in the signature # kwargs that didn't match any of the parameters in the signature
has_varargs = has_var_kwargs = False # indicates if the signature defines *args and **kwargs respectively unmatched_kwargs = list(kwargs)
# indicates if the signature defines *args and **kwargs respectively
has_varargs = has_var_kwargs = False
if signature: try:
try: sig = signature(func)
sig = signature(func) except ValueError:
except ValueError: # signature() doesn't work against every kind of callable
return # signature() doesn't work against every kind of callable return
for param in six.itervalues(sig.parameters): for param in six.itervalues(sig.parameters):
if param.kind == param.POSITIONAL_OR_KEYWORD: if param.kind == param.POSITIONAL_OR_KEYWORD:
if param.name in unmatched_kwargs and unmatched_args: if param.name in unmatched_kwargs and unmatched_args:
pos_kwargs_conflicts.append(param.name) pos_kwargs_conflicts.append(param.name)
elif unmatched_args:
del unmatched_args[0]
elif param.name in unmatched_kwargs:
unmatched_kwargs.remove(param.name)
elif param.default is param.empty:
unsatisfied_args.append(param.name)
elif param.kind == param.POSITIONAL_ONLY:
if unmatched_args:
del unmatched_args[0]
elif param.name in unmatched_kwargs:
unmatched_kwargs.remove(param.name)
positional_only_kwargs.append(param.name)
elif param.default is param.empty:
unsatisfied_args.append(param.name)
elif param.kind == param.KEYWORD_ONLY:
if param.name in unmatched_kwargs:
unmatched_kwargs.remove(param.name)
elif param.default is param.empty:
unsatisfied_kwargs.append(param.name)
elif param.kind == param.VAR_POSITIONAL:
has_varargs = True
elif param.kind == param.VAR_KEYWORD:
has_var_kwargs = True
else:
if not isfunction(func) and not ismethod(func) and hasattr(func, '__call__'):
func = func.__call__
try:
argspec = getargspec(func)
except TypeError:
return # getargspec() doesn't work certain callables
argspec_args = argspec.args if not ismethod(func) else argspec.args[1:]
has_varargs = bool(argspec.varargs)
has_var_kwargs = bool(argspec.keywords)
for arg, default in six.moves.zip_longest(argspec_args, argspec.defaults or (), fillvalue=undefined):
if arg in unmatched_kwargs and unmatched_args:
pos_kwargs_conflicts.append(arg)
elif unmatched_args: elif unmatched_args:
del unmatched_args[0] del unmatched_args[0]
elif arg in unmatched_kwargs: elif param.name in unmatched_kwargs:
unmatched_kwargs.remove(arg) unmatched_kwargs.remove(param.name)
elif default is undefined: elif param.default is param.empty:
unsatisfied_args.append(arg) unsatisfied_args.append(param.name)
elif param.kind == param.POSITIONAL_ONLY:
if unmatched_args:
del unmatched_args[0]
elif param.name in unmatched_kwargs:
unmatched_kwargs.remove(param.name)
positional_only_kwargs.append(param.name)
elif param.default is param.empty:
unsatisfied_args.append(param.name)
elif param.kind == param.KEYWORD_ONLY:
if param.name in unmatched_kwargs:
unmatched_kwargs.remove(param.name)
elif param.default is param.empty:
unsatisfied_kwargs.append(param.name)
elif param.kind == param.VAR_POSITIONAL:
has_varargs = True
elif param.kind == param.VAR_KEYWORD:
has_var_kwargs = True
# Make sure there are no conflicts between args and kwargs # Make sure there are no conflicts between args and kwargs
if pos_kwargs_conflicts: if pos_kwargs_conflicts:
@@ -365,21 +360,26 @@ def check_callable_args(func, args, kwargs):
raise ValueError('The following arguments cannot be given as keyword arguments: %s' % raise ValueError('The following arguments cannot be given as keyword arguments: %s' %
', '.join(positional_only_kwargs)) ', '.join(positional_only_kwargs))
# Check that the number of positional arguments minus the number of matched kwargs matches the argspec # Check that the number of positional arguments minus the number of matched kwargs matches the
# argspec
if unsatisfied_args: if unsatisfied_args:
raise ValueError('The following arguments have not been supplied: %s' % ', '.join(unsatisfied_args)) raise ValueError('The following arguments have not been supplied: %s' %
', '.join(unsatisfied_args))
# Check that all keyword-only arguments have been supplied # Check that all keyword-only arguments have been supplied
if unsatisfied_kwargs: if unsatisfied_kwargs:
raise ValueError('The following keyword-only arguments have not been supplied in kwargs: %s' % raise ValueError(
', '.join(unsatisfied_kwargs)) 'The following keyword-only arguments have not been supplied in kwargs: %s' %
', '.join(unsatisfied_kwargs))
# Check that the callable can accept the given number of positional arguments # Check that the callable can accept the given number of positional arguments
if not has_varargs and unmatched_args: if not has_varargs and unmatched_args:
raise ValueError('The list of positional arguments is longer than the target callable can handle ' raise ValueError(
'(allowed: %d, given in args: %d)' % (len(args) - len(unmatched_args), len(args))) 'The list of positional arguments is longer than the target callable can handle '
'(allowed: %d, given in args: %d)' % (len(args) - len(unmatched_args), len(args)))
# Check that the callable can accept the given keyword arguments # Check that the callable can accept the given keyword arguments
if not has_var_kwargs and unmatched_kwargs: if not has_var_kwargs and unmatched_kwargs:
raise ValueError('The target callable does not accept the following keyword arguments: %s' % raise ValueError(
', '.join(unmatched_kwargs)) 'The target callable does not accept the following keyword arguments: %s' %
', '.join(unmatched_kwargs))

View File

@@ -4,5 +4,5 @@ from .arrow import Arrow
from .factory import ArrowFactory from .factory import ArrowFactory
from .api import get, now, utcnow from .api import get, now, utcnow
__version__ = '0.7.0' __version__ = '0.10.0'
VERSION = __version__ VERSION = __version__

View File

@@ -51,5 +51,5 @@ def factory(type):
return ArrowFactory(type) return ArrowFactory(type)
__all__ = ['get', 'utcnow', 'now', 'factory', 'iso'] __all__ = ['get', 'utcnow', 'now', 'factory']

View File

@@ -12,6 +12,8 @@ from dateutil import tz as dateutil_tz
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
import calendar import calendar
import sys import sys
import warnings
from arrow import util, locales, parser, formatter from arrow import util, locales, parser, formatter
@@ -45,6 +47,7 @@ class Arrow(object):
_ATTRS = ['year', 'month', 'day', 'hour', 'minute', 'second', 'microsecond'] _ATTRS = ['year', 'month', 'day', 'hour', 'minute', 'second', 'microsecond']
_ATTRS_PLURAL = ['{0}s'.format(a) for a in _ATTRS] _ATTRS_PLURAL = ['{0}s'.format(a) for a in _ATTRS]
_MONTHS_PER_QUARTER = 3
def __init__(self, year, month, day, hour=0, minute=0, second=0, microsecond=0, def __init__(self, year, month, day, hour=0, minute=0, second=0, microsecond=0,
tzinfo=None): tzinfo=None):
@@ -306,6 +309,9 @@ class Arrow(object):
if name == 'week': if name == 'week':
return self.isocalendar()[1] return self.isocalendar()[1]
if name == 'quarter':
return int((self.month-1)/self._MONTHS_PER_QUARTER) + 1
if not name.startswith('_'): if not name.startswith('_'):
value = getattr(self._datetime, name, None) value = getattr(self._datetime, name, None)
@@ -378,16 +384,16 @@ class Arrow(object):
>>> arw.replace(year=2014, month=6) >>> arw.replace(year=2014, month=6)
<Arrow [2014-06-11T22:27:34.787885+00:00]> <Arrow [2014-06-11T22:27:34.787885+00:00]>
Use plural property names to shift their current value relatively:
>>> arw.replace(years=1, months=-1)
<Arrow [2014-04-11T22:27:34.787885+00:00]>
You can also provide a timezone expression can also be replaced: You can also provide a timezone expression can also be replaced:
>>> arw.replace(tzinfo=tz.tzlocal()) >>> arw.replace(tzinfo=tz.tzlocal())
<Arrow [2013-05-11T22:27:34.787885-07:00]> <Arrow [2013-05-11T22:27:34.787885-07:00]>
Use plural property names to shift their current value relatively (**deprecated**):
>>> arw.replace(years=1, months=-1)
<Arrow [2014-04-11T22:27:34.787885+00:00]>
Recognized timezone expressions: Recognized timezone expressions:
- A ``tzinfo`` object. - A ``tzinfo`` object.
@@ -398,21 +404,29 @@ class Arrow(object):
''' '''
absolute_kwargs = {} absolute_kwargs = {}
relative_kwargs = {} relative_kwargs = {} # TODO: DEPRECATED; remove in next release
for key, value in kwargs.items(): for key, value in kwargs.items():
if key in self._ATTRS: if key in self._ATTRS:
absolute_kwargs[key] = value absolute_kwargs[key] = value
elif key in self._ATTRS_PLURAL or key == 'weeks': elif key in self._ATTRS_PLURAL or key in ['weeks', 'quarters']:
# TODO: DEPRECATED
warnings.warn("replace() with plural property to shift value"
"is deprecated, use shift() instead",
DeprecationWarning)
relative_kwargs[key] = value relative_kwargs[key] = value
elif key == 'week': elif key in ['week', 'quarter']:
raise AttributeError('setting absolute week is not supported') raise AttributeError('setting absolute {0} is not supported'.format(key))
elif key !='tzinfo': elif key !='tzinfo':
raise AttributeError() raise AttributeError('unknown attribute: "{0}"'.format(key))
# core datetime does not support quarters, translate to months.
relative_kwargs.setdefault('months', 0)
relative_kwargs['months'] += relative_kwargs.pop('quarters', 0) * self._MONTHS_PER_QUARTER
current = self._datetime.replace(**absolute_kwargs) current = self._datetime.replace(**absolute_kwargs)
current += relativedelta(**relative_kwargs) current += relativedelta(**relative_kwargs) # TODO: DEPRECATED
tzinfo = kwargs.get('tzinfo') tzinfo = kwargs.get('tzinfo')
@@ -422,9 +436,41 @@ class Arrow(object):
return self.fromdatetime(current) return self.fromdatetime(current)
def shift(self, **kwargs):
''' Returns a new :class:`Arrow <arrow.arrow.Arrow>` object with attributes updated
according to inputs.
Use plural property names to shift their current value relatively:
>>> import arrow
>>> arw = arrow.utcnow()
>>> arw
<Arrow [2013-05-11T22:27:34.787885+00:00]>
>>> arw.shift(years=1, months=-1)
<Arrow [2014-04-11T22:27:34.787885+00:00]>
'''
relative_kwargs = {}
for key, value in kwargs.items():
if key in self._ATTRS_PLURAL or key in ['weeks', 'quarters']:
relative_kwargs[key] = value
else:
raise AttributeError()
# core datetime does not support quarters, translate to months.
relative_kwargs.setdefault('months', 0)
relative_kwargs['months'] += relative_kwargs.pop('quarters', 0) * self._MONTHS_PER_QUARTER
current = self._datetime + relativedelta(**relative_kwargs)
return self.fromdatetime(current)
def to(self, tz): def to(self, tz):
''' Returns a new :class:`Arrow <arrow.arrow.Arrow>` object, converted to the target ''' Returns a new :class:`Arrow <arrow.arrow.Arrow>` object, converted
timezone. to the target timezone.
:param tz: an expression representing a timezone. :param tz: an expression representing a timezone.
@@ -587,6 +633,7 @@ class Arrow(object):
Defaults to now in the current :class:`Arrow <arrow.arrow.Arrow>` object's timezone. Defaults to now in the current :class:`Arrow <arrow.arrow.Arrow>` object's timezone.
:param locale: (optional) a ``str`` specifying a locale. Defaults to 'en_us'. :param locale: (optional) a ``str`` specifying a locale. Defaults to 'en_us'.
:param only_distance: (optional) returns only time difference eg: "11 seconds" without "in" or "ago" part. :param only_distance: (optional) returns only time difference eg: "11 seconds" without "in" or "ago" part.
Usage:: Usage::
>>> earlier = arrow.utcnow().replace(hours=-2) >>> earlier = arrow.utcnow().replace(hours=-2)
@@ -651,7 +698,8 @@ class Arrow(object):
elif diff < 29808000: elif diff < 29808000:
self_months = self._datetime.year * 12 + self._datetime.month self_months = self._datetime.year * 12 + self._datetime.month
other_months = dt.year * 12 + dt.month other_months = dt.year * 12 + dt.month
months = sign * abs(other_months - self_months)
months = sign * int(max(abs(other_months - self_months), 2))
return locale.describe('months', months, only_distance=only_distance) return locale.describe('months', months, only_distance=only_distance)
@@ -676,7 +724,7 @@ class Arrow(object):
def __sub__(self, other): def __sub__(self, other):
if isinstance(other, timedelta): if isinstance(other, (timedelta, relativedelta)):
return self.fromdatetime(self._datetime - other, self._datetime.tzinfo) return self.fromdatetime(self._datetime - other, self._datetime.tzinfo)
elif isinstance(other, datetime): elif isinstance(other, datetime):
@@ -688,7 +736,11 @@ class Arrow(object):
raise TypeError() raise TypeError()
def __rsub__(self, other): def __rsub__(self, other):
return self.__sub__(other)
if isinstance(other, datetime):
return other - self._datetime
raise TypeError()
# comparisons # comparisons
@@ -702,8 +754,6 @@ class Arrow(object):
if not isinstance(other, (Arrow, datetime)): if not isinstance(other, (Arrow, datetime)):
return False return False
other = self._get_datetime(other)
return self._datetime == self._get_datetime(other) return self._datetime == self._get_datetime(other)
def __ne__(self, other): def __ne__(self, other):
@@ -882,7 +932,9 @@ class Arrow(object):
return cls.max, limit return cls.max, limit
else: else:
return end, sys.maxsize if limit is None:
return end, sys.maxsize
return end, limit
@staticmethod @staticmethod
def _get_timestamp_from_input(timestamp): def _get_timestamp_from_input(timestamp):

View File

@@ -94,7 +94,7 @@ class DateTimeFormatter(object):
tz = dateutil_tz.tzutc() if dt.tzinfo is None else dt.tzinfo tz = dateutil_tz.tzutc() if dt.tzinfo is None else dt.tzinfo
total_minutes = int(util.total_seconds(tz.utcoffset(dt)) / 60) total_minutes = int(util.total_seconds(tz.utcoffset(dt)) / 60)
sign = '+' if total_minutes > 0 else '-' sign = '+' if total_minutes >= 0 else '-'
total_minutes = abs(total_minutes) total_minutes = abs(total_minutes)
hour, minute = divmod(total_minutes, 60) hour, minute = divmod(total_minutes, 60)

Some files were not shown because too many files have changed in this diff Show More