Compare commits

...

316 Commits

Author SHA1 Message Date
JonnyWong16
70b6bd4efd v2.1.13 2018-06-16 16:42:20 -07:00
JonnyWong16
4f4a99d695 Fix soft crash when viewing photos not in an album 2018-06-16 16:38:06 -07:00
JonnyWong16
f65baa22f1 Fix font awesome icon in setting alerts 2018-06-12 19:28:33 -07:00
JonnyWong16
a1fb081d47 Fix font awesome info page breadcrumb icon 2018-06-11 11:36:50 -07:00
JonnyWong16
63ac3eec98 Fix font awesome remove ajax loader icon 2018-06-11 11:30:51 -07:00
JonnyWong16
73c11c053d Add clip to notification param media type example 2018-06-11 11:11:51 -07:00
JonnyWong16
85c398ce05 Change graph resolution font awesome icon 2018-06-10 19:55:31 -07:00
JonnyWong16
7387cb8322 Add date and time parameters for notifications and newsletters 2018-06-10 19:48:46 -07:00
JonnyWong16
37841b6e8c Fix font awesome icons in notifier text accordions 2018-06-10 19:36:32 -07:00
JonnyWong16
af1355e220 Fix blank font awesome icons 2018-06-10 18:23:22 -07:00
JonnyWong16
37bd005907 Update Discord links 2018-06-10 10:48:05 -07:00
JonnyWong16
311030b58e Add buttons to Discord/Reddit on support page 2018-06-10 10:47:48 -07:00
JonnyWong16
bdd124327f Update to Font Awesome v5 2018-06-10 10:15:49 -07:00
JonnyWong16
70e5e698fa Add support page 2018-06-09 21:28:40 -07:00
JonnyWong16
33e799955e Correct sessions.product column type 2018-06-09 00:03:23 -07:00
JonnyWong16
331be52327 v2.1.12 2018-06-08 22:12:51 -07:00
JonnyWong16
6ece690e23 Fix grouping plays on graphs with new grouping logic 2018-06-08 18:20:51 -07:00
JonnyWong16
6b63a1399e Add collection tag to raw newsletter data 2018-06-08 17:42:20 -07:00
JonnyWong16
5c77cf652b Fix typo in Join notifier help text 2018-06-08 17:18:05 -07:00
JonnyWong16
d6f9a82edb Fix mutable default arguments 2018-06-07 18:51:52 -07:00
JonnyWong16
a887489666 Change notifier link sources to blank for disabled 2018-06-07 18:51:20 -07:00
JonnyWong16
8c0bcd0059 Ability to stop a stream using session key 2018-06-05 20:30:07 -07:00
JonnyWong16
c18ee81130 Fix typo in http handler 2018-06-03 12:25:15 -07:00
JonnyWong16
44428cc6e5 Update logger blacklist for newsleter/notifier configs 2018-06-03 11:01:58 -07:00
JonnyWong16
c19cc858bd v2.1.11-beta 2018-06-02 10:39:32 -07:00
JonnyWong16
668913fd60 Allow manual PMS URL override in config file for XML shortcuts 2018-06-02 09:55:39 -07:00
JonnyWong16
50c5407a46 Fix Monitor Remote Access checkbox (Plex server update chaged bool to int) 2018-06-02 09:32:05 -07:00
JonnyWong16
939755d3b7 Add Plex XML shortcuts to libraries, users, and sync headers 2018-06-02 08:58:55 -07:00
JonnyWong16
54f4696713 Refactor open Plex XML script 2018-06-02 08:53:53 -07:00
JonnyWong16
c85af521fe Add Plex XML shortcuts for metadata and server resources 2018-06-02 08:26:30 -07:00
JonnyWong16
917d19db85 Fix grouping or new plays not previously in history 2018-06-01 18:45:58 -07:00
JonnyWong16
7292f25eb9 Reword group play history setting 2018-05-31 09:10:44 -07:00
JonnyWong16
22a2ad4bc7 Fix progress percent in grouping logic 2018-05-31 08:34:58 -07:00
JonnyWong16
95e56f5ea5 Fix activity progress bar not updating in some cases 2018-05-31 08:33:50 -07:00
JonnyWong16
ed24232a0a Improve logic for grouping history items 2018-05-30 22:18:41 -07:00
JonnyWong16
15225faee7 Add filename to notification parameters 2018-05-30 21:52:18 -07:00
JonnyWong16
041a35a35a Fallback to title if missing index for update metadata 2018-05-30 21:46:31 -07:00
JonnyWong16
6d365c174a Update X-Plex headers 2018-05-30 21:39:45 -07:00
JonnyWong16
b5f2f55972 v2.1.10-beta 2018-05-28 17:22:16 -07:00
JonnyWong16
ac207260c8 Do not send newsletter if failed to render template 2018-05-27 23:02:56 -07:00
JonnyWong16
e93808381c Fix track listing layout on info pages 2018-05-27 22:43:56 -07:00
JonnyWong16
7acb8f7dc5 Fix artist summary not showing up on newsletter 2018-05-27 22:35:54 -07:00
JonnyWong16
ba9f4a1f9e Use track artist for music 2018-05-27 22:24:43 -07:00
JonnyWong16
8502c28e25 Fallback poster_key and poster_title for clip notification 2018-05-27 15:39:28 -07:00
JonnyWong16
10add90451 Merge pull request #1295 from samwiseg00/feature-add-timestamp-discord
Add timestamps for rich metadata info on discord
2018-05-27 14:47:53 -07:00
samwiseg00
ddb7fa04ca add timestamps for rich metadata info on discord 2018-05-27 17:44:35 -04:00
JonnyWong16
e21a13b7ff Revert "Hack to check for live tv stopped websocket event"
This reverts commit 1245b4fbd3.
2018-05-27 14:13:24 -07:00
JonnyWong16
1245b4fbd3 Hack to check for live tv stopped websocket event 2018-05-27 14:04:47 -07:00
JonnyWong16
94b00c75c2 Enable notifications for clip media type 2018-05-27 13:41:56 -07:00
JonnyWong16
2edcf26110 Use HTTPS for cloudinary urls 2018-05-27 13:07:18 -07:00
JonnyWong16
a9fdf73e8b Check live tv websocket event using key instead of rating key 2018-05-27 13:00:34 -07:00
JonnyWong16
4884cee309 Fix live tv stream resolution 2018-05-27 10:13:42 -07:00
JonnyWong16
b3c7256bcf Newsletter footer inherit styles 2018-05-26 17:29:21 -07:00
JonnyWong16
2c9a7ced13 Forgot product in session db write 2018-05-26 10:14:54 -07:00
JonnyWong16
aa365eb6a3 Improved checking of live tv session websocket events 2018-05-26 10:14:36 -07:00
JonnyWong16
2366a8811b Catch exception from failed SMTP connection 2018-05-25 12:19:46 -07:00
JonnyWong16
53aafbd19e Fix typo from d5bffc3 2018-05-25 12:18:29 -07:00
JonnyWong16
d5bffc374c Fallback to blank poster/art on newsletter if image hosting is disabled 2018-05-25 08:26:25 -07:00
JonnyWong16
5cd5c36d8c Actually add live notification parameter 2018-05-23 17:17:47 -07:00
JonnyWong16
7f9e8f6211 Clean up script.js 2018-05-23 17:13:20 -07:00
JonnyWong16
f743a817ba Update python-twitter to 3.4.1 2018-05-23 17:12:19 -07:00
JonnyWong16
8e4aba7ed4 v2.1.9 2018-05-21 09:07:12 -07:00
JonnyWong16
8c0ef75d4c Fix typos and some cleanup 2018-05-21 09:07:01 -07:00
JonnyWong16
76c4b3bb71 Add Live to notification parameter 2018-05-21 08:49:35 -07:00
JonnyWong16
112b1c7984 Refactor css pointer class 2018-05-20 17:04:55 -07:00
JonnyWong16
c22a2513e3 Update CONTRIBUTING.md 2018-05-19 09:12:13 -07:00
JonnyWong16
f336782fc1 v2.1.8-beta 2018-05-19 09:07:18 -07:00
JonnyWong16
c19afa06de Fallback to originally available at for episode number on info pages 2018-05-18 17:47:19 -07:00
JonnyWong16
e003850d31 Update Facebook permissions scope 2018-05-18 17:41:42 -07:00
JonnyWong16
23cf790079 Return proper status codes for API (Fixes Tautulli/Tautulli-Issues#82) 2018-05-18 17:41:23 -07:00
JonnyWong16
e7f930bd0f Check for Tautulli footer in newsletters 2018-05-17 10:31:55 -07:00
JonnyWong16
348707b6b9 Revert back to HTTP newsletter images from tautulli.com 2018-05-17 09:30:34 -07:00
JonnyWong16
7ad78b4536 Allow images through newsletter password auth 2018-05-17 08:40:58 -07:00
JonnyWong16
a408a62234 Check newsletter auth setting when checking guest access enabled 2018-05-17 08:34:36 -07:00
JonnyWong16
a1e9e7e87f Add newsletter password to newsletter parameters 2018-05-16 23:20:53 -07:00
JonnyWong16
fa99f6e684 Add self-hosted newsletter authentication metnods 2018-05-16 23:11:28 -07:00
JonnyWong16
11e9bd2d54 Fix incorrect <div> tag 2018-05-16 21:59:15 -07:00
JonnyWong16
50165af4b7 Update tautulli.com URLs to HTTPS 2018-05-15 20:38:25 -07:00
JonnyWong16
5dd22c23f2 Patch Twitter str encoding for Python 2 2018-05-15 08:44:13 -07:00
JonnyWong16
79b45c1c46 Auto quality when fetching cloudinary transform 2018-05-15 08:43:20 -07:00
JonnyWong16
af917c4915 Add session key to activity processor log messages 2018-05-14 09:03:18 -07:00
JonnyWong16
c3238b5a83 Fix Imgur database migration again 2018-05-14 09:02:32 -07:00
JonnyWong16
908dbc3243 v2.1.7-beta 2018-05-13 12:10:12 -07:00
JonnyWong16
14b6df8c25 Add newsletter commands to API docs 2018-05-13 11:46:58 -07:00
JonnyWong16
d3e53cb97f Add button to delete all Imgur/Cloudinary uploads 2018-05-13 11:29:59 -07:00
JonnyWong16
445eea5c1e Add plaintext message to newsletter email 2018-05-12 12:16:33 -07:00
JonnyWong16
c5918d7d6c Add option to use inline css styles 2018-05-11 21:43:26 -07:00
JonnyWong16
b8e025193e Add max-width to newsletter card titles 2018-05-11 21:23:19 -07:00
JonnyWong16
85772cdd83 Strip whitespace before sending newsletters 2018-05-11 19:18:05 -07:00
JonnyWong16
7f2bab3082 Switch to header styles for newsletter template 2018-05-11 18:27:31 -07:00
JonnyWong16
8185cc1c40 Keep /newsletter/image proxy for backwards compatibility 2018-05-11 09:40:47 -07:00
JonnyWong16
63bfe96124 Add get_stream_data to API 2018-05-11 08:08:26 -07:00
JonnyWong16
88b640f5e2 Self-hosted newsletter images to use /image endpoint instead of proxying through newsletter 2018-05-10 20:28:42 -07:00
JonnyWong16
26b2342956 Fix typo 2018-05-10 12:04:52 -07:00
JonnyWong16
883280be09 Add Linux distro to Google Analytics 2018-05-10 08:52:28 -07:00
JonnyWong16
7c76b0678a Log platform info on startup 2018-05-10 08:47:54 -07:00
JonnyWong16
e91ba46265 v2.1.6-beta 2018-05-09 20:26:06 -07:00
JonnyWong16
62104c95e3 Move newsletter filename setting and rename config tab 2018-05-08 19:39:27 -07:00
JonnyWong16
178bd89e7c Better init config checkboxes JS 2018-05-08 17:40:17 -07:00
JonnyWong16
365260401c Hide newsletter agent options when save only checked 2018-05-08 17:25:25 -07:00
JonnyWong16
04029bd4d3 Fix NoneType error in newsletter ID name for parameters 2018-05-08 12:56:12 -07:00
JonnyWong16
9cf1128712 Update newsletter preview note for image hosting 2018-05-08 11:22:16 -07:00
JonnyWong16
2eebce9f6c Add HTTP root to newsletter ID name note 2018-05-08 11:01:20 -07:00
JonnyWong16
b08e071b81 Note newesletter ID name and filename as optional 2018-05-08 10:59:00 -07:00
JonnyWong16
7778d84728 Add setting to specify newsletter ID name for static URLs 2018-05-08 10:54:07 -07:00
JonnyWong16
8e3fe7bfa2 v2.1.5-beta 2018-05-07 21:22:46 -07:00
JonnyWong16
6f22c823be Typo in static newsletter URL help text 2018-05-07 21:03:09 -07:00
JonnyWong16
34d7c67813 Merge pull request #1287 from RafaelSchridi/patch-3
Typo in Newsletter Parameters
2018-05-07 21:02:25 -07:00
JonnyWong16
862ed5ce9f Add option to save newsletter only 2018-05-07 20:49:37 -07:00
JonnyWong16
84406e6797 Add setting to enable static newsletter URL 2018-05-07 20:39:04 -07:00
JonnyWong16
19cf567366 Add option to change the newsletter filename 2018-05-07 20:02:04 -07:00
JonnyWong16
8af697a157 Add URL to retrieve latest scheduled newsletter 2018-05-07 19:42:04 -07:00
JonnyWong16
76122bea5d Fix Imgur URL database upgrade migration 2018-05-07 19:09:35 -07:00
JonnyWong16
1a12422908 Show local note on newsletter preview if image hosting enabled 2018-05-07 18:26:40 -07:00
JonnyWong16
2df9f0b48b Add Cloudinary versioning when retrieving the image 2018-05-07 16:13:00 -07:00
JonnyWong16
8540b80e57 Refresh image when uploading to image hosting 2018-05-07 15:32:21 -07:00
JonnyWong16
8ad565a444 Add visible Plex server identifier in the settings 2018-05-07 09:13:16 -07:00
JonnyWong16
f91b6481b3 Close email SMTP connection cleanly if exception 2018-05-06 22:43:55 -07:00
JonnyWong16
826db082c9 Add setting for custom newsletter templates folder 2018-05-06 15:37:07 -07:00
Rafael Schridi
f3d64a7886 Typo in Newsletter Parameters 2018-05-06 23:05:36 +02:00
JonnyWong16
031d078bc2 Add Cache-Control header for newsletter images (Fixes Tautulli/Tautulli-Issues#71) 2018-05-06 08:34:21 -07:00
JonnyWong16
04fcd78102 Uncoulpe self-hosted newsletter and image setting 2018-05-06 08:33:28 -07:00
JonnyWong16
53d1e0f541 Remove HTTP root from newsletter preview iframe 2018-05-05 23:40:44 -07:00
JonnyWong16
9719f0b25b Update newsletter warning message 2018-05-05 22:59:10 -07:00
JonnyWong16
6d1d5bc822 Check for disabled image hosting 2018-05-05 13:06:20 -07:00
JonnyWong16
0d7bbe044d Don't fetch recently added on homepage if Plex Cloud server is sleeping 2018-05-05 11:54:13 -07:00
JonnyWong16
b1dc5816a4 v2.1.4 2018-05-05 11:21:03 -07:00
JonnyWong16
476011a783 Fix newsletter URL with no HTTP root 2018-05-05 11:13:27 -07:00
JonnyWong16
e038c57c4c v2.1.3-beta 2018-05-04 22:36:11 -07:00
JonnyWong16
a989a53750 Encode image title for Cloudinary upload 2018-05-04 16:11:42 -07:00
JonnyWong16
d8cfdea704 Log individual condition evalutation 2018-05-04 15:52:04 -07:00
JonnyWong16
ed4722c4ce Improve refreshing of cached Plex images 2018-05-03 20:29:23 -07:00
JonnyWong16
17ab5f05ed Patch apshceduler sun-sat as 0-6 2018-05-03 17:58:28 -07:00
JonnyWong16
71ab2248d7 Make sure Cloudinary parameters are strings 2018-05-03 08:34:32 -07:00
JonnyWong16
4fb4410552 Fix potential XSS in search 2018-05-02 10:26:05 -07:00
JonnyWong16
a915d2333f Catch failed hostname resolution (Fixes Tautulli/Tautulli-Issues#68) 2018-05-01 16:57:43 -07:00
JonnyWong16
aaf5a18251 Forgot missing '/' 2018-05-01 15:51:23 -07:00
JonnyWong16
b90026801b Fix double HTTP root in newsletter URL 2018-05-01 15:37:37 -07:00
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
206 changed files with 34388 additions and 2962 deletions

2
.gitignore vendored
View File

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

308
API.md
View File

@@ -32,6 +32,21 @@ General optional parameters:
## API methods
### add_newsletter_config
Add a new notification agent.
```
Required parameters:
agent_id (int): The newsletter type to add
Optional parameters:
None
Returns:
None
```
### add_notifier_config
Add a new notification agent.
@@ -93,27 +108,30 @@ Returns:
Delete and recreate the cache directory.
### delete_image_cache
Delete and recreate the image cache directory.
### delete_imgur_poster
Delete the Imgur poster.
### delete_hosted_images
Delete the images uploaded to image hosting services.
```
Required parameters:
None
Optional parameters:
rating_key (int): 1234
(Note: Must be the movie, show, season, artist, or album rating key)
Optional parameters:
None
service (str): 'imgur' or 'cloudinary'
delete_all (bool): 'true' to delete all images form the service
Returns:
json:
{"result": "success",
"message": "Deleted Imgur poster."}
"message": "Deleted hosted images from Imgur."}
```
### delete_image_cache
Delete and recreate the image cache directory.
### delete_library
Delete a library section from Tautulli. Also erases all history for the library.
@@ -191,6 +209,36 @@ Returns:
```
### delete_newsletter
Remove a newsletter from the database.
```
Required parameters:
newsletter_id (int): The newsletter to delete
Optional parameters:
None
Returns:
None
```
### delete_newsletter_log
Delete the Tautulli newsletter logs.
```
Required paramters:
None
Optional parameters:
None
Returns:
None
```
### delete_notification_log
Delete the Tautulli notification logs.
@@ -386,6 +434,7 @@ Returns:
"optimized_version_profile": "",
"optimized_version_title": "",
"originally_available_at": "2016-04-24",
"original_title": "",
"parent_media_index": "6",
"parent_rating_key": "153036",
"parent_thumb": "/library/metadata/153036/thumb/1503889210",
@@ -630,6 +679,7 @@ Returns:
"full_title": "Game of Thrones - The Red Woman",
"grandparent_rating_key": 351,
"grandparent_title": "Game of Thrones",
"original_title": "",
"group_count": 1,
"group_ids": "1124",
"id": 1124,
@@ -916,9 +966,9 @@ Optional parameters:
Returns:
json:
[{"section_id": 1, "section_name": "Movies"},
{"section_id": 7, "section_name": "Music"},
{"section_id": 2, "section_name": "TV Shows"},
[{"section_id": 1, "section_name": "Movies", "section_type": "movie"},
{"section_id": 7, "section_name": "Music", "section_type": "artist"},
{"section_id": 2, "section_name": "TV Shows", "section_type": "show"},
{...}
]
```
@@ -1124,6 +1174,7 @@ Returns:
}
],
"media_type": "episode",
"original_title": "",
"originally_available_at": "2016-04-24",
"parent_media_index": "6",
"parent_rating_key": "153036",
@@ -1166,6 +1217,109 @@ Returns:
```
### get_newsletter_config
Get the configuration for an existing notification agent.
```
Required parameters:
newsletter_id (int): The newsletter config to retrieve
Optional parameters:
None
Returns:
json:
{"id": 1,
"agent_id": 0,
"agent_name": "recently_added",
"agent_label": "Recently Added",
"friendly_name": "",
"id_name": "",
"cron": "0 0 * * 1",
"active": 1,
"subject": "Recently Added to {server_name}! ({end_date})",
"body": "View the newsletter here: {newsletter_url}",
"message": "",
"config": {"custom_cron": 0,
"filename": "newsletter_{newsletter_uuid}.html",
"formatted": 1,
"incl_libraries": ["1", "2"],
"notifier_id": 1,
"save_only": 0,
"time_frame": 7,
"time_frame_units": "days"
},
"email_config": {...},
"config_options": [{...}, ...],
"email_config_options": [{...}, ...]
}
```
### get_newsletter_log
Get the data on the Tautulli newsletter logs table.
```
Required parameters:
None
Optional parameters:
order_column (str): "timestamp", "newsletter_id", "agent_name", "notify_action",
"subject_text", "start_date", "end_date", "uuid"
order_dir (str): "desc" or "asc"
start (int): Row to start from, 0
length (int): Number of items to return, 25
search (str): A string to search for, "Telegram"
Returns:
json:
{"draw": 1,
"recordsTotal": 1039,
"recordsFiltered": 163,
"data":
[{"agent_id": 0,
"agent_name": "recently_added",
"end_date": "2018-03-18",
"id": 7,
"newsletter_id": 1,
"notify_action": "on_cron",
"start_date": "2018-03-05",
"subject_text": "Recently Added to Plex (Winterfell-Server)! (2018-03-18)",
"success": 1,
"timestamp": 1462253821,
"uuid": "7fe4g65i"
},
{...},
{...}
]
}
```
### get_newsletters
Get a list of configured newsletters.
```
Required parameters:
None
Optional parameters:
None
Returns:
json:
[{"id": 1,
"agent_id": 0,
"agent_name": "recently_added",
"agent_label": "Recently Added",
"friendly_name": "",
"cron": "0 0 * * 1",
"active": 1
}
]
```
### get_notification_log
Get the data on the Tautulli notification logs table.
@@ -1174,8 +1328,8 @@ Required parameters:
None
Optional parameters:
order_column (str): "timestamp", "agent_name", "notify_action",
"subject_text", "body_text", "script_args"
order_column (str): "timestamp", "notifier_id", "agent_name", "notify_action",
"subject_text", "body_text",
order_dir (str): "desc" or "asc"
start (int): Row to start from, 0
length (int): Number of items to return, 25
@@ -1188,15 +1342,14 @@ Returns:
"recordsFiltered": 163,
"data":
[{"agent_id": 13,
"agent_name": "Telegram",
"body_text": "Game of Thrones - S06E01 - The Red Woman [Transcode].",
"agent_name": "telegram",
"body_text": "DanyKhaleesi69 started playing The Red Woman.",
"id": 1000,
"notify_action": "play",
"poster_url": "http://i.imgur.com/ZSqS8Ri.jpg",
"notify_action": "on_play",
"rating_key": 153037,
"script_args": "[]",
"session_key": 147,
"subject_text": "Tautulli (Winterfell-Server)",
"success": 1,
"timestamp": 1462253821,
"user": "DanyKhaleesi69",
"user_id": 8008135
@@ -1629,6 +1782,7 @@ Returns:
"library_name": "",
"media_index": "1",
"media_type": "episode",
"original_title": "",
"parent_media_index": "6",
"parent_rating_key": "153036",
"parent_thumb": "/library/metadata/153036/thumb/1462175062",
@@ -1777,6 +1931,69 @@ Returns:
```
### get_stream_data
Get the stream details from history or current stream.
```
Required parameters:
row_id (int): The row ID number for the history item, OR
session_key (int): The session key of the current stream
Optional parameters:
None
Returns:
json:
{"aspect_ratio": "2.35",
"audio_bitrate": 231,
"audio_channels": 6,
"audio_codec": "aac",
"audio_decision": "transcode",
"bitrate": 2731,
"container": "mp4",
"current_session": "",
"grandparent_title": "",
"media_type": "movie",
"optimized_version": "",
"optimized_version_profile": "",
"optimized_version_title": "",
"original_title": "",
"pre_tautulli": "",
"quality_profile": "1.5 Mbps 480p",
"stream_audio_bitrate": 203,
"stream_audio_channels": 2,
"stream_audio_codec": "aac",
"stream_audio_decision": "transcode",
"stream_bitrate": 730,
"stream_container": "mkv",
"stream_container_decision": "transcode",
"stream_subtitle_codec": "",
"stream_subtitle_decision": "",
"stream_video_bitrate": 527,
"stream_video_codec": "h264",
"stream_video_decision": "transcode",
"stream_video_framerate": "24p",
"stream_video_height": 306,
"stream_video_resolution": "SD",
"stream_video_width": 720,
"subtitle_codec": "",
"subtitles": "",
"synced_version": "",
"synced_version_profile": "",
"title": "Frozen",
"transcode_hw_decoding": "",
"transcode_hw_encoding": "",
"video_bitrate": 2500,
"video_codec": "h264",
"video_decision": "transcode",
"video_framerate": "24p",
"video_height": 816,
"video_resolution": "1080",
"video_width": 1920
}
```
### get_stream_type_by_top_10_platforms
Get graph data by stream type by top 10 platforms.
@@ -2215,6 +2432,23 @@ Returns:
```
### notify_newsletter
Send a newsletter using Tautulli.
```
Required parameters:
newsletter_id (int): The ID number of the newsletter agent
Optional parameters:
subject (str): The subject of the newsletter
body (str): The body of the newsletter
message (str): The message of the newsletter
Returns:
None
```
### notify_recently_added
Send a recently added notification using Tautulli.
@@ -2244,8 +2478,12 @@ Required parameters:
rating_key (str): 54321
Optional parameters:
width (str): 150
height (str): 255
width (str): 300
height (str): 450
opacity (str): 25
background (str): 282828
blur (str): 3
img_format (str): png
fallback (str): "poster", "cover", "art"
refresh (bool): True or False whether to refresh the image cache
@@ -2312,7 +2550,7 @@ Returns:
### set_mobile_device_config
Configure an exisitng notificaiton agent.
Configure an existing notification agent.
```
Required parameters:
@@ -2326,8 +2564,24 @@ Returns:
```
### set_newsletter_config
Configure an existing newsletter agent.
```
Required parameters:
newsletter_id (int): The newsletter config to update
agent_id (int): The newsletter type of the newsletter
Optional parameters:
Pass all the config options for the agent with the 'newsletter_config_' and 'newsletter_email_' prefix.
Returns:
None
```
### set_notifier_config
Configure an exisitng notificaiton agent.
Configure an existing notification agent.
```
Required parameters:
@@ -2370,15 +2624,15 @@ Returns:
### terminate_session
Add a new notification agent.
Stop a streaming session.
```
Required parameters:
session_id (str): The id of the session to terminate
message (str): A custom message to send to the client
session_key (int): The session key of the session to terminate, OR
session_id (str): The session id of the session to terminate
Optional parameters:
None
message (str): A custom message to send to the client
Returns:
None

View File

@@ -1,5 +1,207 @@
# Changelog
## v2.1.13 (2018-06-16)
* Monitoring:
* Fix: Soft crash when viewing photos not in an album.
* Notifications:
* New: Added current date and time notification parameters.
* UI:
* New: Added support page with embedded Discord chat using WidgetBot.
## v2.1.12 (2018-06-08)
* Notifications:
* Change: Blank notification link source means disabled instead of default.
* Newsletters:
* New: Make collection tags available in the raw newsletter data for custom templates.
* API:
* New: Ability to terminate a stream using the session key.
## v2.1.11-beta (2018-06-02)
* Monitoring:
* Fix: Activity progress bar not updating in some cases.
* Fix: Monitory Remote Access setting disabled due to Plex Media Server API changes.
* Change: Improved logic for grouping history items without being successive plays.
* Notifications:
* New: Added filename to notification parameters.
* Other:
* Fix: Update metadata failing for tracks without track numbers.
## v2.1.10-beta (2018-05-28)
* Monitoring:
* Fix: Improved monitoring of live tv sessions.
* Change: Use track artist instead of album artist.
* Notifications:
* New: Added timestamp to Discord notification embeds. (Thanks @samwiseg00)
* New: Enable notifications for "clip" media types.
* Fix: Actually add the "live" notification parameter.
* Change: Update Twitter for 280 characters.
* Change: Use HTTPS url for Cloudinary images.
* Newsletters:
* Fix: Artist summaries not showing up on newsletter cards.
* Change: Do not send the newsletter if the template fails to render.
## v2.1.9 (2018-05-21)
* Notifications:
* New: Added "live" to notification parameters.
## v2.1.8-beta (2018-05-19)
* Newsletters:
* New: Added authentication options for self-hosted newsletters.
* Change: Check if the Tautulli footer has been removed in custom newsletter templates.
* Notifications:
* Fix: Cloudinary images not working for Twitter notifications.
* API:
* Fix: Return proper HTTP status codes for errors.
## v2.1.7-beta (2018-05-13)
* Newsletters:
* New: Option to toggle between inline or internal CSS style templates.
* New: Button to delete all uploaded images from Imgur/Cloudinary.
* Fix: Long titles overflowing the newsletter cards.
* Change: Self-hosted images on newsletters to use the /image endpoint instead of proxying through /newsletter/image.
* Change: Strip whitespace from newsletter for smaller file size before sending to email.
* API:
* New: Added get_stream_data command to API.
* New: Added newsletter API commands to documentation.
## v2.1.6-beta (2018-05-09)
* Newsletters:
* Change: Setting to specify static URL ID name instead of using the newsletter ID number.
* Change: Reorganize newsletter config options.
## v2.1.5-beta (2018-05-07)
* Newsletters:
* New: Added setting for a custom newsletter template folder.
* New: Added option to enable static newsletter URLs to retrieve the last sent scheduled newsletter.
* New: Added ability to change the newsletter output directory and filenames.
* New: Added option to save the newsletter file without sending it to a notification agent.
* Fix: Check for disabled image hosting setting.
* Fix: Cache newsletter images when refreshing the page.
* Fix: Refresh image from the Plex server when uploading to image hosting.
* Change: Allow all image hosting options with self-hosted newsletters.
* UI:
* Change: Don't retrieve recently added on the homepage if the Plex Cloud server is sleeping.
* Other:
* Fix: Imgur database upgrade migration.
## v2.1.4 (2018-05-05)
* Newsletters:
* Fix: Newsletter URL without an HTTP root.
## v2.1.3-beta (2018-05-04)
* Newsletters:
* Fix: HTTP root doubled in newsletter URL.
* Fix: Configuration would not open with failed hostname resolution.
* Fix: Schedule one day off when using weekday names in cron.
* Fix: Images not refreshing when changed in Plex.
* Fix: Cloudinary upload with non-ASCII image titles.
* Other:
* Fix: Potential XSS vulnerability in search.
## 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:

View File

@@ -4,12 +4,12 @@
If you think you can contribute code to the Tautulli repository, do not hesitate to submit a pull request.
### Branches
All pull requests should be based on the `dev` branch, to minimize cross merges. When you want to develop a new feature, clone the repository with `git clone origin/dev -b FEATURE_NAME`. Use meaningful commit messages.
All pull requests should be based on the `nightly` branch, to minimize cross merges. When you want to develop a new feature, clone the repository with `git clone origin/nightly -b FEATURE_NAME`. Use meaningful commit messages.
### Python Code
#### Compatibility
The code should work with Python 2.7. Note that Tautulli runs on different platforms, including Network Attached Storage devices such as Synology.
The code should work with Python 2.7. Note that Tautulli runs on many different platforms.
Re-use existing code. Do not hesitate to add logging in your code. You can the logger module `plexpy.logger.*` for this. Web requests are invoked via `plexpy.request.*` and derived ones. Use these methods to automatically add proper and meaningful error handling.
@@ -29,13 +29,10 @@ Although Tautulli did not adapt a code convention in the past, we try to follow
#### Documentation
Document your code. Use docstrings See [PEP-257](https://www.python.org/dev/peps/pep-0257/) for more information.
#### Continuous Integration
Tautulli has a configuration file for [travis-ci](https://travis-ci.org/). You can add your forked repo to Travis to have it check your code against PEP8, PyLint, and PyFlakes for you. Your pull request will show a green check mark or a red cross on each tested commit, depending on if linting passes.
### HTML/Template code
#### Compatibility
HTML5 compatible browsers are targetted. There is no specific mobile version of Tautulli yet.
HTML5 compatible browsers are targeted.
#### Conventions
* 4 space indentation

View File

@@ -1,6 +1,6 @@
# Tautulli
[![Discord](https://img.shields.io/badge/Discord-Tautulli-7289DA.svg?style=flat-square)](https://discord.gg/tQcWEUp)
[![Discord](https://img.shields.io/badge/Discord-Tautulli-7289DA.svg?style=flat-square)](https://tautulli.com/discord)
[![Reddit](https://img.shields.io/badge/Reddit-Tautulli-FF5700.svg?style=flat-square)](https://www.reddit.com/r/Tautulli/)
[![Plex Forums](https://img.shields.io/badge/Plex%20Forums-Tautulli-E5A00D.svg?style=flat-square)](https://forums.plex.tv/discussion/307821/tautulli-monitor-your-plex-media-server)
@@ -27,15 +27,15 @@ This project is based on code from [Headphones](https://github.com/rembo10/headp
## Preview
* [Full preview gallery available on our website](http://tautulli.com)
* [Full preview gallery available on our website](https://tautulli.com)
![Tautulli Homepage](http://tautulli.com/images/screenshots/activity-compressed.jpg?v=2)
![Tautulli Homepage](https://tautulli.com/images/screenshots/activity-compressed.jpg?v=2)
## Installation and Support
* Read the [Installation Guides](https://github.com/Tautulli/Tautulli-Wiki/wiki/Installation) for instructions to install Tautulli.
* The [Frequently Asked Questions](https://github.com/Tautulli/Tautulli-Wiki/wiki/Frequently-Asked-Questions) in the wiki can help you with common problems.
* Support is available on [Discord](https://discord.gg/tQcWEUp), [Reddit](https://www.reddit.com/r/Tautulli), or the [Plex Forums](https://forums.plex.tv/discussion/307821/tautulli-monitor-your-plex-media-server).
* Support is available on [Discord](https://tautulli.com/discord), [Reddit](https://www.reddit.com/r/Tautulli), or the [Plex Forums](https://forums.plex.tv/discussion/307821/tautulli-monitor-your-plex-media-server).
## Issues & Feature Requests

View File

@@ -17,7 +17,6 @@
<link href="${http_root}css/pnotify.custom.min.css" rel="stylesheet" />
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
<link href="${http_root}css/opensans.min.css" rel="stylesheet">
<link href="${http_root}css/font-awesome.min.css" rel="stylesheet">
${next.headIncludes()}
<!-- Favicons -->
@@ -134,7 +133,7 @@
<li role="separator" class="divider"></li>
<li><a href="logs"><i class="fa fa-fw fa-list-alt"></i> View Logs</a></li>
<li><a href="${anon_url('https://github.com/%s/%s-Wiki/wiki/Frequently-Asked-Questions' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank"><i class="fa fa-fw fa-question-circle"></i> FAQ</a></li>
<li><a href="settings?support=true"><i class="fa fa-fw fa-comment"></i> Support</a></li>
<li><a href="support"><i class="fa fa-fw fa-comment"></i> Support</a></li>
<li role="separator" class="divider"></li>
<li><a href="#" data-target="#donate-modal" data-toggle="modal"><i class="fa fa-fw fa-heart"></i> Donate</a></li>
<li role="separator" class="divider"></li>
@@ -289,9 +288,13 @@ ${next.modalIncludes()}
<script src="${http_root}js/jquery-2.1.4.min.js"></script>
<script src="${http_root}js/bootstrap.min.js"></script>
<script src="${http_root}js/bootstrap-hover-dropdown.min.js"></script>
<script>window.FontAwesomeConfig = {searchPseudoElements: true}</script>
<script src="${http_root}js/fontawesome-v5.0.13.min.js"></script>
<script src="${http_root}js/fontawesome-v4-shims.min.js"></script>
<script src="${http_root}js/pnotify.custom.min.js"></script>
<script src="${http_root}js/script.js${cache_param}"></script>
<script src="${http_root}js/jquery.qrcode.min.js"></script>
<script src="${http_root}js/jquery.tripleclick.min.js"></script>
% if _session['user_group'] == 'admin' and BROWSER_NOTIFIERS:
<script src="${http_root}js/ajaxNotifications.js"></script>
% endif

View File

@@ -49,6 +49,10 @@ DOCUMENTATION :: END
<td>Cache Directory:</td>
<td>${plexpy.CONFIG.CACHE_DIR}</td>
</tr>
<tr>
<td>Newsletter Directory:</td>
<td>${plexpy.CONFIG.NEWSLETTER_DIR}</td>
</tr>
<tr>
<td>GeoLite2 Database:</td>
% if plexpy.CONFIG.GEOIP_DB:
@@ -65,7 +69,7 @@ DOCUMENTATION :: END
% endif
<tr>
<td>Platform:</td>
<td>${common.PLATFORM} ${common.PLATFORM_VERSION}</td>
<td>${common.PLATFORM} ${common.PLATFORM_RELEASE} (${common.PLATFORM_VERSION + (' - {}'.format(common.PLATFORM_LINUX_DISTRO) if common.PLATFORM_LINUX_DISTRO else '')})</td>
</tr>
<tr>
<td>Python Version:</td>
@@ -74,7 +78,7 @@ DOCUMENTATION :: END
<tr>
<td class="top-line">Resources:</td>
<td class="top-line">
<a class="no-highlight" href="${anon_url('http://tautulli.com')}" target="_blank">Tautulli Website</a> |
<a class="no-highlight" href="${anon_url('https://tautulli.com')}" target="_blank">Tautulli Website</a> |
<a class="no-highlight" href="${anon_url('https://github.com/%s/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank">GitHub Source</a> |
<a class="no-highlight guidelines-modal-link" href="${anon_url('https://github.com/%s/%s-Issues' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" data-id="issue">GitHub Issues</a> |
<a class="no-highlight" href="${anon_url('https://github.com/%s/%s-Wiki' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank">GitHub Wiki</a> |
@@ -84,7 +88,7 @@ DOCUMENTATION :: END
<tr>
<td>Support:</td>
<td>
<a class="no-highlight support-modal-link" href="${anon_url('https://discord.gg/tQcWEUp')}" target="_blank">Tautulli Discord Server</a> |
<a class="no-highlight support-modal-link" href="${anon_url('https://tautulli.com/discord')}" target="_blank">Tautulli Discord Server</a> |
<a class="no-highlight support-modal-link" href="${anon_url('https://www.reddit.com/r/Tautulli')}" target="_blank">Tautulli Subreddit</a> |
<a class="no-highlight support-modal-link" href="${anon_url('https://forums.plex.tv/discussion/307821/tautulli-monitor-your-plex-media-server')}" target="_blank">Plex Forums</a>
</td>

View File

@@ -70,6 +70,7 @@ div.form-control .selectize-input {
background-color: #555;
border-radius: 3px;
transition: background-color .3s;
height: 32px !important;
}
.react-selectize.root-node .react-selectize-control,
.selectize-control.form-control .selectize-input {
@@ -112,6 +113,10 @@ div.form-control .selectize-input {
.wizard-input-section .selectize-dropdown.form-control.selectize-pms-ip {
margin-top: 0 !important;
}
#condition-widget .fa-plus,
#condition-widget .fa-minus {
cursor: pointer;
}
.react-selectize.root-node .react-selectize-control .react-selectize-placeholder {
color: #fff !important;
}
@@ -125,8 +130,10 @@ div.form-control .selectize-input {
padding-bottom: 2px !important;
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-left: 5px !important;
}
.react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .value-wrapper:not(:first-child):before {
content: "or";
@@ -134,9 +141,6 @@ div.form-control .selectize-input {
text-transform: uppercase;
font-size: 10px;
}
.react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values.negative-operator .value-wrapper:not(:first-child):before {
content: "and" !important;
}
.react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .resizable-input {
padding-top: 3px !important;
padding-bottom: 3px !important;
@@ -467,6 +471,18 @@ fieldset[disabled] .btn-bright.active {
.btn-group select {
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 {
margin-bottom: 0;
}
@@ -745,7 +761,10 @@ a .users-poster-face:hover {
transition: all .2s ease-in-out;
overflow: hidden;
}
.dashboard-activity-background-overlay {
.dashboard-activity-background {
background-color: #282828;
background-position: center;
background-size: cover;
display: -webkit-flex;
display: flex;
-webkit-flex-wrap: nowrap;
@@ -754,30 +773,13 @@ a .users-poster-face:hover {
width: 100%;
padding: 5px;
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;
transition: background 1s linear;
-webkit-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 {
background-color: #282828;
@@ -808,14 +810,14 @@ a .users-poster-face:hover {
background-size: cover;
height: 225px;
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;
-webkit-filter: blur(3px);
-moz-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;
}
.dashboard-activity-cover {
@@ -1162,7 +1164,10 @@ a .dashboard-activity-metadata-user-thumb:hover {
transition: all .2s ease-in-out;
overflow: hidden;
}
.dashboard-stats-background-overlay {
.dashboard-stats-background {
background-color: #282828;
background-position: center;
background-size: cover;
display: -webkit-flex;
display: flex;
-webkit-flex-wrap: nowrap;
@@ -1171,30 +1176,13 @@ a .dashboard-activity-metadata-user-thumb:hover {
width: 100%;
padding: 5px;
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;
transition: background .2s ease-in-out;
-webkit-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 {
opacity: 1;
@@ -1214,17 +1202,6 @@ a .dashboard-activity-metadata-user-thumb:hover {
z-index: 1;
}
.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-position: center;
background-size: cover;
@@ -1234,10 +1211,6 @@ a .dashboard-activity-metadata-user-thumb:hover {
transition: background .2s ease-in-out;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
opacity: 0.60;
-webkit-filter: blur(3px);
-moz-filter: blur(3px);
filter: blur(3px);
z-index: 2;
}
.dashboard-stats-cover {
@@ -1630,10 +1603,8 @@ a:hover .dashboard-recent-media-cover {
margin: 0;
background: none;
}
.summary-navbar-list .breadcrumb > li + li:before {
.summary-navbar-list .breadcrumb > .breadcrumb-arrow {
color: #444;
font-family: FontAwesome;
content: "\f054";
padding: 0 15px;
}
.summary-navbar-list .breadcrumb > .active {
@@ -2144,21 +2115,24 @@ a:hover .item-children-poster {
}
.settings-alert ul li {
list-style: none;
padding: 5px 12px 5px 35px;
padding: 5px 12px;
margin: 0;
border: 1px solid #ebccd1;
border-radius: 4px;
}
.settings-alert ul li:before {
content: "\f071";
font-family: FontAwesome;
font-style: normal;
font-weight: normal;
text-decoration: inherit;
font-size: 18px;
position: absolute;
top: 5px;
left: 12px;
display: none;
content: "\f071 ";
font-family: "Font Awesome 5 Solid";
}
.settings-alert ul li .svg-inline--fa {
margin-right: 5px;
}
.settings-warning {
color: #eb8600;
}
span.settings-warning {
padding-left: 10px;
}
#menu_link_show_advanced_settings.active {
color: #fff;
@@ -2960,6 +2934,7 @@ a .home-platforms-list-cover-face:hover
}
.stacked-configs > li > span > a.toggle-left,
.stacked-configs > li > span > span.toggle-left {
float: left;
color: #444;
padding-right: 8px;
}
@@ -2970,13 +2945,6 @@ a .home-platforms-list-cover-face:hover
.stacked-configs > li > span > span.active {
color: #f9be03;
}
.stacked-configs > li.new-notification-agent,
.stacked-configs > li.notification-agent,
.stacked-configs > li.add-notification-agent,
.stacked-configs > li.mobile-device,
.stacked-configs > li.add-mobile-device {
cursor: pointer;
}
.stacked-configs > li.mobile-device > span > a.toggle-left,
.stacked-configs > li.mobile-device > span > span.toggle-left {
color: #999;
@@ -2988,10 +2956,13 @@ a .home-platforms-list-cover-face:hover
background: #282828;
list-style: none;
}
.accordion .link {
.accordion li {
margin: 0;
}
.accordion li .link {
cursor: pointer;
display: block;
padding: 8px 20px 8px 30px;
padding: 8px 12px 8px 12px;
color: #999;
border-bottom: 1px solid #2d2d2d;
position: relative;
@@ -2999,37 +2970,28 @@ a .home-platforms-list-cover-face:hover
-o-transition: all 0.3s ease;
transition: all 0.3s ease;
}
.accordion li {
margin: 0;
.accordion li .link:hover {
color: #fff;
background: #2f2f2f;
}
.accordion li .link span.toggle-right {
float: right;
padding-left: 10px;
}
.accordion li:last-child .link {
border-bottom: 0;
}
.accordion li i.fa {
position: absolute;
top: 10px;
left: 10px;
.accordion li .fa-chevron-down {
color: #999;
-webkit-transition: all 0.3s ease;
-o-transition: all 0.3s ease;
transition: all 0.3s ease;
}
.accordion li i.fa-chevron-down {
right: 12px;
left: auto;
font-size: 16px;
}
.accordion li .link:hover {
color: #FFF;
background: #2f2f2f;
}
.accordion li.open .link {
color: #f9be03;
}
.accordion li.open i {
.accordion li.open .fa-chevron-down {
color: #f9be03;
}
.accordion li.open i.fa-chevron-down {
-webkit-transform: rotate(180deg);
-ms-transform: rotate(180deg);
-o-transform: rotate(180deg);
@@ -3187,34 +3149,6 @@ div.dataTables_info {
-o-transition: none !important;
transition: none !important;
}
.settings-alert {
float: left;
padding: 0;
margin: 5px 0;
border: 0;
position: relative;
}
.settings-alert ul {
padding: 0;
}
.settings-alert ul li {
list-style: none;
padding: 5px 12px 5px 35px;
margin: 0;
border: 1px solid #ebccd1;
border-radius: 4px;
}
.settings-alert ul li:before {
content: "\f071";
font-family: FontAwesome;
font-style: normal;
font-weight: normal;
text-decoration: inherit;
font-size: 18px;
position: absolute;
top: 5px;
left: 12px;
}
#users-to-delete > li,
#users-to-purge > li,
#libraries-to-delete > li,
@@ -3547,8 +3481,7 @@ a.no-highlight:hover {
}
.login-logo {
margin: 0 auto 50px auto;
width: 340px;
height: 100px;
text-align: center;
}
.login-container .form-group {
margin-bottom: 20px;
@@ -3657,38 +3590,71 @@ a:hover .overlay-refresh-image:hover {
}
#plexpy-notifiers-table .friendly_name,
#notifier-config-modal span.notifier_id,
#plexpy-newsletters-table .friendly_name,
#newsletter-config-modal span.newsletter_id,
#plexpy-mobile-devices-table .friendly_name,
#mobile-device-config-modal span.notifier_id {
color: #777;
}
#notifier-config-modal .nav-tabs {
#notifier-config-modal .nav-tabs,
#newsletter-config-modal .nav-tabs {
margin-bottom: 10px;
padding-left: 15px;
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;
}
#notifier-config-modal .nav-tabs > li > a {
#notifier-config-modal .nav-tabs > li > a,
#newsletter-config-modal .nav-tabs > li > a {
padding: 5px 10px;
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;
background: #222;
}
#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: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;
background: #222;
}
#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: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-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 {
width: 50%;
}
@@ -3841,6 +3807,90 @@ a:hover .overlay-refresh-image:hover {
background-color: #107c10;
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 {
background-image: url(../images/libraries/movie.svg);
}
@@ -3952,3 +4002,121 @@ a:hover .overlay-refresh-image:hover {
-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;
}
.modal-config-section {
margin-top: 10px !important;
padding-top: 10px;
border-top: 1px solid #444;
}
.newsletter-logo {
margin: 0 auto 50px auto;
text-align: center;
}
.pointer {
cursor: pointer;
}
.iframe-container {
width: 100%;
height: calc(100vh - 200px);
position: relative;
}
.iframe-overlay {
width: 100%;
height: 100%;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: url(../images/discord-overlay.png) no-repeat;
background-size: cover;
border: 1px solid #36393e;
}
.iframe-button-container {
margin: 0;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.iframe-button {
color: #fff;
border-radius: 20px;
text-align: center;
cursor: pointer;
font-size: 15px;
height: 28px;
line-height: 28px;
min-width: 200px;
transition: box-shadow 0.3s ease;
padding: 0 15px;
background: rgba(114, 137, 218, 0.4);
text-transform: uppercase;
text-decoration: none;
display: block;
}
.iframe-button:hover,
.iframe-button:focus {
color: #fff;
box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 0px 99999px inset, rgba(0, 0, 0, 0.2) 0px 1px 5px 0px, rgba(0, 0, 0, 0.14) 0px 2px 2px 0px, rgba(0, 0, 0, 0.12) 0px 3px 1px -2px;
}
.iframe-button:active {
box-shadow: rgba(255, 255, 255, 0.1) 0px 0px 0px 99999px inset, rgba(0, 0, 0, 0.2) 0px 5px 5px -3px, rgba(0, 0, 0, 0.14) 0px 8px 10px 1px, rgba(0, 0, 0, 0.12) 0px 3px 14px 2px;
}
.iframe {
width: 100%;
height: 100%;
position: inherit;
display: block;
border: 0;
}
.fa-blank {
visibility: hidden;
}

View File

@@ -64,7 +64,7 @@ DOCUMENTATION :: END
from collections import defaultdict
from urllib import quote
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
%>
<%
@@ -79,20 +79,19 @@ DOCUMENTATION :: END
<div class="dashboard-activity-instance" id="activity-instance-${sk}" data-key="${sk}" data-id="${data['session_id']}"
data-rating_key="${data['rating_key']}" data-parent_rating_key="${data['parent_rating_key']}" data-grandparent_rating_key="${data['grandparent_rating_key']}">
<div class="dashboard-activity-container">
<div class="dashboard-activity-background-overlay">
% 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>
% else:
% 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>
% else:
<!--Hacky solution to escape the image url until I come up with something better-->
<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
% endif
<%
if data['channel_stream'] == 0:
background_url = 'pms_image_proxy?img=' + data['art'] + '&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art&refresh=true'
else:
if (data['art'] and data['art'].startswith('http')) or (data['thumb'] and data['thumb'].startswith('http')):
background_url = data['art']
else:
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(${background_url});">
<div class="dashboard-activity-poster-container hidden-xs">
% 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
% if data['channel_stream'] == 0:
% 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>
</a>
% 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:
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(images/art.png);"></div>
% 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-cover" style="background-image: url(${data['channel_icon']});"></div>
% 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>
% endif
% endif
@@ -301,14 +304,13 @@ DOCUMENTATION :: END
<li class="dashboard-activity-info-item">
<div class="sub-heading">Bandwidth</div>
<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'])
if bw != "Unknown":
if bw > 1000:
bw = str(round(bw / 1000.0, 1)) + ' Mbps'
else:
bw = str(bw) + ' kbps'
if bw > 1000:
bw = str(round(bw / 1000.0, 1)) + ' Mbps'
else:
bw = str(bw) + ' kbps'
%>
<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>
@@ -385,8 +387,8 @@ DOCUMENTATION :: END
<a href="${grandparent_href}" title="${data['grandparent_title']}">${data['grandparent_title']}</a>
- <a href="${href}" title="${data['title']}">${data['title']}</a>
% elif data['media_type'] == 'track':
<a id="metadata-grandparent_title-${sk}" href="${grandparent_href}" title="${data['grandparent_title']}">${data['grandparent_title']}</a>
- <a id="metadata-title-${sk}" href="${href}" title="${data['title']}">${data['title']}</a>
<a id="metadata-title-${sk}" href="${href}" title="${data['title']}">${data['title']}</a>
- <a id="metadata-grandparent_title-${sk}" href="${grandparent_href}" title="${data['original_title'] or data['grandparent_title']}">${data['original_title'] or data['grandparent_title']}</a>
% elif data['media_type'] == 'photo':
<span title="${data['parent_title']}">${data['parent_title']}</span>
% elif data['media_type'] == 'clip':
@@ -440,7 +442,12 @@ DOCUMENTATION :: END
% elif data['media_type'] == 'photo':
<span title="${data['title']}" class="sub-heading">${data['title']}</span>
% 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
% elif data['channel_title']:
<span title="${data['channel_title']}" class="sub-heading">${data['channel_title']}</span>

View File

@@ -168,7 +168,7 @@
</div>
<div class="row">
<div class="col-md-6">
<h4><i class="fa fa-expand"></i> <span class="yaxis-text">Play count</span> by source resolution <small>Last <span class="days">30</span> days</small></h4>
<h4><i class="fa fa-expand-arrows-alt"></i> <span class="yaxis-text">Play count</span> by source resolution <small>Last <span class="days">30</span> days</small></h4>
<p class="help-block">
The combined total of tv and movies by their original resolution (pre-transcoding).
</p>
@@ -181,7 +181,7 @@
</div>
</div>
<div class="col-md-6">
<h4><i class="fa fa-expand"></i> <span class="yaxis-text">Play count</span> by stream resolution <small>Last <span class="days">30</span> days</small></h4>
<h4><i class="fa fa-expand-arrows-alt"></i> <span class="yaxis-text">Play count</span> by stream resolution <small>Last <span class="days">30</span> days</small></h4>
<p class="help-block">
The combined total of tv and movies by their streamed resolution (post-transcoding).
</p>

View File

@@ -113,7 +113,7 @@
// Load user ids and names (for the selector)
$.ajax({
url: 'get_user_names',
type: 'get',
type: 'GET',
dataType: 'json',
success: function (data) {
var select = $('#history-user');
@@ -130,6 +130,7 @@
function loadHistoryTable(media_type, selected_user_id) {
history_table_options.ajax = {
url: 'get_history',
type: 'POST',
data: function (d) {
return {
json_data: JSON.stringify(d),

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-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 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&fallback=art);"></div>
% else:
<div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(images/art.png);"></div>
% endif
% elif stat_id == 'top_platforms':
<div id="stats-background-${stat_id}" class="dashboard-stats-background platform-${row0['platform_name']} no-image"></div>
% else:
<div id="stats-background-${stat_id}" class="dashboard-stats-background flat"></div>
% endif
% if stat_id in ('top_movies', 'popular_movies', 'top_tv', 'popular_tv', 'top_music', 'popular_music', 'last_watched'):
% 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);">
% else:
<div id="stats-background-${stat_id}" class="dashboard-stats-background" style="background-image: url(images/art.png);">
% endif
% elif stat_id == 'top_platforms':
<div id="stats-background-${stat_id}" class="dashboard-stats-background platform-${row0['platform_name']}-rgba no-image">
% else:
<div id="stats-background-${stat_id}" class="dashboard-stats-background flat">
% endif
% 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">
% 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
<% 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 '#' %>
@@ -200,7 +199,7 @@ DOCUMENTATION :: END
}).addClass('platform-' + $(elem).data('platform'));
$('#stats-background-' + stat_id).removeClass(function (index, className) {
return (className.match (/(^|\s)platform-\S+/g) || []).join(' ');
}).addClass('platform-' + $(elem).data('platform'));
}).addClass('platform-' + $(elem).data('platform') + '-rgba');
} else {
if (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'));
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 {
$('#stats-background-' + stat_id).css('background-image', 'url(images/art.png)');
}
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 + '-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 {
$('#stats-thumb-' + stat_id).css('background-image', 'url(images/' + fallback + '.png)');
$('#stats-thumb-' + stat_id + '-bg').css('background-image', 'url(images/' + fallback + '.png)');

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 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

@@ -5,13 +5,14 @@
</%def>
<%def name="body()">
<% from plexpy import PLEX_SERVER_UP %>
<div class="container-fluid">
% for section in config['home_sections']:
% if section == 'current_activity':
<div class="row">
<div class="col-md-12">
<div class="padded-header" id="current-activity-header">
<h3><span id="sessions-shortcut">Activity</span> &nbsp;&nbsp;
<h3><span id="sessions-xml">Activity</span> &nbsp;&nbsp;
<small>
<span id="currentActivityHeader" style="display: none;">
Streams: <span id="currentActivityHeader-streams"></span> |
@@ -22,9 +23,10 @@
</h3>
</div>
<div id="currentActivity">
<% from plexpy import PLEX_SERVER_UP %>
% if PLEX_SERVER_UP:
<div class="text-muted" id="dashboard-checking-activity"><i class="fa fa-refresh fa-spin"></i> Checking for activity...</div>
% 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':
@@ -123,7 +125,7 @@
</label>
</div>
<div class="input-group pull-left" style="width: 1px;" id="recently-added-count-selection">
<input type="number" class="form-control number-input" name="recently-added-count" id="recently-added-count" value="${config['home_stats_recently_added_count']}" min="1" max="100" data-default="50" data-toggle="tooltip" title="Min: 1 item<br>Max: 100 items" />
<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>
</div>
</div>
@@ -133,7 +135,17 @@
<div class="row">
<div class="col-md-12">
<div id="recentlyAdded" style="margin-right: -15px;">
% if PLEX_SERVER_UP:
<div class="text-muted"><i class="fa fa-refresh fa-spin"></i> Looking for new items...</div>
% elif config['pms_is_cloud']:
<div class="text-muted">Plex Cloud server is sleeping.</div>
% else:
<div 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
<br>
</div>
</div>
@@ -220,10 +232,10 @@
</%def>
<%def name="javascriptIncludes()">
<% from plexpy import PLEX_SERVER_UP %>
<script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/jquery.scrollbar.min.js"></script>
<script src="${http_root}js/jquery.mousewheel.min.js"></script>
<script src="${http_root}js/jquery.tripleclick.min.js"></script>
<script>
var date_format = 'YYYY-MM-DD';
var time_format = 'hh:mm a';
@@ -252,7 +264,6 @@
});
}
</script>
<% from plexpy import PLEX_SERVER_UP %>
% if 'current_activity' in config['home_sections'] and PLEX_SERVER_UP:
<script>
var defaultHandler = {
@@ -375,16 +386,16 @@
if (s.media_type === 'track') {
// Update if artist changed
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)
.attr('href', 'info?rating_key=' + s.grandparent_rating_key)
.attr('title', s.grandparent_title)
.text(s.grandparent_title);
.attr('title', s.original_title || s.grandparent_title)
.text(s.original_title || s.grandparent_title);
}
// Update cover if album changed
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 + '-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)
.attr('href', 'info?rating_key=' + s.parent_rating_key)
.attr('title', s.parent_title);
@@ -394,7 +405,11 @@
.text(s.parent_title);
}
// Update cover if track changed
if (s.parent_rating_key !== instance.data('parent_rating_key')) {
if (s.rating_key !== instance.data('rating_key')) {
$('#metadata-grandparent_title-' + key)
.attr('href', 'info?rating_key=' + s.grandparent_rating_key)
.attr('title', s.original_title || s.grandparent_title)
.text(s.original_title || s.grandparent_title);
$('#metadata-title-' + key)
.attr('href', 'info?rating_key=' + s.rating_key)
.attr('title', s.title)
@@ -507,17 +522,15 @@
$('#location-' + key).html(s.location.toUpperCase());
if (s.media_type !== 'photo' && parseInt(s.bandwidth)) {
var bw = parseInt(s.bandwidth);
if (bw !== "Unknown") {
if (bw > 1000) {
bw = (bw / 1000).toFixed(1) + ' Mbps';
} else {
bw = bw + ' kbps'
}
if (s.media_type !== 'photo' && s.bandwidth !== 'Unknown') {
var bw = parseInt(s.bandwidth) || 0;
if (bw > 1000) {
bw = (bw / 1000).toFixed(1) + ' Mbps';
} else {
bw = bw + ' kbps'
}
$('#stream-bandwidth-' + key).html(bw);
}
};
// Update the stream progress times
$('#stream-eta-' + key).html(moment().add(parseInt(s.duration) - parseInt(s.view_offset), 'milliseconds').format(time_format));
@@ -532,7 +545,7 @@
.attr('data-original-title', 'Transcoder Progress ' + s.transcode_progress + '%');
var progress_bar = $('#progress-bar-' + key);
progress_bar.data('state', s.state);
if (progress_bar.data('last_view_offset') && progress_bar.data('last_view_offset') !== s.view_offset) {
if (progress_bar.data('last_view_offset') !== s.view_offset) {
progress_bar.data('last_view_offset', s.view_offset).data('view_offset', s.view_offset);
}
@@ -589,7 +602,7 @@
if (!(create_instances.length) && activity_ready) {
getCurrentActivity();
}
}, 2000);
}, ${config['home_refresh_interval'] * 1000});
setInterval(function(){
$('.progress_time_offset').each(function () {
@@ -604,7 +617,7 @@
if ($(this).data('state') === 'playing' && $(this).data('view_offset') >= 0) {
var view_offset = parseInt($(this).data('view_offset'));
var stream_duration = parseInt($(this).data('stream_duration'));
var progress_percent = Math.min(Math.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 + '%')
.attr('data-original-title', 'Stream Progress ' + progress_percent + '%')
.data('view_offset', Math.min(view_offset + 1000, stream_duration));
@@ -662,6 +675,7 @@
$.ajax({
url: 'terminate_session',
data: {
session_key: key,
session_id: session_id,
message: message
},
@@ -680,10 +694,8 @@
});
});
$('#sessions-shortcut').on('tripleclick', function () {
$.getJSON('return_sessions_url', function(sessions_url) {
window.open(sessions_url, '_blank');
});
$('#sessions-xml').on('tripleclick', function () {
openPlexXML('/status/sessions');
});
% endif
</script>
@@ -746,7 +758,7 @@
getLibraryStats();
</script>
% endif
% if 'recently_added' in config['home_sections']:
% if 'recently_added' in config['home_sections'] and PLEX_SERVER_UP:
<script>
function recentlyAdded(recently_added_count, recently_added_type) {
showMsg("Loading recently added items...", true, false, 0);

View File

@@ -83,31 +83,44 @@ DOCUMENTATION :: END
<ul class="list-unstyled breadcrumb">
% if data['media_type'] in ('movie', 'collection'):
<li><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li>
<li class="active">${data['title']}</li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li class="active metadata-xml">${data['title']}</li>
% elif data['media_type'] == 'show':
<li><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li>
<li class="active">${data['title']}</li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li class="active metadata-xml">${data['title']}</li>
% elif data['media_type'] == 'season':
<li class="hidden-xs hidden-sm"><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a></li>
<li class="active">Season ${data['media_index']}</li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li class="active metadata-xml">Season ${data['media_index']}</li>
% elif data['media_type'] == 'episode':
<li class="hidden-xs hidden-sm"><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li class="hidden-xs hidden-sm"><a href="info?rating_key=${data['grandparent_rating_key']}">${data['grandparent_title']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li><a href="info?rating_key=${data['parent_rating_key']}">Season ${data['parent_media_index']}</a></li>
<li class="active">Episode ${data['media_index']} - ${data['title']}</li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li class="active metadata-xml">Episode ${data['media_index']} - ${data['title']}</li>
% elif data['media_type'] == 'artist':
<li><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li>
<li class="active">${data['title']}</li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li class="active metadata-xml">${data['title']}</li>
% elif data['media_type'] == 'album':
<li class="hidden-xs hidden-sm"><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a></li>
<li class="active">${data['title']}</li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li class="active metadata-xml">${data['title']}</li>
% elif data['media_type'] == 'track':
<li class="hidden-xs hidden-sm"><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li class="hidden-xs hidden-sm"><a href="info?rating_key=${data['grandparent_rating_key']}">${data['grandparent_title']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a></li>
<li class="active">Track ${data['media_index']} - ${data['title']}</li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li class="active metadata-xml">Track ${data['media_index']} - ${data['title']}</li>
% endif
</ul>
</div>
@@ -165,7 +178,7 @@ DOCUMENTATION :: END
<h1><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a></h1>
<h2>${data['title']}</h2>
% elif data['media_type'] == 'track':
<h1><a href="info?rating_key=${data['grandparent_rating_key']}">${data['grandparent_title']}</a></h1>
<h1><a href="info?rating_key=${data['grandparent_rating_key']}">${data['original_title'] or data['grandparent_title']}</a></h1>
<h2><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a> - ${data['title']}</h2>
<h3 class="hidden-xs">T${data['media_index']}</h3>
% endif
@@ -371,7 +384,11 @@ DOCUMENTATION :: END
<div class="col-md-12">
<div class="table-card-header">
<div class="header-bar">
% if data['media_type'] in ('artist', 'album', 'track'):
<span>Play History for <strong>${data['title']}</strong></span>
% else:
<span>Watch History for <strong>${data['title']}</strong></span>
% endif
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
@@ -400,14 +417,14 @@ DOCUMENTATION :: END
% if data.get('poster_url'):
<div class="btn-group">
% 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:
<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
<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-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>
</span>
</div>
@@ -502,7 +519,7 @@ DOCUMENTATION :: END
% elif data['media_type'] == 'album':
${data['parent_title']}<br />${data['title']}
% elif data['media_type'] == 'track':
${data['grandparent_title']}<br />${data['title']}<br />${data['parent_title']}
${data['original_title'] or data['grandparent_title']}<br />${data['title']}<br />${data['parent_title']}
% endif
</strong>
</p>
@@ -547,7 +564,7 @@ DOCUMENTATION :: END
function get_history() {
history_table_options.ajax = {
url: 'get_history',
type: 'post',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
@@ -563,7 +580,7 @@ DOCUMENTATION :: END
function get_history() {
history_table_options.ajax = {
url: 'get_history',
type: 'post',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
@@ -579,7 +596,7 @@ DOCUMENTATION :: END
function get_history() {
history_table_options.ajax = {
url: 'get_history',
type: 'post',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
@@ -699,13 +716,17 @@ DOCUMENTATION :: END
</script>
% endif
<script>
$('.metadata-xml').on('tripleclick', function () {
openPlexXML("/library/metadata/${data['rating_key']}");
});
$("#airdate").html(moment($("#airdate").text()).format('MMM DD, YYYY'));
$("#runtime").html(millisecondsToMinutes($("#runtime").text(), true));
$('div.art-face').animate({ opacity: 0.2 }, { duration: 1000 });
</script>
% if data.get('poster_url'):
<script>
$('.imgur-poster-tooltip').popover({
$('.hosted-poster-tooltip').popover({
html: true,
container: 'body',
trigger: 'hover',
@@ -716,14 +737,14 @@ DOCUMENTATION :: END
}
});
$('#delete-imgur-poster').on('click', function () {
var msg = 'Are you sure you want to delete the Imgur poster for <strong>' + $(this).data('title') + '</strong>?<br><br>' +
$('#delete-hosted-poster').on('click', function () {
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.';
var url = 'delete_imgur_poster';
var url = 'delete_hosted_images';
var data = { rating_key: $(this).data('id') };
var callback = function () {
$('.imgur-poster-tooltip').popover('destroy');
$('#delete-imgur-poster').closest('.btn-group').remove();
$('.hosted-poster-tooltip').popover('destroy');
$('#delete-hosted-poster').closest('.btn-group').remove();
};
confirmAjaxCall(url, msg, data, false, callback);
});

View File

@@ -91,7 +91,7 @@ DOCUMENTATION :: END
<div class="item-children-poster-face episode-item" style="background-image: url(pms_image_proxy?img=${child['thumb']}&width=500&height=250&fallback=art);">
<div class="item-children-card-overlay">
<div class="item-children-overlay-text">
Episode ${child['media_index']}
Episode ${child['media_index'] or child['originally_available_at']}
</div>
</div>
</div>
@@ -122,16 +122,24 @@ DOCUMENTATION :: END
% elif data['children_type'] == 'track':
% if loop.index % 2 == 0:
<div class="item-children-list-item-even">
<span class="item-children-list-item-index">${child['media_index']}</span>
<span class="item-children-list-item-title"><a href="info?rating_key=${child['rating_key']}" title="${child['title']}">${child['title']}</a></span>
<span class="item-children-list-item-index">&nbsp;${child['media_index']}</span>
<span class="item-children-list-item-title"><a href="info?rating_key=${child['rating_key']}" title="${child['title']}">${child['title']}</a>
% if child['original_title']:
<span class="text-muted"> - ${child['original_title']}</span>
% endif
</span>
<span class="item-children-list-item-duration" id="item-children-list-item-duration-${loop.index + 1}">
<script>$('#item-children-list-item-duration-${loop.index + 1}').text(moment.utc(${child['duration']}).format("m:ss"));</script>
</span>
</div>
% else:
<div class="item-children-list-item-odd">
<span class="item-children-list-item-index">${child['media_index']}</span>
<span class="item-children-list-item-title"><a href="info?rating_key=${child['rating_key']}" title="${child['title']}">${child['title']}</a></span>
<span class="item-children-list-item-index">&nbsp;${child['media_index']}</span>
<span class="item-children-list-item-title"><a href="info?rating_key=${child['rating_key']}" title="${child['title']}">${child['title']}</a>
% if child['original_title']:
<span class="text-muted"> - ${child['original_title']}</span>
% endif
</span>
<span class="item-children-list-item-duration" id="item-children-list-item-duration-${loop.index + 1}">
<script>$('#item-children-list-item-duration-${loop.index + 1}').text(moment.utc(${child['duration']}).format("m:ss"));</script>
</span>

View File

@@ -251,7 +251,7 @@ DOCUMENTATION :: END
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
% endif
<div class="item-children-instance-text-wrapper album-item">
<h3 title="${child['grandparent_title']}">${child['grandparent_title']}</h3>
<h3 title="${child['original_title'] or child['grandparent_title']}">${child['original_title'] or child['grandparent_title']}</h3>
<h3 title="${child['title']}">${child['title']}</h3>
<h3 title="${child['parent_title']}" class="text-muted">${child['parent_title']}</h3>
</div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,17 +1,19 @@
function initConfigCheckbox(elem) {
var config = $(elem).closest('div').next();
function initConfigCheckbox(elem, toggleElem, reverse) {
toggleElem = (toggleElem === undefined) ? null : toggleElem;
reverse = (reverse === undefined) ? false : reverse;
var config = toggleElem ? $(toggleElem) : $(elem).closest('div').next();
config.css('overflow', 'hidden');
if ($(elem).is(":checked")) {
config.show();
config.toggle(!reverse);
} else {
config.hide();
config.toggle(reverse);
}
$(elem).click(function () {
var config = $(this).closest('div').next();
var config = toggleElem ? $(toggleElem) : $(this).closest('div').next();
if ($(this).is(":checked")) {
config.slideDown();
config.slideToggleBool(!reverse);
} else {
config.slideUp();
config.slideToggleBool(reverse);
}
});
}
@@ -35,8 +37,8 @@ function showMsg(msg, loader, timeout, ms, error) {
}
var message = $("<div class='msg'>" + msg + "</div>");
if (loader) {
message = $("<i class='fa fa-refresh fa-spin'></i> " + msg + "</div>");
feedback.css("padding", "14px 10px")
message = $("<div class='msg'><i class='fa fa-refresh fa-spin'></i> " + msg + "</div>");
feedback.css("padding", "14px 10px");
}
if (error) {
feedback.css("background-color", "rgba(255,0,0,0.5)");
@@ -59,7 +61,7 @@ function confirmAjaxCall(url, msg, data, loader_msg, callback) {
$('#confirm-modal').modal();
$('#confirm-modal').one('click', '#confirm-button', function () {
if (loader_msg) {
showMsg(loader_msg, true, false)
showMsg(loader_msg, true, false);
}
$.ajax({
url: url,
@@ -71,9 +73,9 @@ function confirmAjaxCall(url, msg, data, loader_msg, callback) {
var result = $.parseJSON(xhr.responseText);
var msg = result.message;
if (result.result == 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
} else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true);
}
if (typeof callback === "function") {
callback(result);
@@ -85,8 +87,8 @@ function confirmAjaxCall(url, msg, data, loader_msg, callback) {
function doAjaxCall(url, elem, reload, form, showMsg, callback) {
// Set Message
feedback = (showMsg) ? $("#ajaxMsg") : $();
update = $("#updatebar");
var feedback = (showMsg) ? $("#ajaxMsg") : $();
var update = $("#updatebar");
if (update.is(":visible")) {
var height = update.height() + 35;
feedback.css("bottom", height + "px");
@@ -96,22 +98,23 @@ function doAjaxCall(url, elem, reload, form, showMsg, callback) {
feedback.fadeIn();
// Get Form data
var formID = "#" + url;
if (form == true) {
var dataString = $(formID).serialize();
var dataString;
if (form === true) {
dataString = $(formID).serialize();
}
// Loader Image
var loader = $("<i class='fa fa-refresh fa-spin'></i>");
var loader = $("<i class='fa fa-refresh fa-spin ajaxLoader-" + url +"></i>");
// Data Success Message
var dataSucces = $(elem).data('success');
if (typeof dataSucces === "undefined") {
// Standard Message when variable is not set
var dataSucces = "Success!";
dataSucces = "Success!";
}
// Data Errror Message
// Data Error Message
var dataError = $(elem).data('error');
if (typeof dataError === "undefined") {
// Standard Message when variable is not set
var dataError = "There was an error";
dataError = "There was an error";
}
// Get Success & Error message from inline data, else use standard message
var succesMsg = $("<div class='msg'><i class='fa fa-check'></i> " + dataSucces + "</div>");
@@ -120,7 +123,7 @@ function doAjaxCall(url, elem, reload, form, showMsg, callback) {
if (form) {
if ($('td#select input[type=checkbox]').length > 0 && !$('td#select input[type=checkbox]').is(':checked') ||
$('#importLastFM #username:visible').length > 0 && $("#importLastFM #username").val().length === 0) {
feedback.addClass('error')
feedback.addClass('error');
$(feedback).prepend(errorMsg);
setTimeout(function () {
errorMsg.fadeOut(function () {
@@ -128,7 +131,7 @@ function doAjaxCall(url, elem, reload, form, showMsg, callback) {
feedback.fadeOut(function () {
feedback.removeClass('error');
});
})
});
$(formID + " select").children('option[disabled=disabled]').attr('selected', 'selected');
}, 2000);
return false;
@@ -144,33 +147,33 @@ function doAjaxCall(url, elem, reload, form, showMsg, callback) {
feedback.prepend(loader);
},
error: function (jqXHR, textStatus, errorThrown) {
feedback.addClass('error')
feedback.addClass('error');
feedback.prepend(errorMsg);
setTimeout(function () {
errorMsg.fadeOut(function () {
$(this).remove();
feedback.fadeOut(function () {
feedback.removeClass('error')
feedback.removeClass('error');
});
})
});
}, 2000);
},
success: function (data, jqXHR) {
feedback.prepend(succesMsg);
feedback.addClass('success')
feedback.addClass('success');
setTimeout(function (e) {
succesMsg.fadeOut(function () {
$(this).remove();
feedback.fadeOut(function () {
feedback.removeClass('success');
});
if (reload == true) refreshSubmenu();
if (reload == "table") {
if (reload === true) refreshSubmenu();
if (reload === "table") {
refreshTable();
}
if (reload == "tabs") refreshTab();
if (reload == "page") location.reload();
if (reload == "submenu&table") {
if (reload === "tabs") refreshTab();
if (reload === "page") location.reload();
if (reload === "submenu&table") {
refreshSubmenu();
refreshTable();
}
@@ -179,12 +182,12 @@ function doAjaxCall(url, elem, reload, form, showMsg, callback) {
$(formID + " select").children('option[disabled=disabled]').attr(
'selected', 'selected');
}
})
});
}, 2000);
},
complete: function (jqXHR, textStatus) {
// Remove loaders and stuff, ajax request is complete!
loader.remove();
feedback.remove('.ajaxLoader-' + url);
if (typeof callback === "function") {
callback(jqXHR);
}
@@ -215,19 +218,20 @@ function isPrivateIP(ip_address) {
$.cachedScript('js/ipaddr.min.js').done(function () {
if (ipaddr.isValid(ip_address)) {
var addr = ipaddr.process(ip_address)
var addr = ipaddr.process(ip_address);
var rangeList = [];
if (addr.kind() === 'ipv4') {
var rangeList = [
rangeList = [
ipaddr.parseCIDR('127.0.0.0/8'),
ipaddr.parseCIDR('10.0.0.0/8'),
ipaddr.parseCIDR('172.16.0.0/12'),
ipaddr.parseCIDR('192.168.0.0/16')
]
];
} else {
var rangeList = [
rangeList = [
ipaddr.parseCIDR('fd00::/8')
]
];
}
if (ipaddr.subnetMatch(addr, rangeList, -1) >= 0) {
@@ -238,12 +242,13 @@ function isPrivateIP(ip_address) {
} else {
defer.resolve('n/a');
}
})
});
return defer.promise();
}
function humanTime(seconds) {
var text;
if (seconds >= 86400) {
text = '<h3>' + Math.floor(moment.duration(seconds, 'seconds').asDays()) + '</h3><p> days</p>' + '<h3>' +
Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) + '</h3><p> hrs</p>' + '<h3>' +
@@ -265,6 +270,7 @@ function humanTime(seconds) {
}
function humanTimeClean(seconds) {
var text;
if (seconds >= 86400) {
text = Math.floor(moment.duration(seconds, 'seconds').asDays()) + ' days ' + Math.floor(moment.duration((
seconds % 86400), 'seconds').asHours()) + ' hrs ' + Math.floor(moment.duration(
@@ -341,7 +347,7 @@ function getCookie(cname) {
for (var i = 0; i < ca.length; i++) {
var c = ca[i];
while (c.charAt(0) == ' ') c = c.substring(1);
if (c.indexOf(name) == 0) return c.substring(name.length, c.length);
if (c.indexOf(name) === 0) return c.substring(name.length, c.length);
}
return "";
}
@@ -354,24 +360,24 @@ var Accordion = function (el, multiple) {
links.on('click', {
el: this.el,
multiple: this.multiple
}, this.dropdown)
}
}, this.dropdown);
};
Accordion.prototype.dropdown = function (e) {
var $el = e.data.el;
$this = $(this),
$next = $this.next();
$this = $(this);
$next = $this.next();
$next.slideToggle();
$this.parent().toggleClass('open');
if (!e.data.multiple) {
$el.find('.submenu').not($next).slideUp().parent().removeClass('open');
};
}
}
};
function clearSearchButton(tableName, table) {
$('#' + tableName + '_filter').find('input[type=search]').wrap(
'<div class="input-group" role="group" aria-label="Search"></div>').after(
'<span class="input-group-btn"><button class="btn btn-form" data-toggle="button" aria-pressed="false" autocomplete="off" id="clear-search-' +
tableName + '"><i class="fa fa-remove"></i></button></span>')
tableName + '"><i class="fa fa-remove"></i></button></span>');
$('#clear-search-' + tableName).click(function () {
table.search('').draw();
});
@@ -401,7 +407,6 @@ $('*').on('click', '.refresh_pms_image', function (e) {
} else {
if (pms_proxy_url.indexOf('refresh=true') > -1) {
pms_proxy_url = pms_proxy_url.replace("&refresh=true", "");
console.log(pms_proxy_url)
background_div.css('background-image', 'url(' + pms_proxy_url + ')');
background_div.css('background-image', 'url(' + pms_proxy_url + '&refresh=true)');
} else {
@@ -416,8 +421,7 @@ function humanFileSize(bytes, si) {
if (Math.abs(bytes) < thresh) {
return bytes + ' B';
}
var units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
var units = si ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
var u = -1;
do {
@@ -436,10 +440,10 @@ function forceMinMax(elem) {
if (isNaN(val)) {
elem.val(default_val);
}
else if (min != undefined && val < min) {
else if (min !== undefined && val < min) {
elem.val(min);
}
else if (max != undefined && val > max) {
else if (max !== undefined && val > max) {
elem.val(max);
}
else {
@@ -449,4 +453,15 @@ function forceMinMax(elem) {
function capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
}
$.fn.slideToggleBool = function(bool, options) {
return bool ? $(this).slideDown(options) : $(this).slideUp(options);
};
function openPlexXML(endpoint, plextv, params) {
var data = $.extend({endpoint: endpoint, plextv: plextv}, params);
$.getJSON('return_plex_xml_url', data, function(xml_url) {
window.open(xml_url, '_blank');
});
}

View File

@@ -67,7 +67,7 @@ history_table_options = {
expand_history = '<span class="expand-history-tooltip" data-toggle="tooltip" title="Show Detailed History"><i class="fa fa-plus-circle fa-fw"></i></span>';
$(td).html('<div><a href="#"><div style="float: left;">' + expand_history + '&nbsp;' + date + '</div></a></div>');
} else {
$(td).html('<div style="float: left;"><i class="fa fa-fw"></i>&nbsp;' + date + '</div>');
$(td).html('<div style="float: left;"><i class="fa fa-plus-circle fa-fw fa-blank">&nbsp;</i>&nbsp;' + date + '</div>');
}
},
"searchable": false,

View File

@@ -58,7 +58,7 @@ media_info_table_options = {
expand_details = '<span class="expand-media-info-tooltip" data-toggle="tooltip" title="Show Photos"><i class="fa fa-plus-circle fa-fw"></i></span>';
$(td).html('<div><a href="#"><div style="float: left;">' + expand_details + '&nbsp;' + date + '</div></a></div>');
} else {
$(td).html('<div style="float: left;"><i class="fa fa-fw"></i>&nbsp;' + date + '</div>');
$(td).html('<div style="float: left;"><i class="fa fa-plus-circle fa-fw fa-blank"></i>&nbsp;' + date + '</div>');
}
}
},

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],
"data": "success",
"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>');
} else {
$(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...";
showMsg(msg, false, false, 0)
}
}
};

View File

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

View File

@@ -10,7 +10,7 @@
<div class='container-fluid'>
<div class='table-card-header'>
<div class="header-bar">
<span><i class="fa fa-book"></i> All Libraries</span>
<span id="libraries-xml"><i class="fa fa-book"></i> All Libraries</span>
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
@@ -91,7 +91,7 @@
json_data: JSON.stringify(d)
};
}
}
};
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] });
@@ -198,5 +198,9 @@
});
});
% endif
$('#libraries-xml').on('tripleclick', function () {
openPlexXML('/library/sections/all');
});
</script>
</%def>

View File

@@ -374,7 +374,7 @@ DOCUMENTATION :: END
// Build watch history table
history_table_options.ajax = {
url: 'get_history',
type: 'post',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
@@ -406,7 +406,7 @@ DOCUMENTATION :: END
// Build media info table
media_info_table_options.ajax = {
url: 'get_library_media_info',
type: 'post',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),

View File

@@ -35,8 +35,7 @@ DOCUMENTATION :: END
% 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-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&fallback=art);"></div>
<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-thumb-${section_type}" class="dashboard-stats-flat svg-icon library-${section_type} hidden-xs"></div>
<div class="dashboard-stats-info-container">
<div id="library-stats-title-${section_type}" class="dashboard-stats-info-title">

View File

@@ -85,7 +85,7 @@
dataType: 'json',
statusCode: {
200: function() {
window.location = "${http_root}";
window.location = "${redirect_uri or http_root}";
},
401: function() {
$('#incorrect-login').show();

View File

@@ -50,6 +50,7 @@
<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-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>
</div>
</div>
@@ -62,6 +63,7 @@
<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-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="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>
</ul>
<div class="tab-content">
@@ -141,6 +143,25 @@
<tbody></tbody>
</table>
</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">
<table class="display login_log_table" id="login_log_table" width="100%">
<thead>
@@ -191,6 +212,7 @@
<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/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>
@@ -229,8 +251,8 @@
var selected_log_level = null;
function loadtautullilogs(logfile, selected_log_level) {
log_table_options.ajax = {
url: "get_log",
type: 'post',
url: 'get_log',
type: 'POST',
data: function (d) {
return {
logfile: logfile,
@@ -249,7 +271,8 @@
function loadPlexLogs() {
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 = $('#plex_log_table').DataTable(plex_log_table_options);
@@ -257,7 +280,8 @@
function loadPlexScannerLogs() {
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_scanner_log_table = $('#plex_scanner_log_table').DataTable(plex_log_table_options);
@@ -265,7 +289,8 @@
function loadNotificationLogs() {
notification_log_table_options.ajax = {
url: "get_notification_log",
url: 'get_notification_log',
type: 'POST',
data: function (d) {
return {
json_data: JSON.stringify(d)
@@ -275,10 +300,23 @@
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() {
login_log_table_options.pageLength = 50;
login_log_table_options.ajax = {
url: "get_user_logins",
url: 'get_user_logins',
type: 'POST',
data: function (d) {
return {
json_data: JSON.stringify(d)
@@ -296,6 +334,7 @@
$("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide();
loadtautullilogs('tautulli', selected_log_level);
clearSearchButton('tautulli_log_table', log_table);
@@ -309,7 +348,8 @@
$("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide();
$("#clear-login-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide();
loadtautullilogs('tautulli_api', selected_log_level);
clearSearchButton('tautulli_api_log_table', log_table);
});
@@ -322,6 +362,7 @@
$("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide();
loadtautullilogs('plex_websocket', selected_log_level);
clearSearchButton('plex_websocket_log_table', log_table);
@@ -335,6 +376,7 @@
$("#download-plexserverlog").show();
$("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide();
loadPlexLogs();
clearSearchButton('plex_log_table', plex_log_table);
@@ -348,6 +390,7 @@
$("#download-plexserverlog").hide();
$("#download-plexscannerlog").show();
$("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide();
loadPlexScannerLogs();
clearSearchButton('plex_scanner_log_table', plex_scanner_log_table);
@@ -361,11 +404,26 @@
$("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide();
$("#clear-notify-logs").show();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide();
loadNotificationLogs();
clearSearchButton('notification_log_table', notification_log_table);
});
$("#newsletter-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").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();
@@ -374,6 +432,7 @@
$("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").show();
loadLoginLogs();
clearSearchButton('login_log_table', notification_log_table);
@@ -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 () {
$("#confirm-message").text("Are you sure you want to clear the Tautulli Login Logs?");
$('#confirm-modal').modal();

View File

@@ -11,12 +11,11 @@ DOCUMENTATION :: END
<ul class="stacked-configs list-unstyled">
% for device in sorted(devices_list, key=lambda k: k['device_name']):
<li class="mobile-device" data-id="${device['id']}" data-name="${device['device_name']}">
<li class="mobile-device pointer" data-id="${device['id']}" data-name="${device['device_name']}">
<span>
<!--<span class="toggle-right mobile-device-tooltip edit-mobile-device" data-toggle="tooltip" data-placement="top" title="Edit Device"><i class="fa fa-lg fa-pencil"></i></span>-->
<span class="toggle-left"><i class="fa fa-lg fa-mobile"></i></span>
<span class="toggle-left"><i class="fa fa-lg fa-fw fa-mobile"></i></span>
${device['friendly_name'] or device['device_name']} &nbsp;<span class="friendly_name">(${device['id']})</span>
<span class="toggle-right"><i class="fa fa-lg fa-cog"></i></span>
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-cog"></i></span>
<span class="toggle-right friendly_name" id="device-last_seen-${device['id']}">
% if device['last_seen']:
<script>
@@ -26,14 +25,13 @@ DOCUMENTATION :: END
never
% endif
</span>
<!--<span class="toggle-right delete-mobile-device" data-toggle="tooltip" data-placement="top" title="Remove Device"><i class="fa fa-lg fa-times"></i></span>-->
</span>
</li>
% endfor
<li class="add-mobile-device" id="register-mobile-device" data-target="#api-qr-modal" data-toggle="modal">
<li class="add-mobile-device pointer" id="register-mobile-device" data-target="#api-qr-modal" data-toggle="modal">
<span>
<span class="toggle-left"><i class="fa fa-lg fa-mobile"></i></span> Register a new device
<span class="toggle-right"><i class="fa fa-lg fa-plus"></i></span>
<span class="toggle-left"><i class="fa fa-lg fa-fw fa-mobile"></i></span> Register a new device
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-plus"></i></span>
</span>
</li>
</ul>
@@ -55,7 +53,7 @@ DOCUMENTATION :: END
})
}
return deferred;
}
};
function checkQRAddress(url) {
var parser = document.createElement('a');
@@ -82,7 +80,7 @@ DOCUMENTATION :: END
verifiedDevice = false;
getPlexPyURL().then(function (url) {
checkQRAddress(url)
checkQRAddress(url);
$.get('generate_api_key', { device: true }).then(function (token) {
$('#api_qr_address').val(url);
@@ -120,7 +118,7 @@ DOCUMENTATION :: END
$('#api_qr_address').change(function () {
var url = $(this).val();
checkQRAddress(url)
checkQRAddress(url);
$('#api_qr_code').empty().qrcode({
text: url + '|' + $('#api_qr_token').val()

View File

@@ -0,0 +1,43 @@
<%
import urllib
%>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Tautulli - ${title}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet">
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
<link href="${http_root}css/opensans.min.css" rel="stylesheet">
<link href="${http_root}css/font-awesome.min.css" rel="stylesheet">
</head>
<body>
<div class="body-container">
<div class="container-fluid">
<div class="row">
<div class="login-container">
<div class="newsletter-logo">
<img src="${http_root}images/newsletter/newsletter-header.png" height="100" alt="PlexPy">
</div>
<div class="row">
<div class="col-sm-6 col-sm-offset-3">
<form action="${uri}" method="post" id="newsletter-form">
<div class="form-group">
<label for="password" class="control-label">
Password
</label>
<input type="password" id="key" name="key" class="form-control" autofocus>
</div>
<button id="enter" type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Enter</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,792 @@
% 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_saving_sending" aria-controls="tabs-newsletter_saving_sending" role="tab" data-toggle="tab">Saving & Sending</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 modal-config-section">
<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 modal-config-section">
<div class="form-group">
<label for="id_name">Unique ID Name</label>
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" id="id_name" name="id_name" value="${newsletter['id_name']}" size="30">
</div>
</div>
<p class="help-block">Optional: Enter a unique ID name to create a static URL to the last sent scheduled newsletter at <span class="inline-pre">${http_root}newsletter/id/&lt;id_name&gt;</span>. Only letters (a-z), numbers (0-9), underscores (_) and hyphens (-) are allowed. Leave blank to disable.</p>
</div>
<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_saving_sending">
<div class="row">
<div class="col-md-12">
<label>Saving</label>
<div class="checkbox">
<label>
<input type="checkbox" id="newsletter_config_save_only_checkbox" data-id="newsletter_config_save_only" class="checkboxes" value="1" ${checked(newsletter['config']['save_only'])}> Save HTML File Only
</label>
<p class="help-block">Enable to save the newsletter HTML file without sending it to any notification agent.</p>
<input type="hidden" id="newsletter_config_save_only" name="newsletter_config_save_only" value="${newsletter['config']['save_only']}">
</div>
<div class="form-group">
<label for="newsletter_config_filename">HTML File Name</label>
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" id="newsletter_config_filename" name="newsletter_config_filename" value="${newsletter['config']['filename']}" size="30">
</div>
</div>
<p class="help-block">Optional: Enter the file name to use when saving the newsletter (ending with <span class="inline-pre">.html</span>). You may use any of the <a href="#newsletter-text-sub-modal" data-toggle="modal">newsletter text parameters</a>. Leave blank for default.</p>
</div>
</div>
<div class="col-md-12 modal-config-section" id="newsletter_agent_options">
<label>Sending</label>
<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 id="newsletter-email-config">
% 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>
<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();
});
function validateFilename() {
var filename = $('#newsletter_config_filename').val();
if (filename !== '' && !(filename.endsWith('.html'))) {
showMsg('<i class="fa fa-times"></i> Failed to save newsletter. Invalid file name.', false, true, 5000, true);
return false;
} else {
return true;
}
}
function validateIDName() {
var id_name = $('#id_name').val();
if (/^[a-zA-Z0-9_-]*$/.test(id_name)) {
return true;
} else {
showMsg('<i class="fa fa-times"></i> Failed to save newsletter. Invalid unique ID name.', false, true, 5000, true);
return false;
}
}
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});
initConfigCheckbox('#newsletter_config_save_only_checkbox', '#newsletter_agent_options', true);
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'));
}
if (validateFilename() && validateIDName()){
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: '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 pointer" 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-fw 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-fw 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 pointer" id="add-newsletter-agent" data-target="#add-newsletter-modal" data-toggle="modal">
<span>
<span class="toggle-left"><i class="fa fa-lg fa-fw fa-newspaper-o"></i></span> Add a new newsletter agent
<span class="toggle-right"><i class="fa fa-lg fa-fw 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,14 +1,13 @@
% if notifier:
<%!
import json
from plexpy import helpers, notifiers, users
from plexpy import notifiers, users
from plexpy.helpers import checked
available_notification_actions = notifiers.available_notification_actions()
user_emails = [{'user': u['friendly_name'] or u['username'], 'email': u['email']} for u in users.Users().get_users() if u['email']]
sorted(user_emails, key=lambda u: u['user'])
%>
% if notifier:
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
<link href="${http_root}css/selectize.min.css" rel="stylesheet" />
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
@@ -19,7 +18,7 @@
<div class="container-fluid">
<div class="row">
<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_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>
@@ -28,7 +27,7 @@
</div>
<form action="set_notifier_config" method="post" class="form" id="set_notifier_config" data-parsley-validate>
<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="col-md-12">
<input type="hidden" id="notifier_id" name="notifier_id" value="${notifier['id']}" />
@@ -45,9 +44,6 @@
<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 ''}>
% if item['name'] == 'osx_notify_app':
<a href="javascript:void(0)" id="osxnotifyregister">Register</a>
% endif
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
@@ -75,7 +71,7 @@
% elif item['input_type'] == 'checkbox':
<div class="checkbox">
<label>
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${helpers.checked(item['value'])}> ${item['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']}">
@@ -128,7 +124,7 @@
% endif
% endfor
</div>
<div class="col-md-12" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #444;">
<div class="col-md-12 modal-config-section">
<div class="form-group">
<label for="friendly_name">Description</label>
<div class="row">
@@ -151,7 +147,7 @@
% for action in available_notification_actions:
<div class="checkbox">
<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" ${checked(notifier['actions'][action['name']])}> ${action['label']}
</label>
<p class="help-block">${action['description'] | n}</p>
<input type="hidden" id="${action['name']}" name="${action['name']}" value="${notifier['actions'][action['name']]}">
@@ -167,7 +163,7 @@
<a href="#notify-text-sub-modal" data-toggle="modal">Click here</a> for a description of all the parameters.
</p>
<div id="condition-widget"></div>
<input type="hidden" name="custom_conditions" id="custom_conditions" />
<input type="hidden" id="custom_conditions" name="custom_conditions" />
<div class="form-group">
<label for="custom_conditions_logic">Condition Logic</label>
@@ -206,7 +202,11 @@
% if notifier['agent_name'] == 'scripts':
% for action in available_notification_actions:
<li>
<div class="link"><i class="fa ${action['icon']} fa-fw"></i>&nbsp;${action['label']}<i class="fa fa-chevron-down"></i></div>
<div class="link">
<span class="toggle-left"><i class="fa ${action['icon']} fa-fw"></i></span>&nbsp;
${action['label']}
<span class="toggle-right"><i class="fa fa-chevron-down"></i></span>
</div>
<ul class="submenu">
<li>
<div class="form-group">
@@ -228,7 +228,11 @@
% else:
% for action in available_notification_actions:
<li>
<div class="link"><i class="fa ${action['icon']} fa-fw"></i>&nbsp;${action['label']}<i class="fa fa-chevron-down"></i></div>
<div class="link">
<span class="toggle-left"><i class="fa ${action['icon']} fa-fw"></i></span>&nbsp;
${action['label']}
<span class="toggle-right"><i class="fa fa-chevron-down"></i></span>
</div>
<ul class="submenu">
<li>
<div class="form-group">
@@ -343,21 +347,6 @@
}
});
function setNegativeOperator(select) {
if (select.val() === 'does not contain' || select.val() === 'is not') {
select.closest('.form-group').find('.react-selectize-search-field-and-selected-values').addClass('negative-operator');
} else {
select.closest('.form-group').find('.react-selectize-search-field-and-selected-values').removeClass('negative-operator');
}
}
$('#condition-widget select[name=operator]').each(function () {
setNegativeOperator($(this));
});
$('#condition-widget').on('change', 'select[name=operator]', function () {
setNegativeOperator($(this));
});
function reloadModal() {
$.ajax({
url: 'get_notifier_config_modal',
@@ -425,7 +414,7 @@
$('#duplicate-notifier-item').click(function() {
var msg = 'Are you sure you want to duplicate this <strong>${notifier["agent_label"]}</strong> notification agent?';
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 () {
@@ -433,16 +422,30 @@
});
% 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() {
if ($('#facebook_app_id').val() !== '' && $('#facebook_app_secret').val() !== '') { $('#facebook_facebookStep1').prop('disabled', false); }
else { $('#facebook_facebookStep1').prop('disabled', true); }
if ($('#facebook_app_id').val() !== '' && $('#facebook_app_secret').val() !== '') { $('#facebook_facebook_auth').prop('disabled', false); }
else { $('#facebook_facebook_auth').prop('disabled', true); }
}
disableFacebookRequest();
$('#facebook_app_id, #facebook_app_secret').on('change', function () {
disableFacebookRequest();
});
$('#facebook_facebookStep1').click(function () {
$('#facebook_facebook_auth').click(function () {
// Remove trailing '/' from Facebook redirect URI
if ($('#facebook_redirect_uri') && $('#facebook_redirect_uri').val().endsWith('/')) {
$('#facebook_redirect_uri').val($('#facebook_redirect_uri').val().slice(0, -1));
@@ -450,7 +453,7 @@
var facebook_token;
$.ajax({
url: 'facebookStep1',
url: 'facebook_auth',
data: {
app_id: $('#facebook_app_id').val(),
app_secret: $('#facebook_app_secret').val(),
@@ -508,7 +511,7 @@
});
% elif notifier['agent_name'] == 'osx':
$('#osxnotifyregister').click(function () {
$('#osx_notify_register').click(function () {
var osx_notify_app = $('#osx_notify_app').val();
$.get('osxnotifyregister', { 'app': osx_notify_app }, function (data) { showMsg('<i class="fa fa-check"></i> ' + data, false, true, 3000); });
});
@@ -752,11 +755,12 @@
});
function sendTestNotification() {
showMsg('<i class="fa fa-refresh fa-spin"></i>&nbsp; Sending Notification', false);
if ('${notifier["agent_name"]}' !== 'browser') {
$.ajax({
url: 'send_notification',
data: {
notifier_id: '${notifier["id"]}',
notifier_id: $('#notifier_id').val(),
subject: $('#test_subject').val(),
body: $('#test_body').val(),
script: $('#test_script').val(),
@@ -765,13 +769,11 @@
},
cache: false,
async: true,
complete: function (xhr, status) {
if (xhr.responseText.indexOf('sent') > -1) {
msg = '<i class="fa fa-check"></i>&nbsp; ' + xhr.responseText;
showMsg(msg, false, true, 2000);
success: function (data) {
if (data.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + data.message, false, true, 5000);
} else {
msg = '<i class="fa fa-times"></i>&nbsp; ' + xhr.responseText;
showMsg(msg, false, true, 2000, true);
showMsg('<i class="fa fa-exclamation-circle"></i> ' + data.message, false, true, 5000, true);
}
}
});

View File

@@ -10,23 +10,23 @@ DOCUMENTATION :: END
</%doc>
<ul class="stacked-configs list-unstyled">
% for notifier in sorted(notifiers_list, key=lambda k: (k['agent_label'], k['friendly_name'], k['id'])):
<li class="notification-agent" data-id="${notifier['id']}">
% for notifier in sorted(notifiers_list, key=lambda k: (k['agent_label'].lower(), k['friendly_name'], k['id'])):
<li class="notification-agent pointer" data-id="${notifier['id']}">
<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-fw fa-bell"></i></span>
% if notifier['friendly_name']:
${notifier['agent_label']} &nbsp;<span class="friendly_name">(${notifier['id']} - ${notifier['friendly_name']})</span>
% else:
${notifier['agent_label']} &nbsp;<span class="friendly_name">(${notifier['id']})</span>
% endif
<span class="toggle-right"><i class="fa fa-lg fa-cog"></i></span>
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-cog"></i></span>
</span>
</li>
% endfor
<li class="add-notification-agent" id="add-notification-agent" data-target="#add-notifier-modal" data-toggle="modal">
<li class="add-notification-agent pointer" id="add-notification-agent" data-target="#add-notifier-modal" data-toggle="modal">
<span>
<span class="toggle-left"><i class="fa fa-lg fa-bell"></i></span> Add a new notification agent
<span class="toggle-right"><i class="fa fa-lg fa-plus"></i></span>
<span class="toggle-left"><i class="fa fa-lg fa-fw fa-bell"></i></span> Add a new notification agent
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-plus"></i></span>
</span>
</li>
</ul>

View File

@@ -28,15 +28,17 @@
<%def name="javascriptIncludes()">
<script>
var query_string = "${query.replace('"','\\"').replace('/','\\/') | n}";
$('#search_button').removeClass('btn-inactive');
$('#query').val("${query.replace('"','\\"') | n}").css({ right: '0', width: '250px' }).addClass('active');
$('#query').val(query_string).css({ right: '0', width: '250px' }).addClass('active');
$.ajax({
url: 'get_search_results_children',
type: "GET",
type: "POST",
async: true,
data: {
query: "${query.replace('"','\\"') | n}",
query: query_string,
limit: 30
},
complete: function (xhr, status) {

View File

@@ -4,10 +4,11 @@
import sys
import plexpy
from plexpy import common, notifiers
from plexpy import common, notifiers, newsletters
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>
@@ -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-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-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-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-android_app" aria-controls="tabs-android_app" role="tab" data-toggle="tab">Tautulli Remote Android App <sup><small>beta</small></sup></a></li>
</ul>
@@ -113,9 +115,9 @@
</div>
<div class="checkbox advanced-setting">
<label>
<input type="checkbox" id="group_history_tables" name="group_history_tables" value="1" ${config['group_history_tables']}> Group Successive Play History
<input type="checkbox" id="group_history_tables" name="group_history_tables" value="1" ${config['group_history_tables']}> Group Play History
</label>
<p class="help-block">Group successive play history by the same user as a single entry in the watch statistics, tables, and graphs.</p>
<p class="help-block">Group play history for the same item and user as a single entry when progress is less than the watched percent.</p>
</div>
<div class="checkbox advanced-setting">
<label>
@@ -267,6 +269,21 @@
<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">
<h3>Sections</h3>
</div>
@@ -438,6 +455,18 @@
</div>
<p class="help-block">Port to bind web server to. Note that ports below 1024 may require root.</p>
</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">
<label for="http_root">HTTP Root</label>
<div class="row">
@@ -554,7 +583,7 @@
<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
</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>
</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]"
@@ -572,14 +601,14 @@
<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
</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>
</div>
<div class="checkbox">
<label>
<input type="checkbox" id="allow_guest_access" name="allow_guest_access" value="1" ${config['allow_guest_access']}> Allow Guest Access to Tautulli
</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>
</div>
@@ -617,11 +646,11 @@
<div role="tabpanel" class="tab-pane" id="tabs-plex_media_server">
<div class="padded-header">
<h3>Plex Media Server <small style="color: #fff;">Version <span id="pms_version">${config['pms_version']}</span></small></h3>
<h3 id="resources-xml">Plex Media Server <small style="color: #fff;">Version <span id="pms_version">${config['pms_version']}</span></small></h3>
</div>
<div class="form-group has-feedback" id="pms_ip_group">
<label for="pms_ip">Plex IP or Hostname</label>
<label for="pms_ip">Plex IP Address or Hostname</label>
<div class="row">
<div class="col-md-9" id="selectize-pms-ip-container">
<div class="input-group">
@@ -666,18 +695,24 @@
<label for="pms_url">Plex Server URL</label>
<div class="row">
<div class="col-md-9">
<div class="input-group">
<input type="text" class="form-control" id="pms_url" name="pms_url" value="${config['pms_url']}" size="30" readonly>
<span class="input-group-btn">
<button class="btn btn-form" type="button" id="test_pms_url_button">Test URL</button>
</span>
</div>
<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="form-group advanced-setting">
<label for="pms_url">Plex Server Identifier</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="pms_identifier" name="pms_identifier" value="${config['pms_identifier']}" size="30" readonly>
</div>
</div>
<p class="help-block">
The unique identifier for your Plex server. Retrieved automatically.
</p>
</div>
<div class="checkbox advanced-setting">
<label>
<input type="checkbox" class="pms-settings" id="pms_url_manual" name="pms_url_manual" value="1" ${config['pms_url_manual']}> Manual Connection
@@ -690,7 +725,7 @@
<div class="row">
<div class="col-md-9">
<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">
<button class="btn btn-form" type="button" id="test_pms_web_button">Test URL</button>
</span>
@@ -704,7 +739,6 @@
</div>
<input type="hidden" id="pms_is_cloud" name="pms_is_cloud" value="${config['pms_is_cloud']}">
<input type="hidden" id="pms_identifier" name="pms_identifier" value="${config['pms_identifier']}">
<input type="checkbox" name="server_changed" id="server_changed" value="1" style="display: none;">
<div class="form-group advanced-setting">
@@ -766,7 +800,7 @@
<input type="checkbox" id="monitor_remote_access" name="monitor_remote_access" value="1" ${config['monitor_remote_access']}> Monitor Plex Remote Access
</label>
<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>
</div>
@@ -912,7 +946,7 @@
</div>
<!--<div class="checkbox">
<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>
<p class="help-block">
Enable to send another recently added notification when adding a new version of existing media.<br />
@@ -921,16 +955,107 @@
</div>-->
<div class="padded-header">
<h3>3rd Party APIs</h3>
<h3>Newsletters</h3>
</div>
<div class="checkbox">
<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>
<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 id="imgur_upload_options">
<div id="self_host_newsletter_options" style="overlfow: hidden; display: ${'block' if config['newsletter_self_hosted'] == 'checked' else 'none'}">
<div class="form-group">
<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.
</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="form-group">
<label for="newsletter_auth">Newsletter Authentication</label>
<div class="row">
<div class="col-md-6">
<select class="form-control" id="newsletter_auth" name="newsletter_auth">
<option value="0" ${'selected' if config['newsletter_auth'] == 0 else ''}>Disabled</option>
<option value="1" ${'selected' if config['newsletter_auth'] == 1 else ''}>Password</option>
<option value="2" ${'selected' if config['newsletter_auth'] == 2 else ''}>Tautulli Guest Access</option>
</select>
</div>
</div>
<p class="help-block">Select the authentication method to use for self-hosted newsletters.</p>
<p class="help-block settings-warning newsletter-guest-access-warning">Warning: Guest Access is not enabled under <a data-tab-destination="tabs-web_interface" data-target="#allow_guest_access">Web Interface</a>.</p>
</div>
<div class="form-group" id="newsletter_password_option">
<label for="newsletter_password">Newsletter Password</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="newsletter_password" name="newsletter_password" value="${config['newsletter_password']}">
</div>
</div>
<p class="help-block">Enter the password that will be required to view self-hosted newsletters.</p>
</div>
</div>
<div class="checkbox advanced-setting">
<label>
<input type="checkbox" id="newsletter_inline_styles" name="newsletter_inline_styles" value="1" ${config['newsletter_inline_styles']}> Use Inline Styles Template
</label>
<p class="help-block">
Enable to use newsletter templates with inline CSS styles. Inline styles render better in email clients, but are larger in size which may cause long newsletters to be clipped.<br>
Note: This setting does not affect custom templates. CSS styles will depend on your own template.
</p>
</div>
<div class="form-group advanced-setting">
<label for="newsletter_dir">Custom Newsletter Templates Folder</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="newsletter_custom_dir" name="newsletter_custom_dir" value="${config['newsletter_custom_dir']}">
</div>
</div>
<p class="help-block">Optional: Enter the full path to your custom newsletter templates folder. Leave blank for default.</p>
</div>
<div class="form-group advanced-setting">
<label for="newsletter_dir">Newsletter Output Directory</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="newsletter_dir" name="newsletter_dir" value="${config['newsletter_dir']}">
</div>
</div>
<p class="help-block">Enter the full path to where newsletter files will be saved.</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">
<div class="${'input-group' if config['notify_upload_posters'] in (1, 3) else ''}">
<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 domain</option>
</select>
% if config['notify_upload_posters'] in (1, 3):
<span class="input-group-btn" id="delete_all_uploads_container">
<button class="btn btn-form" type="button" id="delete_all_uploads">Delete All Uploads</button>
</span>
% endif
</div>
</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'}">
<div class="form-group">
<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>
<div class="form-group">
<label for="imgur_client_id">Imgur Client ID</label>
<div class="row">
@@ -938,9 +1063,53 @@
<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>
<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'}">
<div class="form-group">
<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>
<div id="cloudinary_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 3 else 'block'}">
<div class="form-group">
<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>
<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">
Enter your Imgur API client ID in order to upload posters.
You can register a new application <a href="${anon_url('https://api.imgur.com/oauth2/addclient')}" target="_blank">here</a>.<br />
Enter your Cloudinary Cloud Name.
</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 class="checkbox">
@@ -969,6 +1138,9 @@
<p class="help-block">
Add a new notification agent, or configure an existing notification agent by clicking the settings icon on the right.
</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 />
<div id="plexpy-notifiers-table">
<div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Loading notification agents...</div>
@@ -977,13 +1149,33 @@
</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">
Warning: The <a data-tab-destination="tabs-notifications" data-target="#notify_upload_posters">Image Hosting</a> setting must be enabled for images to display on the newsletter.</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 class="padded-header">
<h3>Database Import</h3>
</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">
<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>
@@ -1021,6 +1213,17 @@
<h3>Directories</h3>
</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">
<label for="backup_dir">Backup Directory</label>
<div class="row">
@@ -1045,17 +1248,6 @@
</div>
</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>
<p><input type="button" class="btn btn-bright save-button" value="Save" data-success="Changes saved successfully"></p>
@@ -1076,10 +1268,10 @@
</span>
</p>
</div>
<p class="form-group">
<div class="form-group">
<label>Registered Devices</label>
<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-web_interface" style="cursor: pointer;">Web Interface</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 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>
@@ -1230,8 +1422,36 @@
<div class="row">
<div class="col-md-12">
<ul class="stacked-configs list-unstyled">
% for agent in available_notification_agents:
<li class="new-notification-agent" data-id="${agent['id']}">
% for agent in sorted(available_notification_agents, key=lambda k: k['label'].lower()):
<li class="new-notification-agent pointer" 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="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 pointer" data-id="${agent['id']}">
<span>${agent['label']}</span>
</li>
% endfor
@@ -1247,6 +1467,7 @@
</div>
</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 class="modal-dialog" role="document">
<div class="modal-content">
@@ -1402,6 +1623,53 @@
</div>
<div id="notifier-text-preview-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="notifier-text-preview-modal">
</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 class="modal-dialog" role="document">
<div class="modal-content">
@@ -1495,9 +1763,6 @@
} else if ("${kwargs.get('reinstall_geoip')}" == 'true') {
$('#reinstall_geoip_db').removeClass('no-highlight').css('color','#e9a049');
}
if ("${kwargs.get('support')}" == 'true') {
$('.support-modal-link').removeClass('no-highlight').css('color','#e9a049');
}
}
});
}
@@ -1525,6 +1790,7 @@
}
function loadNotifierConfig(notifier_id) {
showMsg('<i class="fa fa-refresh fa-spin"></i>&nbsp; Loading Configuration', false);
$.ajax({
url: 'get_notifier_config_modal',
data: { notifier_id: notifier_id },
@@ -1532,6 +1798,32 @@
async: true,
complete: function (xhr, status) {
$("#notifier-config-modal").html(xhr.responseText).modal('show');
showMsg('<i class="fa fa-check"></i> Configuration Loaded', false, true, 2000);
}
});
}
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) {
showMsg('<i class="fa fa-refresh fa-spin"></i>&nbsp; Loading Configuration', false);
$.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');
showMsg('<i class="fa fa-check"></i> Configuration Loaded', false, true, 2000);
}
});
}
@@ -1548,6 +1840,7 @@
}
function loadMobileDeviceConfig(mobile_device_id) {
showMsg('<i class="fa fa-refresh fa-spin"></i>&nbsp; Loading Configuration', false);
$.ajax({
url: 'get_mobile_device_config_modal',
data: { mobile_device_id: mobile_device_id },
@@ -1555,6 +1848,7 @@
async: true,
complete: function (xhr, status) {
$("#mobile-device-config-modal").html(xhr.responseText).modal('show');
showMsg('<i class="fa fa-check"></i> Configuration Loaded', false, true, 2000);
}
});
}
@@ -1608,6 +1902,7 @@ $(document).ready(function() {
getConfigurationTable();
getSchedulerTable();
getNotifiersTable();
getNewslettersTable();
getMobileDevicesTable();
loadUpdateDistros();
settingsChanged = false;
@@ -1644,9 +1939,9 @@ $(document).ready(function() {
initConfigCheckbox('#enable_https');
initConfigCheckbox('#https_create_cert');
initConfigCheckbox('#check_github');
initConfigCheckbox('#notify_upload_posters');
initConfigCheckbox('#monitor_pms_updates');
initConfigCheckbox('#newsletter_self_hosted');
$('#menu_link_shutdown').click(function() {
$('#confirm-message').text("Are you sure you want to shutdown Tautulli?");
$('#confirm-modal').modal();
@@ -1691,6 +1986,7 @@ $(document).ready(function() {
getConfigurationTable();
getSchedulerTable();
getNotifiersTable();
getNewslettersTable();
getMobileDevicesTable();
$('#changelog-modal-link').on('click', function (e) {
@@ -1858,6 +2154,9 @@ $(document).ready(function() {
$('#pms_url_manual').prop('checked', false);
$('#pms_url').val('Please verify your server above to retrieve the URL');
PMSCloudCheck();
},
onDropdownOpen: function() {
this.clear();
}
});
var select_pms = $select_pms[0].selectize;
@@ -1931,11 +2230,11 @@ $(document).ready(function() {
data: {
hostname: pms_ip,
port: pms_port,
identifier: pms_identifier,
ssl: pms_ssl,
remote: pms_is_remote,
manual: pms_url_manual,
get_url: serverChanged
get_url: true,
test_websocket: true
},
cache: true,
async: true,
@@ -1948,15 +2247,23 @@ $(document).ready(function() {
var result = xhr;
var identifier = result.identifier;
var url = result.url;
var ws = result.ws;
if (identifier) {
$("#pms_identifier").val(identifier);
if (url) {
$("#pms_url").val(url);
}
$("#pms_verify").html('<i class="fa fa-check"></i>').fadeIn('fast');
$("#pms_ip_group").removeClass("has-error");
serverChanged = false;
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) {
_callback();
@@ -1984,13 +2291,6 @@ $(document).ready(function() {
$("#pms_web_url").val(pms_web_url || 'https://app.plex.tv/desktop');
}
$('#test_pms_url_button').on('click', function(){
var pms_url = $.trim($("#pms_url").val());
if (pms_url.startsWith('http')) {
window.open(pms_url + '/web', '_blank');
}
});
$('#test_pms_web_button').on('click', function(){
var pms_web_url = $.trim($("#pms_web_url").val());
window.open(pms_web_url, '_blank');
@@ -2055,7 +2355,7 @@ $(document).ready(function() {
data: { pref: 'PublishServerOnPlexOnlineKey' },
async: true,
success: function(data) {
if (data !== 'true') {
if (data === 'false' || data === '0') {
$("#remoteAccessCheck").html("Remote access must be enabled on your Plex Server. <a target='_blank' href='${anon_url('https://support.plex.tv/hc/en-us/articles/200484543-Enabling-Remote-Access-for-a-Server')}'>Click here</a> for help.");
$("#monitor_remote_access").attr("checked", false).attr("disabled", true);
}
@@ -2190,6 +2490,7 @@ $(document).ready(function() {
$("#allow_guest_access").attr("disabled", false);
$("#allowGuestCheck").html("");
}
newsletterPasswordEnabled();
}
allowGuestAccessCheck();
@@ -2293,7 +2594,7 @@ $(document).ready(function() {
var result = $.parseJSON(xhr.responseText);
var msg = result.message;
$('#add-notifier-modal').modal('hide');
if (result.result == 'success') {
if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
loadNotifierConfig(result.notifier_id);
} else {
@@ -2304,6 +2605,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() {
var api_enabled = $('#api_enabled').prop('checked');
$('#app_api_msg').toggle(!(api_enabled));
@@ -2313,9 +2640,119 @@ $(document).ready(function() {
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();
}
var parent;
if (upload_val === '1' || upload_val === '3') {
parent = $('#notify_upload_posters').parent();
if ($('#delete_all_uploads_container').length === 0){
parent.addClass('input-group');
parent.append(
'<span class="input-group-btn" id="delete_all_uploads_container">' +
'<button class="btn btn-form" type="button" id="delete_all_uploads">Delete All Uploads</button>' +
'</span>');
}
} else {
parent = $('#notify_upload_posters').parent();
parent.removeClass('input-group');
$('#delete_all_uploads_container').remove();
}
}
$('#notify_upload_posters').change(function () {
imageUpload();
});
$('body').on('click', '#delete_all_uploads', function () {
var image_hosting_option = $('#notify_upload_posters').find(':selected');
var name = image_hosting_option.text();
var msg = 'Are you sure you want to delete all uploaded images on <strong>' + name + '</strong>?' +
'<br />All previous links to the images will no longer work. This cannot be undone!';
var url = 'delete_hosted_images';
var data = { service: name, delete_all: true };
confirmAjaxCall(url, msg, data, false);
});
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() === '0') {
$('#newsletter_upload_warning').show();
} else {
$('#newsletter_upload_warning').hide();
}
}
newsletterUploadEnabled();
$('#notify_upload_posters, #newsletter_self_hosted').change(function () {
baseURLSet();
newsletterUploadEnabled();
});
function newsletterPasswordEnabled() {
if ($('#newsletter_auth').val() === '1') {
$('#newsletter_password_option').slideDown();
} else {
$('#newsletter_password_option').slideUp();
}
if ($('#newsletter_auth').val() === '2' && !($('#allow_guest_access').is(':checked'))) {
$('.newsletter-guest-access-warning').show();
} else {
$('.newsletter-guest-access-warning').hide();
}
}
newsletterPasswordEnabled();
$('#newsletter_auth').change(function () {
newsletterPasswordEnabled();
});
$('#allow_guest_access').click(function () {
newsletterPasswordEnabled();
})
$('body').on('click', 'a[data-tab-destination]', function () {
var tab = $(this).data('tab-destination');
$("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});
}
});
$('#resources-xml').on('tripleclick', function () {
openPlexXML('/api/resources', true, {includeHttps: 1});
});
});
</script>

View File

@@ -46,8 +46,10 @@ DOCUMENTATION :: END
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="info-modal-title">
% if data['media_type'] == 'episode' or data['media_type'] == 'track':
% if data['media_type'] == 'episode':
Stream Info: <strong>${data['grandparent_title']} - ${data['title']} (${user})</strong>
% elif data['media_type'] == 'track':
Stream Info: <strong>${data['original_title'] or data['grandparent_title']} - ${data['title']} (${user})</strong>
% else:
Stream Info: <strong>${data['title']} (${user})</strong>
% endif
@@ -58,6 +60,10 @@ DOCUMENTATION :: END
<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.
</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
<table class="stream-info" style="margin-top: 0;">
<thead>
@@ -84,8 +90,8 @@ DOCUMENTATION :: END
<tbody>
<tr>
<td>Bitrate</td>
<td>${data['stream_bitrate']} kbps</td>
<td>${data['bitrate']} kbps</td>
<td>${data['stream_bitrate']} ${'kbps' if data['stream_bitrate'] else ''}</td>
<td>${data['bitrate']} ${'kbps' if data['bitrate'] else ''}</td>
</tr>
% if data['media_type'] != 'track':
<tr>
@@ -154,8 +160,8 @@ DOCUMENTATION :: END
</tr>
<tr>
<td>Bitrate</td>
<td>${data['stream_video_bitrate']} kbps</td>
<td>${data['video_bitrate']} kbps</td>
<td>${data['stream_video_bitrate']} ${'kbps' if data['stream_video_bitrate'] else ''}</td>
<td>${data['video_bitrate']} ${'kbps' if data['video_bitrate'] else ''}</td>
</tr>
<tr>
<td>Width</td>
@@ -199,8 +205,8 @@ DOCUMENTATION :: END
</tr>
<tr>
<td>Bitrate</td>
<td>${data['stream_audio_bitrate']} kbps</td>
<td>${data['audio_bitrate']} kbps</td>
<td>${data['stream_audio_bitrate']} ${'kbps' if data['stream_audio_bitrate'] else ''}</td>
<td>${data['audio_bitrate']} ${'kbps' if data['audio_bitrate'] else ''}</td>
</tr>
<tr>
<td>Channels</td>

View File

@@ -0,0 +1,68 @@
<%inherit file="base.html"/>
<%!
from plexpy.helpers import anon_url
%>
<%def name="headIncludes()">
</%def>
<%def name="headerIncludes()">
</%def>
<%def name="body()">
<div class='container-fluid'>
<div class='table-card-header'>
<div class="header-bar">
<span><i class="fa fa-comment"></i> Support</span>
</div>
<div class="button-bar">
<div class="btn-group">
<a class="btn btn-dark" href="${anon_url('https://tautulli.com/discord')}" target="_blank"><i class="fab fa-discord"></i>&nbsp; Join Discord</a>
</div>
<div class="btn-group">
<a class="btn btn-dark" href="${anon_url('https://www.reddit.com/r/Tautulli')}" target="_blank"><i class="fab fa-reddit"></i>&nbsp; Join Reddit</a>
</div>
<div class="btn-group">
<button class="btn btn-dark" id="popout-iframe-button"><i class="fa fa-external-link"></i> Pop Out Chat</button>
</div>
</div>
</div>
<div class='table-card-back'>
<div class="iframe-container">
<iframe class="iframe" allowfullscreen="true" id="support-iframe" data-name="Tautulli-Support" data-src="https://support.tautulli.com"
sandbox="allow-presentation allow-forms allow-same-origin allow-pointer-lock allow-scripts allow-popups allow-modals allow-top-navigation"
style="display: none;">
</iframe>
<div class="iframe-overlay">
<div class="iframe-button-container">
<a class="iframe-button">Start chatting now</a>
</div>
</div>
</div>
</div>
</div>
</%def>
<%def name="modalIncludes()">
</%def>
<%def name="javascriptIncludes()">
<script>
var popout_chat;
$('.iframe-button').click(function () {
if (popout_chat) {
popout_chat.close();
}
var iframe = $('#support-iframe');
iframe.attr('src', iframe.data('src')).fadeIn();
$('.iframe-overlay').fadeOut();
});
$('#popout-iframe-button').click(function () {
var iframe = $('#support-iframe');
popout_chat = window.open(iframe.data('src'), 'Tautulli-Discord-Support', 'width=1280,height=720');
iframe.attr('src', '').fadeOut();
$('.iframe-overlay').fadeIn();
});
</script>
</%def>

View File

@@ -16,7 +16,7 @@
<div class='container-fluid'>
<div class='table-card-header'>
<div class="header-bar">
<span><i class="fa fa-cloud-download"></i> Synced Items</span>
<span id="sync-xml"><i class="fa fa-cloud-download"></i> Synced Items</span>
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
@@ -100,7 +100,7 @@
// Load user ids and names (for the selector)
$.ajax({
url: 'get_user_names',
type: 'get',
type: 'GET',
dataType: 'json',
success: function (data) {
var select = $('#sync-user');
@@ -116,7 +116,8 @@
function loadSyncTable(selected_user_id) {
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);
var colvis = new $.fn.dataTable.ColVis(sync_table, {
@@ -184,5 +185,9 @@
$("#refresh-syncs-list").click(function() {
sync_table.ajax.reload();
});
$('#sync-xml').on('tripleclick', function () {
openPlexXML('/servers/{machine_id}/sync_lists', true);
});
</script>
</%def>

View File

@@ -96,7 +96,7 @@ DOCUMENTATION :: END
</div>
</div>
<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>
@@ -188,7 +188,7 @@ DOCUMENTATION :: END
},
complete: function (xhr, status) {
$('#search-results-list').html(xhr.responseText);
$('#update_query_title').html(query_string)
$('#update_query_title').text(query_string)
}
});
}

View File

@@ -413,7 +413,7 @@ DOCUMENTATION :: END
// Build watch history table
history_table_options.ajax = {
url: 'get_history',
type: 'post',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
@@ -442,7 +442,8 @@ DOCUMENTATION :: END
function loadSyncTable() {
// Build user sync table
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.column(2).visible(false);
@@ -457,7 +458,7 @@ DOCUMENTATION :: END
// Build user IP table
user_ip_table_options.ajax = {
url: 'get_user_ips',
type: 'post',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
@@ -474,6 +475,7 @@ DOCUMENTATION :: END
// Build user login table
login_log_table_options.ajax = {
url: 'get_user_logins',
type: 'POST',
data: function(d) {
return {
json_data: JSON.stringify(d),

View File

@@ -108,8 +108,8 @@ DOCUMENTATION :: END
</div>
</a>
<div class="dashboard-recent-media-metacontainer">
<h3 title="${item['grandparent_title']}">
<a href="info?rating_key=${item['grandparent_rating_key']}" title="${item['grandparent_title']}">${item['grandparent_title']}</a>
<h3 title="${item['original_title'] or item['grandparent_title']}">
<a href="info?rating_key=${item['grandparent_rating_key']}" title="${item['original_title'] or item['grandparent_title']}">${item['original_title'] or item['grandparent_title']}</a>
</h3>
<h3 class="text-muted" title="${item['title']}">
<a href="info?source=history&rating_key=${item['rating_key']}" title="${item['title']}">${item['title']}</a>

View File

@@ -10,7 +10,7 @@
<div class='container-fluid'>
<div class='table-card-header'>
<div class="header-bar">
<span><i class="fa fa-group"></i> All Users</span>
<span id="users-xml"><i class="fa fa-group"></i> All Users</span>
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
@@ -94,7 +94,7 @@
json_data: JSON.stringify(d)
};
}
}
};
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] });
@@ -202,5 +202,9 @@
});
});
% endif
$('#users-xml').on('tripleclick', function () {
openPlexXML('/api/users', true);
});
</script>
</%def>

View File

@@ -374,6 +374,9 @@ $(document).ready(function() {
$('#pms_is_remote_checkbox').prop('disabled', false);
$('#pms_ssl_checkbox').prop('disabled', false);
}
},
onDropdownOpen: function() {
this.clear();
}
});
var select_pms = $select_pms[0].selectize;

View File

@@ -0,0 +1,969 @@
% if data:
<%
import plexpy
from plexpy.helpers import grouper, get_img_service
recently_added = data['recently_added']
if plexpy.CONFIG.NEWSLETTER_SELF_HOSTED and plexpy.CONFIG.HTTP_BASE_URL:
base_url = plexpy.CONFIG.HTTP_BASE_URL + plexpy.HTTP_ROOT + 'newsletter/'
elif preview:
base_url = 'newsletter/'
else:
base_url = ''
service = get_img_service(include_self=True)
if service == 'self-hosted' and plexpy.CONFIG.HTTP_BASE_URL:
base_url_image = plexpy.CONFIG.HTTP_BASE_URL + plexpy.HTTP_ROOT + 'image/'
elif preview and service and service != 'self-hosted':
base_url_image = 'image/'
else:
base_url_image = ''
%>
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Tautulli Newsletter - ${subject}</title>
<style>
/* -------------------------------------
GLOBAL RESETS
------------------------------------- */
img {
border: none;
-ms-interpolation-mode: bicubic;
max-width: 100%;
}
body {
font-family: 'Open Sans', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
}
table td {
font-family: 'Open Sans', Helvetica, Arial, sans-serif;
font-size: 14px;
vertical-align: top;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
.body {
width: 100%;
}
.container {
display: block;
margin: 0 auto !important;
max-width: 1042px;
padding: 10px;
width: 1042px;
}
.content {
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 1037px;
padding: 10px;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.local-preview-note {
text-align: center;
padding-top: 10px;
}
.local-preview-note p {
color: #282A2D;
font-size: 12px;
}
.main {
background: #282A2D;
border-radius: 3px;
width: 100%;
color: #ffffff;
}
.wrapper {
box-sizing: border-box;
padding: 5px;
overflow: auto;
}
.content-block {
padding-bottom: 10px;
padding-top: 10px;
}
.footer {
clear: both;
margin-top: 10px;
text-align: center;
width: 100%;
font-size: 12px;
}
.footer-bar {
margin-left: auto;
margin-right: auto;
width: 200px;
border-top: 1px solid #E5A00D;
margin-top: 25px;
}
.footer td,
.footer p,
.footer span,
.footer a {
color: #fff;
font-size: 12px;
text-align: center;
}
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1,
h2,
h3,
h4 {
color: #ffffff;
font-family: 'Open Sans', Helvetica, Arial, sans-serif;
font-weight: 400;
margin: 0;
}
p,
ul,
ol {
font-family: 'Open Sans', Helvetica, Arial, sans-serif;
font-weight: 400;
margin: 0;
}
p li,
ul li,
ol li {
list-style-position: inside;
margin-left: 5px;
}
a {
text-decoration: underline;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.mb5 {
margin-bottom: 5px;
}
.nowrap {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
}
.view-full {
clear: both;
color: #282A2D;
font-size: 12px;
margin-bottom: 10px;
text-align: center;
width: 100%;
}
.view-full a {
color: #282A2D;
}
.powered-by a {
text-decoration: underline;
}
/* -------------------------------------
HEADER
------------------------------------- */
.header {
width: 100%;
height: 90px;
text-align: center;
}
.header-img {
width: 492px;
height: 90px;
margin-left: -35px;
}
.server-name {
font-size: 30px;
text-align: center;
}
.dates {
color: #aaaaaa;
font-size: 20px;
text-align: center;
}
.body-message {
font-size: 20px;
text-align: center;
width: 80%;
margin-left: auto;
margin-right: auto;
}
/* -------------------------------------
MEDIA SECTIONS
------------------------------------- */
h2 {
font-size: 30px;
font-weight: 300;
margin: 20px 10px 0 10px;
text-align: center;
}
h3 {
font-size: 25px;
font-weight: 300;
margin: 0 0 10px 0;
}
.sub-header-icon {
height: 30px;
width: 30px;
vertical-align: middle;
margin-right: 5px;
margin-bottom: 5px;
}
.sub-header-bar {
margin-left: auto;
margin-right: auto;
font-size: 30px;
text-align: center;
width: 200px;
border-top: 1px solid #E5A00D;
margin-top: 15px;
margin-bottom: 25px;
}
.sub-header-title {
margin-left: auto;
margin-right: auto;
font-size: 30px;
text-align: center;
font-weight: lighter;
}
.sub-header-count {
margin-left: auto;
margin-right: auto;
font-size: 30px;
text-align: center;
}
.sub-header-count .count {
color: #E5A00D;
}
.sub-header-count .count-units {
color: #aaaaaa;
font-size: 20px;
text-transform: uppercase;
}
/* -------------------------------------
MEDIA CARDS
------------------------------------- */
.card-instance {
font-size: 12px;
overflow: hidden;
padding: 3px;
width: 502px;
min-width: 502px;
max-width: 502px;
}
.card-instance.pad {
padding: 0 !important;
width: 251px !important;
min-width: 251px !important;
max-width: 251px !important;
}
.card-instance.movie,
.card-instance.show {
height: 233px;
}
.card-instance.album {
height: 158px;
}
.card-background {
background-color: #282828;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
background-clip: padding-box;
border: 1px solid rgba(255,255,255,.1);
}
.card-poster-container {
width: 152px;
min-width: 152px;
}
.card-instance.movie .card-poster-container,
.card-instance.show .card-poster-container{
height: 227px;
}
.card-instance.album .card-poster-container {
height: 152px;
}
.card-poster {
background-color: #3F4245;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
background-clip: padding-box;
border: 1px solid rgba(255,255,255,.1);
}
.card-poster-overlay {
display: block;
}
.card-info-container {
padding-left: 4px;
text-align: left;
}
.card-instance.movie .card-info-container,
.card-instance.show .card-info-container{
height: 227px;
}
.card-instance.album .card-info-container {
height: 152px;
}
.card-info-container .card-info-container-table {
height: 100%;
}
.card-info-title {
border-bottom: 1px solid rgba(255, 255, 255, .1);
line-height: 1.2rem;
font-size: 0.9rem;
padding: 5px;
}
.card-info-title a {
text-decoration: none;
color: #ffffff;
}
.card-info-body {
font-size: 0.75rem;
padding: 5px;
height: 100%;
}
.card-info-body > p {
max-width: 325px;
color: #ffffff;
}
.card-instance.movie .card-info-body,
.card-instance.show .card-info-body {
}
.card-instance.album .card-info-body {
height: 82px;
min-height: 82px;
}
.card-info-footer {
font-size: 0.6rem;
padding-top: 0px;
padding-right: 5px;
padding-bottom: 5px;
padding-left: 5px;
}
.card-info-footer .badge-container {
max-width: 260px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-info-footer .star-rating-container {
width: 65px;
}
.card-info-footer .star-rating {
margin-left: 4px;
font-size: 0.8rem;
line-height: 1rem;
width: 0.5rem;
display: inline-block;
vertical-align: bottom;
}
.star-rating.full {
color: #E5A00D;
}
.star-rating.empty {
color: #aaaaaa;
}
.badge {
display: inline-block;
min-width: 10px;
margin-right: 4px;
padding: 3px 7px;
font-size: 11px;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: middle;
background-color: rgba(0, 0, 0, .25);
border-radius: 2px;
text-overflow: ellipsis;
overflow: hidden;
color: #ffffff;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 1040px) {
.card-instance {
display: block !important;
margin-top: 0 !important;
margin-right: auto !important;
margin-bottom: 0 !important;
margin-left: auto !important;
}
table[class=body] .header {
height: 75px !important;
}
table[class=body] .header-img {
width: 410px;
height: 75px;
margin-left: -30px;
}
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] .server-name {
font-size: 20px !important;
}
table[class=body] .dates,
table[class=body] .body-message {
font-size: 14px !important;
}
table[class=body] .sub-header > div {
font-size: 20px !important;
}
table[class=body] .sub-header-title {
font-size: 20px !important;
}
table[class=body] .sub-header-icon {
height: 20px !important;
width: 20px !important;
}
table[class=body] .count {
font-size: 20px !important;
}
table[class=body] .count-units {
font-size: 14px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
}
</style>
</head>
<body style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;-webkit-font-smoothing: antialiased;font-size: 14px;line-height: 1.4;margin: 0;padding: 0;-ms-text-size-adjust: 100%;-webkit-text-size-adjust: 100%;">
% if preview and service and service != 'self-hosted':
<div class="local-preview-note" style="text-align: center;padding-top: 10px;"><p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;color: #282A2D;font-size: 12px;">Note: Local preview images only - images will be uploaded to ${service.capitalize()} when the newsletter is sent.</p></div> <!-- IGNORE SAVE -->
% elif preview and not service:
<div class="local-preview-note" style="text-align: center;padding-top: 10px;"><p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;color: #282A2D;font-size: 12px;">Warning: The Image Hosting setting must be enabled for images to display on the newsletter.</p></div> <!-- IGNORE SAVE -->
% endif
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
<td class="container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;display: block;max-width: 1042px;padding: 10px;width: 1042px;margin: 0 auto !important;">
<div class="content" style="box-sizing: border-box;display: block;margin: 0 auto;max-width: 1037px;padding: 10px;">
<span class="preheader" style="color: transparent;display: none;height: 0;max-height: 0;max-width: 0;opacity: 0;overflow: hidden;mso-hide: all;visibility: hidden;width: 0;">Tautulli Newsletter - ${subject}</span>
% if base_url and not preview:
<div class="view-full" style="clear: both;color: #282A2D;font-size: 12px;margin-bottom: 10px;text-align: center;width: 100%;"> <!-- IGNORE SAVE -->
<a href="${base_url + uuid}" title="View full newsletter" target="_blank" style="text-decoration: underline;color: #282A2D;">Click here to view the full newsletter.</a> <!-- IGNORE SAVE -->
</div> <!-- IGNORE SAVE -->
% endif
<table border="0" cellpadding="3" cellspacing="0" class="main" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background: #282A2D;border-radius: 3px;color: #ffffff;">
<tr>
<td class="wrapper" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;box-sizing: border-box;padding: 5px;overflow: auto;">
<div class="header" style="width: 100%;height: 90px;text-align: center;">
<img src="${base_url_image + 'images/newsletter/newsletter-header.png' if base_url_image else 'http://tautulli.com/images/newsletter/newsletter-header.png'}" class="header-img" width="492" height="90" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;width: 492px;height: 90px;margin-left: -35px;">
</div>
<div class="server-name" style="font-size: 30px;text-align: center;">${parameters['server_name']}</div>
<div class="dates" style="color: #aaaaaa;font-size: 20px;text-align: center;">${parameters['start_date']} - ${parameters['end_date']}</div>
</td>
</tr>
% if message:
<tr>
<td class="wrapper" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;box-sizing: border-box;padding: 5px;overflow: auto;">
<div class="sub-header-bar" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;width: 200px;border-top: 1px solid #E5A00D;margin-top: 15px;margin-bottom: 25px;"></div>
<div class="body-message" style="font-size: 20px;text-align: center;width: 80%;margin-left: auto;margin-right: auto;">${'<br>'.join(l for l in message.splitlines()) | n}</div>
</td>
</tr>
% endif
% if recently_added.get('movie'):
<tr>
<td class="wrapper" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;box-sizing: border-box;padding: 5px;overflow: auto;">
<div class="sub-header-bar" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;width: 200px;border-top: 1px solid #E5A00D;margin-top: 15px;margin-bottom: 25px;"></div>
<div class="sub-header-title" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;font-weight: lighter;">
<img src="${(base_url_image + 'images/libraries/movie.png') if base_url_image else 'http://tautulli.com/images/libraries/movie.png'}" class="sub-header-icon" width="30" height="30" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;height: 30px;width: 30px;vertical-align: middle;margin-right: 5px;margin-bottom: 5px;"> Recently Added Movies
</div>
<div class="sub-header-count" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;">
<span class="count" style="color: #E5A00D;">${len(recently_added['movie'])}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">movie${'s' if len(recently_added['movie']) > 1 else ''}</span>
</div>
</td>
</tr>
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
% for movie_a, movie_b in grouper(recently_added['movie'], 2):
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
% for movie in (movie_a, movie_b):
% if movie:
% if not movie_b:
<td align="center" valign="top" class="card-instance pad" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 0 !important;width: 251px !important;min-width: 251px !important;max-width: 251px !important;"></td>
% endif
<td align="center" valign="top" class="card-instance movie" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 3px;width: 502px;min-width: 502px;max-width: 502px;height: 233px;">
<table border="0" cellpadding="0" cellspacing="3" width="100%" class="card-background" style="background-image: url(${(base_url_image + movie['art_hash']) if base_url_image else movie['art_url']});border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background-color: #282828;background-position: center;background-size: cover;background-repeat: no-repeat;background-clip: padding-box;border: 1px solid rgba(255,255,255,.1);">
<tr>
<td class="card-poster-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;width: 152px;min-width: 152px;height: 227px;">
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + movie['thumb_hash']) if base_url_image else movie['thumb_url']});border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background-color: #3F4245;background-position: center;background-size: cover;background-repeat: no-repeat;background-clip: padding-box;border: 1px solid rgba(255,255,255,.1);">
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${movie['rating_key']}" title="${movie['title']}" target="_blank" style="text-decoration: underline;">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-poster.png' if base_url_image else 'http://tautulli.com/images/newsletter/view-on-plex-poster.png'}" width="150" height="225" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;display: block;">
</a>
</td>
</tr>
</table>
</td>
<td class="card-info-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;padding-left: 4px;text-align: left;height: 227px;">
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;height: 100%;">
<tr>
<td class="card-info-title nowrap" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.9rem;vertical-align: top;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;border-bottom: 1px solid rgba(255, 255, 255, .1);line-height: 1.2rem;padding: 5px;max-width: 320px;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${movie['rating_key']}" title="${movie['title']}" target="_blank" style="text-decoration: none;color: #ffffff;">${movie['title']}</a>
</td>
</tr>
<tr>
<td class="card-info-body" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.75rem;vertical-align: top;padding: 5px;height: 100%;">
% if movie['tagline']:
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;color: #ffffff;">
<em>${movie['tagline']}</em>
</p>
% endif
<p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;color: #ffffff;">
${movie['summary'][:450] + (movie['summary'][450:] and '...')}
</p>
</td>
</tr>
<tr>
<td class="card-info-footer nowrap" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.6rem;vertical-align: top;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;padding-top: 0px;padding-right: 5px;padding-bottom: 5px;padding-left: 5px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
<td class="badge-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;max-width: 260px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
% if movie['year']:
<td class="badge" title="${movie['year']}" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 11px;vertical-align: middle;display: inline-block;min-width: 10px;margin-right: 4px;padding: 3px 7px;line-height: 1;text-align: center;white-space: nowrap;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${movie['year']}</td>
% endif
% if movie['duration']:
<% duration = int(int(movie['duration'])/60000) %>
<td class="badge" title="${duration} mins" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 11px;vertical-align: middle;display: inline-block;min-width: 10px;margin-right: 4px;padding: 3px 7px;line-height: 1;text-align: center;white-space: nowrap;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${duration} mins</td>
% endif
% if movie['genres']:
% for genre in movie['genres'][:]:
<td class="badge" title="${genre}" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 11px;vertical-align: middle;display: inline-block;min-width: 10px;margin-right: 4px;padding: 3px 7px;line-height: 1;text-align: center;white-space: nowrap;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${genre}</td>
% endfor
% endif
</tr>
</table>
</td>
% if movie['rating']:
<% rating = int(round(float(movie['rating']) / 2)) %>
<td class="star-rating-container" title="${int(float(movie['rating'])/0.1)}%" align="right" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;width: 65px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
% for _ in range(rating):
<td class="star-rating full" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.8rem;vertical-align: bottom;margin-left: 4px;line-height: 1rem;width: 0.5rem;display: inline-block;color: #E5A00D;">&#9733;</td>
% endfor
% for _ in range(5-rating):
<td class="star-rating empty" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.8rem;vertical-align: bottom;margin-left: 4px;line-height: 1rem;width: 0.5rem;display: inline-block;color: #aaaaaa;">&#9734;</td>
% endfor
</tr>
</table>
</td>
% endif
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
% if not movie_b:
<td align="center" valign="top" class="card-instance pad" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 0 !important;width: 251px !important;min-width: 251px !important;max-width: 251px !important;"></td>
% endif
% endif
% endfor
</tr>
</table>
</td>
</tr>
% endfor
</table>
</td>
</tr>
% endif
% if recently_added.get('show'):
<tr>
<td class="wrapper" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;box-sizing: border-box;padding: 5px;overflow: auto;">
<div class="sub-header-bar" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;width: 200px;border-top: 1px solid #E5A00D;margin-top: 15px;margin-bottom: 25px;"></div>
<div class="sub-header-title" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;font-weight: lighter;">
<img src="${(base_url_image + 'images/libraries/show.png') if base_url_image else 'http://tautulli.com/images/libraries/show.png'}" class="sub-header-icon" width="30" height="30" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;height: 30px;width: 30px;vertical-align: middle;margin-right: 5px;margin-bottom: 5px;"> Recently Added TV Shows
</div>
<div class="sub-header-count" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;">
<span class="count" style="color: #E5A00D;">${len(recently_added['show'])}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">show${'s' if len(recently_added['show']) > 1 else ''}</span> /
<% total_episodes = sum(season['episode_count'] for show in recently_added['show'] for season in show['season']) %>
<span class="count" style="color: #E5A00D;">${total_episodes}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">episode${'s' if total > 1 else ''}</span>
</div>
</td>
</tr>
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
% for show_a, show_b in grouper(recently_added['show'], 2):
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
% for show in (show_a, show_b):
% if show:
% if not show_b:
<td align="center" valign="top" class="card-instance pad" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 0 !important;width: 251px !important;min-width: 251px !important;max-width: 251px !important;"></td>
% endif
<%
if show['season_count'] == 1:
if show['season'][0]['episode_count'] == 1:
link_rating_key = show['season'][0]['episode'][0]['rating_key']
else:
link_rating_key = show['season'][0]['episode'][0]['parent_rating_key']
else:
link_rating_key = show['rating_key']
%>
<td align="center" valign="top" class="card-instance show" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 3px;width: 502px;min-width: 502px;max-width: 502px;height: 233px;">
<table border="0" cellpadding="0" cellspacing="3" width="100%" class="card-background" style="background-image: url(${(base_url_image + show['art_hash']) if base_url_image else show['art_url']});border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background-color: #282828;background-position: center;background-size: cover;background-repeat: no-repeat;background-clip: padding-box;border: 1px solid rgba(255,255,255,.1);">
<tr>
<td class="card-poster-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;width: 152px;min-width: 152px;height: 227px;">
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + show['thumb_hash']) if base_url_image else show['thumb_url']});border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background-color: #3F4245;background-position: center;background-size: cover;background-repeat: no-repeat;background-clip: padding-box;border: 1px solid rgba(255,255,255,.1);">
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${link_rating_key}" title="${show['title']}" target="_blank" style="text-decoration: underline;">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-poster.png' if base_url_image else 'http://tautulli.com/images/newsletter/view-on-plex-poster.png'}" width="150" height="225" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;display: block;">
</a>
</td>
</tr>
</table>
</td>
<td class="card-info-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;padding-left: 4px;text-align: left;height: 227px;">
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;height: 100%;">
<tr>
<td class="card-info-title nowrap" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.9rem;vertical-align: top;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;border-bottom: 1px solid rgba(255, 255, 255, .1);line-height: 1.2rem;padding: 5px;max-width: 320px;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${link_rating_key}" title="${show['title']}" target="_blank" style="text-decoration: none;color: #ffffff;">${show['title']}</a>
</td>
</tr>
<tr>
<td class="card-info-body" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.75rem;vertical-align: top;padding: 5px;height: 100%;">
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;color: #ffffff;">
% if show['season_count'] > 1:
<em>${show['season_count']} seasons /</em>
% endif
<% total_show_episodes = sum(s['episode_count'] for s in show['season']) %>
<em>${total_show_episodes} episode${'s' if total_show_episodes > 1 else ''}</em>
</p>
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;color: #ffffff;">
% for i, season in enumerate(show['season'][:8]):
% if season['episode_count'] == 1:
Season ${season['media_index']} &middot; Episode ${season['episode'][0]['media_index']} - ${season['episode'][0]['title']}
% else:
Season ${season['media_index']} &middot; Episodes ${season['episode_range']}
% endif
% if i < min(show['season_count'], 7):
<br>
% elif i == 7 and show['season_count'] > 8:
...plus ${show['season_count'] - 8} more seasons!
% endif
% endfor
</p>
<p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;color: #ffffff;">
% if show['season_count'] == 1 and show['season'][0]['episode_count'] == 1:
${show['season'][0]['episode'][0]['summary'][:350] + (show['season'][0]['episode'][0]['summary'][350:] and '...')}
% else:
<% length = max(0, 350 - 50 * (show['season_count'] - 1)) %>
% if length:
${show['summary'][:length] + (show['summary'][length:] and '...')}
% endif
% endif
</p>
</td>
</tr>
<tr>
<td class="card-info-footer nowrap" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.6rem;vertical-align: top;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;padding-top: 0px;padding-right: 5px;padding-bottom: 5px;padding-left: 5px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
<td class="badge-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;max-width: 260px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
% if show['year']:
<td class="badge" title="${show['year']}" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 11px;vertical-align: middle;display: inline-block;min-width: 10px;margin-right: 4px;padding: 3px 7px;line-height: 1;text-align: center;white-space: nowrap;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${show['year']}</td>
% endif
% if show['duration']:
<% duration = int(int(show['duration'])/60000) %>
<td class="badge" title="${duration} mins" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 11px;vertical-align: middle;display: inline-block;min-width: 10px;margin-right: 4px;padding: 3px 7px;line-height: 1;text-align: center;white-space: nowrap;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${duration} mins</td>
% endif
% if show['genres']:
% for genre in show['genres'][:2]:
<td class="badge" title="${genre}" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 11px;vertical-align: middle;display: inline-block;min-width: 10px;margin-right: 4px;padding: 3px 7px;line-height: 1;text-align: center;white-space: nowrap;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${genre}</td>
% endfor
% endif
</tr>
</table>
</td>
% if show['rating']:
<% rating = int(round(float(show['rating']) / 2)) %>
<td class="star-rating-container" title="${int(float(show['rating'])/0.1)}%" align="right" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;width: 65px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
% for _ in range(rating):
<td class="star-rating full" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.8rem;vertical-align: bottom;margin-left: 4px;line-height: 1rem;width: 0.5rem;display: inline-block;color: #E5A00D;">&#9733;</td>
% endfor
% for _ in range(5-rating):
<td class="star-rating empty" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.8rem;vertical-align: bottom;margin-left: 4px;line-height: 1rem;width: 0.5rem;display: inline-block;color: #aaaaaa;">&#9734;</td>
% endfor
</tr>
</table>
</td>
% endif
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
% if not show_b:
<td align="center" valign="top" class="card-instance pad" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 0 !important;width: 251px !important;min-width: 251px !important;max-width: 251px !important;"></td>
% endif
% endif
% endfor
</tr>
</table>
</td>
</tr>
% endfor
</table>
</td>
</tr>
% endif
% if recently_added.get('artist'):
<tr>
<td class="wrapper" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;box-sizing: border-box;padding: 5px;overflow: auto;">
<div class="sub-header-bar" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;width: 200px;border-top: 1px solid #E5A00D;margin-top: 15px;margin-bottom: 25px;"></div>
<div class="sub-header-title" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;font-weight: lighter;">
<img src="${(base_url_image + 'images/libraries/artist.png') if base_url_image else 'http://tautulli.com/images/libraries/artist.png'}" class="sub-header-icon" width="30" height="30" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;height: 30px;width: 30px;vertical-align: middle;margin-right: 5px;margin-bottom: 5px;"> Recently Added Music
</div>
<div class="sub-header-count" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;">
<span class="count" style="color: #E5A00D;">${len(recently_added['artist'])}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">artist${'s' if len(recently_added['artist']) > 1 else ''}</span> /
<% total_albums = sum(artist['album_count'] for artist in recently_added['artist']) %>
<span class="count" style="color: #E5A00D;">${total_albums}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">album${'s' if total > 1 else ''}</span>
</div>
</td>
</tr>
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
% for album_a, album_b in grouper([a for artist in recently_added['artist'] for a in artist['album']], 2):
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
% for album in (album_a, album_b):
% if album:
% if not album_b:
<td align="center" valign="top" class="card-instance pad" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 0 !important;width: 251px !important;min-width: 251px !important;max-width: 251px !important;"></td>
% endif
<td align="center" valign="top" class="card-instance album" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 3px;width: 502px;min-width: 502px;max-width: 502px;height: 158px;">
<table border="0" cellpadding="0" cellspacing="3" width="100%" class="card-background" style="background-image: url(${(base_url_image + album['art_hash']) if base_url_image else album['art_url']});border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background-color: #282828;background-position: center;background-size: cover;background-repeat: no-repeat;background-clip: padding-box;border: 1px solid rgba(255,255,255,.1);">
<tr>
<td class="card-poster-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;width: 152px;min-width: 152px;height: 152px;">
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + album['thumb_hash']) if base_url_image else album['thumb_url']});border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background-color: #3F4245;background-position: center;background-size: cover;background-repeat: no-repeat;background-clip: padding-box;border: 1px solid rgba(255,255,255,.1);">
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${album['rating_key']}" title="${album['title']}" target="_blank" style="text-decoration: underline;">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-cover.png' if base_url_image else 'http://tautulli.com/images/newsletter/view-on-plex-cover.png'}" width="150" height="150" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;display: block;">
</a>
</td>
</tr>
</table>
</td>
<td class="card-info-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;padding-left: 4px;text-align: left;height: 152px;">
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;height: 100%;">
<tr>
<td class="card-info-title nowrap" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.9rem;vertical-align: top;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;border-bottom: 1px solid rgba(255, 255, 255, .1);line-height: 1.2rem;padding: 5px;max-width: 320px;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${album['rating_key']}" title="${album['title']}" target="_blank" style="text-decoration: none;color: #ffffff;">${album['title']}</a>
</td>
</tr>
<tr>
<td class="card-info-body" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.75rem;vertical-align: top;padding: 5px;height: 82px;min-height: 82px;">
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;color: #ffffff;">
<em>${album['parent_title']} &middot; ${album['track_count']} track${'s' if album['track_count'] > 1 else ''}</em>
</p>
% if album['parent_title'].lower() != 'various artists':
<p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;color: #ffffff;">
${album['summary'][:200] + (album['summary'][200:] and '...')}
</p>
% endif
</td>
</tr>
<tr>
<td class="card-info-footer nowrap" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.6rem;vertical-align: top;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;padding-top: 0px;padding-right: 5px;padding-bottom: 5px;padding-left: 5px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
<td class="badge-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;max-width: 260px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
% if album['year']:
<td class="badge" title="${album['year']}" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 11px;vertical-align: middle;display: inline-block;min-width: 10px;margin-right: 4px;padding: 3px 7px;line-height: 1;text-align: center;white-space: nowrap;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${album['year']}</td>
% endif
% if album['genres']:
% for genre in album['genres'][:2]:
<td class="badge" title="${genre}" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 11px;vertical-align: middle;display: inline-block;min-width: 10px;margin-right: 4px;padding: 3px 7px;line-height: 1;text-align: center;white-space: nowrap;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${genre}</td>
% endfor
% endif
</tr>
</table>
</td>
% if album['rating']:
<% rating = int(round(float(album['rating']) / 2)) %>
<td class="star-rating-container" title="${int(float(album['rating'])/0.1)}%" align="right" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;width: 65px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
% for _ in range(rating):
<td class="star-rating full" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.8rem;vertical-align: bottom;margin-left: 4px;line-height: 1rem;width: 0.5rem;display: inline-block;color: #E5A00D;">&#9733;</td>
% endfor
% for _ in range(5-rating):
<td class="star-rating empty" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.8rem;vertical-align: bottom;margin-left: 4px;line-height: 1rem;width: 0.5rem;display: inline-block;color: #aaaaaa;">&#9734;</td>
% endfor
</tr>
</table>
</td>
% endif
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
% if not album_b:
<td align="center" valign="top" class="card-instance pad" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 0 !important;width: 251px !important;min-width: 251px !important;max-width: 251px !important;"></td>
% endif
% endif
% endfor
</tr>
</table>
</td>
</tr>
% endfor
</table>
</td>
</tr>
% endif
<tr>
<td class="footer" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;clear: both;margin-top: 10px;text-align: center;width: 100%;">
<div class="footer-bar" style="margin-left: auto;margin-right: auto;width: 200px;border-top: 1px solid #E5A00D;margin-top: 25px;"></div>
<div class="content-block powered-by" style="padding-bottom: 10px;padding-top: 10px;">
<!-- FOOTER MESSAGE - DO NOT REMOVE -->
</div>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</body>
</html>
% endif

View File

@@ -0,0 +1,970 @@
% if data:
<%
import plexpy
from plexpy.helpers import grouper, get_img_service
recently_added = data['recently_added']
if plexpy.CONFIG.NEWSLETTER_SELF_HOSTED and plexpy.CONFIG.HTTP_BASE_URL:
base_url = plexpy.CONFIG.HTTP_BASE_URL + plexpy.HTTP_ROOT + 'newsletter/'
elif preview:
base_url = 'newsletter/'
else:
base_url = ''
service = get_img_service(include_self=True)
if service == 'self-hosted' and plexpy.CONFIG.HTTP_BASE_URL:
base_url_image = plexpy.CONFIG.HTTP_BASE_URL + plexpy.HTTP_ROOT + 'image/'
elif preview and service and service != 'self-hosted':
base_url_image = 'image/'
else:
base_url_image = ''
%>
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width"/>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<title>Tautulli Newsletter - ${subject}</title>
<style>
/* -------------------------------------
GLOBAL RESETS
------------------------------------- */
img {
border: none;
-ms-interpolation-mode: bicubic;
max-width: 100%;
}
body {
font-family: 'Open Sans', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
}
table td {
font-family: 'Open Sans', Helvetica, Arial, sans-serif;
font-size: 14px;
vertical-align: top;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
.body {
width: 100%;
}
.container {
display: block;
margin: 0 auto !important;
max-width: 1042px;
padding: 10px;
width: 1042px;
}
.content {
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 1037px;
padding: 10px;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.local-preview-note {
text-align: center;
padding-top: 10px;
}
.local-preview-note p {
color: #282A2D;
font-size: 12px;
}
.main {
background: #282A2D;
border-radius: 3px;
width: 100%;
color: #ffffff;
}
.wrapper {
box-sizing: border-box;
padding: 5px;
overflow: auto;
}
.content-block {
padding-bottom: 10px;
padding-top: 10px;
}
.footer {
clear: both;
margin-top: 10px;
text-align: center;
width: 100%;
font-size: 12px;
}
.footer-bar {
margin-left: auto;
margin-right: auto;
width: 200px;
border-top: 1px solid #E5A00D;
margin-top: 25px;
}
.footer td,
.footer p,
.footer span,
.footer a {
color: #fff;
font-size: 12px;
text-align: center;
}
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1,
h2,
h3,
h4 {
color: #ffffff;
font-family: 'Open Sans', Helvetica, Arial, sans-serif;
font-weight: 400;
margin: 0;
}
p,
ul,
ol {
font-family: 'Open Sans', Helvetica, Arial, sans-serif;
font-weight: 400;
margin: 0;
}
p li,
ul li,
ol li {
list-style-position: inside;
margin-left: 5px;
}
a {
text-decoration: underline;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.mb5 {
margin-bottom: 5px;
}
.nowrap {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
}
.view-full {
clear: both;
color: #282A2D;
font-size: 12px;
margin-bottom: 10px;
text-align: center;
width: 100%;
}
.view-full a {
color: #282A2D;
}
.powered-by a {
text-decoration: underline;
}
/* -------------------------------------
HEADER
------------------------------------- */
.header {
width: 100%;
height: 90px;
text-align: center;
}
.header-img {
width: 492px;
height: 90px;
margin-left: -35px;
}
.server-name {
font-size: 30px;
text-align: center;
}
.dates {
color: #aaaaaa;
font-size: 20px;
text-align: center;
}
.body-message {
font-size: 20px;
text-align: center;
width: 80%;
margin-left: auto;
margin-right: auto;
}
/* -------------------------------------
MEDIA SECTIONS
------------------------------------- */
h2 {
font-size: 30px;
font-weight: 300;
margin: 20px 10px 0 10px;
text-align: center;
}
h3 {
font-size: 25px;
font-weight: 300;
margin: 0 0 10px 0;
}
.sub-header-icon {
height: 30px;
width: 30px;
vertical-align: middle;
margin-right: 5px;
margin-bottom: 5px;
}
.sub-header-bar {
margin-left: auto;
margin-right: auto;
font-size: 30px;
text-align: center;
width: 200px;
border-top: 1px solid #E5A00D;
margin-top: 15px;
margin-bottom: 25px;
}
.sub-header-title {
margin-left: auto;
margin-right: auto;
font-size: 30px;
text-align: center;
font-weight: lighter;
}
.sub-header-count {
margin-left: auto;
margin-right: auto;
font-size: 30px;
text-align: center;
}
.sub-header-count .count {
color: #E5A00D;
}
.sub-header-count .count-units {
color: #aaaaaa;
font-size: 20px;
text-transform: uppercase;
}
/* -------------------------------------
MEDIA CARDS
------------------------------------- */
.card-instance {
font-size: 12px;
overflow: hidden;
padding: 3px;
width: 502px;
min-width: 502px;
max-width: 502px;
}
.card-instance.pad {
padding: 0 !important;
width: 251px !important;
min-width: 251px !important;
max-width: 251px !important;
}
.card-instance.movie,
.card-instance.show {
height: 233px;
}
.card-instance.album {
height: 158px;
}
.card-background {
background-color: #282828;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
background-clip: padding-box;
border: 1px solid rgba(255,255,255,.1);
}
.card-poster-container {
width: 152px;
min-width: 152px;
}
.card-instance.movie .card-poster-container,
.card-instance.show .card-poster-container{
height: 227px;
}
.card-instance.album .card-poster-container {
height: 152px;
}
.card-poster {
background-color: #3F4245;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
background-clip: padding-box;
border: 1px solid rgba(255,255,255,.1);
}
.card-poster-overlay {
display: block;
}
.card-info-container {
padding-left: 4px;
text-align: left;
}
.card-instance.movie .card-info-container,
.card-instance.show .card-info-container{
height: 227px;
}
.card-instance.album .card-info-container {
height: 152px;
}
.card-info-container .card-info-container-table {
height: 100%;
}
.card-info-title {
border-bottom: 1px solid rgba(255, 255, 255, .1);
line-height: 1.2rem;
font-size: 0.9rem;
padding: 5px;
max-width: 320px;
}
.card-info-title a {
text-decoration: none;
color: #ffffff;
}
.card-info-body {
font-size: 0.75rem;
padding: 5px;
height: 100%;
}
.card-info-body > p {
max-width: 325px;
color: #ffffff;
}
.card-instance.movie .card-info-body,
.card-instance.show .card-info-body {
}
.card-instance.album .card-info-body {
height: 82px;
min-height: 82px;
}
.card-info-footer {
font-size: 0.6rem;
padding-top: 0px;
padding-right: 5px;
padding-bottom: 5px;
padding-left: 5px;
}
.card-info-footer .badge-container {
max-width: 260px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-info-footer .star-rating-container {
width: 65px;
}
.card-info-footer .star-rating {
margin-left: 4px;
font-size: 0.8rem;
line-height: 1rem;
width: 0.5rem;
display: inline-block;
vertical-align: bottom;
}
.star-rating.full {
color: #E5A00D;
}
.star-rating.empty {
color: #aaaaaa;
}
.badge {
display: inline-block;
min-width: 10px;
margin-right: 4px;
padding: 3px 7px;
font-size: 11px;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: middle;
background-color: rgba(0, 0, 0, .25);
border-radius: 2px;
text-overflow: ellipsis;
overflow: hidden;
color: #ffffff;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 1040px) {
.card-instance {
display: block !important;
margin-top: 0 !important;
margin-right: auto !important;
margin-bottom: 0 !important;
margin-left: auto !important;
}
table[class=body] .header {
height: 75px !important;
}
table[class=body] .header-img {
width: 410px;
height: 75px;
margin-left: -30px;
}
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] .server-name {
font-size: 20px !important;
}
table[class=body] .dates,
table[class=body] .body-message {
font-size: 14px !important;
}
table[class=body] .sub-header > div {
font-size: 20px !important;
}
table[class=body] .sub-header-title {
font-size: 20px !important;
}
table[class=body] .sub-header-icon {
height: 20px !important;
width: 20px !important;
}
table[class=body] .count {
font-size: 20px !important;
}
table[class=body] .count-units {
font-size: 14px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
}
</style>
</head>
<body>
% if preview and service and service != 'self-hosted':
<div class="local-preview-note"><p>Note: Local preview images only - images will be uploaded to ${service.capitalize()} when the newsletter is sent.</p></div> <!-- IGNORE SAVE -->
% elif preview and not service:
<div class="local-preview-note"><p>Warning: The Image Hosting setting must be enabled for images to display on the newsletter.</p></div> <!-- IGNORE SAVE -->
% endif
<table border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td class="container">
<div class="content">
<span class="preheader">Tautulli Newsletter - ${subject}</span>
% if base_url and not preview:
<div class="view-full"> <!-- IGNORE SAVE -->
<a href="${base_url + uuid}" title="View full newsletter" target="_blank">Click here to view the full newsletter.</a> <!-- IGNORE SAVE -->
</div> <!-- IGNORE SAVE -->
% endif
<table border="0" cellpadding="3" cellspacing="0" class="main">
<tr>
<td class="wrapper">
<div class="header">
<img src="${base_url_image + 'images/newsletter/newsletter-header.png' if base_url_image else 'http://tautulli.com/images/newsletter/newsletter-header.png'}" class="header-img" width="492" height="90"/>
</div>
<div class="server-name">${parameters['server_name']}</div>
<div class="dates">${parameters['start_date']} - ${parameters['end_date']}</div>
</td>
</tr>
% if message:
<tr>
<td class="wrapper">
<div class="sub-header-bar"></div>
<div class="body-message">${'<br>'.join(l for l in message.splitlines()) | n}</div>
</td>
</tr>
% endif
% if recently_added.get('movie'):
<tr>
<td class="wrapper">
<div class="sub-header-bar"></div>
<div class="sub-header-title">
<img src="${(base_url_image + 'images/libraries/movie.png') if base_url_image else 'http://tautulli.com/images/libraries/movie.png'}" class="sub-header-icon" width="30" height="30"/> Recently Added Movies
</div>
<div class="sub-header-count">
<span class="count">${len(recently_added['movie'])}</span> <span class="count-units">movie${'s' if len(recently_added['movie']) > 1 else ''}</span>
</div>
</td>
</tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0">
% for movie_a, movie_b in grouper(recently_added['movie'], 2):
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
% for movie in (movie_a, movie_b):
% if movie:
% if not movie_b:
<td align="center" valign="top" class="card-instance pad"></td>
% endif
<td align="center" valign="top" class="card-instance movie">
<table border="0" cellpadding="0" cellspacing="3" width="100%" class="card-background" style="background-image: url(${(base_url_image + movie['art_hash']) if base_url_image else movie['art_url']});">
<tr>
<td class="card-poster-container">
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + movie['thumb_hash']) if base_url_image else movie['thumb_url']})">
<tr>
<td>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${movie['rating_key']}" title="${movie['title']}" target="_blank">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-poster.png' if base_url_image else 'http://tautulli.com/images/newsletter/view-on-plex-poster.png'}" width="150" height="225">
</a>
</td>
</tr>
</table>
</td>
<td class="card-info-container">
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
<tr>
<td class="card-info-title nowrap">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${movie['rating_key']}" title="${movie['title']}" target="_blank">${movie['title']}</a>
</td>
</tr>
<tr>
<td class="card-info-body">
% if movie['tagline']:
<p class="nowrap mb5">
<em>${movie['tagline']}</em>
</p>
% endif
<p>
${movie['summary'][:450] + (movie['summary'][450:] and '...')}
</p>
</td>
</tr>
<tr>
<td class="card-info-footer nowrap">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="badge-container">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
% if movie['year']:
<td class="badge" title="${movie['year']}">${movie['year']}</td>
% endif
% if movie['duration']:
<% duration = int(int(movie['duration'])/60000) %>
<td class="badge" title="${duration} mins">${duration} mins</td>
% endif
% if movie['genres']:
% for genre in movie['genres'][:]:
<td class="badge" title="${genre}">${genre}</td>
% endfor
% endif
</tr>
</table>
</td>
% if movie['rating']:
<% rating = int(round(float(movie['rating']) / 2)) %>
<td class="star-rating-container" title="${int(float(movie['rating'])/0.1)}%" align="right">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
% for _ in range(rating):
<td class="star-rating full">&#9733;</td>
% endfor
% for _ in range(5-rating):
<td class="star-rating empty">&#9734;</td>
% endfor
</tr>
</table>
</td>
% endif
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
% if not movie_b:
<td align="center" valign="top" class="card-instance pad"></td>
% endif
% endif
% endfor
</tr>
</table>
</td>
</tr>
% endfor
</table>
</td>
</tr>
% endif
% if recently_added.get('show'):
<tr>
<td class="wrapper">
<div class="sub-header-bar"></div>
<div class="sub-header-title">
<img src="${(base_url_image + 'images/libraries/show.png') if base_url_image else 'http://tautulli.com/images/libraries/show.png'}" class="sub-header-icon" width="30" height="30"/> Recently Added TV Shows
</div>
<div class="sub-header-count">
<span class="count">${len(recently_added['show'])}</span> <span class="count-units">show${'s' if len(recently_added['show']) > 1 else ''}</span> /
<% total_episodes = sum(season['episode_count'] for show in recently_added['show'] for season in show['season']) %>
<span class="count">${total_episodes}</span> <span class="count-units">episode${'s' if total > 1 else ''}</span>
</div>
</td>
</tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0">
% for show_a, show_b in grouper(recently_added['show'], 2):
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
% for show in (show_a, show_b):
% if show:
% if not show_b:
<td align="center" valign="top" class="card-instance pad"></td>
% endif
<%
if show['season_count'] == 1:
if show['season'][0]['episode_count'] == 1:
link_rating_key = show['season'][0]['episode'][0]['rating_key']
else:
link_rating_key = show['season'][0]['episode'][0]['parent_rating_key']
else:
link_rating_key = show['rating_key']
%>
<td align="center" valign="top" class="card-instance show">
<table border="0" cellpadding="0" cellspacing="3" width="100%" class="card-background" style="background-image: url(${(base_url_image + show['art_hash']) if base_url_image else show['art_url']});">
<tr>
<td class="card-poster-container">
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + show['thumb_hash']) if base_url_image else show['thumb_url']})">
<tr>
<td>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${link_rating_key}" title="${show['title']}" target="_blank">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-poster.png' if base_url_image else 'http://tautulli.com/images/newsletter/view-on-plex-poster.png'}" width="150" height="225">
</a>
</td>
</tr>
</table>
</td>
<td class="card-info-container">
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
<tr>
<td class="card-info-title nowrap">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${link_rating_key}" title="${show['title']}" target="_blank">${show['title']}</a>
</td>
</tr>
<tr>
<td class="card-info-body">
<p class="nowrap mb5">
% if show['season_count'] > 1:
<em>${show['season_count']} seasons /</em>
% endif
<% total_show_episodes = sum(s['episode_count'] for s in show['season']) %>
<em>${total_show_episodes} episode${'s' if total_show_episodes > 1 else ''}</em>
</p>
<p class="nowrap mb5">
% for i, season in enumerate(show['season'][:8]):
% if season['episode_count'] == 1:
Season ${season['media_index']} &middot; Episode ${season['episode'][0]['media_index']} - ${season['episode'][0]['title']}
% else:
Season ${season['media_index']} &middot; Episodes ${season['episode_range']}
% endif
% if i < min(show['season_count'], 7):
<br>
% elif i == 7 and show['season_count'] > 8:
...plus ${show['season_count'] - 8} more seasons!
% endif
% endfor
</p>
<p>
% if show['season_count'] == 1 and show['season'][0]['episode_count'] == 1:
${show['season'][0]['episode'][0]['summary'][:350] + (show['season'][0]['episode'][0]['summary'][350:] and '...')}
% else:
<% length = max(0, 350 - 50 * (show['season_count'] - 1)) %>
% if length:
${show['summary'][:length] + (show['summary'][length:] and '...')}
% endif
% endif
</p>
</td>
</tr>
<tr>
<td class="card-info-footer nowrap">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="badge-container">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
% if show['year']:
<td class="badge" title="${show['year']}">${show['year']}</td>
% endif
% if show['duration']:
<% duration = int(int(show['duration'])/60000) %>
<td class="badge" title="${duration} mins">${duration} mins</td>
% endif
% if show['genres']:
% for genre in show['genres'][:2]:
<td class="badge" title="${genre}">${genre}</td>
% endfor
% endif
</tr>
</table>
</td>
% if show['rating']:
<% rating = int(round(float(show['rating']) / 2)) %>
<td class="star-rating-container" title="${int(float(show['rating'])/0.1)}%" align="right">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
% for _ in range(rating):
<td class="star-rating full">&#9733;</td>
% endfor
% for _ in range(5-rating):
<td class="star-rating empty">&#9734;</td>
% endfor
</tr>
</table>
</td>
% endif
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
% if not show_b:
<td align="center" valign="top" class="card-instance pad"></td>
% endif
% endif
% endfor
</tr>
</table>
</td>
</tr>
% endfor
</table>
</td>
</tr>
% endif
% if recently_added.get('artist'):
<tr>
<td class="wrapper">
<div class="sub-header-bar"></div>
<div class="sub-header-title">
<img src="${(base_url_image + 'images/libraries/artist.png') if base_url_image else 'http://tautulli.com/images/libraries/artist.png'}" class="sub-header-icon" width="30" height="30"/> Recently Added Music
</div>
<div class="sub-header-count">
<span class="count">${len(recently_added['artist'])}</span> <span class="count-units">artist${'s' if len(recently_added['artist']) > 1 else ''}</span> /
<% total_albums = sum(artist['album_count'] for artist in recently_added['artist']) %>
<span class="count">${total_albums}</span> <span class="count-units">album${'s' if total > 1 else ''}</span>
</div>
</td>
</tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0">
% for album_a, album_b in grouper([a for artist in recently_added['artist'] for a in artist['album']], 2):
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
% for album in (album_a, album_b):
% if album:
% if not album_b:
<td align="center" valign="top" class="card-instance pad"></td>
% endif
<td align="center" valign="top" class="card-instance album">
<table border="0" cellpadding="0" cellspacing="3" width="100%" class="card-background" style="background-image: url(${(base_url_image + album['art_hash']) if base_url_image else album['art_url']});">
<tr>
<td class="card-poster-container">
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + album['thumb_hash']) if base_url_image else album['thumb_url']})">
<tr>
<td>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${album['rating_key']}" title="${album['title']}" target="_blank">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-cover.png' if base_url_image else 'http://tautulli.com/images/newsletter/view-on-plex-cover.png'}" width="150" height="150">
</a>
</td>
</tr>
</table>
</td>
<td class="card-info-container">
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
<tr>
<td class="card-info-title nowrap">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${album['rating_key']}" title="${album['title']}" target="_blank">${album['title']}</a>
</td>
</tr>
<tr>
<td class="card-info-body">
<p class="nowrap mb5">
<em>${album['parent_title']} &middot; ${album['track_count']} track${'s' if album['track_count'] > 1 else ''}</em>
</p>
% if album['parent_title'].lower() != 'various artists':
<p>
${album['summary'][:200] + (album['summary'][200:] and '...')}
</p>
% endif
</td>
</tr>
<tr>
<td class="card-info-footer nowrap">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="badge-container">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
% if album['year']:
<td class="badge" title="${album['year']}">${album['year']}</td>
% endif
% if album['genres']:
% for genre in album['genres'][:2]:
<td class="badge" title="${genre}">${genre}</td>
% endfor
% endif
</tr>
</table>
</td>
% if album['rating']:
<% rating = int(round(float(album['rating']) / 2)) %>
<td class="star-rating-container" title="${int(float(album['rating'])/0.1)}%" align="right">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
% for _ in range(rating):
<td class="star-rating full">&#9733;</td>
% endfor
% for _ in range(5-rating):
<td class="star-rating empty">&#9734;</td>
% endfor
</tr>
</table>
</td>
% endif
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
% if not album_b:
<td align="center" valign="top" class="card-instance pad"></td>
% endif
% endif
% endfor
</tr>
</table>
</td>
</tr>
% endfor
</table>
</td>
</tr>
% endif
<tr>
<td class="footer">
<div class="footer-bar"></div>
<div class="content-block powered-by">
<!-- FOOTER MESSAGE - DO NOT REMOVE -->
</div>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</body>
</html>
% endif

View File

@@ -1,5 +1,10 @@
version_info = (3, 0, 1)
version = '3.0.1'
release = '3.0.1'
from pkg_resources import get_distribution, DistributionNotFound
__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',
'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',
__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_ERROR', 'EVENT_JOB_MISSED', 'EVENT_JOB_SUBMITTED', 'EVENT_JOB_MAX_INSTANCES',
'SchedulerEvent', 'JobEvent', 'JobExecutionEvent')
EVENT_SCHEDULER_START = 1
EVENT_SCHEDULER_SHUTDOWN = 2
EVENT_EXECUTOR_ADDED = 4
EVENT_EXECUTOR_REMOVED = 8
EVENT_JOBSTORE_ADDED = 16
EVENT_JOBSTORE_REMOVED = 32
EVENT_ALL_JOBS_REMOVED = 64
EVENT_JOB_ADDED = 128
EVENT_JOB_REMOVED = 256
EVENT_JOB_MODIFIED = 512
EVENT_JOB_EXECUTED = 1024
EVENT_JOB_ERROR = 2048
EVENT_JOB_MISSED = 4096
EVENT_ALL = (EVENT_SCHEDULER_START | EVENT_SCHEDULER_SHUTDOWN | EVENT_JOBSTORE_ADDED | EVENT_JOBSTORE_REMOVED |
EVENT_SCHEDULER_STARTED = EVENT_SCHEDULER_START = 2 ** 0
EVENT_SCHEDULER_SHUTDOWN = 2 ** 1
EVENT_SCHEDULER_PAUSED = 2 ** 2
EVENT_SCHEDULER_RESUMED = 2 ** 3
EVENT_EXECUTOR_ADDED = 2 ** 4
EVENT_EXECUTOR_REMOVED = 2 ** 5
EVENT_JOBSTORE_ADDED = 2 ** 6
EVENT_JOBSTORE_REMOVED = 2 ** 7
EVENT_ALL_JOBS_REMOVED = 2 ** 8
EVENT_JOB_ADDED = 2 ** 9
EVENT_JOB_REMOVED = 2 ** 10
EVENT_JOB_MODIFIED = 2 ** 11
EVENT_JOB_EXECUTED = 2 ** 12
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_ERROR | EVENT_JOB_MISSED)
EVENT_JOB_ERROR | EVENT_JOB_MISSED | EVENT_JOB_SUBMITTED | EVENT_JOB_MAX_INSTANCES)
class SchedulerEvent(object):
@@ -55,9 +63,21 @@ class JobEvent(SchedulerEvent):
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):
"""
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 retval: the return value of the successfully executed job
@@ -65,7 +85,8 @@ class JobExecutionEvent(JobEvent):
: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)
self.scheduled_run_time = scheduled_run_time
self.retval = retval

View File

@@ -1,28 +1,60 @@
from __future__ import absolute_import
import sys
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):
"""
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``
"""
def start(self, scheduler, alias):
super(AsyncIOExecutor, self).start(scheduler, alias)
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 callback(f):
self._pending_futures.discard(f)
try:
events = f.result()
except:
except BaseException:
self._run_job_error(job.id, *sys.exc_info()[1:])
else:
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)
self._pending_futures.add(f)

View File

@@ -8,13 +8,15 @@ import sys
from pytz import utc
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):
def __init__(self, job):
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)):
@@ -30,13 +32,14 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
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
running scheduler.
Called by the scheduler when the scheduler is being started or when the executor is being
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
"""
"""
self._scheduler = scheduler
self._lock = scheduler._create_lock()
self._logger = logging.getLogger('apscheduler.executors.%s' % alias)
@@ -45,7 +48,8 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
"""
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):
@@ -53,10 +57,12 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
Submits job for execution.
:param Job job: job to execute
:param list[datetime] run_times: list of datetimes specifying when the job should have been run
:raises MaxInstancesReachedError: if the maximum number of allowed instances for this job has been reached
"""
:param list[datetime] run_times: list of datetimes specifying
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'
with self._lock:
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."""
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:
self._instances[job_id] -= 1
if self._instances[job_id] == 0:
del self._instances[job_id]
for event in events:
self._scheduler._dispatch_event(event)
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:
self._instances[job_id] -= 1
if self._instances[job_id] == 0:
del self._instances[job_id]
exc_info = (exc.__class__, exc, traceback)
self._logger.error('Error running job %s', job_id, exc_info=exc_info)
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 = []
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
# 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))
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 = job.func(*job.args, **job.kwargs)
except:
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))
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)
# 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:
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)
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):
"""
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``
"""
@@ -13,7 +14,7 @@ class DebugExecutor(BaseExecutor):
def _do_submit_job(self, job, run_times):
try:
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:])
else:
self._run_job_success(job.id, events)

View File

@@ -21,9 +21,10 @@ class GeventExecutor(BaseExecutor):
def callback(greenlet):
try:
events = greenlet.get()
except:
except BaseException:
self._run_job_error(job.id, *sys.exc_info()[1:])
else:
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:
self._run_job_error(job.id, result.value, result.tb)
self._reactor.getThreadPool().callInThreadWithCallback(callback, run_job, job, job._jobstore_alias, run_times,
self._logger.name)
self._reactor.getThreadPool().callInThreadWithCallback(
callback, run_job, job, job._jobstore_alias, run_times, self._logger.name)

View File

@@ -4,8 +4,9 @@ from uuid import uuid4
import six
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, \
convert_to_datetime
from apscheduler.util import (
ref_to_obj, obj_to_ref, datetime_repr, repr_escape, get_callable_name, check_callable_args,
convert_to_datetime)
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 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 int misfire_grace_time: the time (in seconds) how much this job's execution is allowed to be late
:var int max_instances: the maximum number of concurrently executing instances allowed for this job
:var int misfire_grace_time: the time (in seconds) how much this job's execution is allowed to
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
.. 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',
'name', 'misfire_grace_time', 'coalesce', 'max_instances', 'next_run_time')
__slots__ = ('_scheduler', '_jobstore_alias', 'id', 'trigger', 'executor', 'func', 'func_ref',
'args', 'kwargs', 'name', 'misfire_grace_time', 'coalesce', 'max_instances',
'next_run_time')
def __init__(self, scheduler, id=None, **kwargs):
super(Job, self).__init__()
@@ -38,53 +46,69 @@ class Job(object):
def modify(self, **changes):
"""
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.
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.modify_job`
"""
:return Job: this job instance
"""
self._scheduler.modify_job(self.id, self._jobstore_alias, **changes)
return self
def reschedule(self, trigger, **trigger_args):
"""
Shortcut for switching the trigger on this 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)
return self
def pause(self):
"""
Temporarily suspend the execution of this job.
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.pause_job`
"""
:return Job: this job instance
"""
self._scheduler.pause_job(self.id, self._jobstore_alias)
return self
def resume(self):
"""
Resume the schedule of this job if previously paused.
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.resume_job`
"""
:return Job: this job instance
"""
self._scheduler.resume_job(self.id, self._jobstore_alias)
return self
def remove(self):
"""
Unschedules this job and removes it from its associated job store.
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.remove_job`
"""
"""
self._scheduler.remove_job(self.id, self._jobstore_alias)
@property
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
#
@@ -97,8 +121,8 @@ class Job(object):
:type now: datetime.datetime
:rtype: list[datetime.datetime]
"""
"""
run_times = []
next_run_time = self.next_run_time
while next_run_time and next_run_time <= now:
@@ -108,8 +132,11 @@ class Job(object):
return run_times
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 = {}
if 'id' in changes:
@@ -125,7 +152,7 @@ class Job(object):
args = changes.pop('args') if 'args' in changes else self.args
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_to_obj(func)
elif callable(func):
@@ -177,7 +204,8 @@ class Job(object):
if 'trigger' in changes:
trigger = changes.pop('trigger')
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
@@ -189,10 +217,12 @@ class Job(object):
if 'next_run_time' in changes:
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:
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):
setattr(self, key, value)
@@ -200,9 +230,10 @@ class Job(object):
def __getstate__(self):
# Don't allow this Job to be serialized if the function reference could not be determined
if not self.func_ref:
raise ValueError('This Job cannot be serialized since the reference to its callable (%r) could not be '
'determined. Consider giving a textual reference (module:function name) instead.' %
(self.func,))
raise ValueError(
'This Job cannot be serialized since the reference to its callable (%r) could not '
'be determined. Consider giving a textual reference (module:function name) '
'instead.' % (self.func,))
return {
'version': 1,
@@ -221,7 +252,8 @@ class Job(object):
def __setstate__(self, state):
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.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))
def __str__(self):
return '%s (trigger: %s, next run at: %s)' % (repr_escape(self.name), repr_escape(str(self.trigger)),
datetime_repr(self.next_run_time))
return repr_escape(self.__unicode__())
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."""
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):
"""Raised when the uniqueness of job IDs is being violated."""
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):
"""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):
super(TransientJobError, self).__init__(
six.u('Job (%s) cannot be added to this job store because a reference to the callable could not be '
'determined.') % job_id)
u'Job (%s) cannot be added to this job store because a reference to the callable '
u'could not be determined.' % job_id)
class BaseJobStore(six.with_metaclass(ABCMeta)):
@@ -36,10 +40,11 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
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
running scheduler.
Called by the scheduler when the scheduler is being started or when the job store is being
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
"""
@@ -50,13 +55,22 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
def shutdown(self):
"""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
def lookup_job(self, job_id):
"""
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
point to the scheduler and itself, respectively.
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of
the returned job to point to the scheduler and itself, respectively.
:param str|unicode job_id: identifier of the job
:rtype: Job
@@ -75,7 +89,8 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
@abstractmethod
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
"""
@@ -83,11 +98,12 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
@abstractmethod
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).
Paused jobs (next_run_time is None) should be sorted last.
Returns a list of all jobs in this job store.
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
point to the scheduler and itself, respectively.
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of
the returned jobs to point to the scheduler and itself, respectively.
:rtype: list[Job]
"""

View File

@@ -13,7 +13,8 @@ class MemoryJobStore(BaseJobStore):
def __init__(self):
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
def lookup_job(self, job_id):
@@ -80,13 +81,13 @@ class MemoryJobStore(BaseJobStore):
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
the given timestamp.
Returns the index of the given job, or if it's not found, the index where the job should be
inserted based on the given timestamp.
:type timestamp: int
:type job_id: str
"""
"""
lo, hi = 0, len(self._jobs)
timestamp = float('inf') if timestamp is None else timestamp
while lo < hi:

View File

@@ -1,4 +1,5 @@
from __future__ import absolute_import
import warnings
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
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):
"""
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>`_.
Plugin alias: ``mongodb``
:param str database: database 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
arguments
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available
:param client: a :class:`~pymongo.mongo_client.MongoClient` 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', collection='jobs', client=None,
@@ -42,14 +45,23 @@ class MongoDBJobStore(BaseJobStore):
raise ValueError('The "collection" parameter must not be empty')
if client:
self.connection = maybe_ref(client)
self.client = maybe_ref(client)
else:
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)
@property
def connection(self):
warnings.warn('The "connection" member is deprecated -- use "client" instead',
DeprecationWarning)
return self.client
def lookup_job(self, job_id):
document = self.collection.find_one(job_id, ['job_state'])
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}})
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)])
return utc_timestamp_to_datetime(document['next_run_time']) if document else None
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):
try:
@@ -83,7 +98,7 @@ class MongoDBJobStore(BaseJobStore):
}
result = self.collection.update({'_id': job.id}, {'$set': changes})
if result and result['n'] == 0:
raise JobLookupError(id)
raise JobLookupError(job.id)
def remove_job(self, job_id):
result = self.collection.remove(job_id)
@@ -94,7 +109,7 @@ class MongoDBJobStore(BaseJobStore):
self.collection.remove()
def shutdown(self):
self.connection.disconnect()
self.client.close()
def _reconstitute_job(self, job_state):
job_state = pickle.loads(job_state)
@@ -107,11 +122,13 @@ class MongoDBJobStore(BaseJobStore):
def _get_jobs(self, conditions):
jobs = []
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:
jobs.append(self._reconstitute_job(document['job_state']))
except:
self._logger.exception('Unable to restore job "%s" -- removing it', document['_id'])
except BaseException:
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
@@ -121,4 +138,4 @@ class MongoDBJobStore(BaseJobStore):
return jobs
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 datetime import datetime
from pytz import utc
import six
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
@@ -19,14 +21,16 @@ except ImportError: # pragma: nocover
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``
:param int db: the database number 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 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',
@@ -65,7 +69,8 @@ class RedisJobStore(BaseJobStore):
def get_all_jobs(self):
job_states = self.redis.hgetall(self.jobs_key)
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):
if self.redis.hexists(self.jobs_key, job.id):
@@ -73,8 +78,10 @@ class RedisJobStore(BaseJobStore):
with self.redis.pipeline() as pipe:
pipe.multi()
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol))
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(),
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()
def update_job(self, job):
@@ -82,7 +89,8 @@ class RedisJobStore(BaseJobStore):
raise JobLookupError(job.id)
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:
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
else:
@@ -121,7 +129,7 @@ class RedisJobStore(BaseJobStore):
for job_id, job_state in job_states:
try:
jobs.append(self._reconstitute_job(job_state))
except:
except BaseException:
self._logger.exception('Unable to restore job "%s" -- removing it', 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
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.sql.expression import null
except ImportError: # pragma: nocover
raise ImportError('SQLAlchemyJobStore requires SQLAlchemy installed')
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``
:param str url: connection string (see `SQLAlchemy documentation
<http://docs.sqlalchemy.org/en/latest/core/engines.html?highlight=create_engine#database-urls>`_
on this)
:param engine: an SQLAlchemy Engine to use instead of creating a new one based on ``url``
:param str url: connection string (see
:ref:`SQLAlchemy documentation <sqlalchemy:database_urls>` on this)
:param engine: an SQLAlchemy :class:`~sqlalchemy.engine.Engine` to use instead of creating a
new one based on ``url``
: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 int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available
:param metadata: a :class:`~sqlalchemy.schema.MetaData` instance to use instead of creating a
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,
pickle_protocol=pickle.HIGHEST_PROTOCOL):
pickle_protocol=pickle.HIGHEST_PROTOCOL, tableschema=None, engine_options=None):
super(SQLAlchemyJobStore, self).__init__()
self.pickle_protocol = pickle_protocol
metadata = maybe_ref(metadata) or MetaData()
@@ -40,18 +49,22 @@ class SQLAlchemyJobStore(BaseJobStore):
if engine:
self.engine = maybe_ref(engine)
elif url:
self.engine = create_engine(url)
self.engine = create_engine(url, **(engine_options or {}))
else:
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(
tablename, metadata,
Column('id', Unicode(191, _warn_on_bytestring=False), primary_key=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)
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)
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)
next_run_time = self.engine.execute(selectable).scalar()
return utc_timestamp_to_datetime(next_run_time)
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):
insert = self.jobs_t.insert().values(**{
@@ -116,13 +132,14 @@ class SQLAlchemyJobStore(BaseJobStore):
def _get_jobs(self, *conditions):
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
failed_job_ids = set()
for row in self.engine.execute(selectable):
try:
jobs.append(self._reconstitute_job(row.job_state))
except:
except BaseException:
self._logger.exception('Unable to restore job "%s" -- removing it', 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 functools import wraps
from functools import wraps, partial
from apscheduler.schedulers.base import BaseScheduler
from apscheduler.util import maybe_ref
@@ -10,13 +10,15 @@ except ImportError: # pragma: nocover
try:
import trollius as asyncio
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):
@wraps(func)
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
@@ -24,6 +26,8 @@ class AsyncIOScheduler(BaseScheduler):
"""
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:
============== =============================================================
@@ -34,10 +38,6 @@ class AsyncIOScheduler(BaseScheduler):
_eventloop = None
_timeout = None
def start(self):
super(AsyncIOScheduler, self).start()
self.wakeup()
@run_in_event_loop
def shutdown(self, wait=True):
super(AsyncIOScheduler, self).shutdown(wait)

View File

@@ -1,4 +1,5 @@
from __future__ import absolute_import
from threading import Thread, Event
from apscheduler.schedulers.base import BaseScheduler
@@ -13,11 +14,12 @@ class BackgroundScheduler(BlockingScheduler):
Extra options:
========== ============================================================================================
``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``,
see `the documentation <https://docs.python.org/3.4/library/threading.html#thread-objects>`_
========== =============================================================================
``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``, see
`the documentation
<https://docs.python.org/3.4/library/threading.html#thread-objects>`_
for further details)
========== ============================================================================================
========== =============================================================================
"""
_thread = None
@@ -26,14 +28,14 @@ class BackgroundScheduler(BlockingScheduler):
self._daemon = asbool(config.pop('daemon', True))
super(BackgroundScheduler, self)._configure(config)
def start(self):
BaseScheduler.start(self)
def start(self, *args, **kwargs):
self._event = Event()
BaseScheduler.start(self, *args, **kwargs)
self._thread = Thread(target=self._main_loop, name='APScheduler')
self._thread.daemon = self._daemon
self._thread.start()
def shutdown(self, wait=True):
super(BackgroundScheduler, self).shutdown(wait)
def shutdown(self, *args, **kwargs):
super(BackgroundScheduler, self).shutdown(*args, **kwargs)
self._thread.join()
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 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):
"""
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
def start(self):
super(BlockingScheduler, self).start()
def start(self, *args, **kwargs):
self._event = Event()
super(BlockingScheduler, self).start(*args, **kwargs)
self._main_loop()
def shutdown(self, wait=True):
@@ -23,10 +23,11 @@ class BlockingScheduler(BaseScheduler):
self._event.set()
def _main_loop(self):
while self.running:
wait_seconds = self._process_jobs()
self._event.wait(wait_seconds if wait_seconds is not None else self.MAX_WAIT_TIME)
wait_seconds = TIMEOUT_MAX
while self.state != STATE_STOPPED:
self._event.wait(wait_seconds)
self._event.clear()
wait_seconds = self._process_jobs()
def wakeup(self):
self._event.set()

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
from __future__ import absolute_import
from datetime import timedelta
from functools import wraps
@@ -22,6 +23,8 @@ class TornadoScheduler(BaseScheduler):
"""
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)
=========== ===============================================================
@@ -30,10 +33,6 @@ class TornadoScheduler(BaseScheduler):
_ioloop = None
_timeout = None
def start(self):
super(TornadoScheduler, self).start()
self.wakeup()
@run_in_ioloop
def shutdown(self, wait=True):
super(TornadoScheduler, self).shutdown(wait)
@@ -53,6 +52,10 @@ class TornadoScheduler(BaseScheduler):
self._ioloop.remove_timeout(self._timeout)
del self._timeout
def _create_default_executor(self):
from apscheduler.executors.tornado import TornadoExecutor
return TornadoExecutor()
@run_in_ioloop
def wakeup(self):
self._stop_timer()

View File

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

View File

@@ -1,4 +1,6 @@
from abc import ABCMeta, abstractmethod
from datetime import timedelta
import random
import six
@@ -6,11 +8,41 @@ import six
class BaseTrigger(six.with_metaclass(ABCMeta)):
"""Abstract base class that defines the interface that every trigger must implement."""
__slots__ = ()
@abstractmethod
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 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
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
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 month: month (1-12)
@@ -22,8 +24,9 @@ class CronTrigger(BaseTrigger):
:param int|str second: second (0-59)
: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.tzinfo|str timezone: time zone to use for the date/time calculations
(defaults to scheduler timezone)
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations (defaults
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**.
"""
@@ -31,7 +34,7 @@ class CronTrigger(BaseTrigger):
FIELD_NAMES = ('year', 'month', 'day', 'week', 'day_of_week', 'hour', 'minute', 'second')
FIELDS_MAP = {
'year': BaseField,
'month': BaseField,
'month': MonthField,
'week': WeekField,
'day': DayOfMonthField,
'day_of_week': DayOfWeekField,
@@ -40,15 +43,16 @@ class CronTrigger(BaseTrigger):
'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,
second=None, start_date=None, end_date=None, timezone=None):
def __init__(self, year=None, month=None, day=None, week=None, day_of_week=None, hour=None,
minute=None, second=None, start_date=None, end_date=None, timezone=None,
jitter=None):
if 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
elif end_date and end_date.tzinfo:
elif isinstance(end_date, datetime) and end_date.tzinfo:
self.timezone = end_date.tzinfo
else:
self.timezone = get_localzone()
@@ -56,6 +60,8 @@ class CronTrigger(BaseTrigger):
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.jitter = jitter
values = dict((key, value) for (key, value) in six.iteritems(locals())
if key in self.FIELD_NAMES and value is not None)
self.fields = []
@@ -76,13 +82,35 @@ class CronTrigger(BaseTrigger):
field = field_class(field_name, exprs, is_default)
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):
"""
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 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
"""
@@ -128,12 +156,13 @@ class CronTrigger(BaseTrigger):
else:
values[field.name] = new_value
difference = datetime(**values) - dateval.replace(tzinfo=None)
return self.timezone.normalize(dateval + difference)
return self.timezone.localize(datetime(**values))
def get_next_fire_time(self, previous_fire_time, now):
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:
start_date = max(now, self.start_date) if self.start_date else now
@@ -163,8 +192,36 @@ class CronTrigger(BaseTrigger):
return None
if fieldnum >= 0:
if self.jitter is not None:
next_date = self._apply_jitter(next_date, self.jitter, now)
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):
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
return 'cron[%s]' % (', '.join(options))
@@ -172,5 +229,11 @@ class CronTrigger(BaseTrigger):
def __repr__(self):
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
if self.start_date:
options.append("start_date='%s'" % datetime_repr(self.start_date))
return '<%s (%s)>' % (self.__class__.__name__, ', '.join(options))
options.append("start_date=%r" % 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

@@ -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
import re
from apscheduler.util import asint
__all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression', 'WeekdayPositionExpression',
'LastDayOfMonthExpression')
__all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression',
'WeekdayPositionExpression', 'LastDayOfMonthExpression')
WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
class AllExpression(object):
@@ -22,6 +21,14 @@ class AllExpression(object):
if self.step == 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):
start = field.get_value(date)
minval = field.get_min(date)
@@ -37,6 +44,9 @@ class AllExpression(object):
if next <= maxval:
return next
def __eq__(self, other):
return isinstance(other, self.__class__) and self.step == other.step
def __str__(self):
if self.step:
return '*/%d' % self.step
@@ -51,7 +61,7 @@ class RangeExpression(AllExpression):
r'(?P<first>\d+)(?:-(?P<last>\d+))?(?:/(?P<step>\d+))?$')
def __init__(self, first, last=None, step=None):
AllExpression.__init__(self, step)
super(RangeExpression, self).__init__(step)
first = asint(first)
last = asint(last)
if last is None and step is None:
@@ -61,25 +71,41 @@ class RangeExpression(AllExpression):
self.first = first
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):
start = field.get_value(date)
startval = field.get_value(date)
minval = field.get_min(date)
maxval = field.get_max(date)
# Apply range limits
minval = max(minval, self.first)
if self.last is not None:
maxval = min(maxval, self.last)
start = max(start, minval)
maxval = min(maxval, self.last) if self.last is not None else maxval
nextval = max(minval, startval)
if not self.step:
next = start
else:
distance_to_next = (self.step - (start - minval)) % self.step
next = start + distance_to_next
# Apply the step if defined
if self.step:
distance_to_next = (self.step - (nextval - minval)) % self.step
nextval += distance_to_next
if next <= maxval:
return next
return nextval if nextval <= maxval else None
def __eq__(self, other):
return (isinstance(other, self.__class__) and self.first == other.first and
self.last == other.last)
def __str__(self):
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))
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):
value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE)
@@ -117,7 +174,7 @@ class WeekdayRangeExpression(RangeExpression):
else:
last_num = None
RangeExpression.__init__(self, first_num, last_num)
super(WeekdayRangeExpression, self).__init__(first_num, last_num)
def __str__(self):
if self.last != self.first and self.last is not None:
@@ -133,9 +190,11 @@ class WeekdayRangeExpression(RangeExpression):
class WeekdayPositionExpression(AllExpression):
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):
super(WeekdayPositionExpression, self).__init__(None)
try:
self.option_num = self.options.index(option_name.lower())
except ValueError:
@@ -147,8 +206,7 @@ class WeekdayPositionExpression(AllExpression):
raise ValueError('Invalid weekday name "%s"' % weekday_name)
def get_next_value(self, date, field):
# Figure out the weekday of the month's first day and the number
# of days in that month
# Figure out the weekday of the month's first day and the number of days in that month
first_day_wday, last_day = monthrange(date.year, date.month)
# 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:
target_day = first_hit_day + self.option_num * 7
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:
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):
return '%s %s' % (self.options[self.option_num], WEEKDAYS[self.weekday])
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):
value_re = re.compile(r'last', re.IGNORECASE)
def __init__(self):
pass
super(LastDayOfMonthExpression, self).__init__(None)
def get_next_value(self, date, field):
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
import re
import six
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}
MAX_VALUES = {'year': 2 ** 63, 'month': 12, 'day:': 31, 'week': 53, 'day_of_week': 6, 'hour': 23, 'minute': 59,
'second': 59}
DEFAULT_VALUES = {'year': '*', 'month': 1, 'day': 1, 'week': '*', 'day_of_week': '*', 'hour': 0, 'minute': 0,
'second': 0}
MIN_VALUES = {'year': 1970, 'month': 1, 'day': 1, 'week': 1, 'day_of_week': 0, 'hour': 0,
'minute': 0, 'second': 0}
MAX_VALUES = {'year': 9999, 'month': 12, 'day': 31, 'week': 53, 'day_of_week': 6, 'hour': 23,
'minute': 59, 'second': 59}
DEFAULT_VALUES = {'year': '*', 'month': 1, 'day': 1, 'week': '*', 'day_of_week': '*', 'hour': 0,
'minute': 0, 'second': 0}
SEPARATOR = re.compile(' *, *')
class BaseField(object):
@@ -50,23 +54,29 @@ class BaseField(object):
self.expressions = []
# Split a comma-separated expression list, if any
exprs = str(exprs).strip()
if ',' in exprs:
for expr in exprs.split(','):
self.compile_expression(expr)
else:
self.compile_expression(exprs)
for expr in SEPARATOR.split(str(exprs).strip()):
self.compile_expression(expr)
def compile_expression(self, expr):
for compiler in self.COMPILERS:
match = compiler.value_re.match(expr)
if match:
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)
return
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):
expr_strings = (str(e) for e in self.expressions)
return ','.join(expr_strings)
@@ -94,4 +104,8 @@ class DayOfWeekField(BaseField):
COMPILERS = BaseField.COMPILERS + [WeekdayRangeExpression]
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
"""
__slots__ = 'timezone', 'run_date'
__slots__ = 'run_date'
def __init__(self, run_date=None, timezone=None):
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):
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):
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):
"""
Triggers on specified intervals, starting on ``start_date`` if specified, ``datetime.now()`` + interval
otherwise.
Triggers on specified intervals, starting on ``start_date`` if specified, ``datetime.now()`` +
interval otherwise.
:param int weeks: number of weeks 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 end_date: latest possible date/time to trigger on
: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):
self.interval = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds)
def __init__(self, weeks=0, days=0, hours=0, minutes=0, seconds=0, start_date=None,
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)
if self.interval_length == 0:
self.interval = timedelta(seconds=1)
@@ -33,9 +36,9 @@ class IntervalTrigger(BaseTrigger):
if 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
elif end_date and end_date.tzinfo:
elif isinstance(end_date, datetime) and end_date.tzinfo:
self.timezone = end_date.tzinfo
else:
self.timezone = get_localzone()
@@ -44,6 +47,8 @@ class IntervalTrigger(BaseTrigger):
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.jitter = jitter
def get_next_fire_time(self, previous_fire_time, now):
if previous_fire_time:
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_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:
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):
return 'interval[%s]' % str(self.interval)
def __repr__(self):
return "<%s (interval=%r, start_date='%s')>" % (self.__class__.__name__, self.interval,
datetime_repr(self.start_date))
options = ['interval=%r' % self.interval, 'start_date=%r' % 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 datetime import date, datetime, time, timedelta, tzinfo
from inspect import isfunction, ismethod, getargspec
from calendar import timegm
import re
from functools import partial
from pytz import timezone, utc
import six
@@ -12,14 +12,16 @@ import six
try:
from inspect import signature
except ImportError: # pragma: nocover
try:
from funcsigs import signature
except ImportError:
signature = None
from funcsigs import signature
try:
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',
'utc_timestamp_to_datetime', 'timedelta_seconds', 'datetime_ceil', 'get_callable_name', 'obj_to_ref',
'ref_to_obj', 'maybe_ref', 'repr_escape', 'check_callable_args')
'utc_timestamp_to_datetime', 'timedelta_seconds', 'datetime_ceil', 'get_callable_name',
'obj_to_ref', 'ref_to_obj', 'maybe_ref', 'repr_escape', 'check_callable_args')
class _Undefined(object):
@@ -32,17 +34,18 @@ class _Undefined(object):
def __repr__(self):
return '<undefined>'
undefined = _Undefined() #: a unique object that only signifies that no value is defined
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
:rtype: int
"""
"""
if text is not None:
return int(text)
@@ -52,8 +55,8 @@ def asbool(obj):
Interprets an object as a boolean value.
:rtype: bool
"""
"""
if isinstance(obj, str):
obj = obj.strip().lower()
if obj in ('true', 'yes', 'on', 'y', 't', '1'):
@@ -69,15 +72,19 @@ def astimezone(obj):
Interprets an object as a timezone.
:rtype: tzinfo
"""
"""
if isinstance(obj, six.string_types):
return timezone(obj)
if isinstance(obj, tzinfo):
if not hasattr(obj, 'localize') or not hasattr(obj, 'normalize'):
raise TypeError('Only timezones from the pytz library are supported')
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
if obj is not None:
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):
"""
Converts the given object to a timezone aware datetime object.
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 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 with time (Y-m-d H:M:S) or with date+time with microseconds
(Y-m-d H:M:S.micro).
Date strings are accepted in three different forms: date only (Y-m-d), date with time
(Y-m-d H:M:S) or with date+time with microseconds (Y-m-d H:M:S.micro).
: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 str arg_name: the name of the argument (used in an error message)
:rtype: datetime
"""
"""
if input is None:
return
elif isinstance(input, datetime):
@@ -125,14 +132,16 @@ def convert_to_datetime(input, tz, arg_name):
if datetime_.tzinfo is not None:
return datetime_
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):
tz = timezone(tz)
try:
return tz.localize(datetime_, is_dst=None)
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):
@@ -141,8 +150,8 @@ def datetime_to_utc_timestamp(timeval):
:type timeval: datetime
:rtype: float
"""
"""
if timeval is not None:
return timegm(timeval.utctimetuple()) + timeval.microsecond / 1000000
@@ -153,8 +162,8 @@ def utc_timestamp_to_datetime(timestamp):
:type timestamp: float
:rtype: datetime
"""
"""
if timestamp is not None:
return datetime.fromtimestamp(timestamp, utc)
@@ -165,8 +174,8 @@ def timedelta_seconds(delta):
:type delta: timedelta
:rtype: float
"""
"""
return delta.days * 24 * 60 * 60 + delta.seconds + \
delta.microseconds / 1000000.0
@@ -176,8 +185,8 @@ def datetime_ceil(dateval):
Rounds the given datetime object upwards.
:type dateval: datetime
"""
"""
if dateval.microsecond > 0:
return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond)
return dateval
@@ -192,8 +201,8 @@ def get_callable_name(func):
Returns the best available display name for the given function/callable.
:rtype: str
"""
"""
# the easy case (on Python 3.3+)
if hasattr(func, '__qualname__'):
return func.__qualname__
@@ -222,20 +231,24 @@ def get_callable_name(func):
def obj_to_ref(obj):
"""
Returns the path to the given object.
Returns the path to the given callable.
: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:
ref = '%s:%s' % (obj.__module__, get_callable_name(obj))
obj2 = ref_to_obj(ref)
if obj != obj2:
raise ValueError
except Exception:
raise ValueError('Cannot determine the reference to %r' % obj)
name = get_callable_name(obj)
if '<lambda>' in name:
raise ValueError('Cannot create a reference to a lambda')
if '<locals>' in name:
raise ValueError('Cannot create a reference to a nested function')
return ref
return '%s:%s' % (obj.__module__, name)
def ref_to_obj(ref):
@@ -243,8 +256,8 @@ def ref_to_obj(ref):
Returns the object pointed to by ``ref``.
:type ref: str
"""
"""
if not isinstance(ref, six.string_types):
raise TypeError('References must be strings')
if ':' not in ref:
@@ -252,12 +265,12 @@ def ref_to_obj(ref):
modulename, rest = ref.split(':', 1)
try:
obj = __import__(modulename)
obj = __import__(modulename, fromlist=[rest])
except ImportError:
raise LookupError('Error resolving reference %s: could not import module' % ref)
try:
for name in modulename.split('.')[1:] + rest.split('.'):
for name in rest.split('.'):
obj = getattr(obj, name)
return obj
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.
If it is not a reference, the object is returned as-is.
"""
"""
if not isinstance(ref, str):
return ref
return ref_to_obj(ref)
@@ -281,7 +294,8 @@ if six.PY2:
return string.encode('ascii', 'backslashreplace')
return string
else:
repr_escape = lambda string: string
def repr_escape(string):
return string
def check_callable_args(func, args, kwargs):
@@ -290,70 +304,51 @@ def check_callable_args(func, args, kwargs):
:type args: tuple
:type kwargs: dict
"""
"""
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
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
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
has_varargs = has_var_kwargs = False # indicates if the signature defines *args and **kwargs respectively
# kwargs that didn't match any of the parameters in the signature
unmatched_kwargs = list(kwargs)
# indicates if the signature defines *args and **kwargs respectively
has_varargs = has_var_kwargs = False
if signature:
try:
sig = signature(func)
except ValueError:
return # signature() doesn't work against every kind of callable
try:
sig = signature(func)
except ValueError:
# signature() doesn't work against every kind of callable
return
for param in six.itervalues(sig.parameters):
if param.kind == param.POSITIONAL_OR_KEYWORD:
if param.name in unmatched_kwargs and unmatched_args:
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)
for param in six.itervalues(sig.parameters):
if param.kind == param.POSITIONAL_OR_KEYWORD:
if param.name in unmatched_kwargs and unmatched_args:
pos_kwargs_conflicts.append(param.name)
elif unmatched_args:
del unmatched_args[0]
elif arg in unmatched_kwargs:
unmatched_kwargs.remove(arg)
elif default is undefined:
unsatisfied_args.append(arg)
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
# Make sure there are no conflicts between args and kwargs
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' %
', '.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:
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
if unsatisfied_kwargs:
raise ValueError('The following keyword-only arguments have not been supplied in kwargs: %s' %
', '.join(unsatisfied_kwargs))
raise ValueError(
'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
if not has_varargs and unmatched_args:
raise ValueError('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)))
raise ValueError(
'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
if not has_var_kwargs and unmatched_kwargs:
raise ValueError('The target callable does not accept the following keyword arguments: %s' %
', '.join(unmatched_kwargs))
raise ValueError(
'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 .api import get, now, utcnow
__version__ = '0.7.0'
__version__ = '0.10.0'
VERSION = __version__

View File

@@ -51,5 +51,5 @@ def factory(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
import calendar
import sys
import warnings
from arrow import util, locales, parser, formatter
@@ -45,6 +47,7 @@ class Arrow(object):
_ATTRS = ['year', 'month', 'day', 'hour', 'minute', 'second', 'microsecond']
_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,
tzinfo=None):
@@ -306,6 +309,9 @@ class Arrow(object):
if name == 'week':
return self.isocalendar()[1]
if name == 'quarter':
return int((self.month-1)/self._MONTHS_PER_QUARTER) + 1
if not name.startswith('_'):
value = getattr(self._datetime, name, None)
@@ -378,16 +384,16 @@ class Arrow(object):
>>> arw.replace(year=2014, month=6)
<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:
>>> arw.replace(tzinfo=tz.tzlocal())
<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:
- A ``tzinfo`` object.
@@ -398,21 +404,29 @@ class Arrow(object):
'''
absolute_kwargs = {}
relative_kwargs = {}
relative_kwargs = {} # TODO: DEPRECATED; remove in next release
for key, value in kwargs.items():
if key in self._ATTRS:
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
elif key == 'week':
raise AttributeError('setting absolute week is not supported')
elif key in ['week', 'quarter']:
raise AttributeError('setting absolute {0} is not supported'.format(key))
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 += relativedelta(**relative_kwargs)
current += relativedelta(**relative_kwargs) # TODO: DEPRECATED
tzinfo = kwargs.get('tzinfo')
@@ -422,9 +436,41 @@ class Arrow(object):
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):
''' Returns a new :class:`Arrow <arrow.arrow.Arrow>` object, converted to the target
timezone.
''' Returns a new :class:`Arrow <arrow.arrow.Arrow>` object, converted
to the target 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.
: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.
Usage::
>>> earlier = arrow.utcnow().replace(hours=-2)
@@ -651,7 +698,8 @@ class Arrow(object):
elif diff < 29808000:
self_months = self._datetime.year * 12 + self._datetime.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)
@@ -676,7 +724,7 @@ class Arrow(object):
def __sub__(self, other):
if isinstance(other, timedelta):
if isinstance(other, (timedelta, relativedelta)):
return self.fromdatetime(self._datetime - other, self._datetime.tzinfo)
elif isinstance(other, datetime):
@@ -688,7 +736,11 @@ class Arrow(object):
raise TypeError()
def __rsub__(self, other):
return self.__sub__(other)
if isinstance(other, datetime):
return other - self._datetime
raise TypeError()
# comparisons
@@ -702,8 +754,6 @@ class Arrow(object):
if not isinstance(other, (Arrow, datetime)):
return False
other = self._get_datetime(other)
return self._datetime == self._get_datetime(other)
def __ne__(self, other):
@@ -882,7 +932,9 @@ class Arrow(object):
return cls.max, limit
else:
return end, sys.maxsize
if limit is None:
return end, sys.maxsize
return end, limit
@staticmethod
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
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)
hour, minute = divmod(total_minutes, 60)

View File

@@ -7,8 +7,8 @@ import sys
def get_locale(name):
'''Returns an appropriate :class:`Locale <locale.Locale>` corresponding
to an inpute locale name.
'''Returns an appropriate :class:`Locale <arrow.locales.Locale>`
corresponding to an inpute locale name.
:param name: the name of the locale.
@@ -186,7 +186,7 @@ class Locale(object):
class EnglishLocale(Locale):
names = ['en', 'en_us', 'en_gb', 'en_au', 'en_be', 'en_jp', 'en_za']
names = ['en', 'en_us', 'en_gb', 'en_au', 'en_be', 'en_jp', 'en_za', 'en_ca']
past = '{0} ago'
future = 'in {0}'
@@ -263,10 +263,10 @@ class ItalianLocale(Locale):
day_names = ['', 'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato', 'domenica']
day_abbreviations = ['', 'lun', 'mar', 'mer', 'gio', 'ven', 'sab', 'dom']
ordinal_day_re = r'((?P<value>[1-3]?[0-9](?=°))°)'
ordinal_day_re = r'((?P<value>[1-3]?[0-9](?=[ºª]))[ºª])'
def _ordinal_number(self, n):
return '{0}°'.format(n)
return '{0}º'.format(n)
class SpanishLocale(Locale):
@@ -297,10 +297,10 @@ class SpanishLocale(Locale):
day_names = ['', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo']
day_abbreviations = ['', 'lun', 'mar', 'mie', 'jue', 'vie', 'sab', 'dom']
ordinal_day_re = r'((?P<value>[1-3]?[0-9](?=°))°)'
ordinal_day_re = r'((?P<value>[1-3]?[0-9](?=[ºª]))[ºª])'
def _ordinal_number(self, n):
return '{0}°'.format(n)
return '{0}º'.format(n)
class FrenchLocale(Locale):
@@ -379,7 +379,7 @@ class JapaneseLocale(Locale):
timeframes = {
'now': '現在',
'seconds': '',
'seconds': '',
'minute': '1分',
'minutes': '{0}',
'hour': '1時間',
@@ -559,8 +559,8 @@ class KoreanLocale(Locale):
timeframes = {
'now': '지금',
'seconds': '몇초',
'minute': '',
'seconds': ' ',
'minute': '1',
'minutes': '{0}',
'hour': '1시간',
'hours': '{0}시간',
@@ -919,7 +919,7 @@ class NewNorwegianLocale(Locale):
class PortugueseLocale(Locale):
names = ['pt', 'pt_pt']
past = '{0}'
future = 'em {0}'
@@ -946,11 +946,11 @@ class PortugueseLocale(Locale):
day_names = ['', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira',
'sábado', 'domingo']
day_abbreviations = ['', 'seg', 'ter', 'qua', 'qui', 'sex', 'sab', 'dom']
class BrazilianPortugueseLocale(PortugueseLocale):
names = ['pt_br']
past = 'fazem {0}'
@@ -1034,7 +1034,7 @@ class TurkishLocale(Locale):
'days': '{0} gün',
'month': 'bir ay',
'months': '{0} ay',
'year': 'a yıl',
'year': 'yıl',
'years': '{0} yıl',
}
@@ -1047,6 +1047,37 @@ class TurkishLocale(Locale):
day_abbreviations = ['', 'Pzt', 'Sal', 'Çar', 'Per', 'Cum', 'Cmt', 'Paz']
class AzerbaijaniLocale(Locale):
names = ['az', 'az_az']
past = '{0} əvvəl'
future = '{0} sonra'
timeframes = {
'now': 'indi',
'seconds': 'saniyə',
'minute': 'bir dəqiqə',
'minutes': '{0} dəqiqə',
'hour': 'bir saat',
'hours': '{0} saat',
'day': 'bir gün',
'days': '{0} gün',
'month': 'bir ay',
'months': '{0} ay',
'year': 'il',
'years': '{0} il',
}
month_names = ['', 'Yanvar', 'Fevral', 'Mart', 'Aprel', 'May', 'İyun', 'İyul',
'Avqust', 'Sentyabr', 'Oktyabr', 'Noyabr', 'Dekabr']
month_abbreviations = ['', 'Yan', 'Fev', 'Mar', 'Apr', 'May', 'İyn', 'İyl', 'Avq',
'Sen', 'Okt', 'Noy', 'Dek']
day_names = ['', 'Bazar ertəsi', 'Çərşənbə axşamı', 'Çərşənbə', 'Cümə axşamı', 'Cümə', 'Şənbə', 'Bazar']
day_abbreviations = ['', 'Ber', 'Çax', 'Çər', 'Cax', 'Cüm', 'Şnb', 'Bzr']
class ArabicLocale(Locale):
names = ['ar', 'ar_eg']
@@ -1205,11 +1236,11 @@ class HindiLocale(Locale):
future = '{0} बाद'
timeframes = {
'now': 'अभि',
'now': 'अभ',
'seconds': 'सेकंड्',
'minute': 'एक मिनट ',
'minutes': '{0} मिनट ',
'hour': 'एक घंट',
'hour': 'एक घंट',
'hours': '{0} घंटे',
'day': 'एक दिन',
'days': '{0} दिन',
@@ -1226,8 +1257,8 @@ class HindiLocale(Locale):
'PM': 'शाम',
}
month_names = ['', 'जनवरी', 'रवरी', 'मार्च', 'अप्रैल ', 'मई', 'जून', 'जुलाई',
'गस्त', 'सितम्बर', 'अकूबर', 'नवेम्बर', 'दिसम्बर']
month_names = ['', 'जनवरी', 'रवरी', 'मार्च', 'अप्रैल ', 'मई', 'जून', 'जुलाई',
'गस्त', 'सितबर', 'अक्टूबर', 'नवबर', 'दिसबर']
month_abbreviations = ['', 'जन', 'फ़र', 'मार्च', 'अप्रै', 'मई', 'जून', 'जुलाई', 'आग',
'सित', 'अकत', 'नवे', 'दिस']
@@ -1284,7 +1315,8 @@ class CzechLocale(Locale):
def _format_timeframe(self, timeframe, delta):
'''Czech aware time frame format function, takes into account the differences between past and future forms.'''
'''Czech aware time frame format function, takes into account
the differences between past and future forms.'''
form = self.timeframes[timeframe]
if isinstance(form, dict):
if delta == 0:
@@ -1293,7 +1325,7 @@ class CzechLocale(Locale):
form = form['future']
else:
form = form['past']
delta = abs(delta)
delta = abs(delta)
if isinstance(form, list):
if 2 <= delta % 10 <= 4 and (delta % 100 < 10 or delta % 100 >= 20):
@@ -1303,6 +1335,78 @@ class CzechLocale(Locale):
return form.format(delta)
class SlovakLocale(Locale):
names = ['sk', 'sk_sk']
timeframes = {
'now': 'Teraz',
'seconds': {
'past': 'pár sekundami',
'future': ['{0} sekundy', '{0} sekúnd']
},
'minute': {'past': 'minútou', 'future': 'minútu', 'zero': '{0} minút'},
'minutes': {
'past': '{0} minútami',
'future': ['{0} minúty', '{0} minút']
},
'hour': {'past': 'hodinou', 'future': 'hodinu', 'zero': '{0} hodín'},
'hours': {
'past': '{0} hodinami',
'future': ['{0} hodiny', '{0} hodín']
},
'day': {'past': 'dňom', 'future': 'deň', 'zero': '{0} dní'},
'days': {
'past': '{0} dňami',
'future': ['{0} dni', '{0} dní']
},
'month': {'past': 'mesiacom', 'future': 'mesiac', 'zero': '{0} mesiacov'},
'months': {
'past': '{0} mesiacmi',
'future': ['{0} mesiace', '{0} mesiacov']
},
'year': {'past': 'rokom', 'future': 'rok', 'zero': '{0} rokov'},
'years': {
'past': '{0} rokmi',
'future': ['{0} roky', '{0} rokov']
}
}
past = 'Pred {0}'
future = 'O {0}'
month_names = ['', 'január', 'február', 'marec', 'apríl', 'máj', 'jún',
'júl', 'august', 'september', 'október', 'november', 'december']
month_abbreviations = ['', 'jan', 'feb', 'mar', 'apr', 'máj', 'jún', 'júl',
'aug', 'sep', 'okt', 'nov', 'dec']
day_names = ['', 'pondelok', 'utorok', 'streda', 'štvrtok', 'piatok',
'sobota', 'nedeľa']
day_abbreviations = ['', 'po', 'ut', 'st', 'št', 'pi', 'so', 'ne']
def _format_timeframe(self, timeframe, delta):
'''Slovak aware time frame format function, takes into account
the differences between past and future forms.'''
form = self.timeframes[timeframe]
if isinstance(form, dict):
if delta == 0:
form = form['zero'] # And *never* use 0 in the singular!
elif delta > 0:
form = form['future']
else:
form = form['past']
delta = abs(delta)
if isinstance(form, list):
if 2 <= delta % 10 <= 4 and (delta % 100 < 10 or delta % 100 >= 20):
form = form[0]
else:
form = form[1]
return form.format(delta)
class FarsiLocale(Locale):
names = ['fa', 'fa_ir']
@@ -1463,7 +1567,7 @@ class MarathiLocale(Locale):
day_names = ['', 'सोमवार', 'मंगळवार', 'बुधवार', 'गुरुवार', 'शुक्रवार', 'शनिवार', 'रविवार']
day_abbreviations = ['', 'सोम', 'मंगळ', 'बुध', 'गुरु', 'शुक्र', 'शनि', 'रवि']
def _map_locales():
locales = {}
@@ -1471,14 +1575,14 @@ def _map_locales():
for cls_name, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass):
if issubclass(cls, Locale):
for name in cls.names:
locales[name.lower()] = cls
locales[name.lower()] = cls
return locales
class CatalaLocale(Locale):
names = ['ca', 'ca_ca']
class CatalanLocale(Locale):
names = ['ca', 'ca_es', 'ca_ad', 'ca_fr', 'ca_it']
past = 'Fa {0}'
future = '{0}' # I don't know what's the right phrase in catala for the future.
future = 'En {0}'
timeframes = {
'now': 'Ara mateix',
@@ -1490,15 +1594,15 @@ class CatalaLocale(Locale):
'day': 'un dia',
'days': '{0} dies',
'month': 'un mes',
'months': '{0} messos',
'months': '{0} mesos',
'year': 'un any',
'years': '{0} anys',
}
month_names = ['', 'Jener', 'Febrer', 'Març', 'Abril', 'Maig', 'Juny', 'Juliol', 'Agost', 'Setembre', 'Octubre', 'Novembre', 'Decembre']
month_abbreviations = ['', 'Jener', 'Febrer', 'Març', 'Abril', 'Maig', 'Juny', 'Juliol', 'Agost', 'Setembre', 'Octubre', 'Novembre', 'Decembre']
day_names = ['', 'Dilluns', 'Dimars', 'Dimecres', 'Dijous', 'Divendres', 'Disabte', 'Diumenge']
day_abbreviations = ['', 'Dilluns', 'Dimars', 'Dimecres', 'Dijous', 'Divendres', 'Disabte', 'Diumenge']
month_names = ['', 'Gener', 'Febrer', 'Març', 'Abril', 'Maig', 'Juny', 'Juliol', 'Agost', 'Setembre', 'Octubre', 'Novembre', 'Desembre']
month_abbreviations = ['', 'Gener', 'Febrer', 'Març', 'Abril', 'Maig', 'Juny', 'Juliol', 'Agost', 'Setembre', 'Octubre', 'Novembre', 'Desembre']
day_names = ['', 'Dilluns', 'Dimarts', 'Dimecres', 'Dijous', 'Divendres', 'Dissabte', 'Diumenge']
day_abbreviations = ['', 'Dilluns', 'Dimarts', 'Dimecres', 'Dijous', 'Divendres', 'Dissabte', 'Diumenge']
class BasqueLocale(Locale):
names = ['eu', 'eu_eu']
@@ -1587,6 +1691,50 @@ class HungarianLocale(Locale):
return form.format(abs(delta))
class EsperantoLocale(Locale):
names = ['eo', 'eo_xx']
past = 'antaŭ {0}'
future = 'post {0}'
timeframes = {
'now': 'nun',
'seconds': 'kelkaj sekundoj',
'minute': 'unu minuto',
'minutes': '{0} minutoj',
'hour': 'un horo',
'hours': '{0} horoj',
'day': 'unu tago',
'days': '{0} tagoj',
'month': 'unu monato',
'months': '{0} monatoj',
'year': 'unu jaro',
'years': '{0} jaroj',
}
month_names = ['', 'januaro', 'februaro', 'marto', 'aprilo', 'majo',
'junio', 'julio', 'aŭgusto', 'septembro', 'oktobro',
'novembro', 'decembro']
month_abbreviations = ['', 'jan', 'feb', 'mar', 'apr', 'maj', 'jun',
'jul', 'aŭg', 'sep', 'okt', 'nov', 'dec']
day_names = ['', 'lundo', 'mardo', 'merkredo', 'ĵaŭdo', 'vendredo',
'sabato', 'dimanĉo']
day_abbreviations = ['', 'lun', 'mar', 'mer', 'ĵaŭ', 'ven',
'sab', 'dim']
meridians = {
'am': 'atm',
'pm': 'ptm',
'AM': 'ATM',
'PM': 'PTM',
}
ordinal_day_re = r'((?P<value>[1-3]?[0-9](?=a))a)'
def _ordinal_number(self, n):
return '{0}a'.format(n)
class ThaiLocale(Locale):
names = ['th', 'th_th']
@@ -1700,4 +1848,164 @@ class BengaliLocale(Locale):
return '{0}ষ্ঠ'.format(n)
class RomanshLocale(Locale):
names = ['rm', 'rm_ch']
past = 'avant {0}'
future = 'en {0}'
timeframes = {
'now': 'en quest mument',
'seconds': 'secundas',
'minute': 'ina minuta',
'minutes': '{0} minutas',
'hour': 'in\'ura',
'hours': '{0} ura',
'day': 'in di',
'days': '{0} dis',
'month': 'in mais',
'months': '{0} mais',
'year': 'in onn',
'years': '{0} onns',
}
month_names = [
'', 'schaner', 'favrer', 'mars', 'avrigl', 'matg', 'zercladur',
'fanadur', 'avust', 'settember', 'october', 'november', 'december'
]
month_abbreviations = [
'', 'schan', 'fav', 'mars', 'avr', 'matg', 'zer', 'fan', 'avu',
'set', 'oct', 'nov', 'dec'
]
day_names = [
'', 'glindesdi', 'mardi', 'mesemna', 'gievgia', 'venderdi',
'sonda', 'dumengia'
]
day_abbreviations = [
'', 'gli', 'ma', 'me', 'gie', 've', 'so', 'du'
]
class SwissLocale(Locale):
names = ['de', 'de_ch']
past = 'vor {0}'
future = 'in {0}'
timeframes = {
'now': 'gerade eben',
'seconds': 'Sekunden',
'minute': 'einer Minute',
'minutes': '{0} Minuten',
'hour': 'einer Stunde',
'hours': '{0} Stunden',
'day': 'einem Tag',
'days': '{0} Tage',
'month': 'einem Monat',
'months': '{0} Monaten',
'year': 'einem Jahr',
'years': '{0} Jahren',
}
month_names = [
'', 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli',
'August', 'September', 'Oktober', 'November', 'Dezember'
]
month_abbreviations = [
'', 'Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep',
'Okt', 'Nov', 'Dez'
]
day_names = [
'', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag',
'Samstag', 'Sonntag'
]
day_abbreviations = [
'', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'
]
class RomanianLocale(Locale):
names = ['ro', 'ro_ro']
past = '{0} în urmă'
future = 'peste {0}'
timeframes = {
'now': 'acum',
'seconds': 'câteva secunde',
'minute': 'un minut',
'minutes': '{0} minute',
'hour': 'o oră',
'hours': '{0} ore',
'day': 'o zi',
'days': '{0} zile',
'month': 'o lună',
'months': '{0} luni',
'year': 'un an',
'years': '{0} ani',
}
month_names = ['', 'ianuarie', 'februarie', 'martie', 'aprilie', 'mai', 'iunie', 'iulie',
'august', 'septembrie', 'octombrie', 'noiembrie', 'decembrie']
month_abbreviations = ['', 'ian', 'febr', 'mart', 'apr', 'mai', 'iun', 'iul', 'aug', 'sept', 'oct', 'nov', 'dec']
day_names = ['', 'luni', 'marți', 'miercuri', 'joi', 'vineri', 'sâmbătă', 'duminică']
day_abbreviations = ['', 'Lun', 'Mar', 'Mie', 'Joi', 'Vin', 'Sâm', 'Dum']
class SlovenianLocale(Locale):
names = ['sl', 'sl_si']
past = 'pred {0}'
future = 'čez {0}'
timeframes = {
'now': 'zdaj',
'seconds': 'sekund',
'minute': 'minuta',
'minutes': '{0} minutami',
'hour': 'uro',
'hours': '{0} ur',
'day': 'dan',
'days': '{0} dni',
'month': 'mesec',
'months': '{0} mesecev',
'year': 'leto',
'years': '{0} let',
}
meridians = {
'am': '',
'pm': '',
'AM': '',
'PM': '',
}
month_names = [
'', 'Januar', 'Februar', 'Marec', 'April', 'Maj', 'Junij', 'Julij',
'Avgust', 'September', 'Oktober', 'November', 'December'
]
month_abbreviations = [
'', 'Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun', 'Jul', 'Avg',
'Sep', 'Okt', 'Nov', 'Dec'
]
day_names = [
'', 'Ponedeljek', 'Torek', 'Sreda', 'Četrtek', 'Petek', 'Sobota', 'Nedelja'
]
day_abbreviations = [
'', 'Pon', 'Tor', 'Sre', 'Čet', 'Pet', 'Sob', 'Ned'
]
_locales = _map_locales()

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