Compare commits

...

251 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
1f7be7a4d5 Offload image processing to the Plex server 2018-03-17 14:03:27 -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
184 changed files with 33553 additions and 2622 deletions

2
.gitignore vendored
View File

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

300
API.md
View File

@@ -32,6 +32,21 @@ General optional parameters:
## API methods ## 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_notifier_config
Add a new notification agent. Add a new notification agent.
@@ -93,27 +108,30 @@ Returns:
Delete and recreate the cache directory. Delete and recreate the cache directory.
### delete_image_cache ### delete_hosted_images
Delete and recreate the image cache directory. Delete the images uploaded to image hosting services.
### delete_imgur_poster
Delete the Imgur poster.
``` ```
Required parameters: Required parameters:
None
Optional parameters:
rating_key (int): 1234 rating_key (int): 1234
(Note: Must be the movie, show, season, artist, or album rating key) (Note: Must be the movie, show, season, artist, or album rating key)
Optional parameters: service (str): 'imgur' or 'cloudinary'
None delete_all (bool): 'true' to delete all images form the service
Returns: Returns:
json: json:
{"result": "success", {"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_library
Delete a library section from Tautulli. Also erases all history for the 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_notification_log
Delete the Tautulli notification logs. Delete the Tautulli notification logs.
@@ -386,6 +434,7 @@ Returns:
"optimized_version_profile": "", "optimized_version_profile": "",
"optimized_version_title": "", "optimized_version_title": "",
"originally_available_at": "2016-04-24", "originally_available_at": "2016-04-24",
"original_title": "",
"parent_media_index": "6", "parent_media_index": "6",
"parent_rating_key": "153036", "parent_rating_key": "153036",
"parent_thumb": "/library/metadata/153036/thumb/1503889210", "parent_thumb": "/library/metadata/153036/thumb/1503889210",
@@ -630,6 +679,7 @@ Returns:
"full_title": "Game of Thrones - The Red Woman", "full_title": "Game of Thrones - The Red Woman",
"grandparent_rating_key": 351, "grandparent_rating_key": 351,
"grandparent_title": "Game of Thrones", "grandparent_title": "Game of Thrones",
"original_title": "",
"group_count": 1, "group_count": 1,
"group_ids": "1124", "group_ids": "1124",
"id": 1124, "id": 1124,
@@ -916,9 +966,9 @@ Optional parameters:
Returns: Returns:
json: json:
[{"section_id": 1, "section_name": "Movies"}, [{"section_id": 1, "section_name": "Movies", "section_type": "movie"},
{"section_id": 7, "section_name": "Music"}, {"section_id": 7, "section_name": "Music", "section_type": "artist"},
{"section_id": 2, "section_name": "TV Shows"}, {"section_id": 2, "section_name": "TV Shows", "section_type": "show"},
{...} {...}
] ]
``` ```
@@ -1124,6 +1174,7 @@ Returns:
} }
], ],
"media_type": "episode", "media_type": "episode",
"original_title": "",
"originally_available_at": "2016-04-24", "originally_available_at": "2016-04-24",
"parent_media_index": "6", "parent_media_index": "6",
"parent_rating_key": "153036", "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_notification_log
Get the data on the Tautulli notification logs table. Get the data on the Tautulli notification logs table.
@@ -1174,8 +1328,8 @@ Required parameters:
None None
Optional parameters: Optional parameters:
order_column (str): "timestamp", "agent_name", "notify_action", order_column (str): "timestamp", "notifier_id", "agent_name", "notify_action",
"subject_text", "body_text", "script_args" "subject_text", "body_text",
order_dir (str): "desc" or "asc" order_dir (str): "desc" or "asc"
start (int): Row to start from, 0 start (int): Row to start from, 0
length (int): Number of items to return, 25 length (int): Number of items to return, 25
@@ -1188,15 +1342,14 @@ Returns:
"recordsFiltered": 163, "recordsFiltered": 163,
"data": "data":
[{"agent_id": 13, [{"agent_id": 13,
"agent_name": "Telegram", "agent_name": "telegram",
"body_text": "Game of Thrones - S06E01 - The Red Woman [Transcode].", "body_text": "DanyKhaleesi69 started playing The Red Woman.",
"id": 1000, "id": 1000,
"notify_action": "play", "notify_action": "on_play",
"poster_url": "http://i.imgur.com/ZSqS8Ri.jpg",
"rating_key": 153037, "rating_key": 153037,
"script_args": "[]",
"session_key": 147, "session_key": 147,
"subject_text": "Tautulli (Winterfell-Server)", "subject_text": "Tautulli (Winterfell-Server)",
"success": 1,
"timestamp": 1462253821, "timestamp": 1462253821,
"user": "DanyKhaleesi69", "user": "DanyKhaleesi69",
"user_id": 8008135 "user_id": 8008135
@@ -1629,6 +1782,7 @@ Returns:
"library_name": "", "library_name": "",
"media_index": "1", "media_index": "1",
"media_type": "episode", "media_type": "episode",
"original_title": "",
"parent_media_index": "6", "parent_media_index": "6",
"parent_rating_key": "153036", "parent_rating_key": "153036",
"parent_thumb": "/library/metadata/153036/thumb/1462175062", "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_stream_type_by_top_10_platforms
Get graph data by 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 ### notify_recently_added
Send a recently added notification using Tautulli. Send a recently added notification using Tautulli.
@@ -2244,8 +2478,12 @@ Required parameters:
rating_key (str): 54321 rating_key (str): 54321
Optional parameters: Optional parameters:
width (str): 150 width (str): 300
height (str): 255 height (str): 450
opacity (str): 25
background (str): 282828
blur (str): 3
img_format (str): png
fallback (str): "poster", "cover", "art" fallback (str): "poster", "cover", "art"
refresh (bool): True or False whether to refresh the image cache refresh (bool): True or False whether to refresh the image cache
@@ -2312,7 +2550,7 @@ Returns:
### set_mobile_device_config ### set_mobile_device_config
Configure an exisitng notificaiton agent. Configure an existing notification agent.
``` ```
Required parameters: 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 ### set_notifier_config
Configure an exisitng notificaiton agent. Configure an existing notification agent.
``` ```
Required parameters: Required parameters:

View File

@@ -1,5 +1,146 @@
# Changelog # Changelog
## 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) ## v2.0.26-beta (2018-03-30)
* Monitoring: * 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. If you think you can contribute code to the Tautulli repository, do not hesitate to submit a pull request.
### Branches ### 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 ### Python Code
#### Compatibility #### 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. 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 #### Documentation
Document your code. Use docstrings See [PEP-257](https://www.python.org/dev/peps/pep-0257/) for more information. 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 ### HTML/Template code
#### Compatibility #### Compatibility
HTML5 compatible browsers are targetted. There is no specific mobile version of Tautulli yet. HTML5 compatible browsers are targeted.
#### Conventions #### Conventions
* 4 space indentation * 4 space indentation

View File

@@ -27,9 +27,9 @@ This project is based on code from [Headphones](https://github.com/rembo10/headp
## Preview ## 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 ## Installation and Support

View File

@@ -49,6 +49,10 @@ DOCUMENTATION :: END
<td>Cache Directory:</td> <td>Cache Directory:</td>
<td>${plexpy.CONFIG.CACHE_DIR}</td> <td>${plexpy.CONFIG.CACHE_DIR}</td>
</tr> </tr>
<tr>
<td>Newsletter Directory:</td>
<td>${plexpy.CONFIG.NEWSLETTER_DIR}</td>
</tr>
<tr> <tr>
<td>GeoLite2 Database:</td> <td>GeoLite2 Database:</td>
% if plexpy.CONFIG.GEOIP_DB: % if plexpy.CONFIG.GEOIP_DB:
@@ -65,7 +69,7 @@ DOCUMENTATION :: END
% endif % endif
<tr> <tr>
<td>Platform:</td> <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>
<tr> <tr>
<td>Python Version:</td> <td>Python Version:</td>
@@ -74,7 +78,7 @@ DOCUMENTATION :: END
<tr> <tr>
<td class="top-line">Resources:</td> <td class="top-line">Resources:</td>
<td class="top-line"> <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" 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 guidelines-modal-link" href="${anon_url('https://github.com/%s/%s-Issues' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" data-id="issue">GitHub Issues</a> |
<a class="no-highlight" href="${anon_url('https://github.com/%s/%s-Wiki' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank">GitHub Wiki</a> | <a class="no-highlight" href="${anon_url('https://github.com/%s/%s-Wiki' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank">GitHub Wiki</a> |

View File

@@ -70,6 +70,7 @@ div.form-control .selectize-input {
background-color: #555; background-color: #555;
border-radius: 3px; border-radius: 3px;
transition: background-color .3s; transition: background-color .3s;
height: 32px !important;
} }
.react-selectize.root-node .react-selectize-control, .react-selectize.root-node .react-selectize-control,
.selectize-control.form-control .selectize-input { .selectize-control.form-control .selectize-input {
@@ -125,8 +126,10 @@ div.form-control .selectize-input {
padding-bottom: 2px !important; padding-bottom: 2px !important;
transition: background-color .3s; transition: background-color .3s;
} }
.react-selectize.root-node .simple-value span { .react-selectize.root-node .simple-value span,
.selectize-control.multi .selectize-input > div {
padding-bottom: 2px !important; padding-bottom: 2px !important;
padding-left: 5px !important;
} }
.react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .value-wrapper:not(:first-child):before { .react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .value-wrapper:not(:first-child):before {
content: "or"; content: "or";
@@ -294,6 +297,10 @@ object {
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
} }
.padded-header h3 small {
font-size: 13px;
text-transform: none;
}
.btn { .btn {
outline:0px !important; outline:0px !important;
} }
@@ -460,6 +467,18 @@ fieldset[disabled] .btn-bright.active {
.btn-group select { .btn-group select {
margin-top: 0; margin-top: 0;
} }
.input-group-addon-form {
display: inline-block;
line-height: 1.42857143;
color: #e5e5e5;
background-color: #3B3B3B;
border: 1px solid transparent;
border-top-right-radius: 3px !important;
border-bottom-right-radius: 3px !important;
height: 32px;
width: 100%;
margin-top: 5px;
}
#user-selection label { #user-selection label {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -738,7 +757,10 @@ a .users-poster-face:hover {
transition: all .2s ease-in-out; transition: all .2s ease-in-out;
overflow: hidden; overflow: hidden;
} }
.dashboard-activity-background-overlay { .dashboard-activity-background {
background-color: #282828;
background-position: center;
background-size: cover;
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
-webkit-flex-wrap: nowrap; -webkit-flex-wrap: nowrap;
@@ -747,30 +769,13 @@ a .users-poster-face:hover {
width: 100%; width: 100%;
padding: 5px; padding: 5px;
overflow: hidden; overflow: hidden;
-webkit-box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
-moz-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1);
box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
}
.dashboard-activity-background {
background-color: #282828;
background-position: center;
background-size: cover;
height: 235px;
width: 100%;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0.40;
-webkit-filter: blur(3px);
-moz-filter: blur(3px);
filter: blur(3px);
-webkit-transition: background 1s linear; -webkit-transition: background 1s linear;
transition: background 1s linear; transition: background 1s linear;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
backface-visibility: hidden; backface-visibility: hidden;
z-index: -1; -webkit-box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
-moz-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1);
box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
} }
.dashboard-activity-poster-container { .dashboard-activity-poster-container {
background-color: #282828; background-color: #282828;
@@ -801,14 +806,14 @@ a .users-poster-face:hover {
background-size: cover; background-size: cover;
height: 225px; height: 225px;
width: 150px; width: 150px;
-webkit-transition: background .2s ease-in-out;
transition: background .2s ease-in-out;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
opacity: 0.60; opacity: 0.60;
-webkit-filter: blur(3px); -webkit-filter: blur(3px);
-moz-filter: blur(3px); -moz-filter: blur(3px);
filter: blur(3px); filter: blur(3px);
-webkit-transition: background .2s ease-in-out;
transition: background .2s ease-in-out;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
z-index: 2; z-index: 2;
} }
.dashboard-activity-cover { .dashboard-activity-cover {
@@ -1155,7 +1160,10 @@ a .dashboard-activity-metadata-user-thumb:hover {
transition: all .2s ease-in-out; transition: all .2s ease-in-out;
overflow: hidden; overflow: hidden;
} }
.dashboard-stats-background-overlay { .dashboard-stats-background {
background-color: #282828;
background-position: center;
background-size: cover;
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
-webkit-flex-wrap: nowrap; -webkit-flex-wrap: nowrap;
@@ -1164,30 +1172,13 @@ a .dashboard-activity-metadata-user-thumb:hover {
width: 100%; width: 100%;
padding: 5px; padding: 5px;
overflow: hidden; overflow: hidden;
-webkit-box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
-moz-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1);
box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
}
.dashboard-stats-background {
background-color: #282828;
background-position: center;
background-size: cover;
height: 160px;
width: 100%;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0.40;
-webkit-filter: blur(3px);
-moz-filter: blur(3px);
filter: blur(3px);
-webkit-transition: background .2s ease-in-out; -webkit-transition: background .2s ease-in-out;
transition: background .2s ease-in-out; transition: background .2s ease-in-out;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
backface-visibility: hidden; backface-visibility: hidden;
z-index: -1; -webkit-box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
-moz-box-shadow: 0 0 4px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.1);
box-shadow: 0 0 4px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.1);
} }
.dashboard-stats-background.flat { .dashboard-stats-background.flat {
opacity: 1; opacity: 1;
@@ -1207,17 +1198,6 @@ a .dashboard-activity-metadata-user-thumb:hover {
z-index: 1; z-index: 1;
} }
.dashboard-stats-poster { .dashboard-stats-poster {
background-position: center;
background-size: cover;
height: 150px;
width: 100px;
-webkit-transition: background .2s ease-in-out;
transition: background .2s ease-in-out;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
z-index: 2;
}
.dashboard-stats-poster-blur {
background-color: #282828; background-color: #282828;
background-position: center; background-position: center;
background-size: cover; background-size: cover;
@@ -1227,10 +1207,6 @@ a .dashboard-activity-metadata-user-thumb:hover {
transition: background .2s ease-in-out; transition: background .2s ease-in-out;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
backface-visibility: hidden; backface-visibility: hidden;
opacity: 0.60;
-webkit-filter: blur(3px);
-moz-filter: blur(3px);
filter: blur(3px);
z-index: 2; z-index: 2;
} }
.dashboard-stats-cover { .dashboard-stats-cover {
@@ -2153,6 +2129,12 @@ a:hover .item-children-poster {
top: 5px; top: 5px;
left: 12px; left: 12px;
} }
.settings-warning {
color: #eb8600;
}
span.settings-warning {
padding-left: 10px;
}
#menu_link_show_advanced_settings.active { #menu_link_show_advanced_settings.active {
color: #fff; color: #fff;
background-color: #cc7b19; background-color: #cc7b19;
@@ -2373,18 +2355,6 @@ a .library-user-instance-box:hover {
margin-top: 9px; margin-top: 9px;
width: 175px; width: 175px;
} }
.home-padded-header .info-bar {
float: left;
margin-left: 15px;
line-height: 35px;
}
.home-padded-header .info-bar small {
font-size: 13px;
font-weight: normal;
text-transform: none;
line-height: 1;
color: #777;
}
.home-padded-header .button-bar { .home-padded-header .button-bar {
float: left; float: left;
} }
@@ -2965,6 +2935,7 @@ a .home-platforms-list-cover-face:hover
} }
.stacked-configs > li > span > a.toggle-left, .stacked-configs > li > span > a.toggle-left,
.stacked-configs > li > span > span.toggle-left { .stacked-configs > li > span > span.toggle-left {
float: left;
color: #444; color: #444;
padding-right: 8px; padding-right: 8px;
} }
@@ -2975,13 +2946,6 @@ a .home-platforms-list-cover-face:hover
.stacked-configs > li > span > span.active { .stacked-configs > li > span > span.active {
color: #f9be03; 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 > a.toggle-left,
.stacked-configs > li.mobile-device > span > span.toggle-left { .stacked-configs > li.mobile-device > span > span.toggle-left {
color: #999; color: #999;
@@ -3552,8 +3516,7 @@ a.no-highlight:hover {
} }
.login-logo { .login-logo {
margin: 0 auto 50px auto; margin: 0 auto 50px auto;
width: 340px; text-align: center;
height: 100px;
} }
.login-container .form-group { .login-container .form-group {
margin-bottom: 20px; margin-bottom: 20px;
@@ -3662,38 +3625,71 @@ a:hover .overlay-refresh-image:hover {
} }
#plexpy-notifiers-table .friendly_name, #plexpy-notifiers-table .friendly_name,
#notifier-config-modal span.notifier_id, #notifier-config-modal span.notifier_id,
#plexpy-newsletters-table .friendly_name,
#newsletter-config-modal span.newsletter_id,
#plexpy-mobile-devices-table .friendly_name, #plexpy-mobile-devices-table .friendly_name,
#mobile-device-config-modal span.notifier_id { #mobile-device-config-modal span.notifier_id {
color: #777; color: #777;
} }
#notifier-config-modal .nav-tabs { #notifier-config-modal .nav-tabs,
#newsletter-config-modal .nav-tabs {
margin-bottom: 10px; margin-bottom: 10px;
padding-left: 15px; padding-left: 15px;
border-bottom: 1px solid #444; border-bottom: 1px solid #444;
} }
#notifier-config-modal .nav-tabs > li { #notifier-config-modal .nav-tabs > li,
#newsletter-config-modal .nav-tabs > li {
margin: 0 0 -1px 0; margin: 0 0 -1px 0;
} }
#notifier-config-modal .nav-tabs > li > a { #notifier-config-modal .nav-tabs > li > a,
#newsletter-config-modal .nav-tabs > li > a {
padding: 5px 10px; padding: 5px 10px;
color: #737373; color: #737373;
} }
#notifier-config-modal .nav-tabs > li > a:hover { #notifier-config-modal .nav-tabs > li > a:hover,
#newsletter-config-modal .nav-tabs > li > a:hover {
border-color: #444; border-color: #444;
background: #222; background: #222;
} }
#notifier-config-modal .nav-tabs > li.active > a, #notifier-config-modal .nav-tabs > li.active > a,
#notifier-config-modal .nav-tabs > li.active > a:hover, #notifier-config-modal .nav-tabs > li.active > a:hover,
#notifier-config-modal .nav-tabs > li.active > a:focus { #notifier-config-modal .nav-tabs > li.active > a:focus,
#newsletter-config-modal .nav-tabs > li.active > a,
#newsletter-config-modal .nav-tabs > li.active > a:hover,
#newsletter-config-modal .nav-tabs > li.active > a:focus {
color: #fff; color: #fff;
background: #222; background: #222;
} }
#notifier-config-modal .nav-tabs > li.active > a, #notifier-config-modal .nav-tabs > li.active > a,
#notifier-config-modal .nav-tabs > li.active > a:hover, #notifier-config-modal .nav-tabs > li.active > a:hover,
#notifier-config-modal .nav-tabs > li.active > a:focus { #notifier-config-modal .nav-tabs > li.active > a:focus,
#newsletter-config-modal .nav-tabs > li.active > a,
#newsletter-config-modal .nav-tabs > li.active > a:hover,
#newsletter-config-modal .nav-tabs > li.active > a:focus {
border: 1px solid #444; border: 1px solid #444;
border-bottom-color: transparent; border-bottom-color: transparent;
} }
#newsletter-config-modal #custom_cron {
display: inline-block;
width: initial;
height: 32px;
margin-right: 5px;
margin-top: 4px;
}
#newsletter-config-modal #cron-widget {
display: inline-block;
margin-top: 1px;
}
#newsletter-config-modal #cron-widget select.cron-select {
width: initial;
display: inline;
height: 32px;
margin-top: 4px;
}
#newsletter-config-modal #cron-widget select.cron-select[name=cron-period] option[value=minute],
#newsletter-config-modal #cron-widget select.cron-select[name=cron-period] option[value=hour] {
display: none !important;
}
.git-group input.form-control { .git-group input.form-control {
width: 50%; width: 50%;
} }
@@ -3846,6 +3842,90 @@ a:hover .overlay-refresh-image:hover {
background-color: #107c10; background-color: #107c10;
background-image: url(../images/platforms/xbox.svg); background-image: url(../images/platforms/xbox.svg);
} }
.platform-android-rgba {
background-color: rgba(164, 202, 57, 0.40);
}
.platform-atv-rgba {
background-color: rgba(133, 132, 135, 0.40);
}
.platform-chrome-rgba {
background-color: rgba(237, 94, 80, 0.40);
}
.platform-chromecast-rgba {
background-color: rgba(16, 164, 232, 0.40);
}
.platform-default-rgba {
background-color: rgba(229, 160, 13, 0.40);
}
.platform-dlna-rgba {
background-color: rgba(12, 177, 75, 0.40);
}
.platform-firefox-rgba {
background-color: rgba(230, 120, 23, 0.40);
}
.platform-gtv-rgba {
background-color: rgba(0, 139, 207, 0.40);
}
.platform-ie-rgba {
background-color: rgba(0, 89, 158, 0.40);
}
.platform-ios-rgba {
background-color: rgba(133, 132, 135, 0.40);
}
.platform-kodi-rgba {
background-color: rgba(49, 175, 225, 0.40);
}
.platform-linux-rgba {
background-color: rgba(23, 147, 208, 0.40);
}
.platform-macos-rgba {
background-color: rgba(133, 132, 135, 0.40);
}
.platform-msedge-rgba {
background-color: rgba(0, 120, 215, 0.40);
}
.platform-opera-rgba {
background-color: rgba(255, 27, 45, 0.40);
}
.platform-playstation-rgba {
background-color: rgba(3, 77, 162, 0.40);
}
.platform-plex-rgba {
background-color: rgba(229, 160, 13, 0.40);
}
.platform-plexamp-rgba {
background-color: rgba(229, 160, 13, 0.40);
}
.platform-roku-rgba {
background-color: rgba(109, 60, 151, 0.40);
}
.platform-safari-rgba {
background-color: rgba(0, 169, 236, 0.40);
}
.platform-samsung-rgba {
background-color: rgba(3, 78, 162, 0.40);
}
.platform-synclounge-rgba {
background-color: rgba(21, 25, 36, 0.40);
}
.platform-tivo-rgba {
background-color: rgba(0, 167, 225, 0.40);
}
.platform-wiiu-rgba {
background-color: rgba(3, 169, 244, 0.40);
}
.platform-windows-rgba {
background-color: rgba(47, 192, 245, 0.40);
}
.platform-wp-rgba {
background-color: rgba(104, 33, 122, 0.40);
}
.platform-xbmc-rgba {
background-color: rgba(59, 72, 114, 0.40);
}
.platform-xbox-rgba {
background-color: rgba(16, 124, 16, 0.40);
}
.library-movie { .library-movie {
background-image: url(../images/libraries/movie.svg); background-image: url(../images/libraries/movie.svg);
} }
@@ -3957,3 +4037,62 @@ a:hover .overlay-refresh-image:hover {
-webkit-appearance: none; -webkit-appearance: none;
margin: 0; 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;
}

View File

@@ -79,20 +79,19 @@ DOCUMENTATION :: END
<div class="dashboard-activity-instance" id="activity-instance-${sk}" data-key="${sk}" data-id="${data['session_id']}" <div class="dashboard-activity-instance" id="activity-instance-${sk}" data-key="${sk}" data-id="${data['session_id']}"
data-rating_key="${data['rating_key']}" data-parent_rating_key="${data['parent_rating_key']}" data-grandparent_rating_key="${data['grandparent_rating_key']}"> data-rating_key="${data['rating_key']}" data-parent_rating_key="${data['parent_rating_key']}" data-grandparent_rating_key="${data['grandparent_rating_key']}">
<div class="dashboard-activity-container"> <div class="dashboard-activity-container">
<div class="dashboard-activity-background-overlay"> <%
% if data['channel_stream'] == 0: if data['channel_stream'] == 0:
<div id="background-${sk}" class="dashboard-activity-background" style="background-image: url(pms_image_proxy?img=${data['art']}&width=500&height=280&fallback=art&refresh=true);"></div> background_url = 'pms_image_proxy?img=' + data['art'] + '&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art&refresh=true'
% else: else:
% if (data['art'] and data['art'].startswith('http')) or (data['thumb'] and data['thumb'].startswith('http')): if (data['art'] and data['art'].startswith('http')) or (data['thumb'] and data['thumb'].startswith('http')):
<div id="background-${sk}" class="dashboard-activity-background" style="background-image: url(${data['art']});"></div> background_url = data['art']
% else: else:
<!--Hacky solution to escape the image url until I come up with something better--> background_url = 'pms_image_proxy?img=' + quote(data['art'] or data['thumb']) + '&width=500&height=280&fallback=art&refresh=true&clip=true'
<div id="background-${sk}" class="dashboard-activity-background" style="background-image: url(pms_image_proxy?img=${quote(data['art'] or data['thumb'])}&width=500&height=280&fallback=art&refresh=true&clip=true);"></div> %>
% endif <div id="background-${sk}" class="dashboard-activity-background" style="background-image: url(${background_url});">
% endif
<div class="dashboard-activity-poster-container hidden-xs"> <div class="dashboard-activity-poster-container hidden-xs">
% if data['media_type'] == 'track': % if data['media_type'] == 'track':
<div id="poster-${sk}-bg" class="dashboard-activity-poster-blur" style="background-image: url(pms_image_proxy?img=${data['parent_thumb']}&width=300&height=300&fallback=cover&refresh=true);"></div> <div id="poster-${sk}-bg" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['parent_thumb']}&width=300&height=300&opacity=60&background=282828&blur=3&fallback=cover&refresh=true);"></div>
% endif % endif
% if data['channel_stream'] == 0: % if data['channel_stream'] == 0:
% if data['media_type'] == 'movie': % if data['media_type'] == 'movie':
@@ -121,7 +120,7 @@ DOCUMENTATION :: END
<div id="poster-${sk}" class="dashboard-activity-poster-blur" style="background-image: url(${data['channel_icon']});"></div> <div id="poster-${sk}" class="dashboard-activity-poster-blur" style="background-image: url(${data['channel_icon']});"></div>
<div id="poster-${sk}" class="dashboard-activity-cover" style="background-image: url(${data['channel_icon']});"></div> <div id="poster-${sk}" class="dashboard-activity-cover" style="background-image: url(${data['channel_icon']});"></div>
% else: % else:
<div id="poster-${sk}" class="dashboard-activity-poster-blur" style="background-image: url(pms_image_proxy?img=${data['channel_icon']}&width=300&height=300&fallback=cover&refresh=true);"></div> <div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['channel_icon']}&width=300&height=300&opacity=60&background=282828&blur=3&fallback=cover&refresh=true);"></div>
<div id="poster-${sk}" class="dashboard-activity-cover" style="background-image: url(pms_image_proxy?img=${data['channel_icon']}&width=300&height=300&fallback=cover&refresh=true);"></div> <div id="poster-${sk}" class="dashboard-activity-cover" style="background-image: url(pms_image_proxy?img=${data['channel_icon']}&width=300&height=300&fallback=cover&refresh=true);"></div>
% endif % endif
% endif % endif
@@ -388,8 +387,8 @@ DOCUMENTATION :: END
<a href="${grandparent_href}" title="${data['grandparent_title']}">${data['grandparent_title']}</a> <a href="${grandparent_href}" title="${data['grandparent_title']}">${data['grandparent_title']}</a>
- <a href="${href}" title="${data['title']}">${data['title']}</a> - <a href="${href}" title="${data['title']}">${data['title']}</a>
% elif data['media_type'] == 'track': % 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': % elif data['media_type'] == 'photo':
<span title="${data['parent_title']}">${data['parent_title']}</span> <span title="${data['parent_title']}">${data['parent_title']}</span>
% elif data['media_type'] == 'clip': % elif data['media_type'] == 'clip':

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 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,21 +5,14 @@
</%def> </%def>
<%def name="body()"> <%def name="body()">
<% from plexpy import PLEX_SERVER_UP %>
<div class="container-fluid"> <div class="container-fluid">
% for section in config['home_sections']: % for section in config['home_sections']:
% if section == 'current_activity': % if section == 'current_activity':
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div class="home-padded-header padded-header" id="current-activity-header"> <div class="padded-header" id="current-activity-header">
<h3 class="pull-left"><span id="sessions-shortcut">Current Activity</span></h3> <h3><span id="sessions-shortcut">Activity</span> &nbsp;&nbsp;
<div class="button-bar">
<div class="input-group pull-left" style="width: 1px; margin-right: 3px" id="activity-refresh-interval-selection">
<span class="input-group-addon btn-dark inactive">Refresh Every</span>
<input type="number" class="form-control number-input" name="activity-refresh-interval" id="activity-refresh-interval" value="${config['home_refresh_interval']}" min="2" data-default="2" data-toggle="tooltip" title="Min: 2 seconds" />
<span class="input-group-addon btn-dark inactive">seconds</span>
</div>
</div>
<div class="info-bar">
<small> <small>
<span id="currentActivityHeader" style="display: none;"> <span id="currentActivityHeader" style="display: none;">
Streams: <span id="currentActivityHeader-streams"></span> | Streams: <span id="currentActivityHeader-streams"></span> |
@@ -27,12 +20,13 @@
<span id="currentActivityHeader-bandwidth-tooltip" data-toggle="tooltip" title="Streaming Brain Estimate (Required Bandwidth)"><i class="fa fa-info-circle"></i></span> <span id="currentActivityHeader-bandwidth-tooltip" data-toggle="tooltip" title="Streaming Brain Estimate (Required Bandwidth)"><i class="fa fa-info-circle"></i></span>
</span> </span>
</small> </small>
</div> </h3>
</div> </div>
<div id="currentActivity"> <div id="currentActivity">
<% from plexpy import PLEX_SERVER_UP %>
% if PLEX_SERVER_UP: % if PLEX_SERVER_UP:
<div class="text-muted" id="dashboard-checking-activity"><i class="fa fa-refresh fa-spin"></i> Checking for activity...</div> <div class="text-muted" id="dashboard-checking-activity"><i class="fa fa-refresh fa-spin"></i> Checking for activity...</div>
% elif config['pms_is_cloud']:
<div id="dashboard-no-activity" class="text-muted">Plex Cloud server is sleeping.</div>
% else: % else:
<div id="dashboard-no-activity" class="text-muted">There was an error communicating with your Plex Server. <div id="dashboard-no-activity" class="text-muted">There was an error communicating with your Plex Server.
% if _session['user_group'] == 'admin': % if _session['user_group'] == 'admin':
@@ -131,7 +125,7 @@
</label> </label>
</div> </div>
<div class="input-group pull-left" style="width: 1px;" id="recently-added-count-selection"> <div class="input-group pull-left" style="width: 1px;" id="recently-added-count-selection">
<input type="number" class="form-control 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> <span class="input-group-addon btn-dark inactive">items</span>
</div> </div>
</div> </div>
@@ -141,7 +135,17 @@
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div id="recentlyAdded" style="margin-right: -15px;"> <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> <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> <br>
</div> </div>
</div> </div>
@@ -228,6 +232,7 @@
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
<% from plexpy import PLEX_SERVER_UP %>
<script src="${http_root}js/moment-with-locale.js"></script> <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.scrollbar.min.js"></script>
<script src="${http_root}js/jquery.mousewheel.min.js"></script> <script src="${http_root}js/jquery.mousewheel.min.js"></script>
@@ -260,7 +265,6 @@
}); });
} }
</script> </script>
<% from plexpy import PLEX_SERVER_UP %>
% if 'current_activity' in config['home_sections'] and PLEX_SERVER_UP: % if 'current_activity' in config['home_sections'] and PLEX_SERVER_UP:
<script> <script>
var defaultHandler = { var defaultHandler = {
@@ -383,16 +387,16 @@
if (s.media_type === 'track') { if (s.media_type === 'track') {
// Update if artist changed // Update if artist changed
if (s.grandparent_rating_key !== instance.data('grandparent_rating_key')) { if (s.grandparent_rating_key !== instance.data('grandparent_rating_key')) {
$('#background-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.art + '&width=500&height=280&fallback=art&refresh=true)'); $('#background-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.art + '&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art&refresh=true)');
$('#metadata-grandparent_title-' + key) $('#metadata-grandparent_title-' + key)
.attr('href', 'info?rating_key=' + s.grandparent_rating_key) .attr('href', 'info?rating_key=' + s.grandparent_rating_key)
.attr('title', s.grandparent_title) .attr('title', s.original_title || s.grandparent_title)
.text(s.grandparent_title); .text(s.original_title || s.grandparent_title);
} }
// Update cover if album changed // Update cover if album changed
if (s.parent_rating_key !== instance.data('parent_rating_key')) { if (s.parent_rating_key !== instance.data('parent_rating_key')) {
$('#poster-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&fallback=poster&refresh=true)'); $('#poster-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&fallback=poster&refresh=true)');
$('#poster-' + key + '-bg').css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&fallback=poster&refresh=true)'); $('#poster-' + key + '-bg').css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&opacity=60&background=282828&blur=3&fallback=poster&refresh=true)');
$('#poster-url-' + key) $('#poster-url-' + key)
.attr('href', 'info?rating_key=' + s.parent_rating_key) .attr('href', 'info?rating_key=' + s.parent_rating_key)
.attr('title', s.parent_title); .attr('title', s.parent_title);
@@ -402,7 +406,11 @@
.text(s.parent_title); .text(s.parent_title);
} }
// Update cover if track changed // 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) $('#metadata-title-' + key)
.attr('href', 'info?rating_key=' + s.rating_key) .attr('href', 'info?rating_key=' + s.rating_key)
.attr('title', s.title) .attr('title', s.title)
@@ -591,16 +599,11 @@
} }
getCurrentActivity(); getCurrentActivity();
setInterval(function () {
function refreshActivity(seconds) { if (!(create_instances.length) && activity_ready) {
return setInterval(function () { getCurrentActivity();
if (!(create_instances.length) && activity_ready) { }
getCurrentActivity(); }, ${config['home_refresh_interval'] * 1000});
}
}, seconds * 1000);
}
var refresh_interval = $('#activity-refresh-interval').val();
var activityRefresh = refreshActivity(refresh_interval);
setInterval(function(){ setInterval(function(){
$('.progress_time_offset').each(function () { $('.progress_time_offset').each(function () {
@@ -696,16 +699,6 @@
window.open(sessions_url, '_blank'); window.open(sessions_url, '_blank');
}); });
}); });
$('#activity-refresh-interval').change(function () {
forceMinMax($(this));
clearInterval(activityRefresh);
refresh_interval = $(this).val();
activityRefresh = refreshActivity(refresh_interval);
$.post('set_home_stats_config', { refresh_interval: refresh_interval });
});
$('#activity-refresh-interval').tooltip({ container: 'body', placement: 'top', html: true });
% endif % endif
</script> </script>
% endif % endif
@@ -767,7 +760,7 @@
getLibraryStats(); getLibraryStats();
</script> </script>
% endif % endif
% if 'recently_added' in config['home_sections']: % if 'recently_added' in config['home_sections'] and PLEX_SERVER_UP:
<script> <script>
function recentlyAdded(recently_added_count, recently_added_type) { function recentlyAdded(recently_added_count, recently_added_type) {
showMsg("Loading recently added items...", true, false, 0); showMsg("Loading recently added items...", true, false, 0);

View File

@@ -165,7 +165,7 @@ DOCUMENTATION :: END
<h1><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a></h1> <h1><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a></h1>
<h2>${data['title']}</h2> <h2>${data['title']}</h2>
% elif data['media_type'] == 'track': % 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> <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> <h3 class="hidden-xs">T${data['media_index']}</h3>
% endif % endif
@@ -371,7 +371,11 @@ DOCUMENTATION :: END
<div class="col-md-12"> <div class="col-md-12">
<div class="table-card-header"> <div class="table-card-header">
<div class="header-bar"> <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> <span>Watch History for <strong>${data['title']}</strong></span>
% endif
</div> </div>
<div class="button-bar"> <div class="button-bar">
% if _session['user_group'] == 'admin': % if _session['user_group'] == 'admin':
@@ -400,14 +404,14 @@ DOCUMENTATION :: END
% if data.get('poster_url'): % if data.get('poster_url'):
<div class="btn-group"> <div class="btn-group">
% if data['media_type'] == 'artist' or data['media_type'] == 'album' or data['media_type'] == 'track': % if data['media_type'] == 'artist' or data['media_type'] == 'album' or data['media_type'] == 'track':
<span class="imgur-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="80" data-width="80" style="display: inline-flex;"> <span class="hosted-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="80" data-width="80" style="display: inline-flex;">
% else: % else:
<span class="imgur-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="120" data-width="80" style="display: inline-flex;"> <span class="hosted-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="120" data-width="80" style="display: inline-flex;">
% endif % endif
<button class="btn btn-danger btn-edit" data-toggle="modal" aria-pressed="false" autocomplete="off" id="delete-imgur-poster" <button class="btn btn-danger btn-edit" data-toggle="modal" aria-pressed="false" autocomplete="off" id="delete-hosted-poster"
data-id="${data['parent_rating_key'] if data['media_type'] in ('episode', 'track') else data['rating_key']}" data-id="${data['parent_rating_key'] if data['media_type'] in ('episode', 'track') else data['rating_key']}"
data-title="${data["poster_title"]}"> data-title="${data["poster_title"]}">
<i class="fa fa-picture-o"></i> Delete Imgur Poster <i class="fa fa-picture-o"></i> Delete ${data['img_service']} Poster
</button> </button>
</span> </span>
</div> </div>
@@ -502,7 +506,7 @@ DOCUMENTATION :: END
% elif data['media_type'] == 'album': % elif data['media_type'] == 'album':
${data['parent_title']}<br />${data['title']} ${data['parent_title']}<br />${data['title']}
% elif data['media_type'] == 'track': % 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 % endif
</strong> </strong>
</p> </p>
@@ -705,7 +709,7 @@ DOCUMENTATION :: END
</script> </script>
% if data.get('poster_url'): % if data.get('poster_url'):
<script> <script>
$('.imgur-poster-tooltip').popover({ $('.hosted-poster-tooltip').popover({
html: true, html: true,
container: 'body', container: 'body',
trigger: 'hover', trigger: 'hover',
@@ -716,14 +720,14 @@ DOCUMENTATION :: END
} }
}); });
$('#delete-imgur-poster').on('click', function () { $('#delete-hosted-poster').on('click', function () {
var msg = 'Are you sure you want to delete the Imgur poster for <strong>' + $(this).data('title') + '</strong>?<br><br>' + var msg = 'Are you sure you want to delete the ${data['img_service']} poster for <strong>' + $(this).data('title') + '</strong>?<br><br>' +
'All previous links to this image will no longer work.'; 'All previous links to this image will no longer work.';
var url = 'delete_imgur_poster'; var url = 'delete_hosted_images';
var data = { rating_key: $(this).data('id') }; var data = { rating_key: $(this).data('id') };
var callback = function () { var callback = function () {
$('.imgur-poster-tooltip').popover('destroy'); $('.hosted-poster-tooltip').popover('destroy');
$('#delete-imgur-poster').closest('.btn-group').remove(); $('#delete-hosted-poster').closest('.btn-group').remove();
}; };
confirmAjaxCall(url, msg, data, false, callback); confirmAjaxCall(url, msg, data, false, callback);
}); });

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-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-card-overlay">
<div class="item-children-overlay-text"> <div class="item-children-overlay-text">
Episode ${child['media_index']} Episode ${child['media_index'] or child['originally_available_at']}
</div> </div>
</div> </div>
</div> </div>
@@ -122,16 +122,24 @@ DOCUMENTATION :: END
% elif data['children_type'] == 'track': % elif data['children_type'] == 'track':
% if loop.index % 2 == 0: % if loop.index % 2 == 0:
<div class="item-children-list-item-even"> <div class="item-children-list-item-even">
<span class="item-children-list-item-index">${child['media_index']}</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></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}"> <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> <script>$('#item-children-list-item-duration-${loop.index + 1}').text(moment.utc(${child['duration']}).format("m:ss"));</script>
</span> </span>
</div> </div>
% else: % else:
<div class="item-children-list-item-odd"> <div class="item-children-list-item-odd">
<span class="item-children-list-item-index">${child['media_index']}</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></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}"> <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> <script>$('#item-children-list-item-duration-${loop.index + 1}').text(moment.utc(${child['duration']}).format("m:ss"));</script>
</span> </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> <span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
% endif % endif
<div class="item-children-instance-text-wrapper album-item"> <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['title']}">${child['title']}</h3>
<h3 title="${child['parent_title']}" class="text-muted">${child['parent_title']}</h3> <h3 title="${child['parent_title']}" class="text-muted">${child['parent_title']}</h3>
</div> </div>

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -35,8 +35,7 @@ DOCUMENTATION :: END
% if section_type in data: % if section_type in data:
<div class="dashboard-stats-instance" id="library-stats-instance-${section_type}" data-section_type="${section_type}"> <div class="dashboard-stats-instance" id="library-stats-instance-${section_type}" data-section_type="${section_type}">
<div class="dashboard-stats-container"> <div class="dashboard-stats-container">
<div class="dashboard-stats-background-overlay"> <div id="library-stats-background-${section_type}" class="dashboard-stats-background" style="background-image: url(pms_image_proxy?img=/:/resources/${section_type}-fanart.jpg&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art);">
<div id="library-stats-background-${section_type}" class="dashboard-stats-background" style="background-image: url(pms_image_proxy?img=/:/resources/${section_type}-fanart.jpg&width=500&height=280&fallback=art);"></div>
<div id="library-stats-thumb-${section_type}" class="dashboard-stats-flat svg-icon library-${section_type} hidden-xs"></div> <div id="library-stats-thumb-${section_type}" class="dashboard-stats-flat svg-icon library-${section_type} hidden-xs"></div>
<div class="dashboard-stats-info-container"> <div class="dashboard-stats-info-container">
<div id="library-stats-title-${section_type}" class="dashboard-stats-info-title"> <div id="library-stats-title-${section_type}" class="dashboard-stats-info-title">

View File

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

View File

@@ -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="download-plexscannerlog" style="display: none;"><i class="fa fa-download"></i> Download logs</button>
<button class="btn btn-dark" id="clear-logs"><i class="fa fa-trash-o"></i> Clear logs</button> <button class="btn btn-dark" id="clear-logs"><i class="fa fa-trash-o"></i> Clear logs</button>
<button class="btn btn-dark" id="clear-notify-logs" style="display: none;"><i class="fa fa-trash-o"></i> Clear logs</button> <button class="btn btn-dark" id="clear-notify-logs" style="display: none;"><i class="fa fa-trash-o"></i> Clear logs</button>
<button class="btn btn-dark" id="clear-newsletter-logs" style="display: none;"><i class="fa fa-trash-o"></i> Clear logs</button>
<button class="btn btn-dark" id="clear-login-logs" style="display: none;"><i class="fa fa-trash-o"></i> Clear logs</button> <button class="btn btn-dark" id="clear-login-logs" style="display: none;"><i class="fa fa-trash-o"></i> Clear logs</button>
</div> </div>
</div> </div>
@@ -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-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="plex-websocket-logs-btn" href="#tabs-plex_websocket_log" aria-controls="tabs-plex_websocket_log" role="tab" data-toggle="tab">Plex Websocket Logs</a></li>
<li role="presentation"><a id="notification-logs-btn" href="#tabs-notification_log" aria-controls="tabs-notification_log" role="tab" data-toggle="tab">Notification Logs</a></li> <li role="presentation"><a id="notification-logs-btn" href="#tabs-notification_log" aria-controls="tabs-notification_log" role="tab" data-toggle="tab">Notification Logs</a></li>
<li role="presentation"><a id="newsletter-logs-btn" href="#tabs-newsletter_log" aria-controls="tabs-newsletter_log" role="tab" data-toggle="tab">Newsletter Logs</a></li>
<li role="presentation"><a id="login-logs-btn" href="#tabs-login_log" aria-controls="tabs-login_log" role="tab" data-toggle="tab">Login Logs</a></li> <li role="presentation"><a id="login-logs-btn" href="#tabs-login_log" aria-controls="tabs-login_log" role="tab" data-toggle="tab">Login Logs</a></li>
</ul> </ul>
<div class="tab-content"> <div class="tab-content">
@@ -141,6 +143,25 @@
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
<div role="tabpanel" class="tab-pane" id="tabs-newsletter_log">
<table class="display" id="newsletter_log_table" width="100%">
<thead>
<tr>
<th align="left" id="newsletter_timestamp">Timestamp</th>
<th align="left" id="newsletter_newsletter_id">Newsletter ID</th>
<th align="left" id="newsletter_agent_name">Agent</th>
<th align="left" id="newsletter_notify_action">Action</th>
<th align="left" id="newsletter_subject_text">Subject Text</th>
<th align="left" id="newsletter_body_text">Body Text</th>
<th align="left" id="newsletter_start_date">Start Date</th>
<th align="left" id="newsletter_end_date">End Date</th>
<th align="left" id="newsletter_uuid">UUID</th>
<th align="left" id="newsletter_success"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-login_log"> <div role="tabpanel" class="tab-pane" id="tabs-login_log">
<table class="display login_log_table" id="login_log_table" width="100%"> <table class="display login_log_table" id="login_log_table" width="100%">
<thead> <thead>
@@ -191,6 +212,7 @@
<script src="${http_root}js/tables/logs.js${cache_param}"></script> <script src="${http_root}js/tables/logs.js${cache_param}"></script>
<script src="${http_root}js/tables/plex_logs.js${cache_param}"></script> <script src="${http_root}js/tables/plex_logs.js${cache_param}"></script>
<script src="${http_root}js/tables/notification_logs.js${cache_param}"></script> <script src="${http_root}js/tables/notification_logs.js${cache_param}"></script>
<script src="${http_root}js/tables/newsletter_logs.js${cache_param}"></script>
<script src="${http_root}js/tables/login_logs.js${cache_param}"></script> <script src="${http_root}js/tables/login_logs.js${cache_param}"></script>
<script> <script>
@@ -278,6 +300,18 @@
notification_log_table = $('#notification_log_table').DataTable(notification_log_table_options); notification_log_table = $('#notification_log_table').DataTable(notification_log_table_options);
} }
function loadNewsletterLogs() {
newsletter_log_table_options.ajax = {
url: "get_newsletter_log",
data: function (d) {
return {
json_data: JSON.stringify(d)
};
}
};
newsletter_log_table = $('#newsletter_log_table').DataTable(newsletter_log_table_options);
}
function loadLoginLogs() { function loadLoginLogs() {
login_log_table_options.pageLength = 50; login_log_table_options.pageLength = 50;
login_log_table_options.ajax = { login_log_table_options.ajax = {
@@ -300,6 +334,7 @@
$("#download-plexserverlog").hide(); $("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide(); $("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide(); $("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide(); $("#clear-login-logs").hide();
loadtautullilogs('tautulli', selected_log_level); loadtautullilogs('tautulli', selected_log_level);
clearSearchButton('tautulli_log_table', log_table); clearSearchButton('tautulli_log_table', log_table);
@@ -313,7 +348,8 @@
$("#download-plexserverlog").hide(); $("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide(); $("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide(); $("#clear-notify-logs").hide();
$("#clear-login-logs").hide(); $("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide();
loadtautullilogs('tautulli_api', selected_log_level); loadtautullilogs('tautulli_api', selected_log_level);
clearSearchButton('tautulli_api_log_table', log_table); clearSearchButton('tautulli_api_log_table', log_table);
}); });
@@ -326,6 +362,7 @@
$("#download-plexserverlog").hide(); $("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide(); $("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide(); $("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide(); $("#clear-login-logs").hide();
loadtautullilogs('plex_websocket', selected_log_level); loadtautullilogs('plex_websocket', selected_log_level);
clearSearchButton('plex_websocket_log_table', log_table); clearSearchButton('plex_websocket_log_table', log_table);
@@ -339,6 +376,7 @@
$("#download-plexserverlog").show(); $("#download-plexserverlog").show();
$("#download-plexscannerlog").hide(); $("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide(); $("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide(); $("#clear-login-logs").hide();
loadPlexLogs(); loadPlexLogs();
clearSearchButton('plex_log_table', plex_log_table); clearSearchButton('plex_log_table', plex_log_table);
@@ -352,6 +390,7 @@
$("#download-plexserverlog").hide(); $("#download-plexserverlog").hide();
$("#download-plexscannerlog").show(); $("#download-plexscannerlog").show();
$("#clear-notify-logs").hide(); $("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide(); $("#clear-login-logs").hide();
loadPlexScannerLogs(); loadPlexScannerLogs();
clearSearchButton('plex_scanner_log_table', plex_scanner_log_table); clearSearchButton('plex_scanner_log_table', plex_scanner_log_table);
@@ -365,11 +404,26 @@
$("#download-plexserverlog").hide(); $("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide(); $("#download-plexscannerlog").hide();
$("#clear-notify-logs").show(); $("#clear-notify-logs").show();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").hide(); $("#clear-login-logs").hide();
loadNotificationLogs(); loadNotificationLogs();
clearSearchButton('notification_log_table', notification_log_table); clearSearchButton('notification_log_table', notification_log_table);
}); });
$("#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 () { $("#login-logs-btn").click(function () {
$("#tautulli-log-levels").hide(); $("#tautulli-log-levels").hide();
$("#plex-log-levels").hide(); $("#plex-log-levels").hide();
@@ -378,6 +432,7 @@
$("#download-plexserverlog").hide(); $("#download-plexserverlog").hide();
$("#download-plexscannerlog").hide(); $("#download-plexscannerlog").hide();
$("#clear-notify-logs").hide(); $("#clear-notify-logs").hide();
$("#clear-newsletter-logs").hide();
$("#clear-login-logs").show(); $("#clear-login-logs").show();
loadLoginLogs(); loadLoginLogs();
clearSearchButton('login_log_table', notification_log_table); clearSearchButton('login_log_table', notification_log_table);
@@ -446,6 +501,27 @@
}); });
}); });
$("#clear-newsletter-logs").click(function () {
$("#confirm-message").text("Are you sure you want to clear the Tautulli Newsletter Logs?");
$('#confirm-modal').modal();
$('#confirm-modal').one('click', '#confirm-button', function () {
$.ajax({
url: 'delete_newsletter_log',
type: 'POST',
complete: function (xhr, status) {
result = $.parseJSON(xhr.responseText);
msg = result.message;
if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
} else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
}
newsletter_log_table.draw();
}
});
});
});
$("#clear-login-logs").click(function () { $("#clear-login-logs").click(function () {
$("#confirm-message").text("Are you sure you want to clear the Tautulli Login Logs?"); $("#confirm-message").text("Are you sure you want to clear the Tautulli Login Logs?");
$('#confirm-modal').modal(); $('#confirm-modal').modal();

View File

@@ -11,12 +11,11 @@ DOCUMENTATION :: END
<ul class="stacked-configs list-unstyled"> <ul class="stacked-configs list-unstyled">
% for device in sorted(devices_list, key=lambda k: k['device_name']): % 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>
<!--<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-fw fa-mobile"></i></span>
<span class="toggle-left"><i class="fa fa-lg fa-mobile"></i></span>
${device['friendly_name'] or device['device_name']} &nbsp;<span class="friendly_name">(${device['id']})</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']}"> <span class="toggle-right friendly_name" id="device-last_seen-${device['id']}">
% if device['last_seen']: % if device['last_seen']:
<script> <script>
@@ -26,14 +25,13 @@ DOCUMENTATION :: END
never never
% endif % endif
</span> </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> </span>
</li> </li>
% endfor % 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>
<span class="toggle-left"><i class="fa fa-lg fa-mobile"></i></span> Register a new device <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-plus"></i></span> <span class="toggle-right"><i class="fa fa-lg fa-fw fa-plus"></i></span>
</span> </span>
</li> </li>
</ul> </ul>

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 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() 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']] user_emails = [{'user': u['friendly_name'] or u['username'], 'email': u['email']} for u in users.Users().get_users() if u['email']]
sorted(user_emails, key=lambda u: u['user']) sorted(user_emails, key=lambda u: u['user'])
%> %>
% if notifier:
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
<link href="${http_root}css/selectize.min.css" rel="stylesheet" />
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -19,7 +18,7 @@
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<ul class="nav nav-tabs list-unstyled" role="tablist"> <ul class="nav nav-tabs list-unstyled" role="tablist">
<li role="presentation" class="active"><a href="#tabs-config" aria-controls="tabs-config" role="tab" data-toggle="tab">Configuration</a></li> <li role="presentation" class="active"><a href="#tabs-notifier_config" aria-controls="tabs-notifier_config" role="tab" data-toggle="tab">Configuration</a></li>
<li role="presentation"><a href="#tabs-notify_triggers" aria-controls="tabs-notify_triggers" role="tab" data-toggle="tab">Triggers</a></li> <li role="presentation"><a href="#tabs-notify_triggers" aria-controls="tabs-notify_triggers" role="tab" data-toggle="tab">Triggers</a></li>
<li role="presentation"><a href="#tabs-notify_conditions" aria-controls="tabs-notify_conditions" role="tab" data-toggle="tab">Conditions</a></li> <li role="presentation"><a href="#tabs-notify_conditions" aria-controls="tabs-notify_conditions" role="tab" data-toggle="tab">Conditions</a></li>
<li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">${'Arguments' if notifier['agent_name'] == 'scripts' else 'Text'}</a></li> <li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">${'Arguments' if notifier['agent_name'] == 'scripts' else 'Text'}</a></li>
@@ -28,7 +27,7 @@
</div> </div>
<form action="set_notifier_config" method="post" class="form" id="set_notifier_config" data-parsley-validate> <form action="set_notifier_config" method="post" class="form" id="set_notifier_config" data-parsley-validate>
<div class="tab-content"> <div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="tabs-config"> <div role="tabpanel" class="tab-pane active" id="tabs-notifier_config">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<input type="hidden" id="notifier_id" name="notifier_id" value="${notifier['id']}" /> <input type="hidden" id="notifier_id" name="notifier_id" value="${notifier['id']}" />
@@ -72,7 +71,7 @@
% elif item['input_type'] == 'checkbox': % elif item['input_type'] == 'checkbox':
<div class="checkbox"> <div class="checkbox">
<label> <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> </label>
<p class="help-block">${item['description'] | n}</p> <p class="help-block">${item['description'] | n}</p>
<input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}"> <input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}">
@@ -125,7 +124,7 @@
% endif % endif
% endfor % endfor
</div> </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"> <div class="form-group">
<label for="friendly_name">Description</label> <label for="friendly_name">Description</label>
<div class="row"> <div class="row">
@@ -148,7 +147,7 @@
% for action in available_notification_actions: % for action in available_notification_actions:
<div class="checkbox"> <div class="checkbox">
<label> <label>
<input type="checkbox" data-id="${action['name']}" class="checkboxes" value="1" ${helpers.checked(notifier['actions'][action['name']])}> Notify on ${action['label']} <input type="checkbox" data-id="${action['name']}" class="checkboxes" value="1" ${checked(notifier['actions'][action['name']])}> ${action['label']}
</label> </label>
<p class="help-block">${action['description'] | n}</p> <p class="help-block">${action['description'] | n}</p>
<input type="hidden" id="${action['name']}" name="${action['name']}" value="${notifier['actions'][action['name']]}"> <input type="hidden" id="${action['name']}" name="${action['name']}" value="${notifier['actions'][action['name']]}">
@@ -164,7 +163,7 @@
<a href="#notify-text-sub-modal" data-toggle="modal">Click here</a> for a description of all the parameters. <a href="#notify-text-sub-modal" data-toggle="modal">Click here</a> for a description of all the parameters.
</p> </p>
<div id="condition-widget"></div> <div id="condition-widget"></div>
<input type="hidden" name="custom_conditions" id="custom_conditions" /> <input type="hidden" id="custom_conditions" name="custom_conditions" />
<div class="form-group"> <div class="form-group">
<label for="custom_conditions_logic">Condition Logic</label> <label for="custom_conditions_logic">Condition Logic</label>
@@ -407,7 +406,7 @@
$('#duplicate-notifier-item').click(function() { $('#duplicate-notifier-item').click(function() {
var msg = 'Are you sure you want to duplicate this <strong>${notifier["agent_label"]}</strong> notification agent?'; var msg = 'Are you sure you want to duplicate this <strong>${notifier["agent_label"]}</strong> notification agent?';
var url = 'add_notifier_config'; var url = 'add_notifier_config';
confirmAjaxCall(url, msg, { agent_id: "${notifier['agent_id']}" }, null, duplicateCallback); confirmAjaxCall(url, msg, { agent_id: '${notifier["agent_id"]}' }, null, duplicateCallback);
}); });
$('#save-notifier-item').click(function () { $('#save-notifier-item').click(function () {
@@ -420,7 +419,7 @@
'<div class="form-group">' + '<div class="form-group">' +
'<label>Warning</label>' + '<label>Warning</label>' +
'<p class="help-block" style="color: #eb8600;">Facebook requires HTTPS for authorization. ' + '<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" style="cursor: pointer;">Web Interface</a>.</p>' + '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>' '</div>'
); );
$('#facebook_redirect_uri').val('HTTPS not enabled'); $('#facebook_redirect_uri').val('HTTPS not enabled');
@@ -748,11 +747,12 @@
}); });
function sendTestNotification() { function sendTestNotification() {
showMsg('<i class="fa fa-refresh fa-spin"></i>&nbsp; Sending Notification', false);
if ('${notifier["agent_name"]}' !== 'browser') { if ('${notifier["agent_name"]}' !== 'browser') {
$.ajax({ $.ajax({
url: 'send_notification', url: 'send_notification',
data: { data: {
notifier_id: '${notifier["id"]}', notifier_id: $('#notifier_id').val(),
subject: $('#test_subject').val(), subject: $('#test_subject').val(),
body: $('#test_body').val(), body: $('#test_body').val(),
script: $('#test_script').val(), script: $('#test_script').val(),
@@ -761,13 +761,11 @@
}, },
cache: false, cache: false,
async: true, async: true,
complete: function (xhr, status) { success: function (data) {
if (xhr.responseText.indexOf('sent') > -1) { if (data.result === 'success') {
msg = '<i class="fa fa-check"></i>&nbsp; ' + xhr.responseText; showMsg('<i class="fa fa-check"></i> ' + data.message, false, true, 5000);
showMsg(msg, false, true, 2000);
} else { } else {
msg = '<i class="fa fa-times"></i>&nbsp; ' + xhr.responseText; showMsg('<i class="fa fa-exclamation-circle"></i> ' + data.message, false, true, 5000, true);
showMsg(msg, false, true, 2000, true);
} }
} }
}); });

View File

@@ -11,22 +11,22 @@ DOCUMENTATION :: END
<ul class="stacked-configs list-unstyled"> <ul class="stacked-configs list-unstyled">
% for notifier in sorted(notifiers_list, key=lambda k: (k['agent_label'].lower(), k['friendly_name'], k['id'])): % for notifier in sorted(notifiers_list, key=lambda k: (k['agent_label'].lower(), k['friendly_name'], k['id'])):
<li class="notification-agent" data-id="${notifier['id']}"> <li class="notification-agent pointer" data-id="${notifier['id']}">
<span> <span>
<span class="toggle-left trigger-tooltip ${'active' if notifier['active'] else ''}" data-toggle="tooltip" data-placement="top" title="Triggers ${'active' if notifier['active'] else 'inactive'}"><i class="fa fa-lg fa-bell"></i></span> <span class="toggle-left trigger-tooltip ${'active' if notifier['active'] else ''}" data-toggle="tooltip" data-placement="top" title="Triggers ${'active' if notifier['active'] else 'inactive'}"><i class="fa fa-lg fa-fw fa-bell"></i></span>
% if notifier['friendly_name']: % if notifier['friendly_name']:
${notifier['agent_label']} &nbsp;<span class="friendly_name">(${notifier['id']} - ${notifier['friendly_name']})</span> ${notifier['agent_label']} &nbsp;<span class="friendly_name">(${notifier['id']} - ${notifier['friendly_name']})</span>
% else: % else:
${notifier['agent_label']} &nbsp;<span class="friendly_name">(${notifier['id']})</span> ${notifier['agent_label']} &nbsp;<span class="friendly_name">(${notifier['id']})</span>
% endif % 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> </span>
</li> </li>
% endfor % 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>
<span class="toggle-left"><i class="fa fa-lg fa-bell"></i></span> Add a new notification agent <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-plus"></i></span> <span class="toggle-right"><i class="fa fa-lg fa-fw fa-plus"></i></span>
</span> </span>
</li> </li>
</ul> </ul>

View File

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

View File

@@ -4,10 +4,11 @@
import sys import sys
import plexpy import plexpy
from plexpy import common, notifiers from plexpy import common, notifiers, newsletters
from plexpy.helpers import anon_url, checked from plexpy.helpers import anon_url, checked
available_notification_agents = sorted(notifiers.available_notification_agents(), key=lambda k: k['label']) available_notification_agents = sorted(notifiers.available_notification_agents(), key=lambda k: k['label'].lower())
available_newsletter_agents = sorted(newsletters.available_newsletter_agents(), key=lambda k: k['label'].lower())
%> %>
<%def name="headIncludes()"> <%def name="headIncludes()">
</%def> </%def>
@@ -49,8 +50,9 @@
<li role="presentation"><a href="#tabs-homepage" aria-controls="tabs-homepage" role="tab" data-toggle="tab">Homepage</a></li> <li role="presentation"><a href="#tabs-homepage" aria-controls="tabs-homepage" role="tab" data-toggle="tab">Homepage</a></li>
<li role="presentation"><a href="#tabs-web_interface" aria-controls="tabs-web_interface" role="tab" data-toggle="tab">Web Interface</a></li> <li role="presentation"><a href="#tabs-web_interface" aria-controls="tabs-web_interface" role="tab" data-toggle="tab">Web Interface</a></li>
<li role="presentation"><a href="#tabs-plex_media_server" aria-controls="tabs-plex_media_server" role="tab" data-toggle="tab">Plex Media Server</a></li> <li role="presentation"><a href="#tabs-plex_media_server" aria-controls="tabs-plex_media_server" role="tab" data-toggle="tab">Plex Media Server</a></li>
<li role="presentation"><a href="#tabs-notifications" aria-controls="tabs-notifications" role="tab" data-toggle="tab">Notifications</a></li> <li role="presentation"><a href="#tabs-notifications" aria-controls="tabs-notifications" role="tab" data-toggle="tab">Notifications & Newsletters</a></li>
<li role="presentation"><a href="#tabs-notification_agents" aria-controls="tabs-notification_agents" role="tab" data-toggle="tab">Notification Agents</a></li> <li role="presentation"><a href="#tabs-notification_agents" aria-controls="tabs-notification_agents" role="tab" data-toggle="tab">Notification Agents</a></li>
<li role="presentation"><a href="#tabs-newsletter_agents" aria-controls="tabs-newsletter_agents" role="tab" data-toggle="tab">Newsletter Agents</a></li>
<li role="presentation"><a href="#tabs-import_backups" aria-controls="tabs-import_backups" role="tab" data-toggle="tab">Import & Backups</a></li> <li role="presentation"><a href="#tabs-import_backups" aria-controls="tabs-import_backups" role="tab" data-toggle="tab">Import & Backups</a></li>
<li role="presentation"><a href="#tabs-android_app" aria-controls="tabs-android_app" role="tab" data-toggle="tab">Tautulli Remote Android App <sup><small>beta</small></sup></a></li> <li role="presentation"><a href="#tabs-android_app" aria-controls="tabs-android_app" role="tab" data-toggle="tab">Tautulli Remote Android App <sup><small>beta</small></sup></a></li>
</ul> </ul>
@@ -271,6 +273,17 @@
<h3>Activity</h3> <h3>Activity</h3>
</div> </div>
<div class="form-group">
<label for="home_refresh_interval">Activity Refresh Interval</label>
<div class="row">
<div class="col-md-2">
<input type="text" class="form-control" data-parsley-type="integer" id="home_refresh_interval" name="home_refresh_interval" value="${config['home_refresh_interval']}" size="5" data-parsley-min="2" data-parsley-trigger="change" data-parsley-errors-container="#home_refresh_interval_error" required>
</div>
<div id="home_refresh_interval_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
<p class="help-block">Set the interval (in seconds) to refresh the current activity on the homepage. Minimum 2.</p>
</div>
<div class="padded-header"> <div class="padded-header">
<h3>Sections</h3> <h3>Sections</h3>
</div> </div>
@@ -442,6 +455,18 @@
</div> </div>
<p class="help-block">Port to bind web server to. Note that ports below 1024 may require root.</p> <p class="help-block">Port to bind web server to. Note that ports below 1024 may require root.</p>
</div> </div>
<div class="form-group advanced-setting">
<label for="http_base_url">Public Tautulli Domain</label>
<div class="row">
<div class="col-md-8">
<input type="text" class="form-control" id="http_base_url" name="http_base_url" value="${config['http_base_url']}" placeholder="http://mydomain.com" data-parsley-trigger="change" data-parsley-pattern="^https?:\/\/\S+$" data-parsley-errors-container="#http_base_url_error" data-parsley-error-message="Invalid URL">
</div>
<div id=http_base_url_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
<p class="help-block">
Set your public Tautulli domain for self-hosted notification images and newsletters. (e.g. http://mydomain.com)
</p>
</div>
<div class="form-group advanced-setting"> <div class="form-group advanced-setting">
<label for="http_root">HTTP Root</label> <label for="http_root">HTTP Root</label>
<div class="row"> <div class="row">
@@ -558,7 +583,7 @@
<label> <label>
<input type="checkbox" name="http_hash_password" id="http_hash_password" value="1" ${config['http_hash_password']} data-parsley-trigger="change"> Hash Password in the Config File <input type="checkbox" name="http_hash_password" id="http_hash_password" value="1" ${config['http_hash_password']} data-parsley-trigger="change"> Hash Password in the Config File
</label> </label>
<span id="hashPasswordCheck" style="color: #eb8600; padding-left: 10px;"></span> <span id="hashPasswordCheck" class="settings-warning"></span>
<p class="help-block">Store a hashed password in the config file.<br />Warning: Your password cannot be recovered if forgotten!</p> <p class="help-block">Store a hashed password in the config file.<br />Warning: Your password cannot be recovered if forgotten!</p>
</div> </div>
<input type="text" id="http_hashed_password" name="http_hashed_password" value="${config['http_hashed_password']}" style="display: none;" data-parsley-trigger="change" data-parsley-type="integer" data-parsley-range="[0, 1]" <input type="text" id="http_hashed_password" name="http_hashed_password" value="${config['http_hashed_password']}" style="display: none;" data-parsley-trigger="change" data-parsley-type="integer" data-parsley-range="[0, 1]"
@@ -576,14 +601,14 @@
<label> <label>
<input type="checkbox" class="auth-settings" name="http_plex_admin" id="http_plex_admin" value="1" ${config['http_plex_admin']} data-parsley-trigger="change"> Allow Plex Admin <input type="checkbox" class="auth-settings" name="http_plex_admin" id="http_plex_admin" value="1" ${config['http_plex_admin']} data-parsley-trigger="change"> Allow Plex Admin
</label> </label>
<span id="allowPlexCheck" style="color: #eb8600; padding-left: 10px;"></span> <span id="allowPlexCheck" class="settings-warning"></span>
<p class="help-block">Allow the Plex server admin to login as a Tautulli admin using their Plex.tv account.</p> <p class="help-block">Allow the Plex server admin to login as a Tautulli admin using their Plex.tv account.</p>
</div> </div>
<div class="checkbox"> <div class="checkbox">
<label> <label>
<input type="checkbox" id="allow_guest_access" name="allow_guest_access" value="1" ${config['allow_guest_access']}> Allow Guest Access to Tautulli <input type="checkbox" id="allow_guest_access" name="allow_guest_access" value="1" ${config['allow_guest_access']}> Allow Guest Access to Tautulli
</label> </label>
<span id="allowGuestCheck" style="color: #eb8600; padding-left: 10px;"></span> <span id="allowGuestCheck" class="settings-warning"></span>
<p class="help-block">Allow shared users to login to Tautulli using their Plex.tv account. Individual user access needs to be enabled from Users > Edit Mode.</p> <p class="help-block">Allow shared users to login to Tautulli using their Plex.tv account. Individual user access needs to be enabled from Users > Edit Mode.</p>
</div> </div>
@@ -625,7 +650,7 @@
</div> </div>
<div class="form-group has-feedback" id="pms_ip_group"> <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="row">
<div class="col-md-9" id="selectize-pms-ip-container"> <div class="col-md-9" id="selectize-pms-ip-container">
<div class="input-group"> <div class="input-group">
@@ -677,6 +702,17 @@
The server URL that Tautulli will use to connect to your Plex server. Retrieved automatically. The server URL that Tautulli will use to connect to your Plex server. Retrieved automatically.
</p> </p>
</div> </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"> <div class="checkbox advanced-setting">
<label> <label>
<input type="checkbox" class="pms-settings" id="pms_url_manual" name="pms_url_manual" value="1" ${config['pms_url_manual']}> Manual Connection <input type="checkbox" class="pms-settings" id="pms_url_manual" name="pms_url_manual" value="1" ${config['pms_url_manual']}> Manual Connection
@@ -689,7 +725,7 @@
<div class="row"> <div class="row">
<div class="col-md-9"> <div class="col-md-9">
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" id="pms_web_url" name="pms_web_url" value="${config['pms_web_url']}" size="30" data-parsley-trigger="change" data-parsley-pattern="^https?:\/\/\S+$|^https:\/\/app.plex.tv\/desktop$" data-parsley-errors-container="#pms_web_url_error" data-parsley-error-message="Invalid Plex Web URL."> <input type="text" class="form-control" id="pms_web_url" name="pms_web_url" value="${config['pms_web_url']}" size="30" data-parsley-trigger="change" data-parsley-pattern="^https?:\/\/\S+$|^https:\/\/app.plex.tv\/desktop$" data-parsley-errors-container="#pms_web_url_error" data-parsley-error-message="Invalid Plex Web URL">
<span class="input-group-btn"> <span class="input-group-btn">
<button class="btn btn-form" type="button" id="test_pms_web_button">Test URL</button> <button class="btn btn-form" type="button" id="test_pms_web_button">Test URL</button>
</span> </span>
@@ -703,7 +739,6 @@
</div> </div>
<input type="hidden" id="pms_is_cloud" name="pms_is_cloud" value="${config['pms_is_cloud']}"> <input type="hidden" id="pms_is_cloud" name="pms_is_cloud" value="${config['pms_is_cloud']}">
<input type="hidden" id="pms_identifier" name="pms_identifier" value="${config['pms_identifier']}">
<input type="checkbox" name="server_changed" id="server_changed" value="1" style="display: none;"> <input type="checkbox" name="server_changed" id="server_changed" value="1" style="display: none;">
<div class="form-group advanced-setting"> <div class="form-group advanced-setting">
@@ -765,7 +800,7 @@
<input type="checkbox" id="monitor_remote_access" name="monitor_remote_access" value="1" ${config['monitor_remote_access']}> Monitor Plex Remote Access <input type="checkbox" id="monitor_remote_access" name="monitor_remote_access" value="1" ${config['monitor_remote_access']}> Monitor Plex Remote Access
</label> </label>
<span id="cloudMonitorRemoteAccess" style="display: none; color: #eb8600; padding-left: 10px;"> Not available for Plex Cloud servers.</span> <span id="cloudMonitorRemoteAccess" style="display: none; color: #eb8600; padding-left: 10px;"> Not available for Plex Cloud servers.</span>
<span id="remoteAccessCheck" style="color: #eb8600; padding-left: 10px;"></span> <span id="remoteAccessCheck" class="settings-warning"></span>
<p class="help-block">Enable to have Tautulli check if remote access to the Plex Media Server goes down.</p> <p class="help-block">Enable to have Tautulli check if remote access to the Plex Media Server goes down.</p>
</div> </div>
@@ -911,7 +946,7 @@
</div> </div>
<!--<div class="checkbox"> <!--<div class="checkbox">
<label> <label>
<input type="checkbox" name="notify_recently_added_upgrade" id="notify_recently_added_upgrade" value="1" ${config['notify_recently_added_upgrade']}> Send a Notification for New Versions <span style="color: #eb8600; padding-left: 10px;">[Not working]</span> <input type="checkbox" name="notify_recently_added_upgrade" id="notify_recently_added_upgrade" value="1" ${config['notify_recently_added_upgrade']}> Send a Notification for New Versions <span class="settings-warning">[Not working]</span>
</label> </label>
<p class="help-block"> <p class="help-block">
Enable to send another recently added notification when adding a new version of existing media.<br /> Enable to send another recently added notification when adding a new version of existing media.<br />
@@ -920,16 +955,107 @@
</div>--> </div>-->
<div class="padded-header"> <div class="padded-header">
<h3>3rd Party APIs</h3> <h3>Newsletters</h3>
</div> </div>
<div class="checkbox"> <div class="checkbox">
<label> <label>
<input type="checkbox" name="notify_upload_posters" id="notify_upload_posters" value="1" ${config['notify_upload_posters']}> Upload Posters to Imgur for Notifications <input type="checkbox" id="newsletter_self_hosted" name="newsletter_self_hosted" value="1" ${config['newsletter_self_hosted']}> Self-Hosted Newsletters
</label> </label>
<p class="help-block">Enable to upload Plex posters to Imgur for notifications. Disable if posters are not being used to save bandwidth.</p> <p class="help-block">Enable to host newsletters on your own domain. This will generate a link to an HTML page where you can view the newsletter.</p>
</div> </div>
<div id="imgur_upload_options"> <div id="self_host_newsletter_options" style="overlfow: hidden; display: ${'block' if config['newsletter_self_hosted'] == 'checked' else 'none'}">
<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"> <div class="form-group">
<label for="imgur_client_id">Imgur Client ID</label> <label for="imgur_client_id">Imgur Client ID</label>
<div class="row"> <div class="row">
@@ -937,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"> <input type="text" class="form-control" id="imgur_client_id" name="imgur_client_id" value="${config['imgur_client_id']}" data-parsley-trigger="change">
</div> </div>
</div> </div>
<p class="help-block">Enter your Imgur API Client ID.</p>
</div>
</div>
<div id="self_host_image_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 2 else 'block'}">
<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"> <p class="help-block">
Enter your Imgur API client ID in order to upload posters. Enter your Cloudinary Cloud Name.
You can register a new application <a href="${anon_url('https://api.imgur.com/oauth2/addclient')}" target="_blank">here</a>.<br /> </p>
</div>
<div class="form-group">
<label for="cloudinary_api_key">Cloudinary API Key</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="cloudinary_api_key" name="cloudinary_api_key" value="${config['cloudinary_api_key']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your Cloudinary API Key.
</p>
</div>
<div class="form-group">
<label for="cloudinary_api_secret">Cloudinary API Secret</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="cloudinary_api_secret" name="cloudinary_api_secret" value="${config['cloudinary_api_secret']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your Cloudinary API Secret.
</p>
</div> </div>
</div> </div>
<div class="checkbox"> <div class="checkbox">
@@ -979,6 +1149,26 @@
</div> </div>
<div role="tabpanel" class="tab-pane" id="tabs-newsletter_agents">
<div class="padded-header">
<h3>Newsletter Agents</h3>
</div>
<p class="help-block">
Add a new newsletter agent, or configure an existing newsletter agent by clicking the settings icon on the right.
</p>
<p class="help-block settings-warning" id="newsletter_upload_warning">
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 role="tabpanel" class="tab-pane" id="tabs-import_backups">
<div class="padded-header"> <div class="padded-header">
@@ -1023,6 +1213,17 @@
<h3>Directories</h3> <h3>Directories</h3>
</div> </div>
<div class="form-group">
<label for="log_dir">Log Directory</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control directory-settings" id="log_dir" name="log_dir" value="${config['log_dir']}">
<div class="btn-group">
<button class="btn btn-form" type="button" id="clear_logs">Clear Logs</button>
</div>
</div>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="backup_dir">Backup Directory</label> <label for="backup_dir">Backup Directory</label>
<div class="row"> <div class="row">
@@ -1047,17 +1248,6 @@
</div> </div>
</div> </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> <p><input type="button" class="btn btn-bright save-button" value="Save" data-success="Changes saved successfully"></p>
@@ -1078,10 +1268,10 @@
</span> </span>
</p> </p>
</div> </div>
<p class="form-group"> <div class="form-group">
<label>Registered Devices</label> <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 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 class="row">
<div id="plexpy-mobile-devices-table" class="col-md-12"> <div id="plexpy-mobile-devices-table" class="col-md-12">
<div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Loading registered devices...</div> <div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Loading registered devices...</div>
@@ -1233,7 +1423,35 @@
<div class="col-md-12"> <div class="col-md-12">
<ul class="stacked-configs list-unstyled"> <ul class="stacked-configs list-unstyled">
% for agent in sorted(available_notification_agents, key=lambda k: k['label'].lower()): % for agent in sorted(available_notification_agents, key=lambda k: k['label'].lower()):
<li class="new-notification-agent" data-id="${agent['id']}"> <li class="new-notification-agent 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> <span>${agent['label']}</span>
</li> </li>
% endfor % endfor
@@ -1249,6 +1467,7 @@
</div> </div>
</div> </div>
<div id="notifier-config-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="notifier-config-modal"></div> <div id="notifier-config-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="notifier-config-modal"></div>
<div id="newsletter-config-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="newsletter-config-modal"></div>
<div id="notify-text-sub-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="notify-text-sub-modal"> <div id="notify-text-sub-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="notify-text-sub-modal">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -1404,6 +1623,53 @@
</div> </div>
<div id="notifier-text-preview-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="notifier-text-preview-modal"> <div id="notifier-text-preview-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="notifier-text-preview-modal">
</div> </div>
<div id="newsletter-text-sub-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="newsletter-text-sub-modal">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">
<i class="fa fa-remove"></i>
</button>
<h4 class="modal-title">Newsletter Parameters</h4>
</div>
<div class="modal-body">
<div>
<p class="help-block">
If the value for a selected parameter cannot be provided, it will display as blank.
</p>
% for category in common.NEWSLETTER_PARAMETERS:
<table class="notification-params">
<thead>
<tr>
<th colspan="2">
${category['category']}
</th>
</tr>
</thead>
<tbody>
% for parameter in category['parameters']:
<tr>
<td><strong>{${parameter['value']}}</strong></td>
<td>
${parameter['description']}
% if parameter.get('example'):
<span class="small-muted">(${parameter['example']})</span>
% endif
% if parameter.get('help_text'):
<p class="small-muted">(${parameter['help_text']})</p>
% endif
</td>
</tr>
% endfor
</tbody>
</table>
% endfor
</div>
</div>
<div class="modal-footer"></div>
</div>
</div>
</div>
<div id="changelog-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="changelog-modal"> <div id="changelog-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="changelog-modal">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -1527,6 +1793,7 @@
} }
function loadNotifierConfig(notifier_id) { function loadNotifierConfig(notifier_id) {
showMsg('<i class="fa fa-refresh fa-spin"></i>&nbsp; Loading Configuration', false);
$.ajax({ $.ajax({
url: 'get_notifier_config_modal', url: 'get_notifier_config_modal',
data: { notifier_id: notifier_id }, data: { notifier_id: notifier_id },
@@ -1534,6 +1801,32 @@
async: true, async: true,
complete: function (xhr, status) { complete: function (xhr, status) {
$("#notifier-config-modal").html(xhr.responseText).modal('show'); $("#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);
} }
}); });
} }
@@ -1550,6 +1843,7 @@
} }
function loadMobileDeviceConfig(mobile_device_id) { function loadMobileDeviceConfig(mobile_device_id) {
showMsg('<i class="fa fa-refresh fa-spin"></i>&nbsp; Loading Configuration', false);
$.ajax({ $.ajax({
url: 'get_mobile_device_config_modal', url: 'get_mobile_device_config_modal',
data: { mobile_device_id: mobile_device_id }, data: { mobile_device_id: mobile_device_id },
@@ -1557,6 +1851,7 @@
async: true, async: true,
complete: function (xhr, status) { complete: function (xhr, status) {
$("#mobile-device-config-modal").html(xhr.responseText).modal('show'); $("#mobile-device-config-modal").html(xhr.responseText).modal('show');
showMsg('<i class="fa fa-check"></i> Configuration Loaded', false, true, 2000);
} }
}); });
} }
@@ -1610,6 +1905,7 @@ $(document).ready(function() {
getConfigurationTable(); getConfigurationTable();
getSchedulerTable(); getSchedulerTable();
getNotifiersTable(); getNotifiersTable();
getNewslettersTable();
getMobileDevicesTable(); getMobileDevicesTable();
loadUpdateDistros(); loadUpdateDistros();
settingsChanged = false; settingsChanged = false;
@@ -1646,9 +1942,9 @@ $(document).ready(function() {
initConfigCheckbox('#enable_https'); initConfigCheckbox('#enable_https');
initConfigCheckbox('#https_create_cert'); initConfigCheckbox('#https_create_cert');
initConfigCheckbox('#check_github'); initConfigCheckbox('#check_github');
initConfigCheckbox('#notify_upload_posters');
initConfigCheckbox('#monitor_pms_updates'); initConfigCheckbox('#monitor_pms_updates');
initConfigCheckbox('#newsletter_self_hosted');
$('#menu_link_shutdown').click(function() { $('#menu_link_shutdown').click(function() {
$('#confirm-message').text("Are you sure you want to shutdown Tautulli?"); $('#confirm-message').text("Are you sure you want to shutdown Tautulli?");
$('#confirm-modal').modal(); $('#confirm-modal').modal();
@@ -1693,6 +1989,7 @@ $(document).ready(function() {
getConfigurationTable(); getConfigurationTable();
getSchedulerTable(); getSchedulerTable();
getNotifiersTable(); getNotifiersTable();
getNewslettersTable();
getMobileDevicesTable(); getMobileDevicesTable();
$('#changelog-modal-link').on('click', function (e) { $('#changelog-modal-link').on('click', function (e) {
@@ -2196,6 +2493,7 @@ $(document).ready(function() {
$("#allow_guest_access").attr("disabled", false); $("#allow_guest_access").attr("disabled", false);
$("#allowGuestCheck").html(""); $("#allowGuestCheck").html("");
} }
newsletterPasswordEnabled();
} }
allowGuestAccessCheck(); allowGuestAccessCheck();
@@ -2299,7 +2597,7 @@ $(document).ready(function() {
var result = $.parseJSON(xhr.responseText); var result = $.parseJSON(xhr.responseText);
var msg = result.message; var msg = result.message;
$('#add-notifier-modal').modal('hide'); $('#add-notifier-modal').modal('hide');
if (result.result == 'success') { if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000); showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
loadNotifierConfig(result.notifier_id); loadNotifierConfig(result.notifier_id);
} else { } else {
@@ -2310,6 +2608,32 @@ $(document).ready(function() {
}); });
}); });
// Add a new newsletter agent
$('.new-newsletter-agent').click(function () {
$.ajax({
url: 'add_newsletter_config',
data: { agent_id: $(this).data('id') },
cache: false,
async: true,
complete: function (xhr, status) {
var result = $.parseJSON(xhr.responseText);
var msg = result.message;
$('#add-newsletter-modal').modal('hide');
if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
loadNewsletterConfig(result.newsletter_id);
} else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true);
}
getNewslettersTable();
}
});
});
$('#http_base_url').change(function () {
$(this).val($(this).val().replace(/\/*$/, ''));
});
function apiEnabled() { function apiEnabled() {
var api_enabled = $('#api_enabled').prop('checked'); var api_enabled = $('#api_enabled').prop('checked');
$('#app_api_msg').toggle(!(api_enabled)); $('#app_api_msg').toggle(!(api_enabled));
@@ -2319,9 +2643,115 @@ $(document).ready(function() {
apiEnabled(); apiEnabled();
}); });
function imageUpload() {
var upload_val = $('#notify_upload_posters').val();
if (upload_val === '1') {
$('#imgur_upload_options').slideDown();
} else {
$('#imgur_upload_options').slideUp();
}
if (upload_val === '2') {
$('#self_host_image_options').slideDown();
} else {
$('#self_host_image_options').slideUp();
}
if (upload_val === '3') {
$('#cloudinary_upload_options').slideDown();
} else {
$('#cloudinary_upload_options').slideUp();
}
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 () { $('body').on('click', 'a[data-tab-destination]', function () {
var tab = $(this).data('tab-destination'); var tab = $(this).data('tab-destination');
$("a[href=#" + tab + "]").click(); $("a[href=#" + tab + "]").click();
var scroll_destination = $(this).data('target');
if (scroll_destination) {
if ($(scroll_destination).closest('.advanced-setting').length && !$('#menu_link_show_advanced_settings').hasClass('active')) {
$('#menu_link_show_advanced_settings').click()
}
var body_container = $('.body-container')
var scroll_pos = scroll_destination ? body_container.scrollTop() + $(scroll_destination).offset().top - 100 : 0;
body_container.animate({scrollTop: scroll_pos});
}
}); });
}); });
</script> </script>

View File

@@ -46,8 +46,10 @@ DOCUMENTATION :: END
<div class="modal-header"> <div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button> <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"> <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> 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: % else:
Stream Info: <strong>${data['title']} (${user})</strong> Stream Info: <strong>${data['title']} (${user})</strong>
% endif % endif

View File

@@ -96,7 +96,7 @@ DOCUMENTATION :: END
</div> </div>
</div> </div>
<div class='table-card-back'> <div class='table-card-back'>
<div id="search-results-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading search results...</div> <div id="search-results-list" class="children-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading search results...</div>
</div> </div>
</div> </div>
</div> </div>
@@ -188,7 +188,7 @@ DOCUMENTATION :: END
}, },
complete: function (xhr, status) { complete: function (xhr, status) {
$('#search-results-list').html(xhr.responseText); $('#search-results-list').html(xhr.responseText);
$('#update_query_title').html(query_string) $('#update_query_title').text(query_string)
} }
}); });
} }

View File

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

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) from pkg_resources import get_distribution, DistributionNotFound
version = '3.0.1'
release = '3.0.1'
__version__ = release # PEP 396 try:
release = get_distribution('APScheduler').version.split('-')[0]
except DistributionNotFound:
release = '3.5.0'
version_info = tuple(int(x) if x.isdigit() else x for x in release.split('.'))
version = __version__ = '.'.join(str(x) for x in version_info[:3])
del get_distribution, DistributionNotFound

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,8 @@ import sys
def get_locale(name): def get_locale(name):
'''Returns an appropriate :class:`Locale <locale.Locale>` corresponding '''Returns an appropriate :class:`Locale <arrow.locales.Locale>`
to an inpute locale name. corresponding to an inpute locale name.
:param name: the name of the locale. :param name: the name of the locale.
@@ -186,7 +186,7 @@ class Locale(object):
class EnglishLocale(Locale): 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' past = '{0} ago'
future = 'in {0}' future = 'in {0}'
@@ -263,10 +263,10 @@ class ItalianLocale(Locale):
day_names = ['', 'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato', 'domenica'] day_names = ['', 'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato', 'domenica']
day_abbreviations = ['', 'lun', 'mar', 'mer', 'gio', 'ven', 'sab', 'dom'] 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): def _ordinal_number(self, n):
return '{0}°'.format(n) return '{0}º'.format(n)
class SpanishLocale(Locale): class SpanishLocale(Locale):
@@ -297,10 +297,10 @@ class SpanishLocale(Locale):
day_names = ['', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo'] day_names = ['', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo']
day_abbreviations = ['', 'lun', 'mar', 'mie', 'jue', 'vie', 'sab', 'dom'] 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): def _ordinal_number(self, n):
return '{0}°'.format(n) return '{0}º'.format(n)
class FrenchLocale(Locale): class FrenchLocale(Locale):
@@ -379,7 +379,7 @@ class JapaneseLocale(Locale):
timeframes = { timeframes = {
'now': '現在', 'now': '現在',
'seconds': '', 'seconds': '',
'minute': '1分', 'minute': '1分',
'minutes': '{0}', 'minutes': '{0}',
'hour': '1時間', 'hour': '1時間',
@@ -559,8 +559,8 @@ class KoreanLocale(Locale):
timeframes = { timeframes = {
'now': '지금', 'now': '지금',
'seconds': '몇초', 'seconds': ' ',
'minute': '', 'minute': '1',
'minutes': '{0}', 'minutes': '{0}',
'hour': '1시간', 'hour': '1시간',
'hours': '{0}시간', 'hours': '{0}시간',
@@ -919,7 +919,7 @@ class NewNorwegianLocale(Locale):
class PortugueseLocale(Locale): class PortugueseLocale(Locale):
names = ['pt', 'pt_pt'] names = ['pt', 'pt_pt']
past = '{0}' past = '{0}'
future = 'em {0}' future = 'em {0}'
@@ -946,11 +946,11 @@ class PortugueseLocale(Locale):
day_names = ['', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', day_names = ['', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira',
'sábado', 'domingo'] 'sábado', 'domingo']
day_abbreviations = ['', 'seg', 'ter', 'qua', 'qui', 'sex', 'sab', 'dom'] day_abbreviations = ['', 'seg', 'ter', 'qua', 'qui', 'sex', 'sab', 'dom']
class BrazilianPortugueseLocale(PortugueseLocale): class BrazilianPortugueseLocale(PortugueseLocale):
names = ['pt_br'] names = ['pt_br']
past = 'fazem {0}' past = 'fazem {0}'
@@ -1034,7 +1034,7 @@ class TurkishLocale(Locale):
'days': '{0} gün', 'days': '{0} gün',
'month': 'bir ay', 'month': 'bir ay',
'months': '{0} ay', 'months': '{0} ay',
'year': 'a yıl', 'year': 'yıl',
'years': '{0} yıl', 'years': '{0} yıl',
} }
@@ -1047,6 +1047,37 @@ class TurkishLocale(Locale):
day_abbreviations = ['', 'Pzt', 'Sal', 'Çar', 'Per', 'Cum', 'Cmt', 'Paz'] 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): class ArabicLocale(Locale):
names = ['ar', 'ar_eg'] names = ['ar', 'ar_eg']
@@ -1205,11 +1236,11 @@ class HindiLocale(Locale):
future = '{0} बाद' future = '{0} बाद'
timeframes = { timeframes = {
'now': 'अभि', 'now': 'अभ',
'seconds': 'सेकंड्', 'seconds': 'सेकंड्',
'minute': 'एक मिनट ', 'minute': 'एक मिनट ',
'minutes': '{0} मिनट ', 'minutes': '{0} मिनट ',
'hour': 'एक घंट', 'hour': 'एक घंट',
'hours': '{0} घंटे', 'hours': '{0} घंटे',
'day': 'एक दिन', 'day': 'एक दिन',
'days': '{0} दिन', 'days': '{0} दिन',
@@ -1226,8 +1257,8 @@ class HindiLocale(Locale):
'PM': 'शाम', 'PM': 'शाम',
} }
month_names = ['', 'जनवरी', 'रवरी', 'मार्च', 'अप्रैल ', 'मई', 'जून', 'जुलाई', month_names = ['', 'जनवरी', 'रवरी', 'मार्च', 'अप्रैल ', 'मई', 'जून', 'जुलाई',
'गस्त', 'सितम्बर', 'अकूबर', 'नवेम्बर', 'दिसम्बर'] 'गस्त', 'सितबर', 'अक्टूबर', 'नवबर', 'दिसबर']
month_abbreviations = ['', 'जन', 'फ़र', 'मार्च', 'अप्रै', 'मई', 'जून', 'जुलाई', 'आग', month_abbreviations = ['', 'जन', 'फ़र', 'मार्च', 'अप्रै', 'मई', 'जून', 'जुलाई', 'आग',
'सित', 'अकत', 'नवे', 'दिस'] 'सित', 'अकत', 'नवे', 'दिस']
@@ -1284,7 +1315,8 @@ class CzechLocale(Locale):
def _format_timeframe(self, timeframe, delta): 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] form = self.timeframes[timeframe]
if isinstance(form, dict): if isinstance(form, dict):
if delta == 0: if delta == 0:
@@ -1293,7 +1325,7 @@ class CzechLocale(Locale):
form = form['future'] form = form['future']
else: else:
form = form['past'] form = form['past']
delta = abs(delta) delta = abs(delta)
if isinstance(form, list): if isinstance(form, list):
if 2 <= delta % 10 <= 4 and (delta % 100 < 10 or delta % 100 >= 20): if 2 <= delta % 10 <= 4 and (delta % 100 < 10 or delta % 100 >= 20):
@@ -1303,6 +1335,78 @@ class CzechLocale(Locale):
return form.format(delta) 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): class FarsiLocale(Locale):
names = ['fa', 'fa_ir'] names = ['fa', 'fa_ir']
@@ -1463,7 +1567,7 @@ class MarathiLocale(Locale):
day_names = ['', 'सोमवार', 'मंगळवार', 'बुधवार', 'गुरुवार', 'शुक्रवार', 'शनिवार', 'रविवार'] day_names = ['', 'सोमवार', 'मंगळवार', 'बुधवार', 'गुरुवार', 'शुक्रवार', 'शनिवार', 'रविवार']
day_abbreviations = ['', 'सोम', 'मंगळ', 'बुध', 'गुरु', 'शुक्र', 'शनि', 'रवि'] day_abbreviations = ['', 'सोम', 'मंगळ', 'बुध', 'गुरु', 'शुक्र', 'शनि', 'रवि']
def _map_locales(): def _map_locales():
locales = {} locales = {}
@@ -1471,14 +1575,14 @@ def _map_locales():
for cls_name, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass): for cls_name, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass):
if issubclass(cls, Locale): if issubclass(cls, Locale):
for name in cls.names: for name in cls.names:
locales[name.lower()] = cls locales[name.lower()] = cls
return locales return locales
class CatalaLocale(Locale): class CatalanLocale(Locale):
names = ['ca', 'ca_ca'] names = ['ca', 'ca_es', 'ca_ad', 'ca_fr', 'ca_it']
past = 'Fa {0}' past = 'Fa {0}'
future = '{0}' # I don't know what's the right phrase in catala for the future. future = 'En {0}'
timeframes = { timeframes = {
'now': 'Ara mateix', 'now': 'Ara mateix',
@@ -1490,15 +1594,15 @@ class CatalaLocale(Locale):
'day': 'un dia', 'day': 'un dia',
'days': '{0} dies', 'days': '{0} dies',
'month': 'un mes', 'month': 'un mes',
'months': '{0} messos', 'months': '{0} mesos',
'year': 'un any', 'year': 'un any',
'years': '{0} anys', 'years': '{0} anys',
} }
month_names = ['', 'Jener', 'Febrer', 'Març', 'Abril', 'Maig', 'Juny', 'Juliol', 'Agost', 'Setembre', 'Octubre', 'Novembre', 'Decembre'] month_names = ['', 'Gener', 'Febrer', 'Març', 'Abril', 'Maig', 'Juny', 'Juliol', 'Agost', 'Setembre', 'Octubre', 'Novembre', 'Desembre']
month_abbreviations = ['', 'Jener', 'Febrer', 'Març', 'Abril', 'Maig', 'Juny', 'Juliol', 'Agost', 'Setembre', 'Octubre', 'Novembre', 'Decembre'] month_abbreviations = ['', 'Gener', 'Febrer', 'Març', 'Abril', 'Maig', 'Juny', 'Juliol', 'Agost', 'Setembre', 'Octubre', 'Novembre', 'Desembre']
day_names = ['', 'Dilluns', 'Dimars', 'Dimecres', 'Dijous', 'Divendres', 'Disabte', 'Diumenge'] day_names = ['', 'Dilluns', 'Dimarts', 'Dimecres', 'Dijous', 'Divendres', 'Dissabte', 'Diumenge']
day_abbreviations = ['', 'Dilluns', 'Dimars', 'Dimecres', 'Dijous', 'Divendres', 'Disabte', 'Diumenge'] day_abbreviations = ['', 'Dilluns', 'Dimarts', 'Dimecres', 'Dijous', 'Divendres', 'Dissabte', 'Diumenge']
class BasqueLocale(Locale): class BasqueLocale(Locale):
names = ['eu', 'eu_eu'] names = ['eu', 'eu_eu']
@@ -1587,6 +1691,50 @@ class HungarianLocale(Locale):
return form.format(abs(delta)) 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): class ThaiLocale(Locale):
names = ['th', 'th_th'] names = ['th', 'th_th']
@@ -1700,4 +1848,164 @@ class BengaliLocale(Locale):
return '{0}ষ্ঠ'.format(n) 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() _locales = _map_locales()

View File

@@ -5,7 +5,6 @@ from __future__ import unicode_literals
from datetime import datetime from datetime import datetime
from dateutil import tz from dateutil import tz
import re import re
from arrow import locales from arrow import locales
@@ -15,16 +14,14 @@ class ParserError(RuntimeError):
class DateTimeParser(object): class DateTimeParser(object):
_FORMAT_RE = re.compile('(YYY?Y?|MM?M?M?|Do|DD?D?D?|HH?|hh?|mm?|ss?|SS?S?S?S?S?|ZZ?Z?|a|A|X)') _FORMAT_RE = re.compile('(YYY?Y?|MM?M?M?|Do|DD?D?D?|d?d?d?d|HH?|hh?|mm?|ss?|S+|ZZ?Z?|a|A|X)')
_ESCAPE_RE = re.compile('\[[^\[\]]*\]')
_ONE_THROUGH_SIX_DIGIT_RE = re.compile('\d{1,6}') _ONE_OR_MORE_DIGIT_RE = re.compile('\d+')
_ONE_THROUGH_FIVE_DIGIT_RE = re.compile('\d{1,5}')
_ONE_THROUGH_FOUR_DIGIT_RE = re.compile('\d{1,4}')
_ONE_TWO_OR_THREE_DIGIT_RE = re.compile('\d{1,3}')
_ONE_OR_TWO_DIGIT_RE = re.compile('\d{1,2}') _ONE_OR_TWO_DIGIT_RE = re.compile('\d{1,2}')
_FOUR_DIGIT_RE = re.compile('\d{4}') _FOUR_DIGIT_RE = re.compile('\d{4}')
_TWO_DIGIT_RE = re.compile('\d{2}') _TWO_DIGIT_RE = re.compile('\d{2}')
_TZ_RE = re.compile('[+\-]?\d{2}:?\d{2}') _TZ_RE = re.compile('[+\-]?\d{2}:?(\d{2})?')
_TZ_NAME_RE = re.compile('\w[\w+\-/]+') _TZ_NAME_RE = re.compile('\w[\w+\-/]+')
@@ -47,12 +44,7 @@ class DateTimeParser(object):
'ZZZ': _TZ_NAME_RE, 'ZZZ': _TZ_NAME_RE,
'ZZ': _TZ_RE, 'ZZ': _TZ_RE,
'Z': _TZ_RE, 'Z': _TZ_RE,
'SSSSSS': _ONE_THROUGH_SIX_DIGIT_RE, 'S': _ONE_OR_MORE_DIGIT_RE,
'SSSSS': _ONE_THROUGH_FIVE_DIGIT_RE,
'SSSS': _ONE_THROUGH_FOUR_DIGIT_RE,
'SSS': _ONE_TWO_OR_THREE_DIGIT_RE,
'SS': _ONE_OR_TWO_DIGIT_RE,
'S': re.compile('\d'),
} }
MARKERS = ['YYYY', 'MM', 'DD'] MARKERS = ['YYYY', 'MM', 'DD']
@@ -67,6 +59,10 @@ class DateTimeParser(object):
'MMM': self._choice_re(self.locale.month_abbreviations[1:], 'MMM': self._choice_re(self.locale.month_abbreviations[1:],
re.IGNORECASE), re.IGNORECASE),
'Do': re.compile(self.locale.ordinal_day_re), 'Do': re.compile(self.locale.ordinal_day_re),
'dddd': self._choice_re(self.locale.day_names[1:], re.IGNORECASE),
'ddd': self._choice_re(self.locale.day_abbreviations[1:],
re.IGNORECASE),
'd' : re.compile("[1-7]"),
'a': self._choice_re( 'a': self._choice_re(
(self.locale.meridians['am'], self.locale.meridians['pm']) (self.locale.meridians['am'], self.locale.meridians['pm'])
), ),
@@ -88,11 +84,10 @@ class DateTimeParser(object):
time_parts = re.split('[+-]', time_string, 1) time_parts = re.split('[+-]', time_string, 1)
has_tz = len(time_parts) > 1 has_tz = len(time_parts) > 1
has_seconds = time_parts[0].count(':') > 1 has_seconds = time_parts[0].count(':') > 1
has_subseconds = '.' in time_parts[0] has_subseconds = re.search('[.,]', time_parts[0])
if has_subseconds: if has_subseconds:
subseconds_token = 'S' * min(len(re.split('\D+', time_parts[0].split('.')[1], 1)[0]), 6) formats = ['YYYY-MM-DDTHH:mm:ss%sS' % has_subseconds.group()]
formats = ['YYYY-MM-DDTHH:mm:ss.%s' % subseconds_token]
elif has_seconds: elif has_seconds:
formats = ['YYYY-MM-DDTHH:mm:ss'] formats = ['YYYY-MM-DDTHH:mm:ss']
else: else:
@@ -123,10 +118,18 @@ class DateTimeParser(object):
# we construct a new string by replacing each # we construct a new string by replacing each
# token by its pattern: # token by its pattern:
# 'YYYY-MM-DD' -> '(?P<YYYY>\d{4})-(?P<MM>\d{2})-(?P<DD>\d{2})' # 'YYYY-MM-DD' -> '(?P<YYYY>\d{4})-(?P<MM>\d{2})-(?P<DD>\d{2})'
fmt_pattern = fmt
tokens = [] tokens = []
offset = 0 offset = 0
for m in self._FORMAT_RE.finditer(fmt):
# Extract the bracketed expressions to be reinserted later.
escaped_fmt = re.sub(self._ESCAPE_RE, "#" , fmt)
# Any number of S is the same as one.
escaped_fmt = re.sub('S+', 'S', escaped_fmt)
escaped_data = re.findall(self._ESCAPE_RE, fmt)
fmt_pattern = escaped_fmt
for m in self._FORMAT_RE.finditer(escaped_fmt):
token = m.group(0) token = m.group(0)
try: try:
input_re = self._input_re_map[token] input_re = self._input_re_map[token]
@@ -140,9 +143,20 @@ class DateTimeParser(object):
# are returned in the order found by finditer. # are returned in the order found by finditer.
fmt_pattern = fmt_pattern[:m.start() + offset] + input_pattern + fmt_pattern[m.end() + offset:] fmt_pattern = fmt_pattern[:m.start() + offset] + input_pattern + fmt_pattern[m.end() + offset:]
offset += len(input_pattern) - (m.end() - m.start()) offset += len(input_pattern) - (m.end() - m.start())
match = re.search(fmt_pattern, string, flags=re.IGNORECASE)
final_fmt_pattern = ""
a = fmt_pattern.split("#")
b = escaped_data
# Due to the way Python splits, 'a' will always be longer
for i in range(len(a)):
final_fmt_pattern += a[i]
if i < len(b):
final_fmt_pattern += b[i][1:-1]
match = re.search(final_fmt_pattern, string, flags=re.IGNORECASE)
if match is None: if match is None:
raise ParserError('Failed to match \'{0}\' when parsing \'{1}\''.format(fmt_pattern, string)) raise ParserError('Failed to match \'{0}\' when parsing \'{1}\''.format(final_fmt_pattern, string))
parts = {} parts = {}
for token in tokens: for token in tokens:
if token == 'Do': if token == 'Do':
@@ -181,18 +195,22 @@ class DateTimeParser(object):
elif token in ['ss', 's']: elif token in ['ss', 's']:
parts['second'] = int(value) parts['second'] = int(value)
elif token == 'SSSSSS':
parts['microsecond'] = int(value)
elif token == 'SSSSS':
parts['microsecond'] = int(value) * 10
elif token == 'SSSS':
parts['microsecond'] = int(value) * 100
elif token == 'SSS':
parts['microsecond'] = int(value) * 1000
elif token == 'SS':
parts['microsecond'] = int(value) * 10000
elif token == 'S': elif token == 'S':
parts['microsecond'] = int(value) * 100000 # We have the *most significant* digits of an arbitrary-precision integer.
# We want the six most significant digits as an integer, rounded.
# FIXME: add nanosecond support somehow?
value = value.ljust(7, str('0'))
# floating-point (IEEE-754) defaults to half-to-even rounding
seventh_digit = int(value[6])
if seventh_digit == 5:
rounding = int(value[5]) % 2
elif seventh_digit > 5:
rounding = 1
else:
rounding = 0
parts['microsecond'] = int(value[:6]) + rounding
elif token == 'X': elif token == 'X':
parts['timestamp'] = int(value) parts['timestamp'] = int(value)
@@ -242,7 +260,7 @@ class DateTimeParser(object):
try: try:
_datetime = self.parse(string, fmt) _datetime = self.parse(string, fmt)
break break
except: except ParserError:
pass pass
if _datetime is None: if _datetime is None:
@@ -273,7 +291,7 @@ class DateTimeParser(object):
class TzinfoParser(object): class TzinfoParser(object):
_TZINFO_RE = re.compile('([+\-])?(\d\d):?(\d\d)') _TZINFO_RE = re.compile('([+\-])?(\d\d):?(\d\d)?')
@classmethod @classmethod
def parse(cls, string): def parse(cls, string):
@@ -292,6 +310,8 @@ class TzinfoParser(object):
if iso_match: if iso_match:
sign, hours, minutes = iso_match.groups() sign, hours, minutes = iso_match.groups()
if minutes is None:
minutes = 0
seconds = int(hours) * 3600 + int(minutes) * 60 seconds = int(hours) * 3600 + int(minutes) * 60
if sign == '-': if sign == '-':
@@ -303,6 +323,6 @@ class TzinfoParser(object):
tzinfo = tz.gettz(string) tzinfo = tz.gettz(string)
if tzinfo is None: if tzinfo is None:
raise ParserError('Could not parse timezone expression "{0}"', string) raise ParserError('Could not parse timezone expression "{0}"'.format(string))
return tzinfo return tzinfo

View File

@@ -22,6 +22,8 @@ else: # pragma: no cover
total_seconds = _total_seconds_27 total_seconds = _total_seconds_27
def is_timestamp(value): def is_timestamp(value):
if type(value) == bool:
return False
try: try:
float(value) float(value)
return True return True

302
lib/cloudinary/__init__.py Normal file
View File

@@ -0,0 +1,302 @@
from __future__ import absolute_import
import logging
logger = logging.getLogger("Cloudinary")
ch = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
ch.setFormatter(formatter)
logger.addHandler(ch)
import os
import re
from six import python_2_unicode_compatible
from cloudinary import utils
from cloudinary.compat import urlparse, parse_qs
from cloudinary.search import Search
CF_SHARED_CDN = "d3jpl91pxevbkh.cloudfront.net"
OLD_AKAMAI_SHARED_CDN = "cloudinary-a.akamaihd.net"
AKAMAI_SHARED_CDN = "res.cloudinary.com"
SHARED_CDN = AKAMAI_SHARED_CDN
CL_BLANK = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
VERSION = "1.11.0"
USER_AGENT = "CloudinaryPython/" + VERSION
""" :const: USER_AGENT """
USER_PLATFORM = ""
"""
Additional information to be passed with the USER_AGENT, e.g. "CloudinaryMagento/1.0.1".
This value is set in platform-specific implementations that use cloudinary_php.
The format of the value should be <ProductName>/Version[ (comment)].
@see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.43
**Do not set this value in application code!**
"""
def get_user_agent():
"""Provides the `USER_AGENT` string that is passed to the Cloudinary servers.
Prepends `USER_PLATFORM` if it is defined.
:returns: the user agent
:rtype: str
"""
if USER_PLATFORM == "":
return USER_AGENT
else:
return USER_PLATFORM + " " + USER_AGENT
def import_django_settings():
try:
import django.conf
from django.core.exceptions import ImproperlyConfigured
try:
if 'CLOUDINARY' in dir(django.conf.settings):
return django.conf.settings.CLOUDINARY
else:
return None
except ImproperlyConfigured:
return None
except ImportError:
return None
class Config(object):
def __init__(self):
django_settings = import_django_settings()
if django_settings:
self.update(**django_settings)
elif os.environ.get("CLOUDINARY_CLOUD_NAME"):
self.update(
cloud_name=os.environ.get("CLOUDINARY_CLOUD_NAME"),
api_key=os.environ.get("CLOUDINARY_API_KEY"),
api_secret=os.environ.get("CLOUDINARY_API_SECRET"),
secure_distribution=os.environ.get("CLOUDINARY_SECURE_DISTRIBUTION"),
private_cdn=os.environ.get("CLOUDINARY_PRIVATE_CDN") == 'true'
)
elif os.environ.get("CLOUDINARY_URL"):
cloudinary_url = os.environ.get("CLOUDINARY_URL")
self._parse_cloudinary_url(cloudinary_url)
def _parse_cloudinary_url(self, cloudinary_url):
uri = urlparse(cloudinary_url.replace("cloudinary://", "http://"))
for k, v in parse_qs(uri.query).items():
if self._is_nested_key(k):
self._put_nested_key(k, v)
else:
self.__dict__[k] = v[0]
self.update(
cloud_name=uri.hostname,
api_key=uri.username,
api_secret=uri.password,
private_cdn=uri.path != ''
)
if uri.path != '':
self.update(secure_distribution=uri.path[1:])
def __getattr__(self, i):
if i in self.__dict__:
return self.__dict__[i]
else:
return None
def update(self, **keywords):
for k, v in keywords.items():
self.__dict__[k] = v
def _is_nested_key(self, key):
return re.match(r'\w+\[\w+\]', key)
def _put_nested_key(self, key, value):
chain = re.split(r'[\[\]]+', key)
chain = [key for key in chain if key]
outer = self.__dict__
last_key = chain.pop()
for inner_key in chain:
if inner_key in outer:
inner = outer[inner_key]
else:
inner = dict()
outer[inner_key] = inner
outer = inner
if isinstance(value, list):
value = value[0]
outer[last_key] = value
_config = Config()
def config(**keywords):
global _config
_config.update(**keywords)
return _config
def reset_config():
global _config
_config = Config()
@python_2_unicode_compatible
class CloudinaryResource(object):
def __init__(self, public_id=None, format=None, version=None,
signature=None, url_options=None, metadata=None, type=None, resource_type=None,
default_resource_type=None):
self.metadata = metadata
metadata = metadata or {}
self.public_id = public_id or metadata.get('public_id')
self.format = format or metadata.get('format')
self.version = version or metadata.get('version')
self.signature = signature or metadata.get('signature')
self.type = type or metadata.get('type') or "upload"
self.resource_type = resource_type or metadata.get('resource_type') or default_resource_type
self.url_options = url_options or {}
def __str__(self):
return self.public_id
def __len__(self):
return len(self.public_id) if self.public_id is not None else 0
def validate(self):
return self.signature == self.get_expected_signature()
def get_prep_value(self):
if None in [self.public_id,
self.type,
self.resource_type]:
return None
prep = ''
prep = prep + self.resource_type + '/' + self.type + '/'
if self.version: prep = prep + 'v' + str(self.version) + '/'
prep = prep + self.public_id
if self.format: prep = prep + '.' + self.format
return prep
def get_presigned(self):
return self.get_prep_value() + '#' + self.get_expected_signature()
def get_expected_signature(self):
return utils.api_sign_request({"public_id": self.public_id, "version": self.version}, config().api_secret)
@property
def url(self):
return self.build_url(**self.url_options)
def __build_url(self, **options):
combined_options = dict(format=self.format, version=self.version, type=self.type,
resource_type=self.resource_type or "image")
combined_options.update(options)
public_id = combined_options.get('public_id') or self.public_id
return utils.cloudinary_url(public_id, **combined_options)
def build_url(self, **options):
return self.__build_url(**options)[0]
def default_poster_options(self, options):
options["format"] = options.get("format", "jpg")
def default_source_types(self):
return ['webm', 'mp4', 'ogv']
def image(self, **options):
if options.get("resource_type", self.resource_type) == "video":
self.default_poster_options(options)
src, attrs = self.__build_url(**options)
client_hints = attrs.pop("client_hints", config().client_hints)
responsive = attrs.pop("responsive", False)
hidpi = attrs.pop("hidpi", False)
if (responsive or hidpi) and not client_hints:
attrs["data-src"] = src
classes = "cld-responsive" if responsive else "cld-hidpi"
if "class" in attrs: classes += " " + attrs["class"]
attrs["class"] = classes
src = attrs.pop("responsive_placeholder", config().responsive_placeholder)
if src == "blank": src = CL_BLANK
if src: attrs["src"] = src
return u"<img {0}/>".format(utils.html_attrs(attrs))
def video_thumbnail(self, **options):
self.default_poster_options(options)
return self.build_url(**options)
# Creates an HTML video tag for the provided +source+
#
# ==== Options
# * <tt>source_types</tt> - Specify which source type the tag should include. defaults to webm, mp4 and ogv.
# * <tt>source_transformation</tt> - specific transformations to use for a specific source type.
# * <tt>poster</tt> - override default thumbnail:
# * url: provide an ad hoc url
# * options: with specific poster transformations and/or Cloudinary +:public_id+
#
# ==== Examples
# CloudinaryResource("mymovie.mp4").video()
# CloudinaryResource("mymovie.mp4").video(source_types = 'webm')
# CloudinaryResource("mymovie.ogv").video(poster = "myspecialplaceholder.jpg")
# CloudinaryResource("mymovie.webm").video(source_types = ['webm', 'mp4'], poster = {'effect': 'sepia'})
def video(self, **options):
public_id = options.get('public_id', self.public_id)
source = re.sub("\.({0})$".format("|".join(self.default_source_types())), '', public_id)
source_types = options.pop('source_types', [])
source_transformation = options.pop('source_transformation', {})
fallback = options.pop('fallback_content', '')
options['resource_type'] = options.pop('resource_type', self.resource_type or 'video')
if not source_types: source_types = self.default_source_types()
video_options = options.copy()
if 'poster' in video_options:
poster_options = video_options['poster']
if isinstance(poster_options, dict):
if 'public_id' in poster_options:
video_options['poster'] = utils.cloudinary_url(poster_options['public_id'], **poster_options)[0]
else:
video_options['poster'] = self.video_thumbnail(public_id=source, **poster_options)
else:
video_options['poster'] = self.video_thumbnail(public_id=source, **options)
if not video_options['poster']: del video_options['poster']
nested_source_types = isinstance(source_types, list) and len(source_types) > 1
if not nested_source_types:
source = source + '.' + utils.build_array(source_types)[0]
video_url = utils.cloudinary_url(source, **video_options)
video_options = video_url[1]
if not nested_source_types:
video_options['src'] = video_url[0]
if 'html_width' in video_options: video_options['width'] = video_options.pop('html_width')
if 'html_height' in video_options: video_options['height'] = video_options.pop('html_height')
sources = ""
if nested_source_types:
for source_type in source_types:
transformation = options.copy()
transformation.update(source_transformation.get(source_type, {}))
src = utils.cloudinary_url(source, format=source_type, **transformation)[0]
video_type = "ogg" if source_type == 'ogv' else source_type
mime_type = "video/" + video_type
sources += "<source {attributes}>".format(attributes=utils.html_attrs({'src': src, 'type': mime_type}))
html = "<video {attributes}>{sources}{fallback}</video>".format(
attributes=utils.html_attrs(video_options), sources=sources, fallback=fallback)
return html
class CloudinaryImage(CloudinaryResource):
def __init__(self, public_id=None, **kwargs):
super(CloudinaryImage, self).__init__(public_id=public_id, default_resource_type="image", **kwargs)
class CloudinaryVideo(CloudinaryResource):
def __init__(self, public_id=None, **kwargs):
super(CloudinaryVideo, self).__init__(public_id=public_id, default_resource_type="video", **kwargs)

448
lib/cloudinary/api.py Normal file
View File

@@ -0,0 +1,448 @@
# Copyright Cloudinary
import email.utils
import json
import socket
import cloudinary
from six import string_types
import urllib3
import certifi
from cloudinary import utils
from urllib3.exceptions import HTTPError
logger = cloudinary.logger
# intentionally one-liners
class Error(Exception): pass
class NotFound(Error): pass
class NotAllowed(Error): pass
class AlreadyExists(Error): pass
class RateLimited(Error): pass
class BadRequest(Error): pass
class GeneralError(Error): pass
class AuthorizationRequired(Error): pass
EXCEPTION_CODES = {
400: BadRequest,
401: AuthorizationRequired,
403: NotAllowed,
404: NotFound,
409: AlreadyExists,
420: RateLimited,
500: GeneralError
}
class Response(dict):
def __init__(self, result, response, **kwargs):
super(Response, self).__init__(**kwargs)
self.update(result)
self.rate_limit_allowed = int(response.headers["x-featureratelimit-limit"])
self.rate_limit_reset_at = email.utils.parsedate(response.headers["x-featureratelimit-reset"])
self.rate_limit_remaining = int(response.headers["x-featureratelimit-remaining"])
_http = urllib3.PoolManager(
cert_reqs='CERT_REQUIRED',
ca_certs=certifi.where()
)
def ping(**options):
return call_api("get", ["ping"], {}, **options)
def usage(**options):
return call_api("get", ["usage"], {}, **options)
def resource_types(**options):
return call_api("get", ["resources"], {}, **options)
def resources(**options):
resource_type = options.pop("resource_type", "image")
upload_type = options.pop("type", None)
uri = ["resources", resource_type]
if upload_type: uri.append(upload_type)
params = only(options,
"next_cursor", "max_results", "prefix", "tags", "context", "moderations", "direction", "start_at")
return call_api("get", uri, params, **options)
def resources_by_tag(tag, **options):
resource_type = options.pop("resource_type", "image")
uri = ["resources", resource_type, "tags", tag]
params = only(options, "next_cursor", "max_results", "tags", "context", "moderations", "direction")
return call_api("get", uri, params, **options)
def resources_by_moderation(kind, status, **options):
resource_type = options.pop("resource_type", "image")
uri = ["resources", resource_type, "moderations", kind, status]
params = only(options, "next_cursor", "max_results", "tags", "context", "moderations", "direction")
return call_api("get", uri, params, **options)
def resources_by_ids(public_ids, **options):
resource_type = options.pop("resource_type", "image")
upload_type = options.pop("type", "upload")
uri = ["resources", resource_type, upload_type]
params = dict(only(options, "tags", "moderations", "context"), public_ids=public_ids)
return call_api("get", uri, params, **options)
def resource(public_id, **options):
resource_type = options.pop("resource_type", "image")
upload_type = options.pop("type", "upload")
uri = ["resources", resource_type, upload_type, public_id]
params = only(options, "exif", "faces", "colors", "image_metadata", "pages", "phash", "coordinates", "max_results")
return call_api("get", uri, params, **options)
def update(public_id, **options):
resource_type = options.pop("resource_type", "image")
upload_type = options.pop("type", "upload")
uri = ["resources", resource_type, upload_type, public_id]
params = only(options, "moderation_status", "raw_convert",
"quality_override", "ocr",
"categorization", "detection", "similarity_search",
"background_removal", "notification_url")
if "tags" in options:
params["tags"] = ",".join(utils.build_array(options["tags"]))
if "face_coordinates" in options:
params["face_coordinates"] = utils.encode_double_array(options.get("face_coordinates"))
if "custom_coordinates" in options:
params["custom_coordinates"] = utils.encode_double_array(options.get("custom_coordinates"))
if "context" in options:
params["context"] = utils.encode_context(options.get("context"))
if "auto_tagging" in options:
params["auto_tagging"] = str(options.get("auto_tagging"))
if "access_control" in options:
params["access_control"] = utils.json_encode(utils.build_list_of_dicts(options.get("access_control")))
return call_api("post", uri, params, **options)
def delete_resources(public_ids, **options):
resource_type = options.pop("resource_type", "image")
upload_type = options.pop("type", "upload")
uri = ["resources", resource_type, upload_type]
params = __delete_resource_params(options, public_ids=public_ids)
return call_api("delete", uri, params, **options)
def delete_resources_by_prefix(prefix, **options):
resource_type = options.pop("resource_type", "image")
upload_type = options.pop("type", "upload")
uri = ["resources", resource_type, upload_type]
params = __delete_resource_params(options, prefix=prefix)
return call_api("delete", uri, params, **options)
def delete_all_resources(**options):
resource_type = options.pop("resource_type", "image")
upload_type = options.pop("type", "upload")
uri = ["resources", resource_type, upload_type]
params = __delete_resource_params(options, all=True)
return call_api("delete", uri, params, **options)
def delete_resources_by_tag(tag, **options):
resource_type = options.pop("resource_type", "image")
uri = ["resources", resource_type, "tags", tag]
params = __delete_resource_params(options)
return call_api("delete", uri, params, **options)
def delete_derived_resources(derived_resource_ids, **options):
uri = ["derived_resources"]
params = {"derived_resource_ids": derived_resource_ids}
return call_api("delete", uri, params, **options)
def delete_derived_by_transformation(public_ids, transformations,
resource_type='image', type='upload', invalidate=None,
**options):
"""
Delete derived resources of public ids, identified by transformations
:param public_ids: the base resources
:type public_ids: list of str
:param transformations: the transformation of derived resources, optionally including the format
:type transformations: list of (dict or str)
:param type: The upload type
:type type: str
:param resource_type: The type of the resource: defaults to "image"
:type resource_type: str
:param invalidate: (optional) True to invalidate the resources after deletion
:type invalidate: bool
:return: a list of the public ids for which derived resources were deleted
:rtype: dict
"""
uri = ["resources", resource_type, type]
if not isinstance(public_ids, list):
public_ids = [public_ids]
params = {"public_ids": public_ids,
"transformations": utils.build_eager(transformations),
"keep_original": True}
if invalidate is not None:
params['invalidate'] = invalidate
return call_api("delete", uri, params, **options)
def tags(**options):
resource_type = options.pop("resource_type", "image")
uri = ["tags", resource_type]
return call_api("get", uri, only(options, "next_cursor", "max_results", "prefix"), **options)
def transformations(**options):
uri = ["transformations"]
return call_api("get", uri, only(options, "next_cursor", "max_results"), **options)
def transformation(transformation, **options):
uri = ["transformations", transformation_string(transformation)]
return call_api("get", uri, only(options, "next_cursor", "max_results"), **options)
def delete_transformation(transformation, **options):
uri = ["transformations", transformation_string(transformation)]
return call_api("delete", uri, {}, **options)
# updates - currently only supported update is the "allowed_for_strict" boolean flag and unsafe_update
def update_transformation(transformation, **options):
uri = ["transformations", transformation_string(transformation)]
updates = only(options, "allowed_for_strict")
if "unsafe_update" in options:
updates["unsafe_update"] = transformation_string(options.get("unsafe_update"))
if not updates: raise Exception("No updates given")
return call_api("put", uri, updates, **options)
def create_transformation(name, definition, **options):
uri = ["transformations", name]
return call_api("post", uri, {"transformation": transformation_string(definition)}, **options)
def publish_by_ids(public_ids, **options):
resource_type = options.pop("resource_type", "image")
uri = ["resources", resource_type, "publish_resources"]
params = dict(only(options, "type", "overwrite", "invalidate"), public_ids=public_ids)
return call_api("post", uri, params, **options)
def publish_by_prefix(prefix, **options):
resource_type = options.pop("resource_type", "image")
uri = ["resources", resource_type, "publish_resources"]
params = dict(only(options, "type", "overwrite", "invalidate"), prefix=prefix)
return call_api("post", uri, params, **options)
def publish_by_tag(tag, **options):
resource_type = options.pop("resource_type", "image")
uri = ["resources", resource_type, "publish_resources"]
params = dict(only(options, "type", "overwrite", "invalidate"), tag=tag)
return call_api("post", uri, params, **options)
def upload_presets(**options):
uri = ["upload_presets"]
return call_api("get", uri, only(options, "next_cursor", "max_results"), **options)
def upload_preset(name, **options):
uri = ["upload_presets", name]
return call_api("get", uri, only(options, "max_results"), **options)
def delete_upload_preset(name, **options):
uri = ["upload_presets", name]
return call_api("delete", uri, {}, **options)
def update_upload_preset(name, **options):
uri = ["upload_presets", name]
params = utils.build_upload_params(**options)
params = utils.cleanup_params(params)
params.update(only(options, "unsigned", "disallow_public_id"))
return call_api("put", uri, params, **options)
def create_upload_preset(**options):
uri = ["upload_presets"]
params = utils.build_upload_params(**options)
params = utils.cleanup_params(params)
params.update(only(options, "unsigned", "disallow_public_id", "name"))
return call_api("post", uri, params, **options)
def root_folders(**options):
return call_api("get", ["folders"], {}, **options)
def subfolders(of_folder_path, **options):
return call_api("get", ["folders", of_folder_path], {}, **options)
def restore(public_ids, **options):
resource_type = options.pop("resource_type", "image")
upload_type = options.pop("type", "upload")
uri = ["resources", resource_type, upload_type, "restore"]
params = dict(public_ids=public_ids)
return call_api("post", uri, params, **options)
def upload_mappings(**options):
uri = ["upload_mappings"]
return call_api("get", uri, only(options, "next_cursor", "max_results"), **options)
def upload_mapping(name, **options):
uri = ["upload_mappings"]
params = dict(folder=name)
return call_api("get", uri, params, **options)
def delete_upload_mapping(name, **options):
uri = ["upload_mappings"]
params = dict(folder=name)
return call_api("delete", uri, params, **options)
def update_upload_mapping(name, **options):
uri = ["upload_mappings"]
params = dict(folder=name)
params.update(only(options, "template"))
return call_api("put", uri, params, **options)
def create_upload_mapping(name, **options):
uri = ["upload_mappings"]
params = dict(folder=name)
params.update(only(options, "template"))
return call_api("post", uri, params, **options)
def list_streaming_profiles(**options):
uri = ["streaming_profiles"]
return call_api('GET', uri, {}, **options)
def get_streaming_profile(name, **options):
uri = ["streaming_profiles", name]
return call_api('GET', uri, {}, **options)
def delete_streaming_profile(name, **options):
uri = ["streaming_profiles", name]
return call_api('DELETE', uri, {}, **options)
def create_streaming_profile(name, **options):
uri = ["streaming_profiles"]
params = __prepare_streaming_profile_params(**options)
params["name"] = name
return call_api('POST', uri, params, **options)
def update_streaming_profile(name, **options):
uri = ["streaming_profiles", name]
params = __prepare_streaming_profile_params(**options)
return call_api('PUT', uri, params, **options)
def call_json_api(method, uri, jsonBody, **options):
logger.debug(jsonBody)
data = json.dumps(jsonBody).encode('utf-8')
return _call_api(method, uri, body=data, headers={'Content-Type': 'application/json'}, **options)
def call_api(method, uri, params, **options):
return _call_api(method, uri, params=params, **options)
def _call_api(method, uri, params=None, body=None, headers=None, **options):
prefix = options.pop("upload_prefix",
cloudinary.config().upload_prefix) or "https://api.cloudinary.com"
cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name)
if not cloud_name: raise Exception("Must supply cloud_name")
api_key = options.pop("api_key", cloudinary.config().api_key)
if not api_key: raise Exception("Must supply api_key")
api_secret = options.pop("api_secret", cloudinary.config().api_secret)
if not cloud_name: raise Exception("Must supply api_secret")
api_url = "/".join([prefix, "v1_1", cloud_name] + uri)
processed_params = None
if isinstance(params, dict):
processed_params = {}
for key, value in params.items():
if isinstance(value, list):
value_list = {"{}[{}]".format(key, i): i_value for i, i_value in enumerate(value)}
processed_params.update(value_list)
elif value:
processed_params[key] = value
# Add authentication
req_headers = urllib3.make_headers(
basic_auth="{0}:{1}".format(api_key, api_secret),
user_agent=cloudinary.get_user_agent()
)
if headers is not None:
req_headers.update(headers)
kw = {}
if 'timeout' in options:
kw['timeout'] = options['timeout']
if body is not None:
kw['body'] = body
try:
response = _http.request(method.upper(), api_url, processed_params, req_headers, **kw)
body = response.data
except HTTPError as e:
raise GeneralError("Unexpected error {0}", e.message)
except socket.error as e:
raise GeneralError("Socket Error: %s" % (str(e)))
try:
result = json.loads(body.decode('utf-8'))
except Exception as e:
# Error is parsing json
raise GeneralError("Error parsing server response (%d) - %s. Got - %s" % (response.status, body, e))
if "error" in result:
exception_class = EXCEPTION_CODES.get(response.status) or Exception
exception_class = exception_class
raise exception_class("Error {0} - {1}".format(response.status, result["error"]["message"]))
return Response(result, response)
def only(source, *keys):
return {key: source[key] for key in keys if key in source}
def transformation_string(transformation):
if isinstance(transformation, string_types):
return transformation
else:
return cloudinary.utils.generate_transformation_string(**transformation)[0]
def __prepare_streaming_profile_params(**options):
params = only(options, "display_name")
if "representations" in options:
representations = [{"transformation": transformation_string(trans)} for trans in options["representations"]]
params["representations"] = json.dumps(representations)
return params
def __delete_resource_params(options, **params):
p = dict(transformations=utils.build_eager(options.get('transformations')),
**only(options, "keep_original", "next_cursor", "invalidate"))
p.update(params)
return p

View File

@@ -0,0 +1,47 @@
import hashlib
import hmac
import re
import time
from binascii import a2b_hex
from cloudinary.compat import quote_plus
AUTH_TOKEN_NAME = "__cld_token__"
def generate(url=None, acl=None, start_time=None, duration=None, expiration=None, ip=None, key=None,
token_name=AUTH_TOKEN_NAME):
if expiration is None:
if duration is not None:
start = start_time if start_time is not None else int(time.mktime(time.gmtime()))
expiration = start + duration
else:
raise Exception("Must provide either expiration or duration")
token_parts = []
if ip is not None: token_parts.append("ip=" + ip)
if start_time is not None: token_parts.append("st=%d" % start_time)
token_parts.append("exp=%d" % expiration)
if acl is not None: token_parts.append("acl=%s" % _escape_to_lower(acl))
to_sign = list(token_parts)
if url is not None:
to_sign.append("url=%s" % _escape_to_lower(url))
auth = _digest("~".join(to_sign), key)
token_parts.append("hmac=%s" % auth)
return "%(token_name)s=%(token)s" % {"token_name": token_name, "token": "~".join(token_parts)}
def _digest(message, key):
bin_key = a2b_hex(key)
return hmac.new(bin_key, message.encode('utf-8'), hashlib.sha256).hexdigest()
def _escape_to_lower(url):
escaped_url = quote_plus(url)
def toLowercase(match):
return match.group(0).lower()
escaped_url = re.sub(r'%..', toLowercase, escaped_url)
return escaped_url

34
lib/cloudinary/compat.py Normal file
View File

@@ -0,0 +1,34 @@
# Copyright Cloudinary
import six.moves.urllib.parse
urlencode = six.moves.urllib.parse.urlencode
unquote = six.moves.urllib.parse.unquote
urlparse = six.moves.urllib.parse.urlparse
parse_qs = six.moves.urllib.parse.parse_qs
parse_qsl = six.moves.urllib.parse.parse_qsl
quote_plus = six.moves.urllib.parse.quote_plus
httplib = six.moves.http_client
from six import PY3, string_types, StringIO, BytesIO
urllib2 = six.moves.urllib.request
NotConnected = six.moves.http_client.NotConnected
if PY3:
to_bytes = lambda s: s.encode('utf8')
to_bytearray = lambda s: bytearray(s, 'utf8')
to_string = lambda b: b.decode('utf8')
else:
to_bytes = str
to_bytearray = str
to_string = str
try:
cldrange = xrange
except NameError:
def cldrange(*args, **kwargs):
return iter(range(*args, **kwargs))
try:
advance_iterator = next
except NameError:
def advance_iterator(it):
return it.next()

134
lib/cloudinary/forms.py Normal file
View File

@@ -0,0 +1,134 @@
from django import forms
from cloudinary import CloudinaryResource
import cloudinary.uploader
import cloudinary.utils
import re
import json
from django.utils.translation import ugettext_lazy as _
def cl_init_js_callbacks(form, request):
for field in form.fields.values():
if isinstance(field, CloudinaryJsFileField):
field.enable_callback(request)
class CloudinaryInput(forms.TextInput):
input_type = 'file'
def render(self, name, value, attrs=None):
attrs = self.build_attrs(attrs)
options = attrs.get('options', {})
attrs["options"] = ''
params = cloudinary.utils.build_upload_params(**options)
if options.get("unsigned"):
params = cloudinary.utils.cleanup_params(params)
else:
params = cloudinary.utils.sign_request(params, options)
if 'resource_type' not in options: options['resource_type'] = 'auto'
cloudinary_upload_url = cloudinary.utils.cloudinary_api_url("upload", **options)
attrs["data-url"] = cloudinary_upload_url
attrs["data-form-data"] = json.dumps(params)
attrs["data-cloudinary-field"] = name
chunk_size = options.get("chunk_size", None)
if chunk_size: attrs["data-max-chunk-size"] = chunk_size
attrs["class"] = " ".join(["cloudinary-fileupload", attrs.get("class", "")])
widget = super(CloudinaryInput, self).render("file", None, attrs=attrs)
if value:
if isinstance(value, CloudinaryResource):
value_string = value.get_presigned()
else:
value_string = value
widget += forms.HiddenInput().render(name, value_string)
return widget
class CloudinaryJsFileField(forms.Field):
default_error_messages = {
'required': _(u"No file selected!")
}
def __init__(self, attrs=None, options=None, autosave=True, *args, **kwargs):
if attrs is None: attrs = {}
if options is None: options = {}
self.autosave = autosave
attrs = attrs.copy()
attrs["options"] = options.copy()
field_options = {'widget': CloudinaryInput(attrs=attrs)}
field_options.update(kwargs)
super(CloudinaryJsFileField, self).__init__(*args, **field_options)
def enable_callback(self, request):
from django.contrib.staticfiles.storage import staticfiles_storage
self.widget.attrs["options"]["callback"] = request.build_absolute_uri(
staticfiles_storage.url("html/cloudinary_cors.html"))
def to_python(self, value):
"""Convert to CloudinaryResource"""
if not value: return None
m = re.search(r'^([^/]+)/([^/]+)/v(\d+)/([^#]+)#([^/]+)$', value)
if not m:
raise forms.ValidationError("Invalid format")
resource_type = m.group(1)
upload_type = m.group(2)
version = m.group(3)
filename = m.group(4)
signature = m.group(5)
m = re.search(r'(.*)\.(.*)', filename)
if not m:
raise forms.ValidationError("Invalid file name")
public_id = m.group(1)
image_format = m.group(2)
return CloudinaryResource(public_id,
format=image_format,
version=version,
signature=signature,
type=upload_type,
resource_type=resource_type)
def validate(self, value):
"""Validate the signature"""
# Use the parent's handling of required fields, etc.
super(CloudinaryJsFileField, self).validate(value)
if not value: return
if not value.validate():
raise forms.ValidationError("Signature mismatch")
class CloudinaryUnsignedJsFileField(CloudinaryJsFileField):
def __init__(self, upload_preset, attrs=None, options=None, autosave=True, *args, **kwargs):
if attrs is None:
attrs = {}
if options is None:
options = {}
options = options.copy()
options.update({"unsigned": True, "upload_preset": upload_preset})
super(CloudinaryUnsignedJsFileField, self).__init__(attrs, options, autosave, *args, **kwargs)
class CloudinaryFileField(forms.FileField):
my_default_error_messages = {
'required': _(u"No file selected!")
}
default_error_messages = forms.FileField.default_error_messages.copy()
default_error_messages.update(my_default_error_messages)
def __init__(self, options=None, autosave=True, *args, **kwargs):
self.autosave = autosave
self.options = options or {}
super(CloudinaryFileField, self).__init__(*args, **kwargs)
def to_python(self, value):
"""Upload and convert to CloudinaryResource"""
value = super(CloudinaryFileField, self).to_python(value)
if not value:
return None
if self.autosave:
return cloudinary.uploader.upload_image(value, **self.options)
else:
return value

121
lib/cloudinary/models.py Normal file
View File

@@ -0,0 +1,121 @@
import re
from cloudinary import CloudinaryResource, forms, uploader
from django.core.files.uploadedfile import UploadedFile
from django.db import models
# Add introspection rules for South, if it's installed.
try:
from south.modelsinspector import add_introspection_rules
add_introspection_rules([], ["^cloudinary.models.CloudinaryField"])
except ImportError:
pass
CLOUDINARY_FIELD_DB_RE = r'(?:(?P<resource_type>image|raw|video)/(?P<type>upload|private|authenticated)/)?(?:v(?P<version>\d+)/)?(?P<public_id>.*?)(\.(?P<format>[^.]+))?$'
# Taken from six - https://pythonhosted.org/six/
def with_metaclass(meta, *bases):
"""Create a base class with a metaclass."""
# This requires a bit of explanation: the basic idea is to make a dummy
# metaclass for one level of class instantiation that replaces itself with
# the actual metaclass.
class metaclass(meta):
def __new__(cls, name, this_bases, d):
return meta(name, bases, d)
return type.__new__(metaclass, 'temporary_class', (), {})
class CloudinaryField(models.Field):
description = "A resource stored in Cloudinary"
def __init__(self, *args, **kwargs):
options = {'max_length': 255}
self.default_form_class = kwargs.pop("default_form_class", forms.CloudinaryFileField)
options.update(kwargs)
self.type = options.pop("type", "upload")
self.resource_type = options.pop("resource_type", "image")
self.width_field = options.pop("width_field", None)
self.height_field = options.pop("height_field", None)
super(CloudinaryField, self).__init__(*args, **options)
def get_internal_type(self):
return 'CharField'
def value_to_string(self, obj):
# We need to support both legacy `_get_val_from_obj` and new `value_from_object` models.Field methods.
# It would be better to wrap it with try -> except AttributeError -> fallback to legacy.
# Unfortunately, we can catch AttributeError exception from `value_from_object` function itself.
# Parsing exception string is an overkill here, that's why we check for attribute existence
if hasattr(self, 'value_from_object'):
value = self.value_from_object(obj)
else: # fallback for legacy django versions
value = self._get_val_from_obj(obj)
return self.get_prep_value(value)
def parse_cloudinary_resource(self, value):
m = re.match(CLOUDINARY_FIELD_DB_RE, value)
resource_type = m.group('resource_type') or self.resource_type
upload_type = m.group('type') or self.type
return CloudinaryResource(
type=upload_type,
resource_type=resource_type,
version=m.group('version'),
public_id=m.group('public_id'),
format=m.group('format')
)
def from_db_value(self, value, expression, connection, context):
if value is None:
return value
return self.parse_cloudinary_resource(value)
def to_python(self, value):
if isinstance(value, CloudinaryResource):
return value
elif isinstance(value, UploadedFile):
return value
elif value is None:
return value
else:
return self.parse_cloudinary_resource(value)
def upload_options_with_filename(self, model_instance, filename):
return self.upload_options(model_instance)
def upload_options(self, model_instance):
return {}
def pre_save(self, model_instance, add):
value = super(CloudinaryField, self).pre_save(model_instance, add)
if isinstance(value, UploadedFile):
options = {"type": self.type, "resource_type": self.resource_type}
options.update(self.upload_options_with_filename(model_instance, value.name))
instance_value = uploader.upload_resource(value, **options)
setattr(model_instance, self.attname, instance_value)
if self.width_field:
setattr(model_instance, self.width_field, instance_value.metadata['width'])
if self.height_field:
setattr(model_instance, self.height_field, instance_value.metadata['height'])
return self.get_prep_value(instance_value)
else:
return value
def get_prep_value(self, value):
if not value:
return self.get_default()
if isinstance(value, CloudinaryResource):
return value.get_prep_value()
else:
return value
def formfield(self, **kwargs):
options = {"type": self.type, "resource_type": self.resource_type}
options.update(kwargs.pop('options', {}))
defaults = {'form_class': self.default_form_class, 'options': options, 'autosave': False}
defaults.update(kwargs)
return super(CloudinaryField, self).formfield(**defaults)

View File

@@ -0,0 +1,34 @@
# MIT licensed code copied from https://bitbucket.org/chrisatlee/poster
#
# Copyright (c) 2011 Chris AtLee
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""poster module
Support for streaming HTTP uploads, and multipart/form-data encoding
```poster.version``` is a 3-tuple of integers representing the version number.
New releases of poster will always have a version number that compares greater
than an older version of poster.
New in version 0.6."""
import cloudinary.poster.streaminghttp
import cloudinary.poster.encode
version = (0, 8, 2) # Thanks JP!

View File

@@ -0,0 +1,447 @@
# MIT licensed code copied from https://bitbucket.org/chrisatlee/poster
"""multipart/form-data encoding module
This module provides functions that faciliate encoding name/value pairs
as multipart/form-data suitable for a HTTP POST or PUT request.
multipart/form-data is the standard way to upload files over HTTP"""
__all__ = ['gen_boundary', 'encode_and_quote', 'MultipartParam',
'encode_string', 'encode_file_header', 'get_body_size', 'get_headers',
'multipart_encode']
try:
from io import UnsupportedOperation
except ImportError:
UnsupportedOperation = None
try:
import uuid
def gen_boundary():
"""Returns a random string to use as the boundary for a message"""
return uuid.uuid4().hex
except ImportError:
import random, sha
def gen_boundary():
"""Returns a random string to use as the boundary for a message"""
bits = random.getrandbits(160)
return sha.new(str(bits)).hexdigest()
import re, os, mimetypes
from cloudinary.compat import (PY3, string_types, to_bytes, to_string,
to_bytearray, quote_plus, advance_iterator)
try:
from email.header import Header
except ImportError:
# Python 2.4
from email.Header import Header
if PY3:
def encode_and_quote(data):
if data is None:
return None
return quote_plus(to_bytes(data))
else:
def encode_and_quote(data):
"""If ``data`` is unicode, return quote_plus(data.encode("utf-8")) otherwise return quote_plus(data)"""
if data is None:
return None
if isinstance(data, unicode):
data = data.encode("utf-8")
return quote_plus(data)
if PY3:
def _strify(s):
if s is None:
return None
elif isinstance(s, bytes):
return s
else:
try:
return to_bytes(s)
except AttributeError:
return to_bytes(str(s))
else:
def _strify(s):
"""If s is a unicode string, encode it to UTF-8 and return the results, otherwise return str(s), or None if s is None"""
if s is None:
return None
if isinstance(s, unicode):
return s.encode("utf-8")
return str(s)
class MultipartParam(object):
"""Represents a single parameter in a multipart/form-data request
``name`` is the name of this parameter.
If ``value`` is set, it must be a string or unicode object to use as the
data for this parameter.
If ``filename`` is set, it is what to say that this parameter's filename
is. Note that this does not have to be the actual filename any local file.
If ``filetype`` is set, it is used as the Content-Type for this parameter.
If unset it defaults to "text/plain; charset=utf8"
If ``filesize`` is set, it specifies the length of the file ``fileobj``
If ``fileobj`` is set, it must be a file-like object that supports
.read().
Both ``value`` and ``fileobj`` must not be set, doing so will
raise a ValueError assertion.
If ``fileobj`` is set, and ``filesize`` is not specified, then
the file's size will be determined first by stat'ing ``fileobj``'s
file descriptor, and if that fails, by seeking to the end of the file,
recording the current position as the size, and then by seeking back to the
beginning of the file.
``cb`` is a callable which will be called from iter_encode with (self,
current, total), representing the current parameter, current amount
transferred, and the total size.
"""
def __init__(self, name, value=None, filename=None, filetype=None,
filesize=None, fileobj=None, cb=None):
self.name = Header(name).encode()
self.value = _strify(value)
if filename is None:
self.filename = None
else:
if PY3:
byte_filename = filename.encode("ascii", "xmlcharrefreplace")
self.filename = to_string(byte_filename)
encoding = 'unicode_escape'
else:
if isinstance(filename, unicode):
# Encode with XML entities
self.filename = filename.encode("ascii", "xmlcharrefreplace")
else:
self.filename = str(filename)
encoding = 'string_escape'
self.filename = self.filename.encode(encoding).replace(to_bytes('"'), to_bytes('\\"'))
self.filetype = _strify(filetype)
self.filesize = filesize
self.fileobj = fileobj
self.cb = cb
if self.value is not None and self.fileobj is not None:
raise ValueError("Only one of value or fileobj may be specified")
if fileobj is not None and filesize is None:
# Try and determine the file size
try:
self.filesize = os.fstat(fileobj.fileno()).st_size
except (OSError, AttributeError, UnsupportedOperation):
try:
fileobj.seek(0, 2)
self.filesize = fileobj.tell()
fileobj.seek(0)
except:
raise ValueError("Could not determine filesize")
def __cmp__(self, other):
attrs = ['name', 'value', 'filename', 'filetype', 'filesize', 'fileobj']
myattrs = [getattr(self, a) for a in attrs]
oattrs = [getattr(other, a) for a in attrs]
return cmp(myattrs, oattrs)
def reset(self):
if self.fileobj is not None:
self.fileobj.seek(0)
elif self.value is None:
raise ValueError("Don't know how to reset this parameter")
@classmethod
def from_file(cls, paramname, filename):
"""Returns a new MultipartParam object constructed from the local
file at ``filename``.
``filesize`` is determined by os.path.getsize(``filename``)
``filetype`` is determined by mimetypes.guess_type(``filename``)[0]
``filename`` is set to os.path.basename(``filename``)
"""
return cls(paramname, filename=os.path.basename(filename),
filetype=mimetypes.guess_type(filename)[0],
filesize=os.path.getsize(filename),
fileobj=open(filename, "rb"))
@classmethod
def from_params(cls, params):
"""Returns a list of MultipartParam objects from a sequence of
name, value pairs, MultipartParam instances,
or from a mapping of names to values
The values may be strings or file objects, or MultipartParam objects.
MultipartParam object names must match the given names in the
name,value pairs or mapping, if applicable."""
if hasattr(params, 'items'):
params = params.items()
retval = []
for item in params:
if isinstance(item, cls):
retval.append(item)
continue
name, value = item
if isinstance(value, cls):
assert value.name == name
retval.append(value)
continue
if hasattr(value, 'read'):
# Looks like a file object
filename = getattr(value, 'name', None)
if filename is not None:
filetype = mimetypes.guess_type(filename)[0]
else:
filetype = None
retval.append(cls(name=name, filename=filename,
filetype=filetype, fileobj=value))
else:
retval.append(cls(name, value))
return retval
def encode_hdr(self, boundary):
"""Returns the header of the encoding of this parameter"""
boundary = encode_and_quote(boundary)
headers = ["--%s" % boundary]
if self.filename:
disposition = 'form-data; name="%s"; filename="%s"' % (self.name,
to_string(self.filename))
else:
disposition = 'form-data; name="%s"' % self.name
headers.append("Content-Disposition: %s" % disposition)
if self.filetype:
filetype = to_string(self.filetype)
else:
filetype = "text/plain; charset=utf-8"
headers.append("Content-Type: %s" % filetype)
headers.append("")
headers.append("")
return "\r\n".join(headers)
def encode(self, boundary):
"""Returns the string encoding of this parameter"""
if self.value is None:
value = self.fileobj.read()
else:
value = self.value
if re.search(to_bytes("^--%s$" % re.escape(boundary)), value, re.M):
raise ValueError("boundary found in encoded string")
return to_bytes(self.encode_hdr(boundary)) + value + b"\r\n"
def iter_encode(self, boundary, blocksize=4096):
"""Yields the encoding of this parameter
If self.fileobj is set, then blocks of ``blocksize`` bytes are read and
yielded."""
total = self.get_size(boundary)
current = 0
if self.value is not None:
block = self.encode(boundary)
current += len(block)
yield block
if self.cb:
self.cb(self, current, total)
else:
block = to_bytes(self.encode_hdr(boundary))
current += len(block)
yield block
if self.cb:
self.cb(self, current, total)
last_block = to_bytearray("")
encoded_boundary = "--%s" % encode_and_quote(boundary)
boundary_exp = re.compile(to_bytes("^%s$" % re.escape(encoded_boundary)),
re.M)
while True:
block = self.fileobj.read(blocksize)
if not block:
current += 2
yield to_bytes("\r\n")
if self.cb:
self.cb(self, current, total)
break
last_block += block
if boundary_exp.search(last_block):
raise ValueError("boundary found in file data")
last_block = last_block[-len(to_bytes(encoded_boundary))-2:]
current += len(block)
yield block
if self.cb:
self.cb(self, current, total)
def get_size(self, boundary):
"""Returns the size in bytes that this param will be when encoded
with the given boundary."""
if self.filesize is not None:
valuesize = self.filesize
else:
valuesize = len(self.value)
return len(self.encode_hdr(boundary)) + 2 + valuesize
def encode_string(boundary, name, value):
"""Returns ``name`` and ``value`` encoded as a multipart/form-data
variable. ``boundary`` is the boundary string used throughout
a single request to separate variables."""
return MultipartParam(name, value).encode(boundary)
def encode_file_header(boundary, paramname, filesize, filename=None,
filetype=None):
"""Returns the leading data for a multipart/form-data field that contains
file data.
``boundary`` is the boundary string used throughout a single request to
separate variables.
``paramname`` is the name of the variable in this request.
``filesize`` is the size of the file data.
``filename`` if specified is the filename to give to this field. This
field is only useful to the server for determining the original filename.
``filetype`` if specified is the MIME type of this file.
The actual file data should be sent after this header has been sent.
"""
return MultipartParam(paramname, filesize=filesize, filename=filename,
filetype=filetype).encode_hdr(boundary)
def get_body_size(params, boundary):
"""Returns the number of bytes that the multipart/form-data encoding
of ``params`` will be."""
size = sum(p.get_size(boundary) for p in MultipartParam.from_params(params))
return size + len(boundary) + 6
def get_headers(params, boundary):
"""Returns a dictionary with Content-Type and Content-Length headers
for the multipart/form-data encoding of ``params``."""
headers = {}
boundary = quote_plus(boundary)
headers['Content-Type'] = "multipart/form-data; boundary=%s" % boundary
headers['Content-Length'] = str(get_body_size(params, boundary))
return headers
class multipart_yielder:
def __init__(self, params, boundary, cb):
self.params = params
self.boundary = boundary
self.cb = cb
self.i = 0
self.p = None
self.param_iter = None
self.current = 0
self.total = get_body_size(params, boundary)
def __iter__(self):
return self
def __next__(self):
return self.next()
def next(self):
"""generator function to yield multipart/form-data representation
of parameters"""
if self.param_iter is not None:
try:
block = advance_iterator(self.param_iter)
self.current += len(block)
if self.cb:
self.cb(self.p, self.current, self.total)
return block
except StopIteration:
self.p = None
self.param_iter = None
if self.i is None:
raise StopIteration
elif self.i >= len(self.params):
self.param_iter = None
self.p = None
self.i = None
block = to_bytes("--%s--\r\n" % self.boundary)
self.current += len(block)
if self.cb:
self.cb(self.p, self.current, self.total)
return block
self.p = self.params[self.i]
self.param_iter = self.p.iter_encode(self.boundary)
self.i += 1
return advance_iterator(self)
def reset(self):
self.i = 0
self.current = 0
for param in self.params:
param.reset()
def multipart_encode(params, boundary=None, cb=None):
"""Encode ``params`` as multipart/form-data.
``params`` should be a sequence of (name, value) pairs or MultipartParam
objects, or a mapping of names to values.
Values are either strings parameter values, or file-like objects to use as
the parameter value. The file-like objects must support .read() and either
.fileno() or both .seek() and .tell().
If ``boundary`` is set, then it as used as the MIME boundary. Otherwise
a randomly generated boundary will be used. In either case, if the
boundary string appears in the parameter values a ValueError will be
raised.
If ``cb`` is set, it should be a callback which will get called as blocks
of data are encoded. It will be called with (param, current, total),
indicating the current parameter being encoded, the current amount encoded,
and the total amount to encode.
Returns a tuple of `datagen`, `headers`, where `datagen` is a
generator that will yield blocks of data that make up the encoded
parameters, and `headers` is a dictionary with the assoicated
Content-Type and Content-Length headers.
Examples:
>>> datagen, headers = multipart_encode( [("key", "value1"), ("key", "value2")] )
>>> s = "".join(datagen)
>>> assert "value2" in s and "value1" in s
>>> p = MultipartParam("key", "value2")
>>> datagen, headers = multipart_encode( [("key", "value1"), p] )
>>> s = "".join(datagen)
>>> assert "value2" in s and "value1" in s
>>> datagen, headers = multipart_encode( {"key": "value1"} )
>>> s = "".join(datagen)
>>> assert "value2" not in s and "value1" in s
"""
if boundary is None:
boundary = gen_boundary()
else:
boundary = quote_plus(boundary)
headers = get_headers(params, boundary)
params = MultipartParam.from_params(params)
return multipart_yielder(params, boundary, cb), headers

View File

@@ -0,0 +1,201 @@
# MIT licensed code copied from https://bitbucket.org/chrisatlee/poster
"""Streaming HTTP uploads module.
This module extends the standard httplib and urllib2 objects so that
iterable objects can be used in the body of HTTP requests.
In most cases all one should have to do is call :func:`register_openers()`
to register the new streaming http handlers which will take priority over
the default handlers, and then you can use iterable objects in the body
of HTTP requests.
**N.B.** You must specify a Content-Length header if using an iterable object
since there is no way to determine in advance the total size that will be
yielded, and there is no way to reset an interator.
Example usage:
>>> from StringIO import StringIO
>>> import urllib2, poster.streaminghttp
>>> opener = poster.streaminghttp.register_openers()
>>> s = "Test file data"
>>> f = StringIO(s)
>>> req = urllib2.Request("http://localhost:5000", f,
... {'Content-Length': str(len(s))})
"""
import sys, socket
from cloudinary.compat import httplib, urllib2, NotConnected
__all__ = ['StreamingHTTPConnection', 'StreamingHTTPRedirectHandler',
'StreamingHTTPHandler', 'register_openers']
if hasattr(httplib, 'HTTPS'):
__all__.extend(['StreamingHTTPSHandler', 'StreamingHTTPSConnection'])
class _StreamingHTTPMixin:
"""Mixin class for HTTP and HTTPS connections that implements a streaming
send method."""
def send(self, value):
"""Send ``value`` to the server.
``value`` can be a string object, a file-like object that supports
a .read() method, or an iterable object that supports a .next()
method.
"""
# Based on python 2.6's httplib.HTTPConnection.send()
if self.sock is None:
if self.auto_open:
self.connect()
else:
raise NotConnected()
# send the data to the server. if we get a broken pipe, then close
# the socket. we want to reconnect when somebody tries to send again.
#
# NOTE: we DO propagate the error, though, because we cannot simply
# ignore the error... the caller will know if they can retry.
if self.debuglevel > 0:
print("send:", repr(value))
try:
blocksize = 8192
if hasattr(value, 'read') :
if hasattr(value, 'seek'):
value.seek(0)
if self.debuglevel > 0:
print("sendIng a read()able")
data = value.read(blocksize)
while data:
self.sock.sendall(data)
data = value.read(blocksize)
elif hasattr(value, 'next'):
if hasattr(value, 'reset'):
value.reset()
if self.debuglevel > 0:
print("sendIng an iterable")
for data in value:
self.sock.sendall(data)
else:
self.sock.sendall(value)
except socket.error:
e = sys.exc_info()[1]
if e[0] == 32: # Broken pipe
self.close()
raise
class StreamingHTTPConnection(_StreamingHTTPMixin, httplib.HTTPConnection):
"""Subclass of `httplib.HTTPConnection` that overrides the `send()` method
to support iterable body objects"""
class StreamingHTTPRedirectHandler(urllib2.HTTPRedirectHandler):
"""Subclass of `urllib2.HTTPRedirectHandler` that overrides the
`redirect_request` method to properly handle redirected POST requests
This class is required because python 2.5's HTTPRedirectHandler does
not remove the Content-Type or Content-Length headers when requesting
the new resource, but the body of the original request is not preserved.
"""
handler_order = urllib2.HTTPRedirectHandler.handler_order - 1
# From python2.6 urllib2's HTTPRedirectHandler
def redirect_request(self, req, fp, code, msg, headers, newurl):
"""Return a Request or None in response to a redirect.
This is called by the http_error_30x methods when a
redirection response is received. If a redirection should
take place, return a new Request to allow http_error_30x to
perform the redirect. Otherwise, raise HTTPError if no-one
else should try to handle this url. Return None if you can't
but another Handler might.
"""
m = req.get_method()
if (code in (301, 302, 303, 307) and m in ("GET", "HEAD")
or code in (301, 302, 303) and m == "POST"):
# Strictly (according to RFC 2616), 301 or 302 in response
# to a POST MUST NOT cause a redirection without confirmation
# from the user (of urllib2, in this case). In practice,
# essentially all clients do redirect in this case, so we
# do the same.
# be conciliant with URIs containing a space
newurl = newurl.replace(' ', '%20')
newheaders = dict((k, v) for k, v in req.headers.items()
if k.lower() not in (
"content-length", "content-type")
)
return urllib2.Request(newurl,
headers=newheaders,
origin_req_host=req.get_origin_req_host(),
unverifiable=True)
else:
raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp)
class StreamingHTTPHandler(urllib2.HTTPHandler):
"""Subclass of `urllib2.HTTPHandler` that uses
StreamingHTTPConnection as its http connection class."""
handler_order = urllib2.HTTPHandler.handler_order - 1
def http_open(self, req):
"""Open a StreamingHTTPConnection for the given request"""
return self.do_open(StreamingHTTPConnection, req)
def http_request(self, req):
"""Handle a HTTP request. Make sure that Content-Length is specified
if we're using an interable value"""
# Make sure that if we're using an iterable object as the request
# body, that we've also specified Content-Length
if req.has_data():
data = req.get_data()
if hasattr(data, 'read') or hasattr(data, 'next'):
if not req.has_header('Content-length'):
raise ValueError(
"No Content-Length specified for iterable body")
return urllib2.HTTPHandler.do_request_(self, req)
if hasattr(httplib, 'HTTPS'):
class StreamingHTTPSConnection(_StreamingHTTPMixin,
httplib.HTTPSConnection):
"""Subclass of `httplib.HTTSConnection` that overrides the `send()`
method to support iterable body objects"""
class StreamingHTTPSHandler(urllib2.HTTPSHandler):
"""Subclass of `urllib2.HTTPSHandler` that uses
StreamingHTTPSConnection as its http connection class."""
handler_order = urllib2.HTTPSHandler.handler_order - 1
def https_open(self, req):
return self.do_open(StreamingHTTPSConnection, req)
def https_request(self, req):
# Make sure that if we're using an iterable object as the request
# body, that we've also specified Content-Length
if req.has_data():
data = req.get_data()
if hasattr(data, 'read') or hasattr(data, 'next'):
if not req.has_header('Content-length'):
raise ValueError(
"No Content-Length specified for iterable body")
return urllib2.HTTPSHandler.do_request_(self, req)
def get_handlers():
handlers = [StreamingHTTPHandler, StreamingHTTPRedirectHandler]
if hasattr(httplib, "HTTPS"):
handlers.append(StreamingHTTPSHandler)
return handlers
def register_openers():
"""Register the streaming http handlers in the global urllib2 default
opener object.
Returns the created OpenerDirector object."""
opener = urllib2.build_opener(*get_handlers())
urllib2.install_opener(opener)
return opener

59
lib/cloudinary/search.py Normal file
View File

@@ -0,0 +1,59 @@
import json
from copy import deepcopy
from . import api
class Search:
"""Build and execute a search query."""
def __init__(self):
self.query = {}
def expression(self, value):
"""Specify the search query expression."""
self.query["expression"] = value
return self
def max_results(self, value):
"""Set the max results to return"""
self.query["max_results"] = value
return self
def next_cursor(self, value):
"""Get next page in the query using the ``next_cursor`` value from a previous invocation."""
self.query["next_cursor"] = value
return self
def sort_by(self, field_name, direction=None):
"""Add a field to sort results by. If not provided, direction is ``desc``."""
if direction is None:
direction = 'desc'
self._add("sort_by", {field_name: direction})
return self
def aggregate(self, value):
"""Aggregate field."""
self._add("aggregate", value)
return self
def with_field(self, value):
"""Request an additional field in the result set."""
self._add("with_field", value)
return self
def to_json(self):
return json.dumps(self.query)
def execute(self, **options):
"""Execute the search and return results."""
options["content_type"] = 'application/json'
uri = ['resources','search']
return api.call_json_api('post', uri, self.as_dict(), **options)
def _add(self, name, value):
if name not in self.query:
self.query[name] = []
self.query[name].append(value)
return self
def as_dict(self):
return deepcopy(self.query)

View File

@@ -0,0 +1,43 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script>
/*
json2.js
2011-10-19
Public Domain.
NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK.
See http://www.JSON.org/js.html
This code should be minified before deployment.
See http://javascript.crockford.com/jsmin.html
USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO
NOT CONTROL.
*/
var JSON;if(!JSON){JSON={}}(function(){function str(a,b){var c,d,e,f,g=gap,h,i=b[a];if(i&&typeof i==="object"&&typeof i.toJSON==="function"){i=i.toJSON(a)}if(typeof rep==="function"){i=rep.call(b,a,i)}switch(typeof i){case"string":return quote(i);case"number":return isFinite(i)?String(i):"null";case"boolean":case"null":return String(i);case"object":if(!i){return"null"}gap+=indent;h=[];if(Object.prototype.toString.apply(i)==="[object Array]"){f=i.length;for(c=0;c<f;c+=1){h[c]=str(c,i)||"null"}e=h.length===0?"[]":gap?"[\n"+gap+h.join(",\n"+gap)+"\n"+g+"]":"["+h.join(",")+"]";gap=g;return e}if(rep&&typeof rep==="object"){f=rep.length;for(c=0;c<f;c+=1){if(typeof rep[c]==="string"){d=rep[c];e=str(d,i);if(e){h.push(quote(d)+(gap?": ":":")+e)}}}}else{for(d in i){if(Object.prototype.hasOwnProperty.call(i,d)){e=str(d,i);if(e){h.push(quote(d)+(gap?": ":":")+e)}}}}e=h.length===0?"{}":gap?"{\n"+gap+h.join(",\n"+gap)+"\n"+g+"}":"{"+h.join(",")+"}";gap=g;return e}}function quote(a){escapable.lastIndex=0;return escapable.test(a)?'"'+a.replace(escapable,function(a){var b=meta[a];return typeof b==="string"?b:"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)})+'"':'"'+a+'"'}function f(a){return a<10?"0"+a:a}"use strict";if(typeof Date.prototype.toJSON!=="function"){Date.prototype.toJSON=function(a){return isFinite(this.valueOf())?this.getUTCFullYear()+"-"+f(this.getUTCMonth()+1)+"-"+f(this.getUTCDate())+"T"+f(this.getUTCHours())+":"+f(this.getUTCMinutes())+":"+f(this.getUTCSeconds())+"Z":null};String.prototype.toJSON=Number.prototype.toJSON=Boolean.prototype.toJSON=function(a){return this.valueOf()}}var cx=/[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,escapable=/[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,gap,indent,meta={"\b":"\\b","\t":"\\t","\n":"\\n","\f":"\\f","\r":"\\r",'"':'\\"',"\\":"\\\\"},rep;if(typeof JSON.stringify!=="function"){JSON.stringify=function(a,b,c){var d;gap="";indent="";if(typeof c==="number"){for(d=0;d<c;d+=1){indent+=" "}}else if(typeof c==="string"){indent=c}rep=b;if(b&&typeof b!=="function"&&(typeof b!=="object"||typeof b.length!=="number")){throw new Error("JSON.stringify")}return str("",{"":a})}}if(typeof JSON.parse!=="function"){JSON.parse=function(text,reviver){function walk(a,b){var c,d,e=a[b];if(e&&typeof e==="object"){for(c in e){if(Object.prototype.hasOwnProperty.call(e,c)){d=walk(e,c);if(d!==undefined){e[c]=d}else{delete e[c]}}}}return reviver.call(a,b,e)}var j;text=String(text);cx.lastIndex=0;if(cx.test(text)){text=text.replace(cx,function(a){return"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)})}if(/^[\],:{}\s]*$/.test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g,"@").replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g,"]").replace(/(?:^|:|,)(?:\s*\[)+/g,""))){j=eval("("+text+")");return typeof reviver==="function"?walk({"":j},""):j}throw new SyntaxError("JSON.parse")}}})()
/* end of json2.js */
;
function parse(query) {
var result = {};
var params = query.split("&");
for (var i = 0; i < params.length; i++) {
var param = params[i].split("=");
result[param[0]] = decodeURIComponent(param[1]);
}
return JSON.stringify(result);
}
document.body.textContent = document.body.innerText = parse(window.location.search.slice(1));
</script>
</body>
</html>

View File

@@ -0,0 +1,2 @@
!function(t){"use strict";var e=t.HTMLCanvasElement&&t.HTMLCanvasElement.prototype,o=t.Blob&&function(){try{return Boolean(new Blob)}catch(t){return!1}}(),n=o&&t.Uint8Array&&function(){try{return 100===new Blob([new Uint8Array(100)]).size}catch(t){return!1}}(),r=t.BlobBuilder||t.WebKitBlobBuilder||t.MozBlobBuilder||t.MSBlobBuilder,a=/^data:((.*?)(;charset=.*?)?)(;base64)?,/,i=(o||r)&&t.atob&&t.ArrayBuffer&&t.Uint8Array&&function(t){var e,i,l,u,c,f,b,d,B;if(!(e=t.match(a)))throw new Error("invalid data URI");for(i=e[2]?e[1]:"text/plain"+(e[3]||";charset=US-ASCII"),l=!!e[4],u=t.slice(e[0].length),c=l?atob(u):decodeURIComponent(u),f=new ArrayBuffer(c.length),b=new Uint8Array(f),d=0;d<c.length;d+=1)b[d]=c.charCodeAt(d);return o?new Blob([n?b:f],{type:i}):((B=new r).append(f),B.getBlob(i))};t.HTMLCanvasElement&&!e.toBlob&&(e.mozGetAsFile?e.toBlob=function(t,o,n){var r=this;setTimeout(function(){t(n&&e.toDataURL&&i?i(r.toDataURL(o,n)):r.mozGetAsFile("blob",o))})}:e.toDataURL&&i&&(e.toBlob=function(t,e,o){var n=this;setTimeout(function(){t(i(n.toDataURL(e,o)))})})),"function"==typeof define&&define.amd?define(function(){return i}):"object"==typeof module&&module.exports?module.exports=i:t.dataURLtoBlob=i}(window);
//# sourceMappingURL=canvas-to-blob.min.js.map

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,326 @@
/*
* jQuery File Upload Image Preview & Resize Plugin
* https://github.com/blueimp/jQuery-File-Upload
*
* Copyright 2013, Sebastian Tschan
* https://blueimp.net
*
* Licensed under the MIT license:
* https://opensource.org/licenses/MIT
*/
/* jshint nomen:false */
/* global define, require, window, Blob */
;(function (factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
// Register as an anonymous AMD module:
define([
'jquery',
'load-image',
'load-image-meta',
'load-image-scale',
'load-image-exif',
'canvas-to-blob',
'./jquery.fileupload-process'
], factory);
} else if (typeof exports === 'object') {
// Node/CommonJS:
factory(
require('jquery'),
require('blueimp-load-image/js/load-image'),
require('blueimp-load-image/js/load-image-meta'),
require('blueimp-load-image/js/load-image-scale'),
require('blueimp-load-image/js/load-image-exif'),
require('blueimp-canvas-to-blob'),
require('./jquery.fileupload-process')
);
} else {
// Browser globals:
factory(
window.jQuery,
window.loadImage
);
}
}(function ($, loadImage) {
'use strict';
// Prepend to the default processQueue:
$.blueimp.fileupload.prototype.options.processQueue.unshift(
{
action: 'loadImageMetaData',
disableImageHead: '@',
disableExif: '@',
disableExifThumbnail: '@',
disableExifSub: '@',
disableExifGps: '@',
disabled: '@disableImageMetaDataLoad'
},
{
action: 'loadImage',
// Use the action as prefix for the "@" options:
prefix: true,
fileTypes: '@',
maxFileSize: '@',
noRevoke: '@',
disabled: '@disableImageLoad'
},
{
action: 'resizeImage',
// Use "image" as prefix for the "@" options:
prefix: 'image',
maxWidth: '@',
maxHeight: '@',
minWidth: '@',
minHeight: '@',
crop: '@',
orientation: '@',
forceResize: '@',
disabled: '@disableImageResize'
},
{
action: 'saveImage',
quality: '@imageQuality',
type: '@imageType',
disabled: '@disableImageResize'
},
{
action: 'saveImageMetaData',
disabled: '@disableImageMetaDataSave'
},
{
action: 'resizeImage',
// Use "preview" as prefix for the "@" options:
prefix: 'preview',
maxWidth: '@',
maxHeight: '@',
minWidth: '@',
minHeight: '@',
crop: '@',
orientation: '@',
thumbnail: '@',
canvas: '@',
disabled: '@disableImagePreview'
},
{
action: 'setImage',
name: '@imagePreviewName',
disabled: '@disableImagePreview'
},
{
action: 'deleteImageReferences',
disabled: '@disableImageReferencesDeletion'
}
);
// The File Upload Resize plugin extends the fileupload widget
// with image resize functionality:
$.widget('blueimp.fileupload', $.blueimp.fileupload, {
options: {
// The regular expression for the types of images to load:
// matched against the file type:
loadImageFileTypes: /^image\/(gif|jpeg|png|svg\+xml)$/,
// The maximum file size of images to load:
loadImageMaxFileSize: 10000000, // 10MB
// The maximum width of resized images:
imageMaxWidth: 1920,
// The maximum height of resized images:
imageMaxHeight: 1080,
// Defines the image orientation (1-8) or takes the orientation
// value from Exif data if set to true:
imageOrientation: false,
// Define if resized images should be cropped or only scaled:
imageCrop: false,
// Disable the resize image functionality by default:
disableImageResize: true,
// The maximum width of the preview images:
previewMaxWidth: 80,
// The maximum height of the preview images:
previewMaxHeight: 80,
// Defines the preview orientation (1-8) or takes the orientation
// value from Exif data if set to true:
previewOrientation: true,
// Create the preview using the Exif data thumbnail:
previewThumbnail: true,
// Define if preview images should be cropped or only scaled:
previewCrop: false,
// Define if preview images should be resized as canvas elements:
previewCanvas: true
},
processActions: {
// Loads the image given via data.files and data.index
// as img element, if the browser supports the File API.
// Accepts the options fileTypes (regular expression)
// and maxFileSize (integer) to limit the files to load:
loadImage: function (data, options) {
if (options.disabled) {
return data;
}
var that = this,
file = data.files[data.index],
dfd = $.Deferred();
if (($.type(options.maxFileSize) === 'number' &&
file.size > options.maxFileSize) ||
(options.fileTypes &&
!options.fileTypes.test(file.type)) ||
!loadImage(
file,
function (img) {
if (img.src) {
data.img = img;
}
dfd.resolveWith(that, [data]);
},
options
)) {
return data;
}
return dfd.promise();
},
// Resizes the image given as data.canvas or data.img
// and updates data.canvas or data.img with the resized image.
// Also stores the resized image as preview property.
// Accepts the options maxWidth, maxHeight, minWidth,
// minHeight, canvas and crop:
resizeImage: function (data, options) {
if (options.disabled || !(data.canvas || data.img)) {
return data;
}
options = $.extend({canvas: true}, options);
var that = this,
dfd = $.Deferred(),
img = (options.canvas && data.canvas) || data.img,
resolve = function (newImg) {
if (newImg && (newImg.width !== img.width ||
newImg.height !== img.height ||
options.forceResize)) {
data[newImg.getContext ? 'canvas' : 'img'] = newImg;
}
data.preview = newImg;
dfd.resolveWith(that, [data]);
},
thumbnail;
if (data.exif) {
if (options.orientation === true) {
options.orientation = data.exif.get('Orientation');
}
if (options.thumbnail) {
thumbnail = data.exif.get('Thumbnail');
if (thumbnail) {
loadImage(thumbnail, resolve, options);
return dfd.promise();
}
}
// Prevent orienting the same image twice:
if (data.orientation) {
delete options.orientation;
} else {
data.orientation = options.orientation;
}
}
if (img) {
resolve(loadImage.scale(img, options));
return dfd.promise();
}
return data;
},
// Saves the processed image given as data.canvas
// inplace at data.index of data.files:
saveImage: function (data, options) {
if (!data.canvas || options.disabled) {
return data;
}
var that = this,
file = data.files[data.index],
dfd = $.Deferred();
if (data.canvas.toBlob) {
data.canvas.toBlob(
function (blob) {
if (!blob.name) {
if (file.type === blob.type) {
blob.name = file.name;
} else if (file.name) {
blob.name = file.name.replace(
/\.\w+$/,
'.' + blob.type.substr(6)
);
}
}
// Don't restore invalid meta data:
if (file.type !== blob.type) {
delete data.imageHead;
}
// Store the created blob at the position
// of the original file in the files list:
data.files[data.index] = blob;
dfd.resolveWith(that, [data]);
},
options.type || file.type,
options.quality
);
} else {
return data;
}
return dfd.promise();
},
loadImageMetaData: function (data, options) {
if (options.disabled) {
return data;
}
var that = this,
dfd = $.Deferred();
loadImage.parseMetaData(data.files[data.index], function (result) {
$.extend(data, result);
dfd.resolveWith(that, [data]);
}, options);
return dfd.promise();
},
saveImageMetaData: function (data, options) {
if (!(data.imageHead && data.canvas &&
data.canvas.toBlob && !options.disabled)) {
return data;
}
var file = data.files[data.index],
blob = new Blob([
data.imageHead,
// Resized images always have a head size of 20 bytes,
// including the JPEG marker and a minimal JFIF header:
this._blobSlice.call(file, 20)
], {type: file.type});
blob.name = file.name;
data.files[data.index] = blob;
return data;
},
// Sets the resized version of the image as a property of the
// file object, must be called after "saveImage":
setImage: function (data, options) {
if (data.preview && !options.disabled) {
data.files[data.index][options.name || 'preview'] = data.preview;
}
return data;
},
deleteImageReferences: function (data, options) {
if (!options.disabled) {
delete data.img;
delete data.canvas;
delete data.preview;
delete data.imageHead;
}
return data;
}
}
});
}));

View File

@@ -0,0 +1,178 @@
/*
* jQuery File Upload Processing Plugin
* https://github.com/blueimp/jQuery-File-Upload
*
* Copyright 2012, Sebastian Tschan
* https://blueimp.net
*
* Licensed under the MIT license:
* https://opensource.org/licenses/MIT
*/
/* jshint nomen:false */
/* global define, require, window */
;(function (factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
// Register as an anonymous AMD module:
define([
'jquery',
'./jquery.fileupload'
], factory);
} else if (typeof exports === 'object') {
// Node/CommonJS:
factory(
require('jquery'),
require('./jquery.fileupload')
);
} else {
// Browser globals:
factory(
window.jQuery
);
}
}(function ($) {
'use strict';
var originalAdd = $.blueimp.fileupload.prototype.options.add;
// The File Upload Processing plugin extends the fileupload widget
// with file processing functionality:
$.widget('blueimp.fileupload', $.blueimp.fileupload, {
options: {
// The list of processing actions:
processQueue: [
/*
{
action: 'log',
type: 'debug'
}
*/
],
add: function (e, data) {
var $this = $(this);
data.process(function () {
return $this.fileupload('process', data);
});
originalAdd.call(this, e, data);
}
},
processActions: {
/*
log: function (data, options) {
console[options.type](
'Processing "' + data.files[data.index].name + '"'
);
}
*/
},
_processFile: function (data, originalData) {
var that = this,
dfd = $.Deferred().resolveWith(that, [data]),
chain = dfd.promise();
this._trigger('process', null, data);
$.each(data.processQueue, function (i, settings) {
var func = function (data) {
if (originalData.errorThrown) {
return $.Deferred()
.rejectWith(that, [originalData]).promise();
}
return that.processActions[settings.action].call(
that,
data,
settings
);
};
chain = chain.then(func, settings.always && func);
});
chain
.done(function () {
that._trigger('processdone', null, data);
that._trigger('processalways', null, data);
})
.fail(function () {
that._trigger('processfail', null, data);
that._trigger('processalways', null, data);
});
return chain;
},
// Replaces the settings of each processQueue item that
// are strings starting with an "@", using the remaining
// substring as key for the option map,
// e.g. "@autoUpload" is replaced with options.autoUpload:
_transformProcessQueue: function (options) {
var processQueue = [];
$.each(options.processQueue, function () {
var settings = {},
action = this.action,
prefix = this.prefix === true ? action : this.prefix;
$.each(this, function (key, value) {
if ($.type(value) === 'string' &&
value.charAt(0) === '@') {
settings[key] = options[
value.slice(1) || (prefix ? prefix +
key.charAt(0).toUpperCase() + key.slice(1) : key)
];
} else {
settings[key] = value;
}
});
processQueue.push(settings);
});
options.processQueue = processQueue;
},
// Returns the number of files currently in the processsing queue:
processing: function () {
return this._processing;
},
// Processes the files given as files property of the data parameter,
// returns a Promise object that allows to bind callbacks:
process: function (data) {
var that = this,
options = $.extend({}, this.options, data);
if (options.processQueue && options.processQueue.length) {
this._transformProcessQueue(options);
if (this._processing === 0) {
this._trigger('processstart');
}
$.each(data.files, function (index) {
var opts = index ? $.extend({}, options) : options,
func = function () {
if (data.errorThrown) {
return $.Deferred()
.rejectWith(that, [data]).promise();
}
return that._processFile(opts, data);
};
opts.index = index;
that._processing += 1;
that._processingQueue = that._processingQueue.then(func, func)
.always(function () {
that._processing -= 1;
if (that._processing === 0) {
that._trigger('processstop');
}
});
});
}
return this._processingQueue;
},
_create: function () {
this._super();
this._processing = 0;
this._processingQueue = $.Deferred().resolveWith(this)
.promise();
}
});
}));

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