Compare commits

..

135 Commits

Author SHA1 Message Date
JonnyWong16
c19cc858bd v2.1.11-beta 2018-06-02 10:39:32 -07:00
JonnyWong16
668913fd60 Allow manual PMS URL override in config file for XML shortcuts 2018-06-02 09:55:39 -07:00
JonnyWong16
50c5407a46 Fix Monitor Remote Access checkbox (Plex server update chaged bool to int) 2018-06-02 09:32:05 -07:00
JonnyWong16
939755d3b7 Add Plex XML shortcuts to libraries, users, and sync headers 2018-06-02 08:58:55 -07:00
JonnyWong16
54f4696713 Refactor open Plex XML script 2018-06-02 08:53:53 -07:00
JonnyWong16
c85af521fe Add Plex XML shortcuts for metadata and server resources 2018-06-02 08:26:30 -07:00
JonnyWong16
917d19db85 Fix grouping or new plays not previously in history 2018-06-01 18:45:58 -07:00
JonnyWong16
7292f25eb9 Reword group play history setting 2018-05-31 09:10:44 -07:00
JonnyWong16
22a2ad4bc7 Fix progress percent in grouping logic 2018-05-31 08:34:58 -07:00
JonnyWong16
95e56f5ea5 Fix activity progress bar not updating in some cases 2018-05-31 08:33:50 -07:00
JonnyWong16
ed24232a0a Improve logic for grouping history items 2018-05-30 22:18:41 -07:00
JonnyWong16
15225faee7 Add filename to notification parameters 2018-05-30 21:52:18 -07:00
JonnyWong16
041a35a35a Fallback to title if missing index for update metadata 2018-05-30 21:46:31 -07:00
JonnyWong16
6d365c174a Update X-Plex headers 2018-05-30 21:39:45 -07:00
JonnyWong16
b5f2f55972 v2.1.10-beta 2018-05-28 17:22:16 -07:00
JonnyWong16
ac207260c8 Do not send newsletter if failed to render template 2018-05-27 23:02:56 -07:00
JonnyWong16
e93808381c Fix track listing layout on info pages 2018-05-27 22:43:56 -07:00
JonnyWong16
7acb8f7dc5 Fix artist summary not showing up on newsletter 2018-05-27 22:35:54 -07:00
JonnyWong16
ba9f4a1f9e Use track artist for music 2018-05-27 22:24:43 -07:00
JonnyWong16
8502c28e25 Fallback poster_key and poster_title for clip notification 2018-05-27 15:39:28 -07:00
JonnyWong16
10add90451 Merge pull request #1295 from samwiseg00/feature-add-timestamp-discord
Add timestamps for rich metadata info on discord
2018-05-27 14:47:53 -07:00
samwiseg00
ddb7fa04ca add timestamps for rich metadata info on discord 2018-05-27 17:44:35 -04:00
JonnyWong16
e21a13b7ff Revert "Hack to check for live tv stopped websocket event"
This reverts commit 1245b4fbd3.
2018-05-27 14:13:24 -07:00
JonnyWong16
1245b4fbd3 Hack to check for live tv stopped websocket event 2018-05-27 14:04:47 -07:00
JonnyWong16
94b00c75c2 Enable notifications for clip media type 2018-05-27 13:41:56 -07:00
JonnyWong16
2edcf26110 Use HTTPS for cloudinary urls 2018-05-27 13:07:18 -07:00
JonnyWong16
a9fdf73e8b Check live tv websocket event using key instead of rating key 2018-05-27 13:00:34 -07:00
JonnyWong16
4884cee309 Fix live tv stream resolution 2018-05-27 10:13:42 -07:00
JonnyWong16
b3c7256bcf Newsletter footer inherit styles 2018-05-26 17:29:21 -07:00
JonnyWong16
2c9a7ced13 Forgot product in session db write 2018-05-26 10:14:54 -07:00
JonnyWong16
aa365eb6a3 Improved checking of live tv session websocket events 2018-05-26 10:14:36 -07:00
JonnyWong16
2366a8811b Catch exception from failed SMTP connection 2018-05-25 12:19:46 -07:00
JonnyWong16
53aafbd19e Fix typo from d5bffc3 2018-05-25 12:18:29 -07:00
JonnyWong16
d5bffc374c Fallback to blank poster/art on newsletter if image hosting is disabled 2018-05-25 08:26:25 -07:00
JonnyWong16
5cd5c36d8c Actually add live notification parameter 2018-05-23 17:17:47 -07:00
JonnyWong16
7f9e8f6211 Clean up script.js 2018-05-23 17:13:20 -07:00
JonnyWong16
f743a817ba Update python-twitter to 3.4.1 2018-05-23 17:12:19 -07:00
JonnyWong16
8e4aba7ed4 v2.1.9 2018-05-21 09:07:12 -07:00
JonnyWong16
8c0ef75d4c Fix typos and some cleanup 2018-05-21 09:07:01 -07:00
JonnyWong16
76c4b3bb71 Add Live to notification parameter 2018-05-21 08:49:35 -07:00
JonnyWong16
112b1c7984 Refactor css pointer class 2018-05-20 17:04:55 -07:00
JonnyWong16
c22a2513e3 Update CONTRIBUTING.md 2018-05-19 09:12:13 -07:00
JonnyWong16
f336782fc1 v2.1.8-beta 2018-05-19 09:07:18 -07:00
JonnyWong16
c19afa06de Fallback to originally available at for episode number on info pages 2018-05-18 17:47:19 -07:00
JonnyWong16
e003850d31 Update Facebook permissions scope 2018-05-18 17:41:42 -07:00
JonnyWong16
23cf790079 Return proper status codes for API (Fixes Tautulli/Tautulli-Issues#82) 2018-05-18 17:41:23 -07:00
JonnyWong16
e7f930bd0f Check for Tautulli footer in newsletters 2018-05-17 10:31:55 -07:00
JonnyWong16
348707b6b9 Revert back to HTTP newsletter images from tautulli.com 2018-05-17 09:30:34 -07:00
JonnyWong16
7ad78b4536 Allow images through newsletter password auth 2018-05-17 08:40:58 -07:00
JonnyWong16
a408a62234 Check newsletter auth setting when checking guest access enabled 2018-05-17 08:34:36 -07:00
JonnyWong16
a1e9e7e87f Add newsletter password to newsletter parameters 2018-05-16 23:20:53 -07:00
JonnyWong16
fa99f6e684 Add self-hosted newsletter authentication metnods 2018-05-16 23:11:28 -07:00
JonnyWong16
11e9bd2d54 Fix incorrect <div> tag 2018-05-16 21:59:15 -07:00
JonnyWong16
50165af4b7 Update tautulli.com URLs to HTTPS 2018-05-15 20:38:25 -07:00
JonnyWong16
5dd22c23f2 Patch Twitter str encoding for Python 2 2018-05-15 08:44:13 -07:00
JonnyWong16
79b45c1c46 Auto quality when fetching cloudinary transform 2018-05-15 08:43:20 -07:00
JonnyWong16
af917c4915 Add session key to activity processor log messages 2018-05-14 09:03:18 -07:00
JonnyWong16
c3238b5a83 Fix Imgur database migration again 2018-05-14 09:02:32 -07:00
JonnyWong16
908dbc3243 v2.1.7-beta 2018-05-13 12:10:12 -07:00
JonnyWong16
14b6df8c25 Add newsletter commands to API docs 2018-05-13 11:46:58 -07:00
JonnyWong16
d3e53cb97f Add button to delete all Imgur/Cloudinary uploads 2018-05-13 11:29:59 -07:00
JonnyWong16
445eea5c1e Add plaintext message to newsletter email 2018-05-12 12:16:33 -07:00
JonnyWong16
c5918d7d6c Add option to use inline css styles 2018-05-11 21:43:26 -07:00
JonnyWong16
b8e025193e Add max-width to newsletter card titles 2018-05-11 21:23:19 -07:00
JonnyWong16
85772cdd83 Strip whitespace before sending newsletters 2018-05-11 19:18:05 -07:00
JonnyWong16
7f2bab3082 Switch to header styles for newsletter template 2018-05-11 18:27:31 -07:00
JonnyWong16
8185cc1c40 Keep /newsletter/image proxy for backwards compatibility 2018-05-11 09:40:47 -07:00
JonnyWong16
63bfe96124 Add get_stream_data to API 2018-05-11 08:08:26 -07:00
JonnyWong16
88b640f5e2 Self-hosted newsletter images to use /image endpoint instead of proxying through newsletter 2018-05-10 20:28:42 -07:00
JonnyWong16
26b2342956 Fix typo 2018-05-10 12:04:52 -07:00
JonnyWong16
883280be09 Add Linux distro to Google Analytics 2018-05-10 08:52:28 -07:00
JonnyWong16
7c76b0678a Log platform info on startup 2018-05-10 08:47:54 -07:00
JonnyWong16
e91ba46265 v2.1.6-beta 2018-05-09 20:26:06 -07:00
JonnyWong16
62104c95e3 Move newsletter filename setting and rename config tab 2018-05-08 19:39:27 -07:00
JonnyWong16
178bd89e7c Better init config checkboxes JS 2018-05-08 17:40:17 -07:00
JonnyWong16
365260401c Hide newsletter agent options when save only checked 2018-05-08 17:25:25 -07:00
JonnyWong16
04029bd4d3 Fix NoneType error in newsletter ID name for parameters 2018-05-08 12:56:12 -07:00
JonnyWong16
9cf1128712 Update newsletter preview note for image hosting 2018-05-08 11:22:16 -07:00
JonnyWong16
2eebce9f6c Add HTTP root to newsletter ID name note 2018-05-08 11:01:20 -07:00
JonnyWong16
b08e071b81 Note newesletter ID name and filename as optional 2018-05-08 10:59:00 -07:00
JonnyWong16
7778d84728 Add setting to specify newsletter ID name for static URLs 2018-05-08 10:54:07 -07:00
JonnyWong16
8e3fe7bfa2 v2.1.5-beta 2018-05-07 21:22:46 -07:00
JonnyWong16
6f22c823be Typo in static newsletter URL help text 2018-05-07 21:03:09 -07:00
JonnyWong16
34d7c67813 Merge pull request #1287 from RafaelSchridi/patch-3
Typo in Newsletter Parameters
2018-05-07 21:02:25 -07:00
JonnyWong16
862ed5ce9f Add option to save newsletter only 2018-05-07 20:49:37 -07:00
JonnyWong16
84406e6797 Add setting to enable static newsletter URL 2018-05-07 20:39:04 -07:00
JonnyWong16
19cf567366 Add option to change the newsletter filename 2018-05-07 20:02:04 -07:00
JonnyWong16
8af697a157 Add URL to retrieve latest scheduled newsletter 2018-05-07 19:42:04 -07:00
JonnyWong16
76122bea5d Fix Imgur URL database upgrade migration 2018-05-07 19:09:35 -07:00
JonnyWong16
1a12422908 Show local note on newsletter preview if image hosting enabled 2018-05-07 18:26:40 -07:00
JonnyWong16
2df9f0b48b Add Cloudinary versioning when retrieving the image 2018-05-07 16:13:00 -07:00
JonnyWong16
8540b80e57 Refresh image when uploading to image hosting 2018-05-07 15:32:21 -07:00
JonnyWong16
8ad565a444 Add visible Plex server identifier in the settings 2018-05-07 09:13:16 -07:00
JonnyWong16
f91b6481b3 Close email SMTP connection cleanly if exception 2018-05-06 22:43:55 -07:00
JonnyWong16
826db082c9 Add setting for custom newsletter templates folder 2018-05-06 15:37:07 -07:00
Rafael Schridi
f3d64a7886 Typo in Newsletter Parameters 2018-05-06 23:05:36 +02:00
JonnyWong16
031d078bc2 Add Cache-Control header for newsletter images (Fixes Tautulli/Tautulli-Issues#71) 2018-05-06 08:34:21 -07:00
JonnyWong16
04fcd78102 Uncoulpe self-hosted newsletter and image setting 2018-05-06 08:33:28 -07:00
JonnyWong16
53d1e0f541 Remove HTTP root from newsletter preview iframe 2018-05-05 23:40:44 -07:00
JonnyWong16
9719f0b25b Update newsletter warning message 2018-05-05 22:59:10 -07:00
JonnyWong16
6d1d5bc822 Check for disabled image hosting 2018-05-05 13:06:20 -07:00
JonnyWong16
0d7bbe044d Don't fetch recently added on homepage if Plex Cloud server is sleeping 2018-05-05 11:54:13 -07:00
JonnyWong16
b1dc5816a4 v2.1.4 2018-05-05 11:21:03 -07:00
JonnyWong16
476011a783 Fix newsletter URL with no HTTP root 2018-05-05 11:13:27 -07:00
JonnyWong16
e038c57c4c v2.1.3-beta 2018-05-04 22:36:11 -07:00
JonnyWong16
a989a53750 Encode image title for Cloudinary upload 2018-05-04 16:11:42 -07:00
JonnyWong16
d8cfdea704 Log individual condition evalutation 2018-05-04 15:52:04 -07:00
JonnyWong16
ed4722c4ce Improve refreshing of cached Plex images 2018-05-03 20:29:23 -07:00
JonnyWong16
17ab5f05ed Patch apshceduler sun-sat as 0-6 2018-05-03 17:58:28 -07:00
JonnyWong16
71ab2248d7 Make sure Cloudinary parameters are strings 2018-05-03 08:34:32 -07:00
JonnyWong16
4fb4410552 Fix potential XSS in search 2018-05-02 10:26:05 -07:00
JonnyWong16
a915d2333f Catch failed hostname resolution (Fixes Tautulli/Tautulli-Issues#68) 2018-05-01 16:57:43 -07:00
JonnyWong16
aaf5a18251 Forgot missing '/' 2018-05-01 15:51:23 -07:00
JonnyWong16
b90026801b Fix double HTTP root in newsletter URL 2018-05-01 15:37:37 -07:00
JonnyWong16
e9676e3651 v2.1.2-beta 2018-05-01 08:47:32 -07:00
JonnyWong16
c16d3288d8 Update Imgur and Cloudinary help text 2018-04-29 21:18:01 -07:00
JonnyWong16
0d7ade8ca4 Transform images on Cloudinary 2018-04-29 18:46:46 -07:00
JonnyWong16
87b1118e98 Add delete from Cloudinary 2018-04-29 17:49:53 -07:00
JonnyWong16
9f6422cc8d Fix Imgur poster lookup on imfo pages 2018-04-29 16:04:46 -07:00
JonnyWong16
df1a42a4ee Fix update metadata z-index 2018-04-29 15:15:57 -07:00
JonnyWong16
6554136a8f Add Cloudinary image hosting option 2018-04-29 00:11:47 -07:00
JonnyWong16
81e04269fd Remove ratelimit library 2018-04-29 00:10:58 -07:00
JonnyWong16
b6c6590a12 Update six v1.11.0 2018-04-28 21:44:34 -07:00
JonnyWong16
136260a822 Add cloudinary v1.11.0 2018-04-28 21:44:19 -07:00
JonnyWong16
5710bcb43c Hardcode Pushover sounds list (Fixes Tautulli/Tautulli-Issues#65) 2018-04-28 20:07:36 -07:00
JonnyWong16
30bc3f8a66 Fix incorrect {action} for new device (Fixes Tautulli/Tautulli-Issues#63) 2018-04-28 19:59:23 -07:00
JonnyWong16
e0e7d68df2 API success result for empty response data (Fixes Tautulli/Tautulli-Issues#56) 2018-04-28 18:39:42 -07:00
JonnyWong16
cf73639281 Fix Twitter notification with self-hosted images (Fixes Tautulli/Tautulli-Issues#54) 2018-04-28 18:23:10 -07:00
JonnyWong16
008e04d5cf Re-factor script timeout code 2018-04-28 18:12:43 -07:00
JonnyWong16
5f7991665c Only notify Tautulli updates when checked as a scheduled task (Fixes Tautulli/Tautulli-Issues#46) 2018-04-28 17:59:53 -07:00
JonnyWong16
5e000162c6 Merge branch 'nightly' of https://github.com/Tautulli/Tautulli into nightly 2018-04-28 17:54:18 -07:00
JonnyWong16
ea1aba2c87 Merge pull request #1286 from Dam64/patch-1
add Message-ID on mails
2018-04-28 17:54:02 -07:00
JonnyWong16
f321bb869c Add Plex Cloud sleeping message 2018-04-28 17:51:52 -07:00
JonnyWong16
abe496668a Fix typo in Activity Refresh Interval setting 2018-04-28 17:48:55 -07:00
Dam64
9cefc7f701 add Message-ID on mails 2018-04-27 01:17:18 +02:00
91 changed files with 15466 additions and 2655 deletions

300
API.md
View File

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

View File

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

View File

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

View File

@@ -27,9 +27,9 @@ This project is based on code from [Headphones](https://github.com/rembo10/headp
## Preview
* [Full preview gallery available on our website](http://tautulli.com)
* [Full preview gallery available on our website](https://tautulli.com)
![Tautulli Homepage](http://tautulli.com/images/screenshots/activity-compressed.jpg?v=2)
![Tautulli Homepage](https://tautulli.com/images/screenshots/activity-compressed.jpg?v=2)
## Installation and Support

View File

@@ -292,6 +292,7 @@ ${next.modalIncludes()}
<script src="${http_root}js/pnotify.custom.min.js"></script>
<script src="${http_root}js/script.js${cache_param}"></script>
<script src="${http_root}js/jquery.qrcode.min.js"></script>
<script src="${http_root}js/jquery.tripleclick.min.js"></script>
% if _session['user_group'] == 'admin' and BROWSER_NOTIFIERS:
<script src="${http_root}js/ajaxNotifications.js"></script>
% endif

View File

@@ -69,7 +69,7 @@ DOCUMENTATION :: END
% endif
<tr>
<td>Platform:</td>
<td>${common.PLATFORM} ${common.PLATFORM_VERSION}</td>
<td>${common.PLATFORM} ${common.PLATFORM_RELEASE} (${common.PLATFORM_VERSION + (' - {}'.format(common.PLATFORM_LINUX_DISTRO) if common.PLATFORM_LINUX_DISTRO else '')})</td>
</tr>
<tr>
<td>Python Version:</td>
@@ -78,7 +78,7 @@ DOCUMENTATION :: END
<tr>
<td class="top-line">Resources:</td>
<td class="top-line">
<a class="no-highlight" href="${anon_url('http://tautulli.com')}" target="_blank">Tautulli Website</a> |
<a class="no-highlight" href="${anon_url('https://tautulli.com')}" target="_blank">Tautulli Website</a> |
<a class="no-highlight" href="${anon_url('https://github.com/%s/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank">GitHub Source</a> |
<a class="no-highlight guidelines-modal-link" href="${anon_url('https://github.com/%s/%s-Issues' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" data-id="issue">GitHub Issues</a> |
<a class="no-highlight" href="${anon_url('https://github.com/%s/%s-Wiki' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}" target="_blank">GitHub Wiki</a> |

View File

@@ -70,6 +70,7 @@ div.form-control .selectize-input {
background-color: #555;
border-radius: 3px;
transition: background-color .3s;
height: 32px !important;
}
.react-selectize.root-node .react-selectize-control,
.selectize-control.form-control .selectize-input {
@@ -2934,6 +2935,7 @@ a .home-platforms-list-cover-face:hover
}
.stacked-configs > li > span > a.toggle-left,
.stacked-configs > li > span > span.toggle-left {
float: left;
color: #444;
padding-right: 8px;
}
@@ -2944,16 +2946,6 @@ a .home-platforms-list-cover-face:hover
.stacked-configs > li > span > span.active {
color: #f9be03;
}
.stacked-configs > li.new-notification-agent,
.stacked-configs > li.notification-agent,
.stacked-configs > li.add-notification-agent,
.stacked-configs > li.new-newsletter-agent,
.stacked-configs > li.newsletter-agent,
.stacked-configs > li.add-newsletter-agent,
.stacked-configs > li.mobile-device,
.stacked-configs > li.add-mobile-device {
cursor: pointer;
}
.stacked-configs > li.mobile-device > span > a.toggle-left,
.stacked-configs > li.mobile-device > span > span.toggle-left {
color: #999;
@@ -3524,8 +3516,7 @@ a.no-highlight:hover {
}
.login-logo {
margin: 0 auto 50px auto;
width: 340px;
height: 100px;
text-align: center;
}
.login-container .form-group {
margin-bottom: 20px;
@@ -4093,3 +4084,15 @@ a:hover .overlay-refresh-image:hover {
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

@@ -387,8 +387,8 @@ DOCUMENTATION :: END
<a href="${grandparent_href}" title="${data['grandparent_title']}">${data['grandparent_title']}</a>
- <a href="${href}" title="${data['title']}">${data['title']}</a>
% elif data['media_type'] == 'track':
<a id="metadata-grandparent_title-${sk}" href="${grandparent_href}" title="${data['grandparent_title']}">${data['grandparent_title']}</a>
- <a id="metadata-title-${sk}" href="${href}" title="${data['title']}">${data['title']}</a>
<a id="metadata-title-${sk}" href="${href}" title="${data['title']}">${data['title']}</a>
- <a id="metadata-grandparent_title-${sk}" href="${grandparent_href}" title="${data['original_title'] or data['grandparent_title']}">${data['original_title'] or data['grandparent_title']}</a>
% elif data['media_type'] == 'photo':
<span title="${data['parent_title']}">${data['parent_title']}</span>
% elif data['media_type'] == 'clip':

View File

@@ -5,13 +5,14 @@
</%def>
<%def name="body()">
<% from plexpy import PLEX_SERVER_UP %>
<div class="container-fluid">
% for section in config['home_sections']:
% if section == 'current_activity':
<div class="row">
<div class="col-md-12">
<div class="padded-header" id="current-activity-header">
<h3><span id="sessions-shortcut">Activity</span> &nbsp;&nbsp;
<h3><span id="sessions-xml">Activity</span> &nbsp;&nbsp;
<small>
<span id="currentActivityHeader" style="display: none;">
Streams: <span id="currentActivityHeader-streams"></span> |
@@ -22,9 +23,10 @@
</h3>
</div>
<div id="currentActivity">
<% from plexpy import PLEX_SERVER_UP %>
% if PLEX_SERVER_UP:
<div class="text-muted" id="dashboard-checking-activity"><i class="fa fa-refresh fa-spin"></i> Checking for activity...</div>
% elif config['pms_is_cloud']:
<div id="dashboard-no-activity" class="text-muted">Plex Cloud server is sleeping.</div>
% else:
<div id="dashboard-no-activity" class="text-muted">There was an error communicating with your Plex Server.
% if _session['user_group'] == 'admin':
@@ -133,7 +135,17 @@
<div class="row">
<div class="col-md-12">
<div id="recentlyAdded" style="margin-right: -15px;">
% if PLEX_SERVER_UP:
<div class="text-muted"><i class="fa fa-refresh fa-spin"></i> Looking for new items...</div>
% elif config['pms_is_cloud']:
<div class="text-muted">Plex Cloud server is sleeping.</div>
% else:
<div class="text-muted">There was an error communicating with your Plex Server.
% if _session['user_group'] == 'admin':
Check the <a href="logs">logs</a> and verify your server connection in the <a href="settings#tab_tabs-plex_media_server">settings</a>.
% endif
</div>
% endif
<br>
</div>
</div>
@@ -220,10 +232,10 @@
</%def>
<%def name="javascriptIncludes()">
<% from plexpy import PLEX_SERVER_UP %>
<script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/jquery.scrollbar.min.js"></script>
<script src="${http_root}js/jquery.mousewheel.min.js"></script>
<script src="${http_root}js/jquery.tripleclick.min.js"></script>
<script>
var date_format = 'YYYY-MM-DD';
var time_format = 'hh:mm a';
@@ -252,7 +264,6 @@
});
}
</script>
<% from plexpy import PLEX_SERVER_UP %>
% if 'current_activity' in config['home_sections'] and PLEX_SERVER_UP:
<script>
var defaultHandler = {
@@ -378,8 +389,8 @@
$('#background-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.art + '&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art&refresh=true)');
$('#metadata-grandparent_title-' + key)
.attr('href', 'info?rating_key=' + s.grandparent_rating_key)
.attr('title', s.grandparent_title)
.text(s.grandparent_title);
.attr('title', s.original_title || s.grandparent_title)
.text(s.original_title || s.grandparent_title);
}
// Update cover if album changed
if (s.parent_rating_key !== instance.data('parent_rating_key')) {
@@ -394,7 +405,11 @@
.text(s.parent_title);
}
// Update cover if track changed
if (s.parent_rating_key !== instance.data('parent_rating_key')) {
if (s.rating_key !== instance.data('rating_key')) {
$('#metadata-grandparent_title-' + key)
.attr('href', 'info?rating_key=' + s.grandparent_rating_key)
.attr('title', s.original_title || s.grandparent_title)
.text(s.original_title || s.grandparent_title);
$('#metadata-title-' + key)
.attr('href', 'info?rating_key=' + s.rating_key)
.attr('title', s.title)
@@ -530,7 +545,7 @@
.attr('data-original-title', 'Transcoder Progress ' + s.transcode_progress + '%');
var progress_bar = $('#progress-bar-' + key);
progress_bar.data('state', s.state);
if (progress_bar.data('last_view_offset') && progress_bar.data('last_view_offset') !== s.view_offset) {
if (progress_bar.data('last_view_offset') !== s.view_offset) {
progress_bar.data('last_view_offset', s.view_offset).data('view_offset', s.view_offset);
}
@@ -678,10 +693,8 @@
});
});
$('#sessions-shortcut').on('tripleclick', function () {
$.getJSON('return_sessions_url', function(sessions_url) {
window.open(sessions_url, '_blank');
});
$('#sessions-xml').on('tripleclick', function () {
openPlexXML('/status/sessions');
});
% endif
</script>
@@ -744,7 +757,7 @@
getLibraryStats();
</script>
% endif
% if 'recently_added' in config['home_sections']:
% if 'recently_added' in config['home_sections'] and PLEX_SERVER_UP:
<script>
function recentlyAdded(recently_added_count, recently_added_type) {
showMsg("Loading recently added items...", true, false, 0);

View File

@@ -83,31 +83,31 @@ DOCUMENTATION :: END
<ul class="list-unstyled breadcrumb">
% if data['media_type'] in ('movie', 'collection'):
<li><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li>
<li class="active">${data['title']}</li>
<li class="active metadata-xml">${data['title']}</li>
% elif data['media_type'] == 'show':
<li><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li>
<li class="active">${data['title']}</li>
<li class="active metadata-xml">${data['title']}</li>
% elif data['media_type'] == 'season':
<li class="hidden-xs hidden-sm"><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li>
<li><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a></li>
<li class="active">Season ${data['media_index']}</li>
<li class="active metadata-xml">Season ${data['media_index']}</li>
% elif data['media_type'] == 'episode':
<li class="hidden-xs hidden-sm"><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li>
<li class="hidden-xs hidden-sm"><a href="info?rating_key=${data['grandparent_rating_key']}">${data['grandparent_title']}</a></li>
<li><a href="info?rating_key=${data['parent_rating_key']}">Season ${data['parent_media_index']}</a></li>
<li class="active">Episode ${data['media_index']} - ${data['title']}</li>
<li class="active metadata-xml">Episode ${data['media_index']} - ${data['title']}</li>
% elif data['media_type'] == 'artist':
<li><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li>
<li class="active">${data['title']}</li>
<li class="active metadata-xml">${data['title']}</li>
% elif data['media_type'] == 'album':
<li class="hidden-xs hidden-sm"><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li>
<li><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a></li>
<li class="active">${data['title']}</li>
<li class="active metadata-xml">${data['title']}</li>
% elif data['media_type'] == 'track':
<li class="hidden-xs hidden-sm"><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li>
<li class="hidden-xs hidden-sm"><a href="info?rating_key=${data['grandparent_rating_key']}">${data['grandparent_title']}</a></li>
<li><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a></li>
<li class="active">Track ${data['media_index']} - ${data['title']}</li>
<li class="active metadata-xml">Track ${data['media_index']} - ${data['title']}</li>
% endif
</ul>
</div>
@@ -165,7 +165,7 @@ DOCUMENTATION :: END
<h1><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a></h1>
<h2>${data['title']}</h2>
% elif data['media_type'] == 'track':
<h1><a href="info?rating_key=${data['grandparent_rating_key']}">${data['grandparent_title']}</a></h1>
<h1><a href="info?rating_key=${data['grandparent_rating_key']}">${data['original_title'] or data['grandparent_title']}</a></h1>
<h2><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a> - ${data['title']}</h2>
<h3 class="hidden-xs">T${data['media_index']}</h3>
% endif
@@ -371,7 +371,11 @@ DOCUMENTATION :: END
<div class="col-md-12">
<div class="table-card-header">
<div class="header-bar">
% if data['media_type'] in ('artist', 'album', 'track'):
<span>Play History for <strong>${data['title']}</strong></span>
% else:
<span>Watch History for <strong>${data['title']}</strong></span>
% endif
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
@@ -400,14 +404,14 @@ DOCUMENTATION :: END
% if data.get('poster_url'):
<div class="btn-group">
% if data['media_type'] == 'artist' or data['media_type'] == 'album' or data['media_type'] == 'track':
<span class="imgur-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="80" data-width="80" style="display: inline-flex;">
<span class="hosted-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="80" data-width="80" style="display: inline-flex;">
% else:
<span class="imgur-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="120" data-width="80" style="display: inline-flex;">
<span class="hosted-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="120" data-width="80" style="display: inline-flex;">
% endif
<button class="btn btn-danger btn-edit" data-toggle="modal" aria-pressed="false" autocomplete="off" id="delete-imgur-poster"
<button class="btn btn-danger btn-edit" data-toggle="modal" aria-pressed="false" autocomplete="off" id="delete-hosted-poster"
data-id="${data['parent_rating_key'] if data['media_type'] in ('episode', 'track') else data['rating_key']}"
data-title="${data["poster_title"]}">
<i class="fa fa-picture-o"></i> Delete Imgur Poster
<i class="fa fa-picture-o"></i> Delete ${data['img_service']} Poster
</button>
</span>
</div>
@@ -502,7 +506,7 @@ DOCUMENTATION :: END
% elif data['media_type'] == 'album':
${data['parent_title']}<br />${data['title']}
% elif data['media_type'] == 'track':
${data['grandparent_title']}<br />${data['title']}<br />${data['parent_title']}
${data['original_title'] or data['grandparent_title']}<br />${data['title']}<br />${data['parent_title']}
% endif
</strong>
</p>
@@ -699,13 +703,17 @@ DOCUMENTATION :: END
</script>
% endif
<script>
$('.metadata-xml').on('tripleclick', function () {
openPlexXML("/library/metadata/${data['rating_key']}");
});
$("#airdate").html(moment($("#airdate").text()).format('MMM DD, YYYY'));
$("#runtime").html(millisecondsToMinutes($("#runtime").text(), true));
$('div.art-face').animate({ opacity: 0.2 }, { duration: 1000 });
</script>
% if data.get('poster_url'):
<script>
$('.imgur-poster-tooltip').popover({
$('.hosted-poster-tooltip').popover({
html: true,
container: 'body',
trigger: 'hover',
@@ -716,14 +724,14 @@ DOCUMENTATION :: END
}
});
$('#delete-imgur-poster').on('click', function () {
var msg = 'Are you sure you want to delete the Imgur poster for <strong>' + $(this).data('title') + '</strong>?<br><br>' +
$('#delete-hosted-poster').on('click', function () {
var msg = 'Are you sure you want to delete the ${data['img_service']} poster for <strong>' + $(this).data('title') + '</strong>?<br><br>' +
'All previous links to this image will no longer work.';
var url = 'delete_imgur_poster';
var url = 'delete_hosted_images';
var data = { rating_key: $(this).data('id') };
var callback = function () {
$('.imgur-poster-tooltip').popover('destroy');
$('#delete-imgur-poster').closest('.btn-group').remove();
$('.hosted-poster-tooltip').popover('destroy');
$('#delete-hosted-poster').closest('.btn-group').remove();
};
confirmAjaxCall(url, msg, data, false, callback);
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,12 +11,11 @@ DOCUMENTATION :: END
<ul class="stacked-configs list-unstyled">
% for device in sorted(devices_list, key=lambda k: k['device_name']):
<li class="mobile-device" data-id="${device['id']}" data-name="${device['device_name']}">
<li class="mobile-device pointer" data-id="${device['id']}" data-name="${device['device_name']}">
<span>
<!--<span class="toggle-right mobile-device-tooltip edit-mobile-device" data-toggle="tooltip" data-placement="top" title="Edit Device"><i class="fa fa-lg fa-pencil"></i></span>-->
<span class="toggle-left"><i class="fa fa-lg fa-mobile"></i></span>
<span class="toggle-left"><i class="fa fa-lg fa-fw fa-mobile"></i></span>
${device['friendly_name'] or device['device_name']} &nbsp;<span class="friendly_name">(${device['id']})</span>
<span class="toggle-right"><i class="fa fa-lg fa-cog"></i></span>
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-cog"></i></span>
<span class="toggle-right friendly_name" id="device-last_seen-${device['id']}">
% if device['last_seen']:
<script>
@@ -26,14 +25,13 @@ DOCUMENTATION :: END
never
% endif
</span>
<!--<span class="toggle-right delete-mobile-device" data-toggle="tooltip" data-placement="top" title="Remove Device"><i class="fa fa-lg fa-times"></i></span>-->
</span>
</li>
% endfor
<li class="add-mobile-device" id="register-mobile-device" data-target="#api-qr-modal" data-toggle="modal">
<li class="add-mobile-device pointer" id="register-mobile-device" data-target="#api-qr-modal" data-toggle="modal">
<span>
<span class="toggle-left"><i class="fa fa-lg fa-mobile"></i></span> Register a new device
<span class="toggle-right"><i class="fa fa-lg fa-plus"></i></span>
<span class="toggle-left"><i class="fa fa-lg fa-fw fa-mobile"></i></span> Register a new device
<span class="toggle-right"><i class="fa fa-lg fa-fw fa-plus"></i></span>
</span>
</li>
</ul>

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

@@ -20,7 +20,7 @@
<div class="row">
<ul class="nav nav-tabs list-unstyled" role="tablist">
<li role="presentation" class="active"><a href="#tabs-newsletter_config" aria-controls="tabs-newsletter_config" role="tab" data-toggle="tab">Configuration</a></li>
<li role="presentation"><a href="#tabs-newsletter_agent" aria-controls="tabs-newsletter_agent" role="tab" data-toggle="tab">Notification Agent</a></li>
<li role="presentation"><a href="#tabs-newsletter_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>
@@ -70,7 +70,7 @@
<p class="help-block">Set the time frame to include in the newsletter. Note: Days uses calendar days (i.e. since midnight).</p>
</div>
</div>
<div class="col-md-12" style="padding-top: 10px; border-top: 1px solid #444;">
<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']:
@@ -165,7 +165,16 @@
% endif
% endfor
</div>
<div class="col-md-12" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #444;">
<div class="col-md-12 modal-config-section">
<div class="form-group">
<label for="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">
@@ -178,12 +187,32 @@
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-newsletter_agent">
<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_formatted_checkbox" data-id="newsletter_config_formatted" class="checkboxes" value="1" ${checked(newsletter['config']['formatted'])}> Send newsletter as an HTML formatted Email
<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']}">
@@ -234,100 +263,100 @@
Note: Self-hosted newsletters must be enabled under <a data-tab-destination="tabs-notifications" data-dismiss="modal" data-target="#newsletter_self_hosted">Newsletters</a> to include a link to the newsletter.
</p>
</div>
</div>
<div id="newsletter-email-config" class="col-md-12" style="padding-top: 10px; border-top: 1px solid #444;">
% for item in newsletter['email_config_options']:
% if item['input_type'] == 'help':
<div class="form-group">
<label>${item['label']}</label>
<p class="help-block">${item['description'] | n}</p>
</div>
% elif item['input_type'] == 'text' or item['input_type'] == 'password':
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-12">
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
</div>
<div 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>
<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">
% 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>
<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']}">
% 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>
<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>
% 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>
<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()):
% 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
</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>
% endif
</select>
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
</div>
<p class="help-block">${item['description'] | n}</p>
% endif
% endfor
<input type="hidden" id="newsletter_email_html_support" name="newsletter_email_html_support" value="1">
</div>
% endif
% endfor
<input type="hidden" id="newsletter_email_html_support" name="newsletter_email_html_support" value="1">
</div>
</div>
</div>
@@ -458,6 +487,26 @@
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,
@@ -485,6 +534,8 @@
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();
@@ -643,7 +694,9 @@
if ($('#custom_cron').val() === '0'){
$("#cron_value").val(cron_widget.cron('value'));
}
doAjaxCall('set_newsletter_config', $(this), 'tabs', true, true, saveCallback);
if (validateFilename() && validateIDName()){
doAjaxCall('set_newsletter_config', $(this), 'tabs', true, true, saveCallback);
}
}
$('#delete-newsletter-item').click(function () {

View File

@@ -32,7 +32,7 @@
<script>
$(document).ready(function () {
var frame = $('<iframe></iframe>', {
src: '${http_root}real_newsletter?${urllib.urlencode(kwargs) | n}',
src: 'real_newsletter?${urllib.urlencode(kwargs) | n}',
frameborder: '0',
style: 'display: none; height: 100vh; width: 100vw;'
});

View File

@@ -12,15 +12,15 @@ DOCUMENTATION :: END
<% from plexpy.newsletter_handler import NEWSLETTER_SCHED %>
<ul class="stacked-configs list-unstyled">
% for newsletter in sorted(newsletters_list, key=lambda k: (k['agent_label'], k['friendly_name'], k['id'])):
<li class="newsletter-agent" data-id="${newsletter['id']}">
<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-newspaper-o"></i></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-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="newsletter-next_run-${newsletter['id']}">
% if NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])):
<% job = NEWSLETTER_SCHED.get_job('newsletter-{}'.format(newsletter['id'])) %>
@@ -32,10 +32,10 @@ DOCUMENTATION :: END
</span>
</li>
% endfor
<li class="add-newsletter-agent" id="add-newsletter-agent" data-target="#add-newsletter-modal" data-toggle="modal">
<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-newspaper-o"></i></span> Add a new newsletter agent
<span class="toggle-right"><i class="fa fa-lg fa-plus"></i></span>
<span class="toggle-left"><i class="fa fa-lg fa-fw fa-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>

View File

@@ -1,7 +1,8 @@
% if notifier:
<%!
import json
from plexpy import helpers, notifiers, users
from plexpy import notifiers, users
from plexpy.helpers import checked
available_notification_actions = notifiers.available_notification_actions()
user_emails = [{'user': u['friendly_name'] or u['username'], 'email': u['email']} for u in users.Users().get_users() if u['email']]
@@ -70,7 +71,7 @@
% elif item['input_type'] == 'checkbox':
<div class="checkbox">
<label>
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${helpers.checked(item['value'])}> ${item['label']}
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${checked(item['value'])}> ${item['label']}
</label>
<p class="help-block">${item['description'] | n}</p>
<input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}">
@@ -123,7 +124,7 @@
% endif
% endfor
</div>
<div class="col-md-12" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #444;">
<div class="col-md-12 modal-config-section">
<div class="form-group">
<label for="friendly_name">Description</label>
<div class="row">
@@ -146,7 +147,7 @@
% for action in available_notification_actions:
<div class="checkbox">
<label>
<input type="checkbox" data-id="${action['name']}" class="checkboxes" value="1" ${helpers.checked(notifier['actions'][action['name']])}> ${action['label']}
<input type="checkbox" data-id="${action['name']}" class="checkboxes" value="1" ${checked(notifier['actions'][action['name']])}> ${action['label']}
</label>
<p class="help-block">${action['description'] | n}</p>
<input type="hidden" id="${action['name']}" name="${action['name']}" value="${notifier['actions'][action['name']]}">

View File

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

View File

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

View File

@@ -115,9 +115,9 @@
</div>
<div class="checkbox advanced-setting">
<label>
<input type="checkbox" id="group_history_tables" name="group_history_tables" value="1" ${config['group_history_tables']}> Group Successive Play History
<input type="checkbox" id="group_history_tables" name="group_history_tables" value="1" ${config['group_history_tables']}> Group Play History
</label>
<p class="help-block">Group successive play history by the same user as a single entry in the watch statistics, tables, and graphs.</p>
<p class="help-block">Group play history for the same item and user as a single entry when progress is less than the watched percent.</p>
</div>
<div class="checkbox advanced-setting">
<label>
@@ -274,7 +274,7 @@
</div>
<div class="form-group">
<label for="home_refresh_interval">Activty Refresh Interval</label>
<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>
@@ -646,11 +646,11 @@
<div role="tabpanel" class="tab-pane" id="tabs-plex_media_server">
<div class="padded-header">
<h3>Plex Media Server <small style="color: #fff;">Version <span id="pms_version">${config['pms_version']}</span></small></h3>
<h3 id="resources-xml">Plex Media Server <small style="color: #fff;">Version <span id="pms_version">${config['pms_version']}</span></small></h3>
</div>
<div class="form-group has-feedback" id="pms_ip_group">
<label for="pms_ip">Plex IP or Hostname</label>
<label for="pms_ip">Plex IP Address or Hostname</label>
<div class="row">
<div class="col-md-9" id="selectize-pms-ip-container">
<div class="input-group">
@@ -702,6 +702,17 @@
The server URL that Tautulli will use to connect to your Plex server. Retrieved automatically.
</p>
</div>
<div class="form-group advanced-setting">
<label for="pms_url">Plex Server Identifier</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="pms_identifier" name="pms_identifier" value="${config['pms_identifier']}" size="30" readonly>
</div>
</div>
<p class="help-block">
The unique identifier for your Plex server. Retrieved automatically.
</p>
</div>
<div class="checkbox advanced-setting">
<label>
<input type="checkbox" class="pms-settings" id="pms_url_manual" name="pms_url_manual" value="1" ${config['pms_url_manual']}> Manual Connection
@@ -728,7 +739,6 @@
</div>
<input type="hidden" id="pms_is_cloud" name="pms_is_cloud" value="${config['pms_is_cloud']}">
<input type="hidden" id="pms_identifier" name="pms_identifier" value="${config['pms_identifier']}">
<input type="checkbox" name="server_changed" id="server_changed" value="1" style="display: none;">
<div class="form-group advanced-setting">
@@ -955,8 +965,63 @@
<p class="help-block">Enable to host newsletters on your own domain. This will generate a link to an HTML page where you can view the newsletter.</p>
</div>
<div id="self_host_newsletter_options" style="overlfow: hidden; display: ${'block' if config['newsletter_self_hosted'] == 'checked' else 'none'}">
<p class="help-block" id="self_host_newsletter_message">Note: The <span class="inline-pre">${http_root}newsletter</span> endpoint on your domain must be publicly accessible from the internet.</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 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">
@@ -967,16 +1032,30 @@
<label for="notify_upload_posters">Image Hosting</label>
<div class="row">
<div class="col-md-6">
<select class="form-control" id="notify_upload_posters" name="notify_upload_posters">
<option value="0" ${'selected' if config['notify_upload_posters'] == 0 else ''}>Disabled</option>
<option value="1" ${'selected' if config['notify_upload_posters'] == 1 else ''}>Imgur</option>
<option value="2" ${'selected' if config['notify_upload_posters'] == 2 else ''}>Self-hosted on public Tautulli domain</option>
</select>
<div class="${'input-group' if config['notify_upload_posters'] in (1, 3) else ''}">
<select class="form-control" id="notify_upload_posters" name="notify_upload_posters">
<option value="0" ${'selected' if config['notify_upload_posters'] == 0 else ''}>Disabled</option>
<option value="1" ${'selected' if config['notify_upload_posters'] == 1 else ''}>Imgur</option>
<option value="3" ${'selected' if config['notify_upload_posters'] == 3 else ''}>Cloudinary</option>
<option value="2" ${'selected' if config['notify_upload_posters'] == 2 else ''}>Self-hosted on public domain</option>
</select>
% if config['notify_upload_posters'] in (1, 3):
<span class="input-group-btn" id="delete_all_uploads_container">
<button class="btn btn-form" type="button" id="delete_all_uploads">Delete All Uploads</button>
</span>
% endif
</div>
</div>
</div>
<p class="help-block">Select where to host Plex images for notifications and newsletters.</p>
</div>
<div id="imgur_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 1 else 'block'}">
<div class="form-group">
<p class="help-block" id="imgur_upload_message">
You can register a new Imgur application <a href="${anon_url('https://api.imgur.com/oauth2/addclient')}" target="_blank">here</a>.<br>
Warning: Imgur uploads are rate-limited and newsletters may exceed the limit. Please use Cloudinary for newsletters instead.
</p>
</div>
<div class="form-group">
<label for="imgur_client_id">Imgur Client ID</label>
<div class="row">
@@ -984,15 +1063,54 @@
<input type="text" class="form-control" id="imgur_client_id" name="imgur_client_id" value="${config['imgur_client_id']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your Imgur API client ID in order to upload posters.
You can register a new application <a href="${anon_url('https://api.imgur.com/oauth2/addclient')}" target="_blank">here</a>.
</p>
<p class="help-block">Enter your Imgur API Client ID.</p>
</div>
</div>
<div id="self_host_image_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 2 else 'block'}">
<p class="help-block" id="self_host_image_message">Note: The <span class="inline-pre">${http_root}image</span> endpoint on your domain must be publicly accessible from the internet.</p>
<p class="help-block settings-warning base-url-warning">Warning: Public Tautulli domain not set under <a data-tab-destination="tabs-web_interface" data-target="#http_base_url">Web Interface</a>.</p>
<div class="form-group">
<p class="help-block" id="self_host_image_message">Note: The <span class="inline-pre">${http_root}image</span> endpoint on your domain must be publicly accessible from the internet.</p>
<p class="help-block settings-warning base-url-warning">Warning: Public Tautulli domain not set under <a data-tab-destination="tabs-web_interface" data-target="#http_base_url">Web Interface</a>.</p>
</div>
</div>
<div id="cloudinary_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 3 else 'block'}">
<div class="form-group">
<p class="help-block" id="imgur_upload_message">
You can sign up for Cloudinary <a href="${anon_url('https://cloudinary.com')}" target="_blank">here</a>.<br>
</p>
</div>
<div class="form-group">
<label for="cloudinary_cloud_name">Cloudinary Cloud Name</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="cloudinary_cloud_name" name="cloudinary_cloud_name" value="${config['cloudinary_cloud_name']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your Cloudinary Cloud Name.
</p>
</div>
<div class="form-group">
<label for="cloudinary_api_key">Cloudinary API Key</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="cloudinary_api_key" name="cloudinary_api_key" value="${config['cloudinary_api_key']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your Cloudinary API Key.
</p>
</div>
<div class="form-group">
<label for="cloudinary_api_secret">Cloudinary API Secret</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="cloudinary_api_secret" name="cloudinary_api_secret" value="${config['cloudinary_api_secret']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your Cloudinary API Secret.
</p>
</div>
</div>
<div class="checkbox">
<label>
@@ -1041,7 +1159,7 @@
Add a new newsletter agent, or configure an existing newsletter agent by clicking the settings icon on the right.
</p>
<p class="help-block settings-warning" id="newsletter_upload_warning">
Note: Either <a data-tab-destination="tabs-notifications" data-target="#notify_upload_posters">Image Hosting</a> on Imgur or <a data-tab-destination="tabs-notifications" data-target="#newsletter_self_hosted">Self-Hosted Newsletters</a> must be enabled.</span>
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">
@@ -1130,14 +1248,6 @@
</div>
</div>
</div>
<div class="form-group">
<label for="newsletter_dir">Newsletter Directory</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control directory-settings" id="newsletter_dir" name="newsletter_dir" value="${config['newsletter_dir']}">
</div>
</div>
</div>
<p><input type="button" class="btn btn-bright save-button" value="Save" data-success="Changes saved successfully"></p>
@@ -1158,7 +1268,7 @@
</span>
</p>
</div>
<p class="form-group">
<div class="form-group">
<label>Registered Devices</label>
<p class="help-block">Register a new device using a QR code, or configure an existing device by clicking the settings icon on the right.</p>
<p id="app_api_msg" style="color: #eb8600;">The API must be enabled under <a data-tab-destination="tabs-web_interface" data-target="#api_enabled">Web Interface</a> to use the app.</p>
@@ -1313,7 +1423,7 @@
<div class="col-md-12">
<ul class="stacked-configs list-unstyled">
% 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
@@ -1341,7 +1451,7 @@
<div class="col-md-12">
<ul class="stacked-configs list-unstyled">
% for agent in available_newsletter_agents:
<li class="new-newsletter-agent" data-id="${agent['id']}">
<li class="new-newsletter-agent pointer" data-id="${agent['id']}">
<span>${agent['label']}</span>
</li>
% endfor
@@ -1683,6 +1793,7 @@
}
function loadNotifierConfig(notifier_id) {
showMsg('<i class="fa fa-refresh fa-spin"></i>&nbsp; Loading Configuration', false);
$.ajax({
url: 'get_notifier_config_modal',
data: { notifier_id: notifier_id },
@@ -1690,6 +1801,7 @@
async: true,
complete: function (xhr, status) {
$("#notifier-config-modal").html(xhr.responseText).modal('show');
showMsg('<i class="fa fa-check"></i> Configuration Loaded', false, true, 2000);
}
});
}
@@ -1706,6 +1818,7 @@
}
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 },
@@ -1713,6 +1826,7 @@
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);
}
});
}
@@ -1729,6 +1843,7 @@
}
function loadMobileDeviceConfig(mobile_device_id) {
showMsg('<i class="fa fa-refresh fa-spin"></i>&nbsp; Loading Configuration', false);
$.ajax({
url: 'get_mobile_device_config_modal',
data: { mobile_device_id: mobile_device_id },
@@ -1736,6 +1851,7 @@
async: true,
complete: function (xhr, status) {
$("#mobile-device-config-modal").html(xhr.responseText).modal('show');
showMsg('<i class="fa fa-check"></i> Configuration Loaded', false, true, 2000);
}
});
}
@@ -2242,7 +2358,7 @@ $(document).ready(function() {
data: { pref: 'PublishServerOnPlexOnlineKey' },
async: true,
success: function(data) {
if (data !== 'true') {
if (data === 'false' || data === '0') {
$("#remoteAccessCheck").html("Remote access must be enabled on your Plex Server. <a target='_blank' href='${anon_url('https://support.plex.tv/hc/en-us/articles/200484543-Enabling-Remote-Access-for-a-Server')}'>Click here</a> for help.");
$("#monitor_remote_access").attr("checked", false).attr("disabled", true);
}
@@ -2377,6 +2493,7 @@ $(document).ready(function() {
$("#allow_guest_access").attr("disabled", false);
$("#allowGuestCheck").html("");
}
newsletterPasswordEnabled();
}
allowGuestAccessCheck();
@@ -2480,7 +2597,7 @@ $(document).ready(function() {
var result = $.parseJSON(xhr.responseText);
var msg = result.message;
$('#add-notifier-modal').modal('hide');
if (result.result == 'success') {
if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
loadNotifierConfig(result.notifier_id);
} else {
@@ -2502,7 +2619,7 @@ $(document).ready(function() {
var result = $.parseJSON(xhr.responseText);
var msg = result.message;
$('#add-newsletter-modal').modal('hide');
if (result.result == 'success') {
if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
loadNewsletterConfig(result.newsletter_id);
} else {
@@ -2538,11 +2655,42 @@ $(document).ready(function() {
} 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();
@@ -2557,10 +2705,10 @@ $(document).ready(function() {
});
function newsletterUploadEnabled() {
if ($('#notify_upload_posters').val() === '1' || $('#newsletter_self_hosted').is(':checked')) {
$('#newsletter_upload_warning').hide();
} else {
if ($('#notify_upload_posters').val() === '0') {
$('#newsletter_upload_warning').show();
} else {
$('#newsletter_upload_warning').hide();
}
}
newsletterUploadEnabled();
@@ -2570,6 +2718,28 @@ $(document).ready(function() {
newsletterUploadEnabled();
});
function newsletterPasswordEnabled() {
if ($('#newsletter_auth').val() === '1') {
$('#newsletter_password_option').slideDown();
} else {
$('#newsletter_password_option').slideUp();
}
if ($('#newsletter_auth').val() === '2' && !($('#allow_guest_access').is(':checked'))) {
$('.newsletter-guest-access-warning').show();
} else {
$('.newsletter-guest-access-warning').hide();
}
}
newsletterPasswordEnabled();
$('#newsletter_auth').change(function () {
newsletterPasswordEnabled();
});
$('#allow_guest_access').click(function () {
newsletterPasswordEnabled();
})
$('body').on('click', 'a[data-tab-destination]', function () {
var tab = $(this).data('tab-destination');
$("a[href=#" + tab + "]").click();
@@ -2583,6 +2753,10 @@ $(document).ready(function() {
body_container.animate({scrollTop: scroll_pos});
}
});
$('#resources-xml').on('tripleclick', function () {
openPlexXML('/api/resources', true, {includeHttps: 1});
});
});
</script>
</%def>

View File

@@ -46,8 +46,10 @@ DOCUMENTATION :: END
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="info-modal-title">
% if data['media_type'] == 'episode' or data['media_type'] == 'track':
% if data['media_type'] == 'episode':
Stream Info: <strong>${data['grandparent_title']} - ${data['title']} (${user})</strong>
% elif data['media_type'] == 'track':
Stream Info: <strong>${data['original_title'] or data['grandparent_title']} - ${data['title']} (${user})</strong>
% else:
Stream Info: <strong>${data['title']} (${user})</strong>
% endif

View File

@@ -16,7 +16,7 @@
<div class='container-fluid'>
<div class='table-card-header'>
<div class="header-bar">
<span><i class="fa fa-cloud-download"></i> Synced Items</span>
<span id="sync-xml"><i class="fa fa-cloud-download"></i> Synced Items</span>
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
@@ -185,5 +185,9 @@
$("#refresh-syncs-list").click(function() {
sync_table.ajax.reload();
});
$('#sync-xml').on('tripleclick', function () {
openPlexXML('/servers/{machine_id}/sync_lists', true);
});
</script>
</%def>

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,24 @@
% if data:
<%
import plexpy
from plexpy.helpers import grouper
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/'
base_url_image = base_url + 'image/'
elif preview:
base_url = 'newsletter/'
base_url_image = base_url + 'image/'
else:
base_url = base_url_image = ''
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>
@@ -61,17 +68,14 @@
width: 100%;
}
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block;
margin: 0 auto !important;
/* makes it centered */
max-width: 1042px;
padding: 10px;
width: 1042px;
}
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
box-sizing: border-box;
display: block;
@@ -83,6 +87,14 @@
/* -------------------------------------
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;
@@ -157,88 +169,9 @@
text-decoration: underline;
}
/* -------------------------------------
BUTTONS
------------------------------------- */
.btn {
box-sizing: border-box;
width: 100%;
}
.btn > tbody > tr > td {
padding-bottom: 15px;
}
.btn table {
width: auto;
}
.btn table td {
background-color: #ffffff;
border-radius: 5px;
text-align: center;
}
.btn a {
background-color: #ffffff;
border: solid 1px #3498db;
border-radius: 5px;
box-sizing: border-box;
color: #3498db;
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 12px 25px;
text-decoration: none;
text-transform: capitalize;
}
.btn-primary table td {
background-color: #3498db;
}
.btn-primary a {
background-color: #3498db;
border-color: #3498db;
color: #ffffff;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.clear {
clear: both;
}
.mt0 {
margin-top: 0;
}
.mb0 {
margin-bottom: 0;
}
.mb5 {
margin-bottom: 5px;
}
@@ -248,9 +181,6 @@
text-overflow: ellipsis;
overflow: hidden;
}
.clear {
clear: both;
}
.preheader {
color: transparent;
@@ -309,6 +239,7 @@
margin-left: auto;
margin-right: auto;
}
/* -------------------------------------
MEDIA SECTIONS
------------------------------------- */
@@ -363,6 +294,7 @@
font-size: 20px;
text-transform: uppercase;
}
/* -------------------------------------
MEDIA CARDS
------------------------------------- */
@@ -465,21 +397,21 @@
padding-left: 5px;
}
.card-info-footer .badge-container {
max-width: 265px;
max-width: 260px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-info-footer .star-rating-container {
width: 60px;
vertical-align: bottom;
padding-right: 5px;
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;
@@ -490,6 +422,7 @@
.badge {
display: inline-block;
min-width: 10px;
margin-right: 4px;
padding: 3px 7px;
font-size: 11px;
line-height: 1;
@@ -594,37 +527,27 @@
line-height: inherit !important;
text-decoration: none !important;
}
.btn-primary table td:hover {
background-color: #34495e !important;
}
.btn-primary a:hover {
background-color: #34495e !important;
border-color: #34495e !important;
}
}
</style>
</head>
<body class="" 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%;">
<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;">
<!-- START CENTERED WHITE CONTAINER -->
<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;">
<!-- START MAIN CONTENT AREA -->
<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;">
@@ -634,7 +557,6 @@
<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;">
@@ -643,7 +565,6 @@
</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;">
@@ -659,109 +580,105 @@
<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%;">
<tbody>
% 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%;">
<tbody>
<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);">
<tbody>
% 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 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['poster_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);">
<tbody>
<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>
</tbody>
</table>
<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>
<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%;">
<tbody>
<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;">
<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%;">
<tbody>
<tr>
<td class="badge-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;max-width: 265px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;">
% if movie['year']:
<span class="badge" title="${movie['year']}" style="display: inline-block;min-width: 10px;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;">${movie['year']}</span>
% endif
% if movie['duration']:
<% duration = int(int(movie['duration'])/60000) %>
<span class="badge" title="${duration} mins" style="display: inline-block;min-width: 10px;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;">${duration} mins</span>
% endif
% if movie['genres']:
% for genre in movie['genres'][:2]:
<span class="badge" title="${genre}" style="display: inline-block;min-width: 10px;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;">${genre}</span>
% endfor
% endif
</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: bottom;width: 60px;padding-right: 5px;">
% for _ in range(rating):
<span class="star-rating full" style="font-size: 0.8rem;line-height: 1rem;width: 0.5rem;display: inline-block;color: #E5A00D;">&#9733;</span>
% endfor
% for _ in range(5-rating):
<span class="star-rating empty" style="font-size: 0.8rem;line-height: 1rem;width: 0.5rem;display: inline-block;color: #aaaaaa;">&#9734;</span>
% endfor
</td>
% endif
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</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>
</tbody>
</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>
</tbody>
</table>
</td>
</tr>
% endfor
</tbody>
</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>
@@ -783,142 +700,137 @@
<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%;">
<tbody>
% 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%;">
<tbody>
<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);">
<tbody>
% 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 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['poster_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);">
<tbody>
<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>
</tbody>
</table>
<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>
<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%;">
<tbody>
<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;">
<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>
</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
<% 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]):
Season ${season['media_index']} &middot;
% if season['episode_count'] == 1:
Episode ${season['episode'][0]['media_index']} - ${season['episode'][0]['title']}
% else:
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
% 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
</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%;">
<tbody>
<tr>
<td class="badge-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;max-width: 265px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;">
% if show['year']:
<span class="badge" title="${show['year']}" style="display: inline-block;min-width: 10px;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;">${show['year']}</span>
% endif
% if show['duration']:
<% duration = int(int(show['duration'])/60000) %>
<span class="badge" title="${duration} mins" style="display: inline-block;min-width: 10px;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;">${duration} mins</span>
% endif
% if show['genres']:
% for genre in show['genres'][:2]:
<span class="badge" title="${genre}" style="display: inline-block;min-width: 10px;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;">${genre}</span>
% endfor
% endif
</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: bottom;width: 60px;padding-right: 5px;">
% for _ in range(rating):
<span class="star-rating full" style="font-size: 0.8rem;line-height: 1rem;width: 0.5rem;display: inline-block;color: #E5A00D;">&#9733;</span>
% endfor
% for _ in range(5-rating):
<span class="star-rating empty" style="font-size: 0.8rem;line-height: 1rem;width: 0.5rem;display: inline-block;color: #aaaaaa;">&#9734;</span>
% endfor
</td>
% endif
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</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>
</tbody>
</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>
</tbody>
</table>
</td>
</tr>
% endfor
</tbody>
</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>
@@ -940,124 +852,114 @@
<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%;">
<tbody>
% 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%;">
<tbody>
<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);">
<tbody>
% 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 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['poster_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);">
<tbody>
<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>
</tbody>
</table>
<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>
<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%;">
<tbody>
<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;">
<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 artist['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%;">
<tbody>
<tr>
<td class="badge-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;max-width: 265px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;">
% if album['year']:
<span class="badge" title="${album['year']}" style="display: inline-block;min-width: 10px;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;">${album['year']}</span>
% endif
% if album['genres']:
% for genre in album['genres'][:2]:
<span class="badge" title="${genre}" style="display: inline-block;min-width: 10px;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;">${genre}</span>
% endfor
% endif
</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: bottom;width: 60px;padding-right: 5px;">
% for _ in range(rating):
<span class="star-rating full" style="font-size: 0.8rem;line-height: 1rem;width: 0.5rem;display: inline-block;color: #E5A00D;">&#9733;</span>
% endfor
% for _ in range(5-rating):
<span class="star-rating empty" style="font-size: 0.8rem;line-height: 1rem;width: 0.5rem;display: inline-block;color: #aaaaaa;">&#9734;</span>
% endfor
</td>
% endif
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</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>
</tbody>
</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>
</tbody>
</table>
</td>
</tr>
% endfor
</tbody>
</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%;">
<!-- START FOOTER -->
<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;">
Newsletter generated by <a href="http://tautulli.com" target="_blank" style="text-decoration: underline;color: #fff;font-size: 12px;text-align: center;">Tautulli</a>.
<!-- FOOTER MESSAGE - DO NOT REMOVE -->
</div>
<!-- END FOOTER -->
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
</tr>

View File

@@ -1,17 +1,24 @@
% if data:
<%
import plexpy
from plexpy.helpers import grouper
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/'
base_url_image = base_url + 'image/'
elif preview:
base_url = 'newsletter/'
base_url_image = base_url + 'image/'
else:
base_url = base_url_image = ''
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>
@@ -61,17 +68,14 @@
width: 100%;
}
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block;
margin: 0 auto !important;
/* makes it centered */
max-width: 1042px;
padding: 10px;
width: 1042px;
}
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
box-sizing: border-box;
display: block;
@@ -83,6 +87,14 @@
/* -------------------------------------
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;
@@ -157,88 +169,9 @@
text-decoration: underline;
}
/* -------------------------------------
BUTTONS
------------------------------------- */
.btn {
box-sizing: border-box;
width: 100%;
}
.btn > tbody > tr > td {
padding-bottom: 15px;
}
.btn table {
width: auto;
}
.btn table td {
background-color: #ffffff;
border-radius: 5px;
text-align: center;
}
.btn a {
background-color: #ffffff;
border: solid 1px #3498db;
border-radius: 5px;
box-sizing: border-box;
color: #3498db;
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 12px 25px;
text-decoration: none;
text-transform: capitalize;
}
.btn-primary table td {
background-color: #3498db;
}
.btn-primary a {
background-color: #3498db;
border-color: #3498db;
color: #ffffff;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.clear {
clear: both;
}
.mt0 {
margin-top: 0;
}
.mb0 {
margin-bottom: 0;
}
.mb5 {
margin-bottom: 5px;
}
@@ -248,9 +181,6 @@
text-overflow: ellipsis;
overflow: hidden;
}
.clear {
clear: both;
}
.preheader {
color: transparent;
@@ -309,6 +239,7 @@
margin-left: auto;
margin-right: auto;
}
/* -------------------------------------
MEDIA SECTIONS
------------------------------------- */
@@ -363,6 +294,7 @@
font-size: 20px;
text-transform: uppercase;
}
/* -------------------------------------
MEDIA CARDS
------------------------------------- */
@@ -436,6 +368,7 @@
line-height: 1.2rem;
font-size: 0.9rem;
padding: 5px;
max-width: 320px;
}
.card-info-title a {
text-decoration: none;
@@ -465,21 +398,21 @@
padding-left: 5px;
}
.card-info-footer .badge-container {
max-width: 265px;
max-width: 260px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-info-footer .star-rating-container {
width: 60px;
vertical-align: bottom;
padding-right: 5px;
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;
@@ -490,6 +423,7 @@
.badge {
display: inline-block;
min-width: 10px;
margin-right: 4px;
padding: 3px 7px;
font-size: 11px;
line-height: 1;
@@ -594,37 +528,27 @@
line-height: inherit !important;
text-decoration: none !important;
}
.btn-primary table td:hover {
background-color: #34495e !important;
}
.btn-primary a:hover {
background-color: #34495e !important;
border-color: #34495e !important;
}
}
</style>
</head>
<body class="">
<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">
<!-- START CENTERED WHITE CONTAINER -->
<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">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<div class="header">
@@ -634,7 +558,6 @@
<div class="dates">${parameters['start_date']} - ${parameters['end_date']}</div>
</td>
</tr>
% if message:
<tr>
<td class="wrapper">
@@ -643,7 +566,6 @@
</td>
</tr>
% endif
% if recently_added.get('movie'):
<tr>
<td class="wrapper">
@@ -659,109 +581,105 @@
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0">
<tbody>
% for movie_a, movie_b in grouper(recently_added['movie'], 2):
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<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']});">
<tbody>
% 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 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['poster_url']})">
<tbody>
<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>
</tbody>
</table>
<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>
<td class="card-info-container">
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
<tbody>
<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">
<tbody>
<tr>
<td class="badge-container">
% if movie['year']:
<span class="badge" title="${movie['year']}">${movie['year']}</span>
% endif
% if movie['duration']:
<% duration = int(int(movie['duration'])/60000) %>
<span class="badge" title="${duration} mins">${duration} mins</span>
% endif
% if movie['genres']:
% for genre in movie['genres'][:2]:
<span class="badge" title="${genre}">${genre}</span>
% endfor
% endif
</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">
% for _ in range(rating):
<span class="star-rating full">&#9733;</span>
% endfor
% for _ in range(5-rating):
<span class="star-rating empty">&#9734;</span>
% endfor
</td>
% endif
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</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>
</tbody>
</table>
</td>
% if not movie_b:
<td align="center" valign="top" class="card-instance pad"></td>
% endif
% endif
% endfor
</tr>
</tbody>
</table>
</td>
</tr>
% endfor
</tbody>
</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>
@@ -783,142 +701,137 @@
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0">
<tbody>
% for show_a, show_b in grouper(recently_added['show'], 2):
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<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']});">
<tbody>
% 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 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['poster_url']})">
<tbody>
<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>
</tbody>
</table>
<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>
<td class="card-info-container">
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
<tbody>
<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>
</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
<% 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]):
Season ${season['media_index']} &middot;
% if season['episode_count'] == 1:
Episode ${season['episode'][0]['media_index']} - ${season['episode'][0]['title']}
% else:
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
% 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
</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">
<tbody>
<tr>
<td class="badge-container">
% if show['year']:
<span class="badge" title="${show['year']}">${show['year']}</span>
% endif
% if show['duration']:
<% duration = int(int(show['duration'])/60000) %>
<span class="badge" title="${duration} mins">${duration} mins</span>
% endif
% if show['genres']:
% for genre in show['genres'][:2]:
<span class="badge" title="${genre}">${genre}</span>
% endfor
% endif
</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">
% for _ in range(rating):
<span class="star-rating full">&#9733;</span>
% endfor
% for _ in range(5-rating):
<span class="star-rating empty">&#9734;</span>
% endfor
</td>
% endif
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</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>
</tbody>
</table>
</td>
% if not show_b:
<td align="center" valign="top" class="card-instance pad"></td>
% endif
% endif
% endfor
</tr>
</tbody>
</table>
</td>
</tr>
% endfor
</tbody>
</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>
@@ -940,124 +853,114 @@
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0">
<tbody>
% 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%">
<tbody>
<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']});">
<tbody>
% 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 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['poster_url']})">
<tbody>
<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>
</tbody>
</table>
<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>
<td class="card-info-container">
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
<tbody>
<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 artist['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">
<tbody>
<tr>
<td class="badge-container">
% if album['year']:
<span class="badge" title="${album['year']}">${album['year']}</span>
% endif
% if album['genres']:
% for genre in album['genres'][:2]:
<span class="badge" title="${genre}">${genre}</span>
% endfor
% endif
</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">
% for _ in range(rating):
<span class="star-rating full">&#9733;</span>
% endfor
% for _ in range(5-rating):
<span class="star-rating empty">&#9734;</span>
% endfor
</td>
% endif
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</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>
</tbody>
</table>
</td>
% if not album_b:
<td align="center" valign="top" class="card-instance pad"></td>
% endif
% endif
% endfor
</tr>
</tbody>
</table>
</td>
</tr>
% endfor
</tbody>
</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">
<!-- START FOOTER -->
<div class="footer-bar"></div>
<div class="content-block powered-by">
Newsletter generated by <a href="http://tautulli.com" target="_blank">Tautulli</a>.
<!-- FOOTER MESSAGE - DO NOT REMOVE -->
</div>
<!-- END FOOTER -->
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
</tr>

View File

@@ -9,7 +9,7 @@ __all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression',
'WeekdayPositionExpression', 'LastDayOfMonthExpression')
WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']

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();
}
});
}));

View File

@@ -0,0 +1,125 @@
/*
* jQuery File Upload Validation 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
*/
/* global define, require, window */
;(function (factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
// Register as an anonymous AMD module:
define([
'jquery',
'./jquery.fileupload-process'
], factory);
} else if (typeof exports === 'object') {
// Node/CommonJS:
factory(
require('jquery'),
require('./jquery.fileupload-process')
);
} else {
// Browser globals:
factory(
window.jQuery
);
}
}(function ($) {
'use strict';
// Append to the default processQueue:
$.blueimp.fileupload.prototype.options.processQueue.push(
{
action: 'validate',
// Always trigger this action,
// even if the previous action was rejected:
always: true,
// Options taken from the global options map:
acceptFileTypes: '@',
maxFileSize: '@',
minFileSize: '@',
maxNumberOfFiles: '@',
disabled: '@disableValidation'
}
);
// The File Upload Validation plugin extends the fileupload widget
// with file validation functionality:
$.widget('blueimp.fileupload', $.blueimp.fileupload, {
options: {
/*
// The regular expression for allowed file types, matches
// against either file type or file name:
acceptFileTypes: /(\.|\/)(gif|jpe?g|png)$/i,
// The maximum allowed file size in bytes:
maxFileSize: 10000000, // 10 MB
// The minimum allowed file size in bytes:
minFileSize: undefined, // No minimal file size
// The limit of files to be uploaded:
maxNumberOfFiles: 10,
*/
// Function returning the current number of files,
// has to be overriden for maxNumberOfFiles validation:
getNumberOfFiles: $.noop,
// Error and info messages:
messages: {
maxNumberOfFiles: 'Maximum number of files exceeded',
acceptFileTypes: 'File type not allowed',
maxFileSize: 'File is too large',
minFileSize: 'File is too small'
}
},
processActions: {
validate: function (data, options) {
if (options.disabled) {
return data;
}
var dfd = $.Deferred(),
settings = this.options,
file = data.files[data.index],
fileSize;
if (options.minFileSize || options.maxFileSize) {
fileSize = file.size;
}
if ($.type(options.maxNumberOfFiles) === 'number' &&
(settings.getNumberOfFiles() || 0) + data.files.length >
options.maxNumberOfFiles) {
file.error = settings.i18n('maxNumberOfFiles');
} else if (options.acceptFileTypes &&
!(options.acceptFileTypes.test(file.type) ||
options.acceptFileTypes.test(file.name))) {
file.error = settings.i18n('acceptFileTypes');
} else if (fileSize > options.maxFileSize) {
file.error = settings.i18n('maxFileSize');
} else if ($.type(fileSize) === 'number' &&
fileSize < options.minFileSize) {
file.error = settings.i18n('minFileSize');
} else {
delete file.error;
}
if (file.error || data.files.error) {
data.files.error = true;
dfd.rejectWith(this, [data]);
} else {
dfd.resolveWith(this, [data]);
}
return dfd.promise();
}
}
});
}));

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,224 @@
/*
* jQuery Iframe Transport Plugin
* https://github.com/blueimp/jQuery-File-Upload
*
* Copyright 2011, Sebastian Tschan
* https://blueimp.net
*
* Licensed under the MIT license:
* https://opensource.org/licenses/MIT
*/
/* global define, require, window, document, JSON */
;(function (factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
// Register as an anonymous AMD module:
define(['jquery'], factory);
} else if (typeof exports === 'object') {
// Node/CommonJS:
factory(require('jquery'));
} else {
// Browser globals:
factory(window.jQuery);
}
}(function ($) {
'use strict';
// Helper variable to create unique names for the transport iframes:
var counter = 0,
jsonAPI = $,
jsonParse = 'parseJSON';
if ('JSON' in window && 'parse' in JSON) {
jsonAPI = JSON;
jsonParse = 'parse';
}
// The iframe transport accepts four additional options:
// options.fileInput: a jQuery collection of file input fields
// options.paramName: the parameter name for the file form data,
// overrides the name property of the file input field(s),
// can be a string or an array of strings.
// options.formData: an array of objects with name and value properties,
// equivalent to the return data of .serializeArray(), e.g.:
// [{name: 'a', value: 1}, {name: 'b', value: 2}]
// options.initialIframeSrc: the URL of the initial iframe src,
// by default set to "javascript:false;"
$.ajaxTransport('iframe', function (options) {
if (options.async) {
// javascript:false as initial iframe src
// prevents warning popups on HTTPS in IE6:
/*jshint scripturl: true */
var initialIframeSrc = options.initialIframeSrc || 'javascript:false;',
/*jshint scripturl: false */
form,
iframe,
addParamChar;
return {
send: function (_, completeCallback) {
form = $('<form style="display:none;"></form>');
form.attr('accept-charset', options.formAcceptCharset);
addParamChar = /\?/.test(options.url) ? '&' : '?';
// XDomainRequest only supports GET and POST:
if (options.type === 'DELETE') {
options.url = options.url + addParamChar + '_method=DELETE';
options.type = 'POST';
} else if (options.type === 'PUT') {
options.url = options.url + addParamChar + '_method=PUT';
options.type = 'POST';
} else if (options.type === 'PATCH') {
options.url = options.url + addParamChar + '_method=PATCH';
options.type = 'POST';
}
// IE versions below IE8 cannot set the name property of
// elements that have already been added to the DOM,
// so we set the name along with the iframe HTML markup:
counter += 1;
iframe = $(
'<iframe src="' + initialIframeSrc +
'" name="iframe-transport-' + counter + '"></iframe>'
).bind('load', function () {
var fileInputClones,
paramNames = $.isArray(options.paramName) ?
options.paramName : [options.paramName];
iframe
.unbind('load')
.bind('load', function () {
var response;
// Wrap in a try/catch block to catch exceptions thrown
// when trying to access cross-domain iframe contents:
try {
response = iframe.contents();
// Google Chrome and Firefox do not throw an
// exception when calling iframe.contents() on
// cross-domain requests, so we unify the response:
if (!response.length || !response[0].firstChild) {
throw new Error();
}
} catch (e) {
response = undefined;
}
// The complete callback returns the
// iframe content document as response object:
completeCallback(
200,
'success',
{'iframe': response}
);
// Fix for IE endless progress bar activity bug
// (happens on form submits to iframe targets):
$('<iframe src="' + initialIframeSrc + '"></iframe>')
.appendTo(form);
window.setTimeout(function () {
// Removing the form in a setTimeout call
// allows Chrome's developer tools to display
// the response result
form.remove();
}, 0);
});
form
.prop('target', iframe.prop('name'))
.prop('action', options.url)
.prop('method', options.type);
if (options.formData) {
$.each(options.formData, function (index, field) {
$('<input type="hidden"/>')
.prop('name', field.name)
.val(field.value)
.appendTo(form);
});
}
if (options.fileInput && options.fileInput.length &&
options.type === 'POST') {
fileInputClones = options.fileInput.clone();
// Insert a clone for each file input field:
options.fileInput.after(function (index) {
return fileInputClones[index];
});
if (options.paramName) {
options.fileInput.each(function (index) {
$(this).prop(
'name',
paramNames[index] || options.paramName
);
});
}
// Appending the file input fields to the hidden form
// removes them from their original location:
form
.append(options.fileInput)
.prop('enctype', 'multipart/form-data')
// enctype must be set as encoding for IE:
.prop('encoding', 'multipart/form-data');
// Remove the HTML5 form attribute from the input(s):
options.fileInput.removeAttr('form');
}
form.submit();
// Insert the file input fields at their original location
// by replacing the clones with the originals:
if (fileInputClones && fileInputClones.length) {
options.fileInput.each(function (index, input) {
var clone = $(fileInputClones[index]);
// Restore the original name and form properties:
$(input)
.prop('name', clone.prop('name'))
.attr('form', clone.attr('form'));
clone.replaceWith(input);
});
}
});
form.append(iframe).appendTo(document.body);
},
abort: function () {
if (iframe) {
// javascript:false as iframe src aborts the request
// and prevents warning popups on HTTPS in IE6.
// concat is used to avoid the "Script URL" JSLint error:
iframe
.unbind('load')
.prop('src', initialIframeSrc);
}
if (form) {
form.remove();
}
}
};
}
});
// The iframe transport returns the iframe content document as response.
// The following adds converters from iframe to text, json, html, xml
// and script.
// Please note that the Content-Type for JSON responses has to be text/plain
// or text/html, if the browser doesn't include application/json in the
// Accept header, else IE will show a download dialog.
// The Content-Type for XML responses on the other hand has to be always
// application/xml or text/xml, so IE properly parses the XML response.
// See also
// https://github.com/blueimp/jQuery-File-Upload/wiki/Setup#content-type-negotiation
$.ajaxSetup({
converters: {
'iframe text': function (iframe) {
return iframe && $(iframe[0].body).text();
},
'iframe json': function (iframe) {
return iframe && jsonAPI[jsonParse]($(iframe[0].body).text());
},
'iframe html': function (iframe) {
return iframe && $(iframe[0].body).html();
},
'iframe xml': function (iframe) {
var xmlDoc = iframe && iframe[0];
return xmlDoc && $.isXMLDoc(xmlDoc) ? xmlDoc :
$.parseXML((xmlDoc.XMLDocument && xmlDoc.XMLDocument.xml) ||
$(xmlDoc.body).html());
},
'iframe script': function (iframe) {
return iframe && $.globalEval($(iframe[0].body).text());
}
}
});
}));

View File

@@ -0,0 +1,572 @@
/*! jQuery UI - v1.11.4+CommonJS - 2015-08-28
* http://jqueryui.com
* Includes: widget.js
* Copyright 2015 jQuery Foundation and other contributors; Licensed MIT */
(function( factory ) {
if ( typeof define === "function" && define.amd ) {
// AMD. Register as an anonymous module.
define([ "jquery" ], factory );
} else if ( typeof exports === "object" ) {
// Node/CommonJS
factory( require( "jquery" ) );
} else {
// Browser globals
factory( jQuery );
}
}(function( $ ) {
/*!
* jQuery UI Widget 1.11.4
* http://jqueryui.com
*
* Copyright jQuery Foundation and other contributors
* Released under the MIT license.
* http://jquery.org/license
*
* http://api.jqueryui.com/jQuery.widget/
*/
var widget_uuid = 0,
widget_slice = Array.prototype.slice;
$.cleanData = (function( orig ) {
return function( elems ) {
var events, elem, i;
for ( i = 0; (elem = elems[i]) != null; i++ ) {
try {
// Only trigger remove when necessary to save time
events = $._data( elem, "events" );
if ( events && events.remove ) {
$( elem ).triggerHandler( "remove" );
}
// http://bugs.jquery.com/ticket/8235
} catch ( e ) {}
}
orig( elems );
};
})( $.cleanData );
$.widget = function( name, base, prototype ) {
var fullName, existingConstructor, constructor, basePrototype,
// proxiedPrototype allows the provided prototype to remain unmodified
// so that it can be used as a mixin for multiple widgets (#8876)
proxiedPrototype = {},
namespace = name.split( "." )[ 0 ];
name = name.split( "." )[ 1 ];
fullName = namespace + "-" + name;
if ( !prototype ) {
prototype = base;
base = $.Widget;
}
// create selector for plugin
$.expr[ ":" ][ fullName.toLowerCase() ] = function( elem ) {
return !!$.data( elem, fullName );
};
$[ namespace ] = $[ namespace ] || {};
existingConstructor = $[ namespace ][ name ];
constructor = $[ namespace ][ name ] = function( options, element ) {
// allow instantiation without "new" keyword
if ( !this._createWidget ) {
return new constructor( options, element );
}
// allow instantiation without initializing for simple inheritance
// must use "new" keyword (the code above always passes args)
if ( arguments.length ) {
this._createWidget( options, element );
}
};
// extend with the existing constructor to carry over any static properties
$.extend( constructor, existingConstructor, {
version: prototype.version,
// copy the object used to create the prototype in case we need to
// redefine the widget later
_proto: $.extend( {}, prototype ),
// track widgets that inherit from this widget in case this widget is
// redefined after a widget inherits from it
_childConstructors: []
});
basePrototype = new base();
// we need to make the options hash a property directly on the new instance
// otherwise we'll modify the options hash on the prototype that we're
// inheriting from
basePrototype.options = $.widget.extend( {}, basePrototype.options );
$.each( prototype, function( prop, value ) {
if ( !$.isFunction( value ) ) {
proxiedPrototype[ prop ] = value;
return;
}
proxiedPrototype[ prop ] = (function() {
var _super = function() {
return base.prototype[ prop ].apply( this, arguments );
},
_superApply = function( args ) {
return base.prototype[ prop ].apply( this, args );
};
return function() {
var __super = this._super,
__superApply = this._superApply,
returnValue;
this._super = _super;
this._superApply = _superApply;
returnValue = value.apply( this, arguments );
this._super = __super;
this._superApply = __superApply;
return returnValue;
};
})();
});
constructor.prototype = $.widget.extend( basePrototype, {
// TODO: remove support for widgetEventPrefix
// always use the name + a colon as the prefix, e.g., draggable:start
// don't prefix for widgets that aren't DOM-based
widgetEventPrefix: existingConstructor ? (basePrototype.widgetEventPrefix || name) : name
}, proxiedPrototype, {
constructor: constructor,
namespace: namespace,
widgetName: name,
widgetFullName: fullName
});
// If this widget is being redefined then we need to find all widgets that
// are inheriting from it and redefine all of them so that they inherit from
// the new version of this widget. We're essentially trying to replace one
// level in the prototype chain.
if ( existingConstructor ) {
$.each( existingConstructor._childConstructors, function( i, child ) {
var childPrototype = child.prototype;
// redefine the child widget using the same prototype that was
// originally used, but inherit from the new version of the base
$.widget( childPrototype.namespace + "." + childPrototype.widgetName, constructor, child._proto );
});
// remove the list of existing child constructors from the old constructor
// so the old child constructors can be garbage collected
delete existingConstructor._childConstructors;
} else {
base._childConstructors.push( constructor );
}
$.widget.bridge( name, constructor );
return constructor;
};
$.widget.extend = function( target ) {
var input = widget_slice.call( arguments, 1 ),
inputIndex = 0,
inputLength = input.length,
key,
value;
for ( ; inputIndex < inputLength; inputIndex++ ) {
for ( key in input[ inputIndex ] ) {
value = input[ inputIndex ][ key ];
if ( input[ inputIndex ].hasOwnProperty( key ) && value !== undefined ) {
// Clone objects
if ( $.isPlainObject( value ) ) {
target[ key ] = $.isPlainObject( target[ key ] ) ?
$.widget.extend( {}, target[ key ], value ) :
// Don't extend strings, arrays, etc. with objects
$.widget.extend( {}, value );
// Copy everything else by reference
} else {
target[ key ] = value;
}
}
}
}
return target;
};
$.widget.bridge = function( name, object ) {
var fullName = object.prototype.widgetFullName || name;
$.fn[ name ] = function( options ) {
var isMethodCall = typeof options === "string",
args = widget_slice.call( arguments, 1 ),
returnValue = this;
if ( isMethodCall ) {
this.each(function() {
var methodValue,
instance = $.data( this, fullName );
if ( options === "instance" ) {
returnValue = instance;
return false;
}
if ( !instance ) {
return $.error( "cannot call methods on " + name + " prior to initialization; " +
"attempted to call method '" + options + "'" );
}
if ( !$.isFunction( instance[options] ) || options.charAt( 0 ) === "_" ) {
return $.error( "no such method '" + options + "' for " + name + " widget instance" );
}
methodValue = instance[ options ].apply( instance, args );
if ( methodValue !== instance && methodValue !== undefined ) {
returnValue = methodValue && methodValue.jquery ?
returnValue.pushStack( methodValue.get() ) :
methodValue;
return false;
}
});
} else {
// Allow multiple hashes to be passed on init
if ( args.length ) {
options = $.widget.extend.apply( null, [ options ].concat(args) );
}
this.each(function() {
var instance = $.data( this, fullName );
if ( instance ) {
instance.option( options || {} );
if ( instance._init ) {
instance._init();
}
} else {
$.data( this, fullName, new object( options, this ) );
}
});
}
return returnValue;
};
};
$.Widget = function( /* options, element */ ) {};
$.Widget._childConstructors = [];
$.Widget.prototype = {
widgetName: "widget",
widgetEventPrefix: "",
defaultElement: "<div>",
options: {
disabled: false,
// callbacks
create: null
},
_createWidget: function( options, element ) {
element = $( element || this.defaultElement || this )[ 0 ];
this.element = $( element );
this.uuid = widget_uuid++;
this.eventNamespace = "." + this.widgetName + this.uuid;
this.bindings = $();
this.hoverable = $();
this.focusable = $();
if ( element !== this ) {
$.data( element, this.widgetFullName, this );
this._on( true, this.element, {
remove: function( event ) {
if ( event.target === element ) {
this.destroy();
}
}
});
this.document = $( element.style ?
// element within the document
element.ownerDocument :
// element is window or document
element.document || element );
this.window = $( this.document[0].defaultView || this.document[0].parentWindow );
}
this.options = $.widget.extend( {},
this.options,
this._getCreateOptions(),
options );
this._create();
this._trigger( "create", null, this._getCreateEventData() );
this._init();
},
_getCreateOptions: $.noop,
_getCreateEventData: $.noop,
_create: $.noop,
_init: $.noop,
destroy: function() {
this._destroy();
// we can probably remove the unbind calls in 2.0
// all event bindings should go through this._on()
this.element
.unbind( this.eventNamespace )
.removeData( this.widgetFullName )
// support: jquery <1.6.3
// http://bugs.jquery.com/ticket/9413
.removeData( $.camelCase( this.widgetFullName ) );
this.widget()
.unbind( this.eventNamespace )
.removeAttr( "aria-disabled" )
.removeClass(
this.widgetFullName + "-disabled " +
"ui-state-disabled" );
// clean up events and states
this.bindings.unbind( this.eventNamespace );
this.hoverable.removeClass( "ui-state-hover" );
this.focusable.removeClass( "ui-state-focus" );
},
_destroy: $.noop,
widget: function() {
return this.element;
},
option: function( key, value ) {
var options = key,
parts,
curOption,
i;
if ( arguments.length === 0 ) {
// don't return a reference to the internal hash
return $.widget.extend( {}, this.options );
}
if ( typeof key === "string" ) {
// handle nested keys, e.g., "foo.bar" => { foo: { bar: ___ } }
options = {};
parts = key.split( "." );
key = parts.shift();
if ( parts.length ) {
curOption = options[ key ] = $.widget.extend( {}, this.options[ key ] );
for ( i = 0; i < parts.length - 1; i++ ) {
curOption[ parts[ i ] ] = curOption[ parts[ i ] ] || {};
curOption = curOption[ parts[ i ] ];
}
key = parts.pop();
if ( arguments.length === 1 ) {
return curOption[ key ] === undefined ? null : curOption[ key ];
}
curOption[ key ] = value;
} else {
if ( arguments.length === 1 ) {
return this.options[ key ] === undefined ? null : this.options[ key ];
}
options[ key ] = value;
}
}
this._setOptions( options );
return this;
},
_setOptions: function( options ) {
var key;
for ( key in options ) {
this._setOption( key, options[ key ] );
}
return this;
},
_setOption: function( key, value ) {
this.options[ key ] = value;
if ( key === "disabled" ) {
this.widget()
.toggleClass( this.widgetFullName + "-disabled", !!value );
// If the widget is becoming disabled, then nothing is interactive
if ( value ) {
this.hoverable.removeClass( "ui-state-hover" );
this.focusable.removeClass( "ui-state-focus" );
}
}
return this;
},
enable: function() {
return this._setOptions({ disabled: false });
},
disable: function() {
return this._setOptions({ disabled: true });
},
_on: function( suppressDisabledCheck, element, handlers ) {
var delegateElement,
instance = this;
// no suppressDisabledCheck flag, shuffle arguments
if ( typeof suppressDisabledCheck !== "boolean" ) {
handlers = element;
element = suppressDisabledCheck;
suppressDisabledCheck = false;
}
// no element argument, shuffle and use this.element
if ( !handlers ) {
handlers = element;
element = this.element;
delegateElement = this.widget();
} else {
element = delegateElement = $( element );
this.bindings = this.bindings.add( element );
}
$.each( handlers, function( event, handler ) {
function handlerProxy() {
// allow widgets to customize the disabled handling
// - disabled as an array instead of boolean
// - disabled class as method for disabling individual parts
if ( !suppressDisabledCheck &&
( instance.options.disabled === true ||
$( this ).hasClass( "ui-state-disabled" ) ) ) {
return;
}
return ( typeof handler === "string" ? instance[ handler ] : handler )
.apply( instance, arguments );
}
// copy the guid so direct unbinding works
if ( typeof handler !== "string" ) {
handlerProxy.guid = handler.guid =
handler.guid || handlerProxy.guid || $.guid++;
}
var match = event.match( /^([\w:-]*)\s*(.*)$/ ),
eventName = match[1] + instance.eventNamespace,
selector = match[2];
if ( selector ) {
delegateElement.delegate( selector, eventName, handlerProxy );
} else {
element.bind( eventName, handlerProxy );
}
});
},
_off: function( element, eventName ) {
eventName = (eventName || "").split( " " ).join( this.eventNamespace + " " ) +
this.eventNamespace;
element.unbind( eventName ).undelegate( eventName );
// Clear the stack to avoid memory leaks (#10056)
this.bindings = $( this.bindings.not( element ).get() );
this.focusable = $( this.focusable.not( element ).get() );
this.hoverable = $( this.hoverable.not( element ).get() );
},
_delay: function( handler, delay ) {
function handlerProxy() {
return ( typeof handler === "string" ? instance[ handler ] : handler )
.apply( instance, arguments );
}
var instance = this;
return setTimeout( handlerProxy, delay || 0 );
},
_hoverable: function( element ) {
this.hoverable = this.hoverable.add( element );
this._on( element, {
mouseenter: function( event ) {
$( event.currentTarget ).addClass( "ui-state-hover" );
},
mouseleave: function( event ) {
$( event.currentTarget ).removeClass( "ui-state-hover" );
}
});
},
_focusable: function( element ) {
this.focusable = this.focusable.add( element );
this._on( element, {
focusin: function( event ) {
$( event.currentTarget ).addClass( "ui-state-focus" );
},
focusout: function( event ) {
$( event.currentTarget ).removeClass( "ui-state-focus" );
}
});
},
_trigger: function( type, event, data ) {
var prop, orig,
callback = this.options[ type ];
data = data || {};
event = $.Event( event );
event.type = ( type === this.widgetEventPrefix ?
type :
this.widgetEventPrefix + type ).toLowerCase();
// the original event may come from any element
// so we need to reset the target on the new event
event.target = this.element[ 0 ];
// copy original event properties over to the new event
orig = event.originalEvent;
if ( orig ) {
for ( prop in orig ) {
if ( !( prop in event ) ) {
event[ prop ] = orig[ prop ];
}
}
}
this.element.trigger( event, data );
return !( $.isFunction( callback ) &&
callback.apply( this.element[0], [ event ].concat( data ) ) === false ||
event.isDefaultPrevented() );
}
};
$.each( { show: "fadeIn", hide: "fadeOut" }, function( method, defaultEffect ) {
$.Widget.prototype[ "_" + method ] = function( element, options, callback ) {
if ( typeof options === "string" ) {
options = { effect: options };
}
var hasOptions,
effectName = !options ?
method :
options === true || typeof options === "number" ?
defaultEffect :
options.effect || defaultEffect;
options = options || {};
if ( typeof options === "number" ) {
options = { duration: options };
}
hasOptions = !$.isEmptyObject( options );
options.complete = callback;
if ( options.delay ) {
element.delay( options.delay );
}
if ( hasOptions && $.effects && $.effects.effect[ effectName ] ) {
element[ method ]( options );
} else if ( effectName !== method && element[ effectName ] ) {
element[ effectName ]( options.duration, options.easing, callback );
} else {
element.queue(function( next ) {
$( this )[ method ]();
if ( callback ) {
callback.call( element[ 0 ] );
}
next();
});
}
};
});
var widget = $.widget;
}));

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
<form action={{url}} method="POST" enctype="multipart/form-data">
{% for name, value in params.items %}
<input type="hidden" name="{{name}}" value="{{value}}"/>
{% endfor %}
{% block extra %} {% endblock %}
{% block file %}
<input type="file" name="file"/>
{% endblock %}
{% block submit %}
<input type="submit"/>
{% endblock %}
</form>

View File

@@ -0,0 +1,14 @@
{% load staticfiles %}
<script src="{% static "js/jquery.ui.widget.js" %}" type="text/javascript"></script>
<script src="{% static "js/jquery.iframe-transport.js" %}" type="text/javascript"></script>
<script src="{% static "js/jquery.fileupload.js" %}" type="text/javascript"></script>
<script src="{% static "js/jquery.cloudinary.js" %}" type="text/javascript"></script>
{% if processing %}
<script src="{% static "js/load-image.all.min.js" %}" type="text/javascript"></script>
<script src="{% static "js/canvas-to-blob.min.js" %}" type="text/javascript"></script>
<script src="{% static "js/jquery.fileupload-process.js" %}" type="text/javascript"></script>
<script src="{% static "js/jquery.fileupload-image.js" %}" type="text/javascript"></script>
<script src="{% static "js/jquery.fileupload-validate.js" %}" type="text/javascript"></script>
{% endif %}

View File

@@ -0,0 +1,3 @@
<script type='text/javascript'>
$.cloudinary.config({{ params|safe }});
</script>

View File

@@ -0,0 +1 @@
#

View File

@@ -0,0 +1,85 @@
from __future__ import absolute_import
import json
from django import template
from django.forms import Form
from django.utils.safestring import mark_safe
import cloudinary
from cloudinary import CloudinaryResource, utils, uploader
from cloudinary.forms import CloudinaryJsFileField, cl_init_js_callbacks
from cloudinary.compat import PY3
register = template.Library()
@register.simple_tag(takes_context=True)
def cloudinary_url(context, source, options_dict=None, **options):
if options_dict is None:
options = dict(**options)
else:
options = dict(options_dict, **options)
try:
if context['request'].is_secure() and 'secure' not in options:
options['secure'] = True
except KeyError:
pass
if not isinstance(source, CloudinaryResource):
source = CloudinaryResource(source)
return source.build_url(**options)
@register.simple_tag(name='cloudinary', takes_context=True)
def cloudinary_tag(context, image, options_dict=None, **options):
if options_dict is None:
options = dict(**options)
else:
options = dict(options_dict, **options)
try:
if context['request'].is_secure() and 'secure' not in options:
options['secure'] = True
except KeyError:
pass
if not isinstance(image, CloudinaryResource):
image = CloudinaryResource(image)
return mark_safe(image.image(**options))
@register.simple_tag
def cloudinary_direct_upload_field(field_name="image", request=None):
form = type("OnTheFlyForm", (Form,), {field_name: CloudinaryJsFileField()})()
if request:
cl_init_js_callbacks(form, request)
value = form[field_name]
if not PY3:
value = unicode(value)
return value
"""Deprecated - please use cloudinary_direct_upload_field, or a proper form"""
@register.inclusion_tag('cloudinary_direct_upload.html')
def cloudinary_direct_upload(callback_url, **options):
params = utils.build_upload_params(callback=callback_url, **options)
params = utils.sign_request(params, options)
api_url = utils.cloudinary_api_url("upload", resource_type=options.get("resource_type", "image"),
upload_prefix=options.get("upload_prefix"))
return {"params": params, "url": api_url}
@register.inclusion_tag('cloudinary_includes.html')
def cloudinary_includes(processing=False):
return {"processing": processing}
CLOUDINARY_JS_CONFIG_PARAMS = ("api_key", "cloud_name", "private_cdn", "secure_distribution", "cdn_subdomain")
@register.inclusion_tag('cloudinary_js_config.html')
def cloudinary_js_config():
config = cloudinary.config()
return dict(
params=json.dumps(dict(
(param, getattr(config, param)) for param in CLOUDINARY_JS_CONFIG_PARAMS if getattr(config, param, None)
))
)

325
lib/cloudinary/uploader.py Normal file
View File

@@ -0,0 +1,325 @@
# Copyright Cloudinary
import json
import re
import socket
from os.path import getsize
import cloudinary
import urllib3
import certifi
from cloudinary import utils
from cloudinary.api import Error
from cloudinary.compat import string_types
from urllib3.exceptions import HTTPError
from urllib3 import PoolManager
try:
from urllib3.contrib.appengine import AppEngineManager, is_appengine_sandbox
except Exception:
def is_appengine_sandbox():
return False
try: # Python 2.7+
from collections import OrderedDict
except ImportError:
from urllib3.packages.ordered_dict import OrderedDict
if is_appengine_sandbox():
# AppEngineManager uses AppEngine's URLFetch API behind the scenes
_http = AppEngineManager()
else:
# PoolManager uses a socket-level API behind the scenes
_http = PoolManager(
cert_reqs='CERT_REQUIRED',
ca_certs=certifi.where()
)
def upload(file, **options):
params = utils.build_upload_params(**options)
return call_api("upload", params, file=file, **options)
def unsigned_upload(file, upload_preset, **options):
return upload(file, upload_preset=upload_preset, unsigned=True, **options)
def upload_image(file, **options):
result = upload(file, **options)
return cloudinary.CloudinaryImage(
result["public_id"], version=str(result["version"]),
format=result.get("format"), metadata=result)
def upload_resource(file, **options):
result = upload(file, **options)
return cloudinary.CloudinaryResource(
result["public_id"], version=str(result["version"]),
format=result.get("format"), type=result["type"], resource_type=result["resource_type"], metadata=result)
def upload_large(file, **options):
""" Upload large files. """
upload_id = utils.random_public_id()
with open(file, 'rb') as file_io:
results = None
current_loc = 0
chunk_size = options.get("chunk_size", 20000000)
file_size = getsize(file)
chunk = file_io.read(chunk_size)
while chunk:
range = "bytes {0}-{1}/{2}".format(current_loc, current_loc + len(chunk) - 1, file_size)
current_loc += len(chunk)
results = upload_large_part((file, chunk),
http_headers={"Content-Range": range, "X-Unique-Upload-Id": upload_id},
**options)
options["public_id"] = results.get("public_id")
chunk = file_io.read(chunk_size)
return results
def upload_large_part(file, **options):
""" Upload large files. """
params = utils.build_upload_params(**options)
if 'resource_type' not in options: options['resource_type'] = "raw"
return call_api("upload", params, file=file, **options)
def destroy(public_id, **options):
params = {
"timestamp": utils.now(),
"type": options.get("type"),
"invalidate": options.get("invalidate"),
"public_id": public_id
}
return call_api("destroy", params, **options)
def rename(from_public_id, to_public_id, **options):
params = {
"timestamp": utils.now(),
"type": options.get("type"),
"overwrite": options.get("overwrite"),
"invalidate": options.get("invalidate"),
"from_public_id": from_public_id,
"to_public_id": to_public_id
}
return call_api("rename", params, **options)
def explicit(public_id, **options):
params = utils.build_upload_params(**options)
params["public_id"] = public_id
return call_api("explicit", params, **options)
def create_archive(**options):
params = utils.archive_params(**options)
if options.get("target_format") is not None:
params["target_format"] = options.get("target_format")
return call_api("generate_archive", params, **options)
def create_zip(**options):
return create_archive(target_format="zip", **options)
def generate_sprite(tag, **options):
params = {
"timestamp": utils.now(),
"tag": tag,
"async": options.get("async"),
"notification_url": options.get("notification_url"),
"transformation": utils.generate_transformation_string(fetch_format=options.get("format"), **options)[0]
}
return call_api("sprite", params, **options)
def multi(tag, **options):
params = {
"timestamp": utils.now(),
"tag": tag,
"format": options.get("format"),
"async": options.get("async"),
"notification_url": options.get("notification_url"),
"transformation": utils.generate_transformation_string(**options)[0]
}
return call_api("multi", params, **options)
def explode(public_id, **options):
params = {
"timestamp": utils.now(),
"public_id": public_id,
"format": options.get("format"),
"notification_url": options.get("notification_url"),
"transformation": utils.generate_transformation_string(**options)[0]
}
return call_api("explode", params, **options)
# options may include 'exclusive' (boolean) which causes clearing this tag from all other resources
def add_tag(tag, public_ids=None, **options):
exclusive = options.pop("exclusive", None)
command = "set_exclusive" if exclusive else "add"
return call_tags_api(tag, command, public_ids, **options)
def remove_tag(tag, public_ids=None, **options):
return call_tags_api(tag, "remove", public_ids, **options)
def replace_tag(tag, public_ids=None, **options):
return call_tags_api(tag, "replace", public_ids, **options)
def remove_all_tags(public_ids, **options):
"""
Remove all tags from the specified public IDs.
:param public_ids: the public IDs of the resources to update
:param options: additional options passed to the request
:return: dictionary with a list of public IDs that were updated
"""
return call_tags_api(None, "remove_all", public_ids, **options)
def add_context(context, public_ids, **options):
"""
Add a context keys and values. If a particular key already exists, the value associated with the key is updated.
:param context: dictionary of context
:param public_ids: the public IDs of the resources to update
:param options: additional options passed to the request
:return: dictionary with a list of public IDs that were updated
"""
return call_context_api(context, "add", public_ids, **options)
def remove_all_context(public_ids, **options):
"""
Remove all custom context from the specified public IDs.
:param public_ids: the public IDs of the resources to update
:param options: additional options passed to the request
:return: dictionary with a list of public IDs that were updated
"""
return call_context_api(None, "remove_all", public_ids, **options)
def call_tags_api(tag, command, public_ids=None, **options):
params = {
"timestamp": utils.now(),
"tag": tag,
"public_ids": utils.build_array(public_ids),
"command": command,
"type": options.get("type")
}
return call_api("tags", params, **options)
def call_context_api(context, command, public_ids=None, **options):
params = {
"timestamp": utils.now(),
"context": utils.encode_context(context),
"public_ids": utils.build_array(public_ids),
"command": command,
"type": options.get("type")
}
return call_api("context", params, **options)
TEXT_PARAMS = ["public_id",
"font_family",
"font_size",
"font_color",
"text_align",
"font_weight",
"font_style",
"background",
"opacity",
"text_decoration"
]
def text(text, **options):
params = {"timestamp": utils.now(), "text": text}
for key in TEXT_PARAMS:
params[key] = options.get(key)
return call_api("text", params, **options)
def call_api(action, params, http_headers=None, return_error=False, unsigned=False, file=None, timeout=None, **options):
if http_headers is None:
http_headers = {}
file_io = None
try:
if unsigned:
params = utils.cleanup_params(params)
else:
params = utils.sign_request(params, options)
param_list = OrderedDict()
for k, v in params.items():
if isinstance(v, list):
for i in range(len(v)):
param_list["{0}[{1}]".format(k, i)] = v[i]
elif v:
param_list[k] = v
api_url = utils.cloudinary_api_url(action, **options)
if file:
if isinstance(file, string_types):
if re.match(r'ftp:|https?:|s3:|data:[^;]*;base64,([a-zA-Z0-9\/+\n=]+)$', file):
# URL
name = None
data = file
else:
# file path
name = file
with open(file, "rb") as opened:
data = opened.read()
elif hasattr(file, 'read') and callable(file.read):
# stream
data = file.read()
name = file.name if hasattr(file, 'name') and isinstance(file.name, str) else "stream"
elif isinstance(file, tuple):
name = None
data = file
else:
# Not a string, not a stream
name = "file"
data = file
param_list["file"] = (name, data) if name else data
headers = {"User-Agent": cloudinary.get_user_agent()}
headers.update(http_headers)
kw = {}
if timeout is not None:
kw['timeout'] = timeout
code = 200
try:
response = _http.request("POST", api_url, param_list, headers, **kw)
except HTTPError as e:
raise Error("Unexpected error - {0!r}".format(e))
except socket.error as e:
raise Error("Socket error: {0!r}".format(e))
try:
result = json.loads(response.data.decode('utf-8'))
except Exception as e:
# Error is parsing json
raise Error("Error parsing server response (%d) - %s. Got - %s", response.status, response, e)
if "error" in result:
if response.status not in [200, 400, 401, 403, 404, 500]:
code = response.status
if return_error:
result["error"]["http_code"] = code
else:
raise Error(result["error"]["message"])
return result
finally:
if file_io: file_io.close()

912
lib/cloudinary/utils.py Normal file
View File

@@ -0,0 +1,912 @@
# Copyright Cloudinary
import base64
import copy
import hashlib
import json
import random
import re
import string
import struct
import time
import zlib
from collections import OrderedDict
from datetime import datetime, date
from fractions import Fraction
import six.moves.urllib.parse
from six import iteritems
import cloudinary
from cloudinary import auth_token
from cloudinary.compat import PY3, to_bytes, to_bytearray, to_string, string_types, urlparse
VAR_NAME_RE = r'(\$\([a-zA-Z]\w+\))'
urlencode = six.moves.urllib.parse.urlencode
unquote = six.moves.urllib.parse.unquote
""" @deprecated: use cloudinary.SHARED_CDN """
SHARED_CDN = "res.cloudinary.com"
DEFAULT_RESPONSIVE_WIDTH_TRANSFORMATION = {"width": "auto", "crop": "limit"}
RANGE_VALUE_RE = r'^(?P<value>(\d+\.)?\d+)(?P<modifier>[%pP])?$'
RANGE_RE = r'^(\d+\.)?\d+[%pP]?\.\.(\d+\.)?\d+[%pP]?$'
FLOAT_RE = r'^(\d+)\.(\d+)?$'
__LAYER_KEYWORD_PARAMS = [("font_weight", "normal"),
("font_style", "normal"),
("text_decoration", "none"),
("text_align", None),
("stroke", "none")]
def build_array(arg):
if isinstance(arg, list):
return arg
elif arg is None:
return []
else:
return [arg]
def build_list_of_dicts(val):
"""
Converts a value that can be presented as a list of dict.
In case top level item is not a list, it is wrapped with a list
Valid values examples:
- Valid dict: {"k": "v", "k2","v2"}
- List of dict: [{"k": "v"}, {"k2","v2"}]
- JSON decodable string: '{"k": "v"}', or '[{"k": "v"}]'
- List of JSON decodable strings: ['{"k": "v"}', '{"k2","v2"}']
Invalid values examples:
- ["not", "a", "dict"]
- [123, None],
- [["another", "list"]]
:param val: Input value
:type val: Union[list, dict, str]
:return: Converted(or original) list of dict
:raises: ValueError in case value cannot be converted to a list of dict
"""
if val is None:
return []
if isinstance(val, str):
# use OrderedDict to preserve order
val = json.loads(val, object_pairs_hook=OrderedDict)
if isinstance(val, dict):
val = [val]
for index, item in enumerate(val):
if isinstance(item, str):
# use OrderedDict to preserve order
val[index] = json.loads(item, object_pairs_hook=OrderedDict)
if not isinstance(val[index], dict):
raise ValueError("Expected a list of dicts")
return val
def encode_double_array(array):
array = build_array(array)
if len(array) > 0 and isinstance(array[0], list):
return "|".join([",".join([str(i) for i in build_array(inner)]) for inner in array])
else:
return ",".join([str(i) for i in array])
def encode_dict(arg):
if isinstance(arg, dict):
if PY3:
items = arg.items()
else:
items = arg.iteritems()
return "|".join((k + "=" + v) for k, v in items)
else:
return arg
def encode_context(context):
"""
:param context: dict of context to be encoded
:return: a joined string of all keys and values properly escaped and separated by a pipe character
"""
if not isinstance(context, dict):
return context
return "|".join(("{}={}".format(k, v.replace("=", "\\=").replace("|", "\\|"))) for k, v in iteritems(context))
def json_encode(value):
"""
Converts value to a json encoded string
:param value: value to be encoded
:return: JSON encoded string
"""
return json.dumps(value, default=__json_serializer, separators=(',', ':'))
def generate_transformation_string(**options):
responsive_width = options.pop("responsive_width", cloudinary.config().responsive_width)
size = options.pop("size", None)
if size:
options["width"], options["height"] = size.split("x")
width = options.get("width")
height = options.get("height")
has_layer = ("underlay" in options) or ("overlay" in options)
crop = options.pop("crop", None)
angle = ".".join([str(value) for value in build_array(options.pop("angle", None))])
no_html_sizes = has_layer or angle or crop == "fit" or crop == "limit" or responsive_width
if width and (str(width).startswith("auto") or str(width) == "ow" or is_fraction(width) or no_html_sizes):
del options["width"]
if height and (str(height) == "oh" or is_fraction(height) or no_html_sizes):
del options["height"]
background = options.pop("background", None)
if background:
background = background.replace("#", "rgb:")
color = options.pop("color", None)
if color:
color = color.replace("#", "rgb:")
base_transformations = build_array(options.pop("transformation", None))
if any(isinstance(bs, dict) for bs in base_transformations):
def recurse(bs):
if isinstance(bs, dict):
return generate_transformation_string(**bs)[0]
else:
return generate_transformation_string(transformation=bs)[0]
base_transformations = list(map(recurse, base_transformations))
named_transformation = None
else:
named_transformation = ".".join(base_transformations)
base_transformations = []
effect = options.pop("effect", None)
if isinstance(effect, list):
effect = ":".join([str(x) for x in effect])
elif isinstance(effect, dict):
effect = ":".join([str(x) for x in list(effect.items())[0]])
border = options.pop("border", None)
if isinstance(border, dict):
border_color = border.get("color", "black").replace("#", "rgb:")
border = "%(width)spx_solid_%(color)s" % {"color": border_color,
"width": str(border.get("width", 2))}
flags = ".".join(build_array(options.pop("flags", None)))
dpr = options.pop("dpr", cloudinary.config().dpr)
duration = norm_range_value(options.pop("duration", None))
start_offset = norm_range_value(options.pop("start_offset", None))
end_offset = norm_range_value(options.pop("end_offset", None))
offset = split_range(options.pop("offset", None))
if offset:
start_offset = norm_range_value(offset[0])
end_offset = norm_range_value(offset[1])
video_codec = process_video_codec_param(options.pop("video_codec", None))
aspect_ratio = options.pop("aspect_ratio", None)
if isinstance(aspect_ratio, Fraction):
aspect_ratio = str(aspect_ratio.numerator) + ":" + str(aspect_ratio.denominator)
overlay = process_layer(options.pop("overlay", None), "overlay")
underlay = process_layer(options.pop("underlay", None), "underlay")
if_value = process_conditional(options.pop("if", None))
params = {
"a": normalize_expression(angle),
"ar": normalize_expression(aspect_ratio),
"b": background,
"bo": border,
"c": crop,
"co": color,
"dpr": normalize_expression(dpr),
"du": normalize_expression(duration),
"e": normalize_expression(effect),
"eo": normalize_expression(end_offset),
"fl": flags,
"h": normalize_expression(height),
"l": overlay,
"o": normalize_expression(options.pop('opacity',None)),
"q": normalize_expression(options.pop('quality',None)),
"r": normalize_expression(options.pop('radius',None)),
"so": normalize_expression(start_offset),
"t": named_transformation,
"u": underlay,
"w": normalize_expression(width),
"x": normalize_expression(options.pop('x',None)),
"y": normalize_expression(options.pop('y',None)),
"vc": video_codec,
"z": normalize_expression(options.pop('zoom',None))
}
simple_params = {
"ac": "audio_codec",
"af": "audio_frequency",
"br": "bit_rate",
"cs": "color_space",
"d": "default_image",
"dl": "delay",
"dn": "density",
"f": "fetch_format",
"g": "gravity",
"ki": "keyframe_interval",
"p": "prefix",
"pg": "page",
"sp": "streaming_profile",
"vs": "video_sampling",
}
for param, option in simple_params.items():
params[param] = options.pop(option, None)
variables = options.pop('variables',{})
var_params = []
for key,value in options.items():
if re.match(r'^\$', key):
var_params.append(u"{0}_{1}".format(key, normalize_expression(str(value))))
var_params.sort()
if variables:
for var in variables:
var_params.append(u"{0}_{1}".format(var[0], normalize_expression(str(var[1]))))
variables = ','.join(var_params)
sorted_params = sorted([param + "_" + str(value) for param, value in params.items() if (value or value == 0)])
if variables:
sorted_params.insert(0, str(variables))
if if_value is not None:
sorted_params.insert(0, "if_" + str(if_value))
transformation = ",".join(sorted_params)
if "raw_transformation" in options:
transformation = transformation + "," + options.pop("raw_transformation")
transformations = base_transformations + [transformation]
if responsive_width:
responsive_width_transformation = cloudinary.config().responsive_width_transformation \
or DEFAULT_RESPONSIVE_WIDTH_TRANSFORMATION
transformations += [generate_transformation_string(**responsive_width_transformation)[0]]
url = "/".join([trans for trans in transformations if trans])
if str(width).startswith("auto") or responsive_width:
options["responsive"] = True
if dpr == "auto":
options["hidpi"] = True
return url, options
def is_fraction(width):
width = str(width)
return re.match(FLOAT_RE, width) and float(width) < 1
def split_range(range):
if (isinstance(range, list) or isinstance(range, tuple)) and len(range) >= 2:
return [range[0], range[-1]]
elif isinstance(range, string_types) and re.match(RANGE_RE, range):
return range.split("..", 1)
else:
return None
def norm_range_value(value):
if value is None: return None
match = re.match(RANGE_VALUE_RE, str(value))
if match is None: return None
modifier = ''
if match.group('modifier') is not None:
modifier = 'p'
return match.group('value') + modifier
def process_video_codec_param(param):
out_param = param
if isinstance(out_param, dict):
out_param = param['codec']
if 'profile' in param:
out_param = out_param + ':' + param['profile']
if 'level' in param:
out_param = out_param + ':' + param['level']
return out_param
def cleanup_params(params):
return dict([(k, __safe_value(v)) for (k, v) in params.items() if v is not None and not v == ""])
def sign_request(params, options):
api_key = options.get("api_key", cloudinary.config().api_key)
if not api_key: raise ValueError("Must supply api_key")
api_secret = options.get("api_secret", cloudinary.config().api_secret)
if not api_secret: raise ValueError("Must supply api_secret")
params = cleanup_params(params)
params["signature"] = api_sign_request(params, api_secret)
params["api_key"] = api_key
return params
def api_sign_request(params_to_sign, api_secret):
params = [(k + "=" + (",".join(v) if isinstance(v, list) else str(v))) for k, v in params_to_sign.items() if v]
to_sign = "&".join(sorted(params))
return hashlib.sha1(to_bytes(to_sign + api_secret)).hexdigest()
def breakpoint_settings_mapper(breakpoint_settings):
breakpoint_settings = copy.deepcopy(breakpoint_settings)
transformation = breakpoint_settings.get("transformation")
if transformation is not None:
breakpoint_settings["transformation"], _ = generate_transformation_string(**transformation)
return breakpoint_settings
def generate_responsive_breakpoints_string(breakpoints):
if breakpoints is None:
return None
breakpoints = build_array(breakpoints)
return json.dumps(list(map(breakpoint_settings_mapper, breakpoints)))
def finalize_source(source, format, url_suffix):
source = re.sub(r'([^:])/+', r'\1/', source)
if re.match(r'^https?:/', source):
source = smart_escape(source)
source_to_sign = source
else:
source = unquote(source)
if not PY3: source = source.encode('utf8')
source = smart_escape(source)
source_to_sign = source
if url_suffix is not None:
if re.search(r'[\./]', url_suffix): raise ValueError("url_suffix should not include . or /")
source = source + "/" + url_suffix
if format is not None:
source = source + "." + format
source_to_sign = source_to_sign + "." + format
return source, source_to_sign
def finalize_resource_type(resource_type, type, url_suffix, use_root_path, shorten):
upload_type = type or "upload"
if url_suffix is not None:
if resource_type == "image" and upload_type == "upload":
resource_type = "images"
upload_type = None
elif resource_type == "raw" and upload_type == "upload":
resource_type = "files"
upload_type = None
else:
raise ValueError("URL Suffix only supported for image/upload and raw/upload")
if use_root_path:
if (resource_type == "image" and upload_type == "upload") or (resource_type == "images" and upload_type is None):
resource_type = None
upload_type = None
else:
raise ValueError("Root path only supported for image/upload")
if shorten and resource_type == "image" and upload_type == "upload":
resource_type = "iu"
upload_type = None
return resource_type, upload_type
def unsigned_download_url_prefix(source, cloud_name, private_cdn, cdn_subdomain, secure_cdn_subdomain, cname, secure,
secure_distribution):
"""cdn_subdomain and secure_cdn_subdomain
1) Customers in shared distribution (e.g. res.cloudinary.com)
if cdn_domain is true uses res-[1-5].cloudinary.com for both http and https. Setting secure_cdn_subdomain to false disables this for https.
2) Customers with private cdn
if cdn_domain is true uses cloudname-res-[1-5].cloudinary.com for http
if secure_cdn_domain is true uses cloudname-res-[1-5].cloudinary.com for https (please contact support if you require this)
3) Customers with cname
if cdn_domain is true uses a[1-5].cname for http. For https, uses the same naming scheme as 1 for shared distribution and as 2 for private distribution."""
shared_domain = not private_cdn
shard = __crc(source)
if secure:
if secure_distribution is None or secure_distribution == cloudinary.OLD_AKAMAI_SHARED_CDN:
secure_distribution = cloud_name + "-res.cloudinary.com" if private_cdn else cloudinary.SHARED_CDN
shared_domain = shared_domain or secure_distribution == cloudinary.SHARED_CDN
if secure_cdn_subdomain is None and shared_domain:
secure_cdn_subdomain = cdn_subdomain
if secure_cdn_subdomain:
secure_distribution = re.sub('res.cloudinary.com', "res-" + shard + ".cloudinary.com", secure_distribution)
prefix = "https://" + secure_distribution
elif cname:
subdomain = "a" + shard + "." if cdn_subdomain else ""
prefix = "http://" + subdomain + cname
else:
subdomain = cloud_name + "-res" if private_cdn else "res"
if cdn_subdomain: subdomain = subdomain + "-" + shard
prefix = "http://" + subdomain + ".cloudinary.com"
if shared_domain: prefix += "/" + cloud_name
return prefix
def merge(*dict_args):
result = None
for dictionary in dict_args:
if dictionary is not None:
if result is None:
result = dictionary.copy()
else:
result.update(dictionary)
return result
def cloudinary_url(source, **options):
original_source = source
type = options.pop("type", "upload")
if type == 'fetch':
options["fetch_format"] = options.get("fetch_format", options.pop("format", None))
transformation, options = generate_transformation_string(**options)
resource_type = options.pop("resource_type", "image")
version = options.pop("version", None)
format = options.pop("format", None)
cdn_subdomain = options.pop("cdn_subdomain", cloudinary.config().cdn_subdomain)
secure_cdn_subdomain = options.pop("secure_cdn_subdomain", cloudinary.config().secure_cdn_subdomain)
cname = options.pop("cname", cloudinary.config().cname)
shorten = options.pop("shorten", cloudinary.config().shorten)
cloud_name = options.pop("cloud_name", cloudinary.config().cloud_name or None)
if cloud_name is None:
raise ValueError("Must supply cloud_name in tag or in configuration")
secure = options.pop("secure", cloudinary.config().secure)
private_cdn = options.pop("private_cdn", cloudinary.config().private_cdn)
secure_distribution = options.pop("secure_distribution", cloudinary.config().secure_distribution)
sign_url = options.pop("sign_url", cloudinary.config().sign_url)
api_secret = options.pop("api_secret", cloudinary.config().api_secret)
url_suffix = options.pop("url_suffix", None)
use_root_path = options.pop("use_root_path", cloudinary.config().use_root_path)
auth_token = options.pop("auth_token", None)
if auth_token is not False:
auth_token = merge(cloudinary.config().auth_token, auth_token)
if (not source) or type == "upload" and re.match(r'^https?:', source):
return original_source, options
resource_type, type = finalize_resource_type(resource_type, type, url_suffix, use_root_path, shorten)
source, source_to_sign = finalize_source(source, format, url_suffix)
if source_to_sign.find("/") >= 0 \
and not re.match(r'^https?:/', source_to_sign) \
and not re.match(r'^v[0-9]+', source_to_sign) \
and not version:
version = "1"
if version: version = "v" + str(version)
transformation = re.sub(r'([^:])/+', r'\1/', transformation)
signature = None
if sign_url and not auth_token:
to_sign = "/".join(__compact([transformation, source_to_sign]))
signature = "s--" + to_string(
base64.urlsafe_b64encode(hashlib.sha1(to_bytes(to_sign + api_secret)).digest())[0:8]) + "--"
prefix = unsigned_download_url_prefix(source, cloud_name, private_cdn, cdn_subdomain, secure_cdn_subdomain, cname,
secure, secure_distribution)
source = "/".join(__compact([prefix, resource_type, type, signature, transformation, version, source]))
if sign_url and auth_token:
path = urlparse(source).path
token = cloudinary.auth_token.generate( **merge(auth_token, {"url": path}))
source = "%s?%s" % (source, token)
return source, options
def cloudinary_api_url(action='upload', **options):
cloudinary_prefix = options.get("upload_prefix", cloudinary.config().upload_prefix) or "https://api.cloudinary.com"
cloud_name = options.get("cloud_name", cloudinary.config().cloud_name)
if not cloud_name: raise ValueError("Must supply cloud_name")
resource_type = options.get("resource_type", "image")
return "/".join([cloudinary_prefix, "v1_1", cloud_name, resource_type, action])
# Based on ruby's CGI::unescape. In addition does not escape / :
def smart_escape(source,unsafe = r"([^a-zA-Z0-9_.\-\/:]+)"):
def pack(m):
return to_bytes('%' + "%".join(["%02X" % x for x in struct.unpack('B' * len(m.group(1)), m.group(1))]).upper())
return to_string(re.sub(to_bytes(unsafe), pack, to_bytes(source)))
def random_public_id():
return ''.join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(16))
def signed_preloaded_image(result):
filename = ".".join([x for x in [result["public_id"], result["format"]] if x])
path = "/".join([result["resource_type"], "upload", "v" + str(result["version"]), filename])
return path + "#" + result["signature"]
def now():
return str(int(time.time()))
def private_download_url(public_id, format, **options):
cloudinary_params = sign_request({
"timestamp": now(),
"public_id": public_id,
"format": format,
"type": options.get("type"),
"attachment": options.get("attachment"),
"expires_at": options.get("expires_at")
}, options)
return cloudinary_api_url("download", **options) + "?" + urlencode(cloudinary_params)
def zip_download_url(tag, **options):
cloudinary_params = sign_request({
"timestamp": now(),
"tag": tag,
"transformation": generate_transformation_string(**options)[0]
}, options)
return cloudinary_api_url("download_tag.zip", **options) + "?" + urlencode(cloudinary_params)
def bracketize_seq(params):
url_params = dict()
for param_name in params:
param_value = params[param_name]
if isinstance(param_value, list):
param_name += "[]"
url_params[param_name] = param_value
return url_params
def download_archive_url(**options):
params = options.copy()
params.update(mode="download")
cloudinary_params = sign_request(archive_params(**params), options)
return cloudinary_api_url("generate_archive", **options) + "?" + urlencode(bracketize_seq(cloudinary_params), True)
def download_zip_url(**options):
new_options = options.copy()
new_options.update(target_format="zip")
return download_archive_url(**new_options)
def generate_auth_token(**options):
token_options = merge(cloudinary.config().auth_token, options)
return auth_token.generate(**token_options)
def archive_params(**options):
if options.get("timestamp") is None:
timestamp = now()
else:
timestamp = options.get("timestamp")
params = {
"allow_missing": options.get("allow_missing"),
"async": options.get("async"),
"expires_at": options.get("expires_at"),
"flatten_folders": options.get("flatten_folders"),
"flatten_transformations": options.get("flatten_transformations"),
"keep_derived": options.get("keep_derived"),
"mode": options.get("mode"),
"notification_url": options.get("notification_url"),
"phash": options.get("phash"),
"prefixes": options.get("prefixes") and build_array(options.get("prefixes")),
"public_ids": options.get("public_ids") and build_array(options.get("public_ids")),
"skip_transformation_name": options.get("skip_transformation_name"),
"tags": options.get("tags") and build_array(options.get("tags")),
"target_format": options.get("target_format"),
"target_public_id": options.get("target_public_id"),
"target_tags": options.get("target_tags") and build_array(options.get("target_tags")),
"timestamp": timestamp,
"transformations": build_eager(options.get("transformations")),
"type": options.get("type"),
"use_original_filename": options.get("use_original_filename"),
}
return params
def build_eager(transformations):
if transformations is None:
return None
eager = []
for tr in build_array(transformations):
if isinstance(tr, string_types):
single_eager = tr
else:
ext = tr.get("format")
single_eager = "/".join([x for x in [generate_transformation_string(**tr)[0], ext] if x])
eager.append(single_eager)
return "|".join(eager)
def build_custom_headers(headers):
if headers is None:
return None
elif isinstance(headers, list):
pass
elif isinstance(headers, dict):
headers = [k + ": " + v for k, v in headers.items()]
else:
return headers
return "\n".join(headers)
def build_upload_params(**options):
params = {"timestamp": now(),
"transformation": generate_transformation_string(**options)[0],
"public_id": options.get("public_id"),
"callback": options.get("callback"),
"format": options.get("format"),
"type": options.get("type"),
"backup": options.get("backup"),
"faces": options.get("faces"),
"image_metadata": options.get("image_metadata"),
"exif": options.get("exif"),
"colors": options.get("colors"),
"headers": build_custom_headers(options.get("headers")),
"eager": build_eager(options.get("eager")),
"use_filename": options.get("use_filename"),
"unique_filename": options.get("unique_filename"),
"discard_original_filename": options.get("discard_original_filename"),
"invalidate": options.get("invalidate"),
"notification_url": options.get("notification_url"),
"eager_notification_url": options.get("eager_notification_url"),
"eager_async": options.get("eager_async"),
"proxy": options.get("proxy"),
"folder": options.get("folder"),
"overwrite": options.get("overwrite"),
"tags": options.get("tags") and ",".join(build_array(options["tags"])),
"allowed_formats": options.get("allowed_formats") and ",".join(build_array(options["allowed_formats"])),
"face_coordinates": encode_double_array(options.get("face_coordinates")),
"custom_coordinates": encode_double_array(options.get("custom_coordinates")),
"context": encode_context(options.get("context")),
"moderation": options.get("moderation"),
"raw_convert": options.get("raw_convert"),
"quality_override": options.get("quality_override"),
"ocr": options.get("ocr"),
"categorization": options.get("categorization"),
"detection": options.get("detection"),
"similarity_search": options.get("similarity_search"),
"background_removal": options.get("background_removal"),
"upload_preset": options.get("upload_preset"),
"phash": options.get("phash"),
"return_delete_token": options.get("return_delete_token"),
"auto_tagging": options.get("auto_tagging") and str(options.get("auto_tagging")),
"responsive_breakpoints": generate_responsive_breakpoints_string(options.get("responsive_breakpoints")),
"async": options.get("async"),
"access_control": options.get("access_control") and json_encode(build_list_of_dicts(options.get("access_control")))}
return params
def __process_text_options(layer, layer_parameter):
font_family = layer.get("font_family")
font_size = layer.get("font_size")
keywords = []
for attr, default_value in __LAYER_KEYWORD_PARAMS:
attr_value = layer.get(attr)
if attr_value != default_value and attr_value is not None:
keywords.append(attr_value)
letter_spacing = layer.get("letter_spacing")
if letter_spacing is not None:
keywords.append("letter_spacing_" + str(letter_spacing))
line_spacing = layer.get("line_spacing")
if line_spacing is not None:
keywords.append("line_spacing_" + str(line_spacing))
if font_size is None and font_family is None and len(keywords) == 0:
return None
if font_family is None:
raise ValueError("Must supply font_family for text in " + layer_parameter)
if font_size is None:
raise ValueError("Must supply font_size for text in " + layer_parameter)
keywords.insert(0, font_size)
keywords.insert(0, font_family)
return '_'.join([str(k) for k in keywords])
def process_layer(layer, layer_parameter):
if isinstance(layer, string_types) and layer.startswith("fetch:"):
layer = {"url": layer[len('fetch:'):]}
if not isinstance(layer, dict):
return layer
resource_type = layer.get("resource_type")
text = layer.get("text")
type = layer.get("type")
public_id = layer.get("public_id")
format = layer.get("format")
fetch = layer.get("url")
components = list()
if text is not None and resource_type is None:
resource_type = "text"
if fetch and resource_type is None:
resource_type = "fetch"
if public_id is not None and format is not None:
public_id = public_id + "." + format
if public_id is None and resource_type != "text" and resource_type != "fetch":
raise ValueError("Must supply public_id for for non-text " + layer_parameter)
if resource_type is not None and resource_type != "image":
components.append(resource_type)
if type is not None and type != "upload":
components.append(type)
if resource_type == "text" or resource_type == "subtitles":
if public_id is None and text is None:
raise ValueError("Must supply either text or public_id in " + layer_parameter)
text_options = __process_text_options(layer, layer_parameter)
if text_options is not None:
components.append(text_options)
if public_id is not None:
public_id = public_id.replace("/", ':')
components.append(public_id)
if text is not None:
var_pattern = VAR_NAME_RE
match = re.findall(var_pattern,text)
parts= filter(lambda p: p is not None, re.split(var_pattern,text))
encoded_text = []
for part in parts:
if re.match(var_pattern,part):
encoded_text.append(part)
else:
encoded_text.append(smart_escape(smart_escape(part, r"([,/])")))
text = ''.join(encoded_text)
# text = text.replace("%2C", "%252C")
# text = text.replace("/", "%252F")
components.append(text)
elif resource_type == "fetch":
b64 = base64_encode_url(fetch)
components.append(b64)
else:
public_id = public_id.replace("/", ':')
components.append(public_id)
return ':'.join(components)
IF_OPERATORS = {
"=": 'eq',
"!=": 'ne',
"<": 'lt',
">": 'gt',
"<=": 'lte',
">=": 'gte',
"&&": 'and',
"||": 'or',
"*": 'mul',
"/": 'div',
"+": 'add',
"-": 'sub'
}
PREDEFINED_VARS = {
"aspect_ratio": "ar",
"current_page": "cp",
"face_count": "fc",
"height": "h",
"initial_aspect_ratio": "iar",
"initial_height": "ih",
"initial_width": "iw",
"page_count": "pc",
"page_x": "px",
"page_y": "py",
"tags": "tags",
"width": "w"
}
replaceRE = "((\\|\\||>=|<=|&&|!=|>|=|<|/|-|\\+|\\*)(?=[ _])|" + '|'.join(PREDEFINED_VARS.keys())+ ")"
def translate_if(match):
name = match.group(0)
return IF_OPERATORS.get(name,
PREDEFINED_VARS.get(name,
name))
def process_conditional(conditional):
if conditional is None:
return conditional
result = normalize_expression(conditional)
return result
def normalize_expression(expression):
if re.match(r'^!.+!$',str(expression)): # quoted string
return expression
elif expression:
result = str(expression)
result = re.sub(replaceRE, translate_if, result)
result = re.sub('[ _]+', '_', result)
return result
else:
return expression
def __join_pair(key, value):
if value is None or value == "":
return None
elif value is True:
return key
else:
return u"{0}=\"{1}\"".format(key, value)
def html_attrs(attrs, only=None):
return ' '.join(sorted([__join_pair(key, value) for key, value in attrs.items() if only is None or key in only]))
def __safe_value(v):
if isinstance(v, bool):
return "1" if v else "0"
else:
return v
def __crc(source):
return str((zlib.crc32(to_bytearray(source)) & 0xffffffff) % 5 + 1)
def __compact(array):
return filter(lambda x: x, array)
def base64_encode_url(url):
"""
Returns the Base64-decoded version of url.
The method tries to unquote the url because quoting it
:param str url:
the url to encode. the value is URIdecoded and then
re-encoded before converting to base64 representation
"""
try:
url = unquote(url)
except:
pass
url = smart_escape(url)
b64 = base64.b64encode(url.encode('utf-8'))
return b64.decode('ascii')
def __json_serializer(obj):
"""JSON serializer for objects not serializable by default json code"""
if isinstance(obj, (datetime, date)):
return obj.isoformat()
raise TypeError("Object of type %s is not JSON serializable" % type(obj))

View File

@@ -1,59 +0,0 @@
from math import floor
import time
import sys
import threading
import functools
def clamp(value):
'''
Clamp integer between 1 and max
There must be at least 1 method invocation
made over the time period. Make sure the
value passed is at least 1 and is not a
fraction of an invocation.
:param float value: The number of method invocations.
:return: Clamped number of invocations.
:rtype: int
'''
return max(1, min(sys.maxsize, floor(value)))
class RateLimitDecorator:
def __init__(self, period=1, every=1.0):
self.frequency = abs(every) / float(clamp(period))
self.last_called = 0.0
self.lock = threading.RLock()
def __call__(self, func):
'''
Extend the behaviour of the following
function, forwarding method invocations
if the time window hes elapsed.
:param function func: The function to decorate.
:return: Decorated function.
:rtype: function
'''
@functools.wraps(func)
def wrapper(*args, **kwargs):
'''Decorator wrapper function'''
with self.lock:
elapsed = time.time() - self.last_called
left_to_wait = self.frequency - elapsed
if left_to_wait > 0:
time.sleep(left_to_wait)
self.last_called = time.time()
return func(*args, **kwargs)
return wrapper
rate_limited = RateLimitDecorator
__all__ = [
'rate_limited'
]

View File

@@ -1,9 +0,0 @@
class Version(object):
'''Version of the package'''
def __setattr__(self, *args):
raise TypeError('cannot modify immutable instance')
__delattr__ = __setattr__
def __init__(self, num):
super(Version, self).__setattr__('number', num)

View File

@@ -1,6 +1,4 @@
"""Utilities for writing code that runs on Python 2 and 3"""
# Copyright (c) 2010-2014 Benjamin Peterson
# Copyright (c) 2010-2017 Benjamin Peterson
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -20,17 +18,24 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Utilities for writing code that runs on Python 2 and 3"""
from __future__ import absolute_import
import functools
import itertools
import operator
import sys
import types
__author__ = "Benjamin Peterson <benjamin@python.org>"
__version__ = "1.6.1"
__version__ = "1.11.0"
# Useful for very coarse version differentiation.
PY2 = sys.version_info[0] == 2
PY3 = sys.version_info[0] == 3
PY34 = sys.version_info[0:2] >= (3, 4)
if PY3:
string_types = str,
@@ -53,6 +58,7 @@ else:
else:
# It's possible to have sizeof(long) != sizeof(Py_ssize_t).
class X(object):
def __len__(self):
return 1 << 31
try:
@@ -83,14 +89,14 @@ class _LazyDescr(object):
self.name = name
def __get__(self, obj, tp):
result = self._resolve()
setattr(obj, self.name, result) # Invokes __set__.
try:
result = self._resolve()
except ImportError:
# See the nice big comment in MovedModule.__getattr__.
raise AttributeError("%s could not be imported " % self.name)
setattr(obj, self.name, result) # Invokes __set__.
# This is a bit ugly, but it avoids running this again.
delattr(obj.__class__, self.name)
# This is a bit ugly, but it avoids running this again by
# removing this descriptor.
delattr(obj.__class__, self.name)
except AttributeError:
pass
return result
@@ -109,22 +115,7 @@ class MovedModule(_LazyDescr):
return _import_module(self.mod)
def __getattr__(self, attr):
# It turns out many Python frameworks like to traverse sys.modules and
# try to load various attributes. This causes problems if this is a
# platform-specific module on the wrong platform, like _winreg on
# Unixes. Therefore, we silently pretend unimportable modules do not
# have any attributes. See issues #51, #53, #56, and #63 for the full
# tales of woe.
#
# First, if possible, avoid loading the module just to look at __file__,
# __name__, or __path__.
if (attr in ("__file__", "__name__", "__path__") and
self.mod not in sys.modules):
raise AttributeError(attr)
try:
_module = self._resolve()
except ImportError:
raise AttributeError(attr)
_module = self._resolve()
value = getattr(_module, attr)
setattr(self, attr, value)
return value
@@ -170,9 +161,75 @@ class MovedAttribute(_LazyDescr):
return getattr(module, self.attr)
class _SixMetaPathImporter(object):
"""
A meta path importer to import six.moves and its submodules.
This class implements a PEP302 finder and loader. It should be compatible
with Python 2.5 and all existing versions of Python3
"""
def __init__(self, six_module_name):
self.name = six_module_name
self.known_modules = {}
def _add_module(self, mod, *fullnames):
for fullname in fullnames:
self.known_modules[self.name + "." + fullname] = mod
def _get_module(self, fullname):
return self.known_modules[self.name + "." + fullname]
def find_module(self, fullname, path=None):
if fullname in self.known_modules:
return self
return None
def __get_module(self, fullname):
try:
return self.known_modules[fullname]
except KeyError:
raise ImportError("This loader does not know module " + fullname)
def load_module(self, fullname):
try:
# in case of a reload
return sys.modules[fullname]
except KeyError:
pass
mod = self.__get_module(fullname)
if isinstance(mod, MovedModule):
mod = mod._resolve()
else:
mod.__loader__ = self
sys.modules[fullname] = mod
return mod
def is_package(self, fullname):
"""
Return true, if the named module is a package.
We need this method to get correct spec objects with
Python 3.4 (see PEP451)
"""
return hasattr(self.__get_module(fullname), "__path__")
def get_code(self, fullname):
"""Return None
Required, if is_package is implemented"""
self.__get_module(fullname) # eventually raises ImportError
return None
get_source = get_code # same as get_code
_importer = _SixMetaPathImporter(__name__)
class _MovedItems(_LazyModule):
"""Lazy loading of moved objects"""
__path__ = [] # mark as package
_moved_attributes = [
@@ -180,28 +237,37 @@ _moved_attributes = [
MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"),
MovedAttribute("filterfalse", "itertools", "itertools", "ifilterfalse", "filterfalse"),
MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"),
MovedAttribute("intern", "__builtin__", "sys"),
MovedAttribute("map", "itertools", "builtins", "imap", "map"),
MovedAttribute("getcwd", "os", "os", "getcwdu", "getcwd"),
MovedAttribute("getcwdb", "os", "os", "getcwd", "getcwdb"),
MovedAttribute("getoutput", "commands", "subprocess"),
MovedAttribute("range", "__builtin__", "builtins", "xrange", "range"),
MovedAttribute("reload_module", "__builtin__", "imp", "reload"),
MovedAttribute("reload_module", "__builtin__", "importlib" if PY34 else "imp", "reload"),
MovedAttribute("reduce", "__builtin__", "functools"),
MovedAttribute("shlex_quote", "pipes", "shlex", "quote"),
MovedAttribute("StringIO", "StringIO", "io"),
MovedAttribute("UserDict", "UserDict", "collections"),
MovedAttribute("UserList", "UserList", "collections"),
MovedAttribute("UserString", "UserString", "collections"),
MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"),
MovedAttribute("zip", "itertools", "builtins", "izip", "zip"),
MovedAttribute("zip_longest", "itertools", "itertools", "izip_longest", "zip_longest"),
MovedModule("builtins", "__builtin__"),
MovedModule("configparser", "ConfigParser"),
MovedModule("copyreg", "copy_reg"),
MovedModule("dbm_gnu", "gdbm", "dbm.gnu"),
MovedModule("_dummy_thread", "dummy_thread", "_dummy_thread"),
MovedModule("http_cookiejar", "cookielib", "http.cookiejar"),
MovedModule("http_cookies", "Cookie", "http.cookies"),
MovedModule("html_entities", "htmlentitydefs", "html.entities"),
MovedModule("html_parser", "HTMLParser", "html.parser"),
MovedModule("http_client", "httplib", "http.client"),
MovedModule("email_mime_multipart", "email.MIMEMultipart", "email.mime.multipart"),
MovedModule("email_mime_text", "email.MIMEText", "email.mime.text"),
MovedModule("email_mime_base", "email.MIMEBase", "email.mime.base"),
MovedModule("email_mime_image", "email.MIMEImage", "email.mime.image"),
MovedModule("email_mime_multipart", "email.MIMEMultipart", "email.mime.multipart"),
MovedModule("email_mime_nonmultipart", "email.MIMENonMultipart", "email.mime.nonmultipart"),
MovedModule("email_mime_text", "email.MIMEText", "email.mime.text"),
MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"),
MovedModule("CGIHTTPServer", "CGIHTTPServer", "http.server"),
MovedModule("SimpleHTTPServer", "SimpleHTTPServer", "http.server"),
@@ -233,21 +299,28 @@ _moved_attributes = [
MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"),
MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"),
MovedModule("xmlrpc_client", "xmlrpclib", "xmlrpc.client"),
MovedModule("xmlrpc_server", "xmlrpclib", "xmlrpc.server"),
MovedModule("winreg", "_winreg"),
MovedModule("xmlrpc_server", "SimpleXMLRPCServer", "xmlrpc.server"),
]
# Add windows specific modules.
if sys.platform == "win32":
_moved_attributes += [
MovedModule("winreg", "_winreg"),
]
for attr in _moved_attributes:
setattr(_MovedItems, attr.name, attr)
if isinstance(attr, MovedModule):
sys.modules[__name__ + ".moves." + attr.name] = attr
_importer._add_module(attr, "moves." + attr.name)
del attr
_MovedItems._moved_attributes = _moved_attributes
moves = sys.modules[__name__ + ".moves"] = _MovedItems(__name__ + ".moves")
moves = _MovedItems(__name__ + ".moves")
_importer._add_module(moves, "moves")
class Module_six_moves_urllib_parse(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_parse"""
@@ -266,8 +339,17 @@ _urllib_parse_moved_attributes = [
MovedAttribute("quote_plus", "urllib", "urllib.parse"),
MovedAttribute("unquote", "urllib", "urllib.parse"),
MovedAttribute("unquote_plus", "urllib", "urllib.parse"),
MovedAttribute("unquote_to_bytes", "urllib", "urllib.parse", "unquote", "unquote_to_bytes"),
MovedAttribute("urlencode", "urllib", "urllib.parse"),
MovedAttribute("splitquery", "urllib", "urllib.parse"),
MovedAttribute("splittag", "urllib", "urllib.parse"),
MovedAttribute("splituser", "urllib", "urllib.parse"),
MovedAttribute("splitvalue", "urllib", "urllib.parse"),
MovedAttribute("uses_fragment", "urlparse", "urllib.parse"),
MovedAttribute("uses_netloc", "urlparse", "urllib.parse"),
MovedAttribute("uses_params", "urlparse", "urllib.parse"),
MovedAttribute("uses_query", "urlparse", "urllib.parse"),
MovedAttribute("uses_relative", "urlparse", "urllib.parse"),
]
for attr in _urllib_parse_moved_attributes:
setattr(Module_six_moves_urllib_parse, attr.name, attr)
@@ -275,10 +357,12 @@ del attr
Module_six_moves_urllib_parse._moved_attributes = _urllib_parse_moved_attributes
sys.modules[__name__ + ".moves.urllib_parse"] = sys.modules[__name__ + ".moves.urllib.parse"] = Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse")
_importer._add_module(Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse"),
"moves.urllib_parse", "moves.urllib.parse")
class Module_six_moves_urllib_error(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_error"""
@@ -293,10 +377,12 @@ del attr
Module_six_moves_urllib_error._moved_attributes = _urllib_error_moved_attributes
sys.modules[__name__ + ".moves.urllib_error"] = sys.modules[__name__ + ".moves.urllib.error"] = Module_six_moves_urllib_error(__name__ + ".moves.urllib.error")
_importer._add_module(Module_six_moves_urllib_error(__name__ + ".moves.urllib.error"),
"moves.urllib_error", "moves.urllib.error")
class Module_six_moves_urllib_request(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_request"""
@@ -334,6 +420,8 @@ _urllib_request_moved_attributes = [
MovedAttribute("URLopener", "urllib", "urllib.request"),
MovedAttribute("FancyURLopener", "urllib", "urllib.request"),
MovedAttribute("proxy_bypass", "urllib", "urllib.request"),
MovedAttribute("parse_http_list", "urllib2", "urllib.request"),
MovedAttribute("parse_keqv_list", "urllib2", "urllib.request"),
]
for attr in _urllib_request_moved_attributes:
setattr(Module_six_moves_urllib_request, attr.name, attr)
@@ -341,10 +429,12 @@ del attr
Module_six_moves_urllib_request._moved_attributes = _urllib_request_moved_attributes
sys.modules[__name__ + ".moves.urllib_request"] = sys.modules[__name__ + ".moves.urllib.request"] = Module_six_moves_urllib_request(__name__ + ".moves.urllib.request")
_importer._add_module(Module_six_moves_urllib_request(__name__ + ".moves.urllib.request"),
"moves.urllib_request", "moves.urllib.request")
class Module_six_moves_urllib_response(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_response"""
@@ -360,10 +450,12 @@ del attr
Module_six_moves_urllib_response._moved_attributes = _urllib_response_moved_attributes
sys.modules[__name__ + ".moves.urllib_response"] = sys.modules[__name__ + ".moves.urllib.response"] = Module_six_moves_urllib_response(__name__ + ".moves.urllib.response")
_importer._add_module(Module_six_moves_urllib_response(__name__ + ".moves.urllib.response"),
"moves.urllib_response", "moves.urllib.response")
class Module_six_moves_urllib_robotparser(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_robotparser"""
@@ -376,22 +468,25 @@ del attr
Module_six_moves_urllib_robotparser._moved_attributes = _urllib_robotparser_moved_attributes
sys.modules[__name__ + ".moves.urllib_robotparser"] = sys.modules[__name__ + ".moves.urllib.robotparser"] = Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser")
_importer._add_module(Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser"),
"moves.urllib_robotparser", "moves.urllib.robotparser")
class Module_six_moves_urllib(types.ModuleType):
"""Create a six.moves.urllib namespace that resembles the Python 3 namespace"""
parse = sys.modules[__name__ + ".moves.urllib_parse"]
error = sys.modules[__name__ + ".moves.urllib_error"]
request = sys.modules[__name__ + ".moves.urllib_request"]
response = sys.modules[__name__ + ".moves.urllib_response"]
robotparser = sys.modules[__name__ + ".moves.urllib_robotparser"]
__path__ = [] # mark as package
parse = _importer._get_module("moves.urllib_parse")
error = _importer._get_module("moves.urllib_error")
request = _importer._get_module("moves.urllib_request")
response = _importer._get_module("moves.urllib_response")
robotparser = _importer._get_module("moves.urllib_robotparser")
def __dir__(self):
return ['parse', 'error', 'request', 'response', 'robotparser']
sys.modules[__name__ + ".moves.urllib"] = Module_six_moves_urllib(__name__ + ".moves.urllib")
_importer._add_module(Module_six_moves_urllib(__name__ + ".moves.urllib"),
"moves.urllib")
def add_move(move):
@@ -418,11 +513,6 @@ if PY3:
_func_code = "__code__"
_func_defaults = "__defaults__"
_func_globals = "__globals__"
_iterkeys = "keys"
_itervalues = "values"
_iteritems = "items"
_iterlists = "lists"
else:
_meth_func = "im_func"
_meth_self = "im_self"
@@ -432,11 +522,6 @@ else:
_func_defaults = "func_defaults"
_func_globals = "func_globals"
_iterkeys = "iterkeys"
_itervalues = "itervalues"
_iteritems = "iteritems"
_iterlists = "iterlists"
try:
advance_iterator = next
@@ -459,6 +544,9 @@ if PY3:
create_bound_method = types.MethodType
def create_unbound_method(func, cls):
return func
Iterator = object
else:
def get_unbound_function(unbound):
@@ -467,6 +555,9 @@ else:
def create_bound_method(func, obj):
return types.MethodType(func, obj, obj.__class__)
def create_unbound_method(func, cls):
return types.MethodType(func, None, cls)
class Iterator(object):
def next(self):
@@ -485,69 +576,124 @@ get_function_defaults = operator.attrgetter(_func_defaults)
get_function_globals = operator.attrgetter(_func_globals)
def iterkeys(d, **kw):
"""Return an iterator over the keys of a dictionary."""
return iter(getattr(d, _iterkeys)(**kw))
if PY3:
def iterkeys(d, **kw):
return iter(d.keys(**kw))
def itervalues(d, **kw):
"""Return an iterator over the values of a dictionary."""
return iter(getattr(d, _itervalues)(**kw))
def itervalues(d, **kw):
return iter(d.values(**kw))
def iteritems(d, **kw):
"""Return an iterator over the (key, value) pairs of a dictionary."""
return iter(getattr(d, _iteritems)(**kw))
def iteritems(d, **kw):
return iter(d.items(**kw))
def iterlists(d, **kw):
"""Return an iterator over the (key, [values]) pairs of a dictionary."""
return iter(getattr(d, _iterlists)(**kw))
def iterlists(d, **kw):
return iter(d.lists(**kw))
viewkeys = operator.methodcaller("keys")
viewvalues = operator.methodcaller("values")
viewitems = operator.methodcaller("items")
else:
def iterkeys(d, **kw):
return d.iterkeys(**kw)
def itervalues(d, **kw):
return d.itervalues(**kw)
def iteritems(d, **kw):
return d.iteritems(**kw)
def iterlists(d, **kw):
return d.iterlists(**kw)
viewkeys = operator.methodcaller("viewkeys")
viewvalues = operator.methodcaller("viewvalues")
viewitems = operator.methodcaller("viewitems")
_add_doc(iterkeys, "Return an iterator over the keys of a dictionary.")
_add_doc(itervalues, "Return an iterator over the values of a dictionary.")
_add_doc(iteritems,
"Return an iterator over the (key, value) pairs of a dictionary.")
_add_doc(iterlists,
"Return an iterator over the (key, [values]) pairs of a dictionary.")
if PY3:
def b(s):
return s.encode("latin-1")
def u(s):
return s
unichr = chr
if sys.version_info[1] <= 1:
def int2byte(i):
return bytes((i,))
else:
# This is about 2x faster than the implementation above on 3.2+
int2byte = operator.methodcaller("to_bytes", 1, "big")
import struct
int2byte = struct.Struct(">B").pack
del struct
byte2int = operator.itemgetter(0)
indexbytes = operator.getitem
iterbytes = iter
import io
StringIO = io.StringIO
BytesIO = io.BytesIO
_assertCountEqual = "assertCountEqual"
if sys.version_info[1] <= 1:
_assertRaisesRegex = "assertRaisesRegexp"
_assertRegex = "assertRegexpMatches"
else:
_assertRaisesRegex = "assertRaisesRegex"
_assertRegex = "assertRegex"
else:
def b(s):
return s
# Workaround for standalone backslash
def u(s):
return unicode(s.replace(r'\\', r'\\\\'), "unicode_escape")
unichr = unichr
int2byte = chr
def byte2int(bs):
return ord(bs[0])
def indexbytes(buf, i):
return ord(buf[i])
def iterbytes(buf):
return (ord(byte) for byte in buf)
iterbytes = functools.partial(itertools.imap, ord)
import StringIO
StringIO = BytesIO = StringIO.StringIO
_assertCountEqual = "assertItemsEqual"
_assertRaisesRegex = "assertRaisesRegexp"
_assertRegex = "assertRegexpMatches"
_add_doc(b, """Byte literal""")
_add_doc(u, """Text literal""")
def assertCountEqual(self, *args, **kwargs):
return getattr(self, _assertCountEqual)(*args, **kwargs)
def assertRaisesRegex(self, *args, **kwargs):
return getattr(self, _assertRaisesRegex)(*args, **kwargs)
def assertRegex(self, *args, **kwargs):
return getattr(self, _assertRegex)(*args, **kwargs)
if PY3:
exec_ = getattr(moves.builtins, "exec")
def reraise(tp, value, tb=None):
if value.__traceback__ is not tb:
raise value.with_traceback(tb)
raise value
try:
if value is None:
value = tp()
if value.__traceback__ is not tb:
raise value.with_traceback(tb)
raise value
finally:
value = None
tb = None
else:
def exec_(_code_, _globs_=None, _locs_=None):
@@ -562,12 +708,35 @@ else:
_locs_ = _globs_
exec("""exec _code_ in _globs_, _locs_""")
exec_("""def reraise(tp, value, tb=None):
raise tp, value, tb
try:
raise tp, value, tb
finally:
tb = None
""")
if sys.version_info[:2] == (3, 2):
exec_("""def raise_from(value, from_value):
try:
if from_value is None:
raise value
raise value from from_value
finally:
value = None
""")
elif sys.version_info[:2] > (3, 2):
exec_("""def raise_from(value, from_value):
try:
raise value from from_value
finally:
value = None
""")
else:
def raise_from(value, from_value):
raise value
print_ = getattr(moves.builtins, "print", None)
if print_ is None:
def print_(*args, **kwargs):
@@ -575,13 +744,14 @@ if print_ is None:
fp = kwargs.pop("file", sys.stdout)
if fp is None:
return
def write(data):
if not isinstance(data, basestring):
data = str(data)
# If the file has an encoding, encode unicode with it.
if (isinstance(fp, file) and
isinstance(data, unicode) and
fp.encoding is not None):
isinstance(data, unicode) and
fp.encoding is not None):
errors = getattr(fp, "errors", None)
if errors is None:
errors = "strict"
@@ -622,25 +792,100 @@ if print_ is None:
write(sep)
write(arg)
write(end)
if sys.version_info[:2] < (3, 3):
_print = print_
def print_(*args, **kwargs):
fp = kwargs.get("file", sys.stdout)
flush = kwargs.pop("flush", False)
_print(*args, **kwargs)
if flush and fp is not None:
fp.flush()
_add_doc(reraise, """Reraise an exception.""")
if sys.version_info[0:2] < (3, 4):
def wraps(wrapped, assigned=functools.WRAPPER_ASSIGNMENTS,
updated=functools.WRAPPER_UPDATES):
def wrapper(f):
f = functools.wraps(wrapped, assigned, updated)(f)
f.__wrapped__ = wrapped
return f
return wrapper
else:
wraps = functools.wraps
def with_metaclass(meta, *bases):
"""Create a base class with a metaclass."""
return meta("NewBase", bases, {})
# 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(type):
def __new__(cls, name, this_bases, d):
return meta(name, bases, d)
@classmethod
def __prepare__(cls, name, this_bases):
return meta.__prepare__(name, bases)
return type.__new__(metaclass, 'temporary_class', (), {})
def add_metaclass(metaclass):
"""Class decorator for creating a class with a metaclass."""
def wrapper(cls):
orig_vars = cls.__dict__.copy()
orig_vars.pop('__dict__', None)
orig_vars.pop('__weakref__', None)
slots = orig_vars.get('__slots__')
if slots is not None:
if isinstance(slots, str):
slots = [slots]
for slots_var in slots:
orig_vars.pop(slots_var)
orig_vars.pop('__dict__', None)
orig_vars.pop('__weakref__', None)
return metaclass(cls.__name__, cls.__bases__, orig_vars)
return wrapper
def python_2_unicode_compatible(klass):
"""
A decorator that defines __unicode__ and __str__ methods under Python 2.
Under Python 3 it does nothing.
To support Python 2 and 3 with a single code base, define a __str__ method
returning text and apply this decorator to the class.
"""
if PY2:
if '__str__' not in klass.__dict__:
raise ValueError("@python_2_unicode_compatible cannot be applied "
"to %s because it doesn't define __str__()." %
klass.__name__)
klass.__unicode__ = klass.__str__
klass.__str__ = lambda self: self.__unicode__().encode('utf-8')
return klass
# Complete the moves implementation.
# This code is at the end of this module to speed up module loading.
# Turn this module into a package.
__path__ = [] # required for PEP 302 and PEP 451
__package__ = __name__ # see PEP 366 @ReservedAssignment
if globals().get("__spec__") is not None:
__spec__.submodule_search_locations = [] # PEP 451 @UndefinedVariable
# Remove other six meta path importers, since they cause problems. This can
# happen if six is removed from sys.modules and then reloaded. (Setuptools does
# this for some reason.)
if sys.meta_path:
for i, importer in enumerate(sys.meta_path):
# Here's some real nastiness: Another "instance" of the six module might
# be floating around. Therefore, we can't use isinstance() to check for
# the six meta path importer, since the other six instance will have
# inserted an importer with different class.
if (type(importer).__name__ == "_SixMetaPathImporter" and
importer.name == __name__):
del sys.meta_path[i]
break
del i, importer
# Finally, add the importer to the meta path import hook.
sys.meta_path.append(_importer)

View File

@@ -23,7 +23,7 @@ __author__ = 'The Python-Twitter Developers'
__email__ = 'python-twitter@googlegroups.com'
__copyright__ = 'Copyright (c) 2007-2016 The Python-Twitter Developers'
__license__ = 'Apache License 2.0'
__version__ = '3.0rc1'
__version__ = '3.4.1'
__url__ = 'https://github.com/bear/python-twitter'
__download_url__ = 'https://pypi.python.org/pypi/python-twitter'
__description__ = 'A Python wrapper around the Twitter API'

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env python
import errno
import os
import re
import tempfile
from hashlib import md5
@@ -47,7 +46,7 @@ class _FileCache(object):
path = self._GetPath(key)
if not path.startswith(self._root_directory):
raise _FileCacheError('%s does not appear to live under %s' %
(path, self._root_directory ))
(path, self._root_directory))
if os.path.exists(path):
os.remove(path)
@@ -101,61 +100,3 @@ class _FileCache(object):
def _GetPrefix(self, hashed_key):
return os.path.sep.join(hashed_key[0:_FileCache.DEPTH])
class ParseTweet(object):
# compile once on import
regexp = {"RT": "^RT", "MT": r"^MT", "ALNUM": r"(@[a-zA-Z0-9_]+)",
"HASHTAG": r"(#[\w\d]+)", "URL": r"([http://]?[a-zA-Z\d\/]+[\.]+[a-zA-Z\d\/\.]+)"}
regexp = dict((key, re.compile(value)) for key, value in list(regexp.items()))
def __init__(self, timeline_owner, tweet):
""" timeline_owner : twitter handle of user account. tweet - 140 chars from feed; object does all computation on construction
properties:
RT, MT - boolean
URLs - list of URL
Hashtags - list of tags
"""
self.Owner = timeline_owner
self.tweet = tweet
self.UserHandles = ParseTweet.getUserHandles(tweet)
self.Hashtags = ParseTweet.getHashtags(tweet)
self.URLs = ParseTweet.getURLs(tweet)
self.RT = ParseTweet.getAttributeRT(tweet)
self.MT = ParseTweet.getAttributeMT(tweet)
# additional intelligence
if ( self.RT and len(self.UserHandles) > 0 ): # change the owner of tweet?
self.Owner = self.UserHandles[0]
return
def __str__(self):
""" for display method """
return "owner %s, urls: %d, hashtags %d, user_handles %d, len_tweet %d, RT = %s, MT = %s" % (
self.Owner, len(self.URLs), len(self.Hashtags), len(self.UserHandles),
len(self.tweet), self.RT, self.MT)
@staticmethod
def getAttributeRT(tweet):
""" see if tweet is a RT """
return re.search(ParseTweet.regexp["RT"], tweet.strip()) is not None
@staticmethod
def getAttributeMT(tweet):
""" see if tweet is a MT """
return re.search(ParseTweet.regexp["MT"], tweet.strip()) is not None
@staticmethod
def getUserHandles(tweet):
""" given a tweet we try and extract all user handles in order of occurrence"""
return re.findall(ParseTweet.regexp["ALNUM"], tweet)
@staticmethod
def getHashtags(tweet):
""" return all hashtags"""
return re.findall(ParseTweet.regexp["HASHTAG"], tweet)
@staticmethod
def getURLs(tweet):
""" URL : [http://]?[\w\.?/]+"""
return re.findall(ParseTweet.regexp["URL"], tweet)

File diff suppressed because it is too large Load Diff

View File

@@ -8,3 +8,18 @@ class TwitterError(Exception):
def message(self):
'''Returns the first argument used to construct this error.'''
return self.args[0]
class PythonTwitterDeprecationWarning(DeprecationWarning):
"""Base class for python-twitter deprecation warnings"""
pass
class PythonTwitterDeprecationWarning330(PythonTwitterDeprecationWarning):
"""Warning for features to be removed in version 3.3.0"""
pass
class PythonTwitterDeprecationWarning340(PythonTwitterDeprecationWarning):
"""Warning for features to be removed in version 3.4.0"""
pass

View File

@@ -28,6 +28,13 @@ class TwitterModel(object):
def __ne__(self, other):
return not self.__eq__(other)
def __hash__(self):
if hasattr(self, 'id'):
return hash(self.id)
else:
raise TypeError('unhashable type: {} (no id attribute)'
.format(type(self)))
def AsJsonString(self):
""" Returns the TwitterModel as a JSON string based on key/value
pairs returned from the AsDict() method. """
@@ -78,11 +85,14 @@ class TwitterModel(object):
"""
json_data = data.copy()
if kwargs:
for key, val in kwargs.items():
data[key] = val
json_data[key] = val
return cls(**data)
c = cls(**json_data)
c._json = data
return c
class Media(TwitterModel):
@@ -93,11 +103,14 @@ class Media(TwitterModel):
self.param_defaults = {
'display_url': None,
'expanded_url': None,
'ext_alt_text': None,
'id': None,
'media_url': None,
'media_url_https': None,
'sizes': None,
'type': None,
'url': None,
'video_info': None,
}
for (param, default) in self.param_defaults.items():
@@ -172,8 +185,10 @@ class DirectMessage(TwitterModel):
self.param_defaults = {
'created_at': None,
'id': None,
'recipient': None,
'recipient_id': None,
'recipient_screen_name': None,
'sender': None,
'sender_id': None,
'sender_screen_name': None,
'text': None,
@@ -181,6 +196,10 @@ class DirectMessage(TwitterModel):
for (param, default) in self.param_defaults.items():
setattr(self, param, kwargs.get(param, default))
if 'sender' in kwargs:
self.sender = User.NewFromJsonDict(kwargs.get('sender', None))
if 'recipient' in kwargs:
self.recipient = User.NewFromJsonDict(kwargs.get('recipient', None))
def __repr__(self):
if self.text and len(self.text) > 140:
@@ -206,7 +225,7 @@ class Trend(TwitterModel):
'query': None,
'timestamp': None,
'url': None,
'volume': None,
'tweet_volume': None,
}
for (param, default) in self.param_defaults.items():
@@ -218,6 +237,10 @@ class Trend(TwitterModel):
self.timestamp,
self.url)
@property
def volume(self):
return self.tweet_volume
class Hashtag(TwitterModel):
@@ -259,12 +282,12 @@ class UserStatus(TwitterModel):
""" A class representing the UserStatus structure. This is an abbreviated
form of the twitter.User object. """
connections = {'following': False,
'followed_by': False,
'following_received': False,
'following_requested': False,
'blocking': False,
'muting': False}
_connections = {'following': False,
'followed_by': False,
'following_received': False,
'following_requested': False,
'blocking': False,
'muting': False}
def __init__(self, **kwargs):
self.param_defaults = {
@@ -284,10 +307,19 @@ class UserStatus(TwitterModel):
setattr(self, param, kwargs.get(param, default))
if 'connections' in kwargs:
for param in self.connections:
for param in self._connections:
if param in kwargs['connections']:
setattr(self, param, True)
@property
def connections(self):
return {'following': self.following,
'followed_by': self.followed_by,
'following_received': self.following_received,
'following_requested': self.following_requested,
'blocking': self.blocking,
'muting': self.muting}
def __repr__(self):
connections = [param for param in self.connections if getattr(self, param)]
return "UserStatus(ID={uid}, ScreenName={sn}, Connections=[{conn}])".format(
@@ -307,11 +339,14 @@ class User(TwitterModel):
'default_profile': None,
'default_profile_image': None,
'description': None,
'email': None,
'favourites_count': None,
'followers_count': None,
'following': None,
'friends_count': None,
'geo_enabled': None,
'id': None,
'id_str': None,
'lang': None,
'listed_count': None,
'location': None,
@@ -319,12 +354,16 @@ class User(TwitterModel):
'notifications': None,
'profile_background_color': None,
'profile_background_image_url': None,
'profile_background_image_url_https': None,
'profile_background_tile': None,
'profile_banner_url': None,
'profile_image_url': None,
'profile_image_url_https': None,
'profile_link_color': None,
'profile_sidebar_border_color': None,
'profile_sidebar_fill_color': None,
'profile_text_color': None,
'profile_use_background_image': None,
'protected': None,
'screen_name': None,
'status': None,
@@ -333,6 +372,8 @@ class User(TwitterModel):
'url': None,
'utc_offset': None,
'verified': None,
'withheld_in_countries': None,
'withheld_scope': None,
}
for (param, default) in self.param_defaults.items():
@@ -365,6 +406,7 @@ class Status(TwitterModel):
'current_user_retweet': None,
'favorite_count': None,
'favorited': None,
'full_text': None,
'geo': None,
'hashtags': None,
'id': None,
@@ -377,6 +419,9 @@ class Status(TwitterModel):
'media': None,
'place': None,
'possibly_sensitive': None,
'quoted_status': None,
'quoted_status_id': None,
'quoted_status_id_str': None,
'retweet_count': None,
'retweeted': None,
'retweeted_status': None,
@@ -395,6 +440,11 @@ class Status(TwitterModel):
for (param, default) in self.param_defaults.items():
setattr(self, param, kwargs.get(param, default))
if kwargs.get('full_text', None):
self.tweet_mode = 'extended'
else:
self.tweet_mode = 'compatibility'
@property
def created_at_in_seconds(self):
""" Get the time this status message was posted, in seconds since
@@ -414,17 +464,21 @@ class Status(TwitterModel):
string: A string representation of this twitter.Status instance with
the ID of status, username and datetime.
"""
if self.tweet_mode == 'extended':
text = self.full_text
else:
text = self.text
if self.user:
return "Status(ID={0}, ScreenName={1}, Created={2}, Text={3!r})".format(
self.id,
self.user.screen_name,
self.created_at,
self.text)
text)
else:
return u"Status(ID={0}, Created={1}, Text={2!r})".format(
self.id,
self.created_at,
self.text)
text)
@classmethod
def NewFromJsonDict(cls, data, **kwargs):
@@ -439,17 +493,25 @@ class Status(TwitterModel):
current_user_retweet = None
hashtags = None
media = None
quoted_status = None
retweeted_status = None
urls = None
user = None
user_mentions = None
# for loading extended tweets from the streaming API.
if 'extended_tweet' in data:
for k, v in data['extended_tweet'].items():
data[k] = v
if 'user' in data:
user = User.NewFromJsonDict(data['user'])
if 'retweeted_status' in data:
retweeted_status = Status.NewFromJsonDict(data['retweeted_status'])
if 'current_user_retweet' in data:
current_user_retweet = data['current_user_retweet']['id']
if 'quoted_status' in data:
quoted_status = Status.NewFromJsonDict(data.get('quoted_status'))
if 'entities' in data:
if 'urls' in data['entities']:
@@ -470,6 +532,7 @@ class Status(TwitterModel):
current_user_retweet=current_user_retweet,
hashtags=hashtags,
media=media,
quoted_status=quoted_status,
retweeted_status=retweeted_status,
urls=urls,
user=user,

View File

@@ -2,6 +2,7 @@
import re
class Emoticons:
POSITIVE = ["*O", "*-*", "*O*", "*o*", "* *",
":P", ":D", ":d", ":p",
@@ -27,6 +28,7 @@ class Emoticons:
"[:", ";]"
]
class ParseTweet(object):
# compile once on import
regexp = {"RT": "^RT", "MT": r"^MT", "ALNUM": r"(@[a-zA-Z0-9_]+)",
@@ -51,7 +53,7 @@ class ParseTweet(object):
self.Emoticon = ParseTweet.getAttributeEmoticon(tweet)
# additional intelligence
if ( self.RT and len(self.UserHandles) > 0 ): # change the owner of tweet?
if (self.RT and len(self.UserHandles) > 0): # change the owner of tweet?
self.Owner = self.UserHandles[0]
return
@@ -66,10 +68,10 @@ class ParseTweet(object):
emoji = list()
for tok in re.split(ParseTweet.regexp["SPACES"], tweet.strip()):
if tok in Emoticons.POSITIVE:
emoji.append( tok )
emoji.append(tok)
continue
if tok in Emoticons.NEGATIVE:
emoji.append( tok )
emoji.append(tok)
return emoji
@staticmethod

View File

@@ -97,6 +97,7 @@ class RateLimit(object):
and a dictionary of limit, remaining, and reset will be returned.
"""
self.__dict__['resources'] = {}
self.__dict__.update(kwargs)
@staticmethod
@@ -117,10 +118,12 @@ class RateLimit(object):
for non_std_endpoint in NON_STANDARD_ENDPOINTS:
if re.match(non_std_endpoint.regex, resource):
return non_std_endpoint.resource
else:
return resource
return resource
def set_unknown_limit(self, url, limit, remaining, reset):
return self.set_limit(url, limit, remaining, reset)
def set_limit(self, url, limit, remaining, reset):
""" If a resource family is unknown, add it to the object's
dictionary. This is to deal with new endpoints being added to
the API, but not necessarily to the information returned by
@@ -146,13 +149,18 @@ class RateLimit(object):
"""
endpoint = self.url_to_resource(url)
resource_family = endpoint.split('/')[1]
self.__dict__['resources'].update(
{resource_family: {
endpoint: {
"limit": limit,
"remaining": remaining,
"reset": reset
}}})
new_endpoint = {endpoint: {
"limit": enf_type('limit', int, limit),
"remaining": enf_type('remaining', int, remaining),
"reset": enf_type('reset', int, reset)
}}
if not self.resources.get(resource_family, None):
self.resources[resource_family] = {}
self.__dict__['resources'][resource_family].update(new_endpoint)
return self.get_limit(url)
def get_limit(self, url):
""" Gets a EndpointRateLimit object for the given url.
@@ -181,35 +189,3 @@ class RateLimit(object):
return EndpointRateLimit(family_rates['limit'],
family_rates['remaining'],
family_rates['reset'])
def set_limit(self, url, limit, remaining, reset):
""" Set an endpoint's rate limits. The data used for each of the
args should come from Twitter's ``x-rate-limit`` headers.
Args:
url (str):
URL of the endpoint being fetched.
limit (int):
Max number of times a user or app can hit the endpoint
before being rate limited.
remaining (int):
Number of times a user or app can access the endpoint
before being rate limited.
reset (int):
Epoch time at which the rate limit window will reset.
"""
endpoint = self.url_to_resource(url)
resource_family = endpoint.split('/')[1]
try:
family_rates = self.resources.get(resource_family).get(endpoint)
except AttributeError:
self.set_unknown_limit(url, limit, remaining, reset)
family_rates = self.resources.get(resource_family).get(endpoint)
family_rates['limit'] = enf_type('limit', int, limit)
family_rates['remaining'] = enf_type('remaining', int, remaining)
family_rates['reset'] = enf_type('reset', int, reset)
return EndpointRateLimit(family_rates['limit'],
family_rates['remaining'],
family_rates['reset'])

View File

@@ -1,13 +1,33 @@
# encoding: utf-8
from __future__ import unicode_literals
import mimetypes
import os
import re
import sys
from tempfile import NamedTemporaryFile
from unicodedata import normalize
try:
from urllib.parse import urlparse
except ImportError:
from urlparse import urlparse
import requests
from tempfile import NamedTemporaryFile
from twitter import TwitterError
import twitter
if sys.version_info < (3,):
range = xrange
if sys.version_info > (3,):
unicode = str
CHAR_RANGES = [
range(0, 4351),
range(8192, 8205),
range(8208, 8223),
range(8242, 8247)]
TLDS = [
"ac", "ad", "ae", "af", "ag", "ai", "al", "am", "an", "ao", "aq", "ar",
@@ -138,7 +158,14 @@ TLDS = [
"淡马锡", "游戏", "点看", "移动", "组织机构", "网址", "网店", "网络", "谷歌", "集团",
"飞利浦", "餐厅", "닷넷", "닷컴", "삼성", "onion"]
URL_REGEXP = re.compile(r'(?i)((?:https?://|www\\.)*(?:[\w+-_]+[.])(?:' + r'\b|'.join(TLDS) + r'\b|(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5]))+(?:[:\w+\/]?[a-z0-9!\*\'\(\);:&=\+\$/%#\[\]\-_\.,~?])*)', re.UNICODE)
URL_REGEXP = re.compile((
r'('
r'^(?!(https?://|www\.)?\.|ftps?://|([0-9]+\.){{1,3}}\d+)' # exclude urls that start with "."
r'(?:https?://|www\.)*^(?!.*@)(?:[\w+-_]+[.])' # beginning of url
r'(?:{0}\b' # all tlds
r'(?:[:0-9]))' # port numbers & close off TLDs
r'(?:[\w+\/]?[a-z0-9!\*\'\(\);:&=\+\$/%#\[\]\-_\.,~?])*' # path/query params
r')').format(r'\b|'.join(TLDS)), re.U | re.I | re.X)
def calc_expected_status_length(status, short_url_length=23):
@@ -153,12 +180,19 @@ def calc_expected_status_length(status, short_url_length=23):
Expected length of the status message as an integer.
"""
replaced_chars = 0
status_length = len(status)
match = re.findall(URL_REGEXP, status)
if len(match) >= 1:
replaced_chars = len(''.join(match))
status_length = status_length - replaced_chars + (short_url_length * len(match))
status_length = 0
if isinstance(status, bytes):
status = unicode(status)
for word in re.split(r'\s', status):
if is_url(word):
status_length += short_url_length
else:
for character in word:
if any([ord(normalize("NFC", character)) in char_range for char_range in CHAR_RANGES]):
status_length += 1
else:
status_length += 2
status_length += len(re.findall(r'\s', status))
return status_length
@@ -171,16 +205,14 @@ def is_url(text):
Returns:
Boolean of whether the text should be treated as a URL or not.
"""
if re.findall(URL_REGEXP, text):
return True
else:
return False
return bool(re.findall(URL_REGEXP, text))
def http_to_file(http):
data_file = NamedTemporaryFile()
req = requests.get(http, stream=True)
data_file.write(req.raw.data)
for chunk in req.iter_content(chunk_size=1024 * 1024):
data_file.write(chunk)
return data_file
@@ -200,7 +232,8 @@ def parse_media_file(passed_media):
'image/gif',
'image/bmp',
'image/webp']
video_formats = ['video/mp4']
video_formats = ['video/mp4',
'video/quicktime']
# If passed_media is a string, check if it points to a URL, otherwise,
# it should point to local file. Create a reference to a file obj for
@@ -208,7 +241,7 @@ def parse_media_file(passed_media):
if not hasattr(passed_media, 'read'):
if passed_media.startswith('http'):
data_file = http_to_file(passed_media)
filename = os.path.basename(passed_media)
filename = os.path.basename(urlparse(passed_media).path)
else:
data_file = open(os.path.realpath(passed_media), 'rb')
filename = os.path.basename(passed_media)
@@ -216,8 +249,8 @@ def parse_media_file(passed_media):
# Otherwise, if a file object was passed in the first place,
# create the standard reference to media_file (i.e., rename it to fp).
else:
if passed_media.mode != 'rb':
raise TwitterError({'message': 'File mode must be "rb".'})
if passed_media.mode not in ['rb', 'rb+', 'w+b']:
raise TwitterError('File mode must be "rb" or "rb+"')
filename = os.path.basename(passed_media.name)
data_file = passed_media
@@ -226,16 +259,17 @@ def parse_media_file(passed_media):
try:
data_file.seek(0)
except:
except Exception as e:
pass
media_type = mimetypes.guess_type(os.path.basename(filename))[0]
if media_type in img_formats and file_size > 5 * 1048576:
raise TwitterError({'message': 'Images must be less than 5MB.'})
elif media_type in video_formats and file_size > 15 * 1048576:
raise TwitterError({'message': 'Videos must be less than 15MB.'})
elif media_type not in img_formats and media_type not in video_formats:
raise TwitterError({'message': 'Media type could not be determined.'})
if media_type is not None:
if media_type in img_formats and file_size > 5 * 1048576:
raise TwitterError({'message': 'Images must be less than 5MB.'})
elif media_type in video_formats and file_size > 15 * 1048576:
raise TwitterError({'message': 'Videos must be less than 15MB.'})
elif media_type not in img_formats and media_type not in video_formats:
raise TwitterError({'message': 'Media type could not be determined.'})
return data_file, filename, file_size, media_type
@@ -263,3 +297,18 @@ def enf_type(field, _type, val):
raise TwitterError({
'message': '"{0}" must be type {1}'.format(field, _type.__name__)
})
def parse_arg_list(args, attr):
out = []
if isinstance(args, (str, unicode)):
out.append(args)
elif isinstance(args, twitter.User):
out.append(getattr(args, attr))
elif isinstance(args, (list, tuple)):
for item in args:
if isinstance(item, (str, unicode)):
out.append(item)
elif isinstance(item, twitter.User):
out.append(getattr(item, attr))
return ",".join([str(item) for item in out])

View File

@@ -152,6 +152,17 @@ def initialize(config_file):
logger.initLogger(console=not QUIET, log_dir=CONFIG.LOG_DIR,
verbose=VERBOSE)
logger.info(u"Starting Tautulli {}".format(
common.RELEASE
))
logger.info(u"{} {} ({}{})".format(
common.PLATFORM, common.PLATFORM_RELEASE, common.PLATFORM_VERSION,
' - {}'.format(common.PLATFORM_LINUX_DISTRO) if common.PLATFORM_LINUX_DISTRO else ''
))
logger.info(u"Python {}".format(
sys.version
))
if not CONFIG.BACKUP_DIR:
CONFIG.BACKUP_DIR = os.path.join(DATA_DIR, 'backups')
if not os.path.exists(CONFIG.BACKUP_DIR):
@@ -243,7 +254,7 @@ def initialize(config_file):
# Check for new versions
if CONFIG.CHECK_GITHUB_ON_STARTUP and CONFIG.CHECK_GITHUB:
try:
LATEST_VERSION = versioncheck.checkGithub()
LATEST_VERSION = versioncheck.check_github()
except:
logger.exception(u"Unhandled exception")
LATEST_VERSION = CURRENT_VERSION
@@ -378,8 +389,8 @@ def initialize_scheduler():
# Update check
github_minutes = CONFIG.CHECK_GITHUB_INTERVAL if CONFIG.CHECK_GITHUB_INTERVAL and CONFIG.CHECK_GITHUB else 0
schedule_job(versioncheck.checkGithub, 'Check GitHub for updates',
hours=0, minutes=github_minutes, seconds=0, args=(bool(CONFIG.PLEXPY_AUTO_UPDATE),))
schedule_job(versioncheck.check_github, 'Check GitHub for updates',
hours=0, minutes=github_minutes, seconds=0, args=(bool(CONFIG.PLEXPY_AUTO_UPDATE), True))
backup_hours = CONFIG.BACKUP_INTERVAL if 1 <= CONFIG.BACKUP_INTERVAL <= 24 else 6
@@ -509,7 +520,8 @@ def dbcheck():
'transcode_key TEXT, rating_key INTEGER, section_id INTEGER, media_type TEXT, started INTEGER, stopped INTEGER, '
'paused_counter INTEGER DEFAULT 0, state TEXT, user_id INTEGER, user TEXT, friendly_name TEXT, '
'ip_address TEXT, machine_id TEXT, player TEXT, product TEXT, platform TEXT, title TEXT, parent_title TEXT, '
'grandparent_title TEXT, full_title TEXT, media_index INTEGER, parent_media_index INTEGER, '
'grandparent_title TEXT, original_title TEXT, full_title TEXT, '
'media_index INTEGER, parent_media_index INTEGER, '
'thumb TEXT, parent_thumb TEXT, grandparent_thumb TEXT, year INTEGER, '
'parent_rating_key INTEGER, grandparent_rating_key INTEGER, '
'view_offset INTEGER DEFAULT 0, duration INTEGER, video_decision TEXT, audio_decision TEXT, '
@@ -529,6 +541,7 @@ def dbcheck():
'transcode_hw_decoding INTEGER, transcode_hw_encoding INTEGER, '
'optimized_version INTEGER, optimized_version_profile TEXT, optimized_version_title TEXT, '
'synced_version INTEGER, synced_version_profile TEXT, '
'live INTEGER, live_uuid TEXT, '
'buffer_count INTEGER DEFAULT 0, buffer_last_triggered INTEGER, last_paused INTEGER, watched INTEGER DEFAULT 0, '
'write_attempts INTEGER DEFAULT 0, raw_stream_info TEXT)'
)
@@ -569,8 +582,9 @@ def dbcheck():
c_db.execute(
'CREATE TABLE IF NOT EXISTS session_history_metadata (id INTEGER PRIMARY KEY, '
'rating_key INTEGER, parent_rating_key INTEGER, grandparent_rating_key INTEGER, '
'title TEXT, parent_title TEXT, grandparent_title TEXT, full_title TEXT, media_index INTEGER, '
'parent_media_index INTEGER, section_id INTEGER, thumb TEXT, parent_thumb TEXT, grandparent_thumb TEXT, '
'title TEXT, parent_title TEXT, grandparent_title TEXT, original_title TEXT, full_title TEXT, '
'media_index INTEGER, parent_media_index INTEGER, section_id INTEGER, '
'thumb TEXT, parent_thumb TEXT, grandparent_thumb TEXT, '
'art TEXT, media_type TEXT, year INTEGER, originally_available_at TEXT, added_at INTEGER, updated_at INTEGER, '
'last_viewed_at INTEGER, content_rating TEXT, summary TEXT, tagline TEXT, rating TEXT, '
'duration INTEGER DEFAULT 0, guid TEXT, directors TEXT, writers TEXT, actors TEXT, genres TEXT, studio TEXT, '
@@ -637,7 +651,7 @@ def dbcheck():
# newsletters table :: This table keeps record of the newsletter settings
c_db.execute(
'CREATE TABLE IF NOT EXISTS newsletters (id INTEGER PRIMARY KEY AUTOINCREMENT, '
'agent_id INTEGER, agent_name TEXT, agent_label TEXT, '
'agent_id INTEGER, agent_name TEXT, agent_label TEXT, id_name TEXT NOT NULL DEFAULT "", '
'friendly_name TEXT, newsletter_config TEXT, email_config TEXT, '
'subject TEXT, body TEXT, message TEXT, '
'cron TEXT NOT NULL DEFAULT "0 0 * * 0", active INTEGER DEFAULT 0)'
@@ -648,7 +662,7 @@ def dbcheck():
'CREATE TABLE IF NOT EXISTS newsletter_log (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER, '
'newsletter_id INTEGER, agent_id INTEGER, agent_name TEXT, notify_action TEXT, '
'subject_text TEXT, body_text TEXT, message_text TEXT, start_date TEXT, end_date TEXT, '
'start_time INTEGER, end_time INTEGER, uuid TEXT UNIQUE, success INTEGER DEFAULT 0)'
'start_time INTEGER, end_time INTEGER, uuid TEXT UNIQUE, filename TEXT, success INTEGER DEFAULT 0)'
)
# recently_added table :: This table keeps record of recently added items
@@ -693,6 +707,12 @@ def dbcheck():
'img_hash TEXT, imgur_title TEXT, imgur_url TEXT, delete_hash TEXT)'
)
# cloudinary_lookup table :: This table keeps record of the Cloudinary uploads
c_db.execute(
'CREATE TABLE IF NOT EXISTS cloudinary_lookup (id INTEGER PRIMARY KEY AUTOINCREMENT, '
'img_hash TEXT, cloudinary_title TEXT, cloudinary_url TEXT)'
)
# Upgrade sessions table from earlier versions
try:
c_db.execute('SELECT started FROM sessions')
@@ -1047,6 +1067,27 @@ def dbcheck():
'ALTER TABLE sessions ADD COLUMN watched INTEGER DEFAULT 0'
)
# Upgrade sessions table from earlier versions
try:
c_db.execute('SELECT live FROM sessions')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table sessions.")
c_db.execute(
'ALTER TABLE sessions ADD COLUMN live INTEGER'
)
c_db.execute(
'ALTER TABLE sessions ADD COLUMN live_uuid TEXT'
)
# Upgrade sessions table from earlier versions
try:
c_db.execute('SELECT original_title FROM sessions')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table sessions.")
c_db.execute(
'ALTER TABLE sessions ADD COLUMN original_title TEXT'
)
# Upgrade session_history table from earlier versions
try:
c_db.execute('SELECT reference_id FROM session_history')
@@ -1133,6 +1174,15 @@ def dbcheck():
'ALTER TABLE session_history_metadata ADD COLUMN labels TEXT'
)
# Upgrade session_history_metadata table from earlier versions
try:
c_db.execute('SELECT original_title FROM session_history_metadata')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table session_history_metadata.")
c_db.execute(
'ALTER TABLE session_history_metadata ADD COLUMN original_title TEXT'
)
# Upgrade session_history_media_info table from earlier versions
try:
c_db.execute('SELECT transcode_decision FROM session_history_media_info')
@@ -1489,6 +1539,24 @@ def dbcheck():
'ALTER TABLE newsletter_log ADD COLUMN end_time INTEGER'
)
# Upgrade newsletter_log table from earlier versions
try:
c_db.execute('SELECT filename FROM newsletter_log')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table newsletter_log.")
c_db.execute(
'ALTER TABLE newsletter_log ADD COLUMN filename TEXT'
)
# Upgrade newsletters table from earlier versions
try:
c_db.execute('SELECT id_name FROM newsletters')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table newsletters.")
c_db.execute(
'ALTER TABLE newsletters ADD COLUMN id_name TEXT NOT NULL DEFAULT ""'
)
# Upgrade library_sections table from earlier versions (remove UNIQUE constraint on section_id)
try:
result = c_db.execute('SELECT SQL FROM sqlite_master WHERE type="table" AND name="library_sections"').fetchone()
@@ -1687,9 +1755,10 @@ def dbcheck():
for row in result:
img_hash = notification_handler.set_hash_image_info(
rating_key=row['rating_key'], width=600, height=1000, fallback='poster')
data_factory.set_imgur_info(img_hash=img_hash, imgur_title=row['poster_title'],
imgur_url=row['poster_url'], delete_hash=row['delete_hash'])
rating_key=row['rating_key'], width=1000, height=1500, fallback='poster')
data_factory.set_img_info(img_hash=img_hash, img_title=row['poster_title'],
img_url=row['poster_url'], delete_hash=row['delete_hash'],
service='imgur')
db.action('DROP TABLE poster_urls')
except sqlite3.OperationalError:
@@ -1787,7 +1856,8 @@ def initialize_tracker():
'appVersion': common.RELEASE,
'appId': plexpy.INSTALL_TYPE,
'appInstallerId': plexpy.CONFIG.GIT_BRANCH,
'dimension1': '{} {}'.format(common.PLATFORM, common.PLATFORM_VERSION), # App Platform
'dimension1': '{} {}'.format(common.PLATFORM, common.PLATFORM_RELEASE), # App Platform
'dimension2': common.PLATFORM_LINUX_DISTRO, # Linux Distro
'userLanguage': plexpy.SYS_LANGUAGE,
'documentEncoding': plexpy.SYS_ENCODING,
'noninteractive': True

View File

@@ -226,7 +226,11 @@ class ActivityHandler(object):
db_session = ap.get_session_by_key(session_key=self.get_session_key())
this_state = self.timeline['state']
this_key = str(self.timeline['ratingKey'])
this_rating_key = str(self.timeline['ratingKey'])
this_key = self.timeline['key']
# Get the live tv session uuid
this_live_uuid = this_key.split('/')[-1] if this_key.startswith('/livetv/sessions') else None
# If we already have this session in the temp table, check for state changes
if db_session:
@@ -235,10 +239,11 @@ class ActivityHandler(object):
func=force_stop_stream, args=[self.get_session_key()], minutes=5)
last_state = db_session['state']
last_key = str(db_session['rating_key'])
last_rating_key = str(db_session['rating_key'])
last_live_uuid = db_session['live_uuid']
# Make sure the same item is being played
if this_key == last_key:
if this_rating_key == last_rating_key or this_live_uuid == last_live_uuid:
# Update the session state and viewOffset
if this_state == 'playing':
# Update the session in our temp session table

View File

@@ -15,18 +15,13 @@
from collections import defaultdict
import json
import threading
import time
import re
import plexpy
import database
import datafactory
import helpers
import libraries
import log_reader
import logger
import notification_handler
import notifiers
import pmsconnect
import users
@@ -50,6 +45,7 @@ class ActivityProcessor(object):
'title': session.get('title', ''),
'parent_title': session.get('parent_title', ''),
'grandparent_title': session.get('grandparent_title', ''),
'original_title': session.get('original_title', ''),
'full_title': session.get('full_title', ''),
'media_index': session.get('media_index', ''),
'parent_media_index': session.get('parent_media_index', ''),
@@ -60,6 +56,7 @@ class ActivityProcessor(object):
'friendly_name': session.get('friendly_name', ''),
'ip_address': session.get('ip_address', ''),
'player': session.get('player', ''),
'product': session.get('product', ''),
'platform': session.get('platform', ''),
'parent_rating_key': session.get('parent_rating_key', ''),
'grandparent_rating_key': session.get('grandparent_rating_key', ''),
@@ -114,7 +111,9 @@ class ActivityProcessor(object):
'stream_audio_channels': session.get('stream_audio_channels', ''),
'stream_subtitle_decision': session.get('stream_subtitle_decision', ''),
'stream_subtitle_codec': session.get('stream_subtitle_codec', ''),
'subtitles': session.get('subtitles', ''),
'subtitles': session.get('subtitles', 0),
'live': session.get('live', 0),
'live_uuid': session.get('live_uuid', ''),
'raw_stream_info': json.dumps(session),
'stopped': int(time.time())
}
@@ -180,8 +179,9 @@ class ActivityProcessor(object):
if str(session['rating_key']).isdigit() and session['media_type'] in ('movie', 'episode', 'track'):
logging_enabled = True
else:
logger.debug(u"Tautulli ActivityProcessor :: ratingKey %s not logged. Does not meet logging criteria. "
u"Media type is '%s'" % (session['rating_key'], session['media_type']))
logger.debug(u"Tautulli ActivityProcessor :: Session %s ratingKey %s not logged. "
u"Does not meet logging criteria. Media type is '%s'" %
(session['session_key'], session['rating_key'], session['media_type']))
return session['id']
if str(session['paused_counter']).isdigit():
@@ -193,15 +193,16 @@ class ActivityProcessor(object):
if (session['media_type'] == 'movie' or session['media_type'] == 'episode') and \
(real_play_time < int(plexpy.CONFIG.LOGGING_IGNORE_INTERVAL)):
logging_enabled = False
logger.debug(u"Tautulli ActivityProcessor :: Play duration for ratingKey %s is %s secs which is less than %s "
u"seconds, so we're not logging it." %
(session['rating_key'], str(real_play_time), plexpy.CONFIG.LOGGING_IGNORE_INTERVAL))
logger.debug(u"Tautulli ActivityProcessor :: Play duration for session %s ratingKey %s is %s secs "
u"which is less than %s seconds, so we're not logging it." %
(session['session_key'], session['rating_key'], str(real_play_time),
plexpy.CONFIG.LOGGING_IGNORE_INTERVAL))
if not is_import and session['media_type'] == 'track':
if real_play_time < 15 and session['duration'] >= 30:
logging_enabled = False
logger.debug(u"Tautulli ActivityProcessor :: Play duration for ratingKey %s is %s secs, "
logger.debug(u"Tautulli ActivityProcessor :: Play duration for session %s ratingKey %s is %s secs, "
u"looks like it was skipped so we're not logging it" %
(session['rating_key'], str(real_play_time)))
(session['session_key'], session['rating_key'], str(real_play_time)))
elif is_import and import_ignore_interval:
if (session['media_type'] == 'movie' or session['media_type'] == 'episode') and \
(real_play_time < int(import_ignore_interval)):
@@ -266,14 +267,15 @@ class ActivityProcessor(object):
self.db.upsert(table_name='session_history', key_dict=keys, value_dict=values)
# Check if we should group the session, select the last two rows from the user
query = 'SELECT id, rating_key, view_offset, user_id, reference_id FROM session_history \
WHERE user_id = ? ORDER BY id DESC LIMIT 2 '
query = 'SELECT id, rating_key, view_offset, user_id, reference_id FROM session_history ' \
'WHERE user_id = ? AND rating_key = ? ORDER BY id DESC LIMIT 2 '
args = [session['user_id']]
args = [session['user_id'], session['rating_key']]
result = self.db.select(query=query, args=args)
new_session = prev_session = None
prev_progress_percent = media_watched_percent = 0
# Get the last insert row id
last_id = self.db.last_insert_id()
@@ -290,11 +292,23 @@ class ActivityProcessor(object):
'user_id': result[1]['user_id'],
'reference_id': result[1]['reference_id']}
watched_percent = {'movie': plexpy.CONFIG.MOVIE_WATCHED_PERCENT,
'episode': plexpy.CONFIG.TV_WATCHED_PERCENT,
'track': plexpy.CONFIG.MUSIC_WATCHED_PERCENT
}
prev_progress_percent = helpers.get_percent(prev_session['view_offset'], session['duration'])
media_watched_percent = watched_percent.get(session['media_type'], 0)
query = 'UPDATE session_history SET reference_id = ? WHERE id = ? '
# If rating_key is the same in the previous session, then set the reference_id to the previous row, else set the reference_id to the new id
# If previous session view offset less than watched percent,
# and new session view offset is greater,
# then set the reference_id to the previous row,
# else set the reference_id to the new id
if prev_session is None and new_session is None:
args = [last_id, last_id]
elif prev_session['rating_key'] == new_session['rating_key'] and prev_session['view_offset'] <= new_session['view_offset']:
elif prev_progress_percent < media_watched_percent and \
prev_session['view_offset'] <= new_session['view_offset']:
args = [prev_session['reference_id'], new_session['id']]
else:
args = [new_session['id'], new_session['id']]
@@ -394,6 +408,7 @@ class ActivityProcessor(object):
'title': session['title'],
'parent_title': session['parent_title'],
'grandparent_title': session['grandparent_title'],
'original_title': session['original_title'],
'full_title': session['full_title'],
'media_index': metadata['media_index'],
'parent_media_index': metadata['parent_media_index'],

View File

@@ -37,6 +37,8 @@ import logger
import mobile_app
import notification_handler
import notifiers
import newsletter_handler
import newsletters
import users
@@ -443,6 +445,51 @@ class API2:
return
def notify_newsletter(self, newsletter_id='', subject='', body='', message='', **kwargs):
""" 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
```
"""
if not newsletter_id:
self._api_msg = 'Newsletter failed: no newsletter id provided.'
self._api_result_type = 'error'
return
newsletter = newsletters.get_newsletter_config(newsletter_id=newsletter_id)
if not newsletter:
self._api_msg = 'Newsletter failed: invalid newsletter_id provided %s.' % newsletter_id
self._api_result_type = 'error'
return
logger.api_debug(u'Tautulli APIv2 :: Sending newsletter.')
success = newsletter_handler.notify(newsletter_id=newsletter_id,
notify_action='api',
subject=subject,
body=body,
message=message,
**kwargs)
if success:
self._api_msg = 'Newsletter sent.'
self._api_result_type = 'success'
else:
self._api_msg = 'Newsletter failed.'
self._api_result_type = 'error'
return
def _api_make_md(self):
""" Tries to make a API.md to simplify the api docs. """
@@ -530,7 +577,7 @@ General optional parameters:
return data
def _api_responds(self, result_type='error', data=None, msg=''):
""" Formats the result to a predefined dict so we can hange it the to
""" Formats the result to a predefined dict so we can change it the to
the desired output by _api_out_as """
if data is None:
@@ -564,6 +611,7 @@ General optional parameters:
# if we fail to generate the output fake an error
except Exception as e:
logger.api_exception(u'Tautulli APIv2 :: ' + traceback.format_exc())
cherrypy.response.status = 500
out['message'] = traceback.format_exc()
out['result'] = 'error'
@@ -573,6 +621,7 @@ General optional parameters:
out = xmltodict.unparse(out, pretty=True)
except Exception as e:
logger.api_error(u'Tautulli APIv2 :: Failed to parse xml result')
cherrypy.response.status = 500
try:
out['message'] = e
out['result'] = 'error'
@@ -613,6 +662,7 @@ General optional parameters:
result = call(**self._api_kwargs)
except Exception as e:
logger.api_error(u'Tautulli APIv2 :: Failed to run %s with %s: %s' % (self._api_cmd, self._api_kwargs, e))
cherrypy.response.status = 400
if self._api_debug:
cherrypy.request.show_tracebacks = True
# Reraise the exception so the traceback hits the browser
@@ -637,19 +687,19 @@ General optional parameters:
except:
pass
# Fallback if we cant "parse the reponse"
# Fallback if we cant "parse the response"
if ret is None:
ret = result
if ret or self._api_result_type == 'success':
if ret is not None or self._api_result_type == 'success':
# To allow override for restart etc
# if the call returns some data we are gonna assume its a success
self._api_result_type = 'success'
else:
self._api_result_type = 'error'
# Since some of them metods use a api like response for the ui
# {result: error, message: 'Some shit happend'}
# Since some of them methods use a api like response for the ui
# {result: error, message: 'Some shit happened'}
if isinstance(ret, dict):
if ret.get('message'):
self._api_msg = ret.pop('message', None)
@@ -657,4 +707,7 @@ General optional parameters:
if ret.get('result'):
self._api_result_type = ret.pop('result', None)
if self._api_result_type == 'error':
cherrypy.response.status = 500
return self._api_out_as(self._api_responds(result_type=self._api_result_type, msg=self._api_msg, data=ret))

View File

@@ -20,20 +20,23 @@ import version
# Identify Our Application
PLATFORM = platform.system()
PLATFORM_VERSION = platform.release()
PLATFORM_RELEASE = platform.release()
PLATFORM_VERSION = platform.version()
PLATFORM_LINUX_DISTRO = ' '.join(x for x in platform.linux_distribution() if x)
PLATFORM_DEVICE_NAME = platform.node()
BRANCH = version.PLEXPY_BRANCH
RELEASE = version.PLEXPY_RELEASE_VERSION
USER_AGENT = 'Tautulli/{} ({} {})'.format(RELEASE, PLATFORM, PLATFORM_VERSION)
USER_AGENT = 'Tautulli/{} ({} {})'.format(RELEASE, PLATFORM, PLATFORM_RELEASE)
DEFAULT_USER_THUMB = "interfaces/default/images/gravatar-default-80x80.png"
DEFAULT_POSTER_THUMB = "interfaces/default/images/poster.png"
DEFAULT_COVER_THUMB = "interfaces/default/images/cover.png"
DEFAULT_ART = "interfaces/default/images/art.png"
ONLINE_POSTER_THUMB = "http://tautulli.com/images/poster.png"
ONLINE_COVER_THUMB = "http://tautulli.com/images/cover.png"
ONLINE_ART = "http://tautulli.com/images/art.png"
ONLINE_POSTER_THUMB = "https://tautulli.com/images/poster.png"
ONLINE_COVER_THUMB = "https://tautulli.com/images/cover.png"
ONLINE_ART = "https://tautulli.com/images/art.png"
MEDIA_TYPE_HEADERS = {
'movie': 'Movies',
@@ -337,6 +340,7 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Optimized Version', 'type': 'int', 'value': 'optimized_version', 'description': 'If the stream is an optimized version.', 'example': '0 or 1'},
{'name': 'Optimized Version Profile', 'type': 'str', 'value': 'optimized_version_profile', 'description': 'The optimized version profile of the stream.'},
{'name': 'Synced Version', 'type': 'int', 'value': 'synced_version', 'description': 'If the stream is an synced version.', 'example': '0 or 1'},
{'name': 'Live', 'type': 'int', 'value': 'live', 'description': 'If the stream is live TV.', 'example': '0 or 1'},
{'name': 'Stream Local', 'type': 'int', 'value': 'stream_local', 'description': 'If the stream is local.', 'example': '0 or 1'},
{'name': 'Stream Location', 'type': 'str', 'value': 'stream_location', 'description': 'The network location of the stream.', 'example': 'lan or wan'},
{'name': 'Stream Bandwidth', 'type': 'int', 'value': 'stream_bandwidth', 'description': 'The required bandwidth (in kbps) of the stream.', 'help_text': 'not the used bandwidth'},
@@ -400,6 +404,7 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Artist Name', 'type': 'str', 'value': 'artist_name', 'description': 'The name of the artist.'},
{'name': 'Album Name', 'type': 'str', 'value': 'album_name', 'description': 'The title of the album.'},
{'name': 'Track Name', 'type': 'str', 'value': 'track_name', 'description': 'The title of the track.'},
{'name': 'Track Artist', 'type': 'str', 'value': 'track_artist', 'description': 'The name of the artist of the track.'},
{'name': 'Season Number', 'type': 'int', 'value': 'season_num', 'description': 'The season number.', 'example': 'e.g. 1, or 1-3'},
{'name': 'Season Number 00', 'type': 'int', 'value': 'season_num00', 'description': 'The two digit season number.', 'example': 'e.g. 01, or 01-03'},
{'name': 'Episode Number', 'type': 'int', 'value': 'episode_num', 'description': 'The episode number.', 'example': 'e.g. 6, or 6-10'},
@@ -471,6 +476,7 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Subtitle Language', 'type': 'str', 'value': 'subtitle_language', 'description': 'The subtitle language of the original media.'},
{'name': 'Subtitle Language Code', 'type': 'str', 'value': 'subtitle_language_code', 'description': 'The subtitle language code of the original media.'},
{'name': 'File', 'type': 'str', 'value': 'file', 'description': 'The file path to the item.'},
{'name': 'Filename', 'type': 'str', 'value': 'filename', 'description': 'The file name of the item.'},
{'name': 'File Size', 'type': 'int', 'value': 'file_size', 'description': 'The file size of the item.'},
{'name': 'Section ID', 'type': 'int', 'value': 'section_id', 'description': 'The unique identifier for the library.'},
{'name': 'Rating Key', 'type': 'int', 'value': 'rating_key', 'description': 'The unique identifier for the movie, episode, or track.'},
@@ -519,13 +525,17 @@ NEWSLETTER_PARAMETERS = [
'category': 'Global',
'parameters': [
{'name': 'Server Name', 'type': 'str', 'value': 'server_name', 'description': 'The name of your Plex Server.'},
{'name': 'Start Date', 'type': 'str', 'value': 'start_date', 'description': 'The start date of the newesletter.'},
{'name': 'End Date', 'type': 'str', 'value': 'end_date', 'description': 'The end date of the newesletter.'},
{'name': 'Start Date', 'type': 'str', 'value': 'start_date', 'description': 'The start date of the newsletter.'},
{'name': 'End Date', 'type': 'str', 'value': 'end_date', 'description': 'The end date of the newsletter.'},
{'name': 'Week Number', 'type': 'int', 'value': 'week_number', 'description': 'The week number of the year.'},
{'name': 'Newsletter Time Frame', 'type': 'int', 'value': 'newsletter_time_frame', 'description': 'The time frame included in the newsletter.'},
{'name': 'Newsletter Time Frame Units', 'type': 'str', 'value': 'newsletter_time_frame_units', 'description': 'The time frame units included in the newsletter.'},
{'name': 'Newsletter URL', 'type': 'str', 'value': 'newsletter_url', 'description': 'The self-hosted URL to the newsletter.'},
{'name': 'Newsletter Static URL', 'type': 'str', 'value': 'newsletter_static_url', 'description': 'The static self-hosted URL to the latest scheduled newsletter for the agent.'},
{'name': 'Newsletter UUID', 'type': 'str', 'value': 'newsletter_uuid', 'description': 'The unique identifier for the newsletter.'},
{'name': 'Newsletter ID', 'type': 'int', 'value': 'newsletter_id', 'description': 'The unique ID number for the newsletter agent.'},
{'name': 'Newsletter ID Name', 'type': 'int', 'value': 'newsletter_id_name', 'description': 'The unique ID name for the newsletter agent.'},
{'name': 'Newsletter Password', 'type': 'str', 'value': 'newsletter_password', 'description': 'The password required to view the newsletter if enabled.'},
]
},
{

View File

@@ -54,6 +54,7 @@ _CONFIG_DEFINITIONS = {
'PMS_TOKEN': (str, 'PMS', ''),
'PMS_SSL': (int, 'PMS', 0),
'PMS_URL': (str, 'PMS', ''),
'PMS_URL_OVERRIDE': (str, 'PMS', ''),
'PMS_URL_MANUAL': (int, 'PMS', 0),
'PMS_USE_BIF': (int, 'PMS', 0),
'PMS_UUID': (str, 'PMS', ''),
@@ -115,6 +116,9 @@ _CONFIG_DEFINITIONS = {
'CHECK_GITHUB_INTERVAL': (int, 'General', 360),
'CHECK_GITHUB_ON_STARTUP': (int, 'General', 1),
'CLEANUP_FILES': (int, 'General', 0),
'CLOUDINARY_CLOUD_NAME': (str, 'Cloudinary', ''),
'CLOUDINARY_API_KEY': (str, 'Cloudinary', ''),
'CLOUDINARY_API_SECRET': (str, 'Cloudinary', ''),
'CONFIG_VERSION': (int, 'Advanced', 0),
'DO_NOT_OVERRIDE_GIT_BRANCH': (int, 'General', 0),
'EMAIL_ENABLED': (int, 'Email', 0),
@@ -309,9 +313,14 @@ _CONFIG_DEFINITIONS = {
'MONITOR_REMOTE_ACCESS': (int, 'Monitoring', 0),
'MONITORING_INTERVAL': (int, 'Monitoring', 60),
'MONITORING_USE_WEBSOCKET': (int, 'Monitoring', 0),
'NEWSLETTER_AUTH': (int, 'Newsletter', 0),
'NEWSLETTER_PASSWORD': (str, 'Newsletter', ''),
'NEWSLETTER_CUSTOM_DIR': (str, 'Newsletter', ''),
'NEWSLETTER_INLINE_STYLES': (int, 'Newsletter', 1),
'NEWSLETTER_TEMPLATES': (str, 'Newsletter', 'newsletters'),
'NEWSLETTER_DIR': (str, 'Newsletter', ''),
'NEWSLETTER_SELF_HOSTED': (int, 'Newsletter', 0),
'NEWSLETTER_STATIC_URL': (int, 'Newsletter', 0),
'NMA_APIKEY': (str, 'NMA', ''),
'NMA_ENABLED': (int, 'NMA', 0),
'NMA_PRIORITY': (int, 'NMA', 0),

View File

@@ -65,7 +65,7 @@ class DataFactory(object):
columns = [
'session_history.reference_id',
'session_history.id',
'started AS date',
'MAX(started) AS date',
'MIN(started) AS started',
'MAX(stopped) AS stopped',
'SUM(CASE WHEN stopped > 0 THEN (stopped - started) ELSE 0 END) - \
@@ -86,6 +86,7 @@ class DataFactory(object):
'session_history_metadata.title',
'session_history_metadata.parent_title',
'session_history_metadata.grandparent_title',
'session_history_metadata.original_title',
'session_history_metadata.year',
'session_history_metadata.media_index',
'session_history_metadata.parent_media_index',
@@ -132,6 +133,7 @@ class DataFactory(object):
'title',
'parent_title',
'grandparent_title',
'original_title',
'year',
'media_index',
'parent_media_index',
@@ -233,6 +235,7 @@ class DataFactory(object):
'title': item['parent_title'],
'parent_title': item['parent_title'],
'grandparent_title': item['grandparent_title'],
'original_title': item['original_title'],
'year': item['year'],
'media_index': item['media_index'],
'parent_media_index': item['parent_media_index'],
@@ -480,7 +483,8 @@ class DataFactory(object):
elif stat == 'top_music':
top_music = []
try:
query = 'SELECT t.id, t.grandparent_title, t.grandparent_rating_key, t.grandparent_thumb, t.section_id, ' \
query = 'SELECT t.id, t.grandparent_title, t.original_title, ' \
't.grandparent_rating_key, t.grandparent_thumb, t.section_id, ' \
't.art, t.media_type, t.content_rating, t.labels, t.started, ' \
'MAX(t.started) AS last_watch, COUNT(t.id) AS total_plays, SUM(t.d) AS total_duration ' \
'FROM (SELECT *, SUM(CASE WHEN stopped > 0 THEN (stopped - started) - ' \
@@ -492,7 +496,7 @@ class DataFactory(object):
' >= datetime("now", "-%s days", "localtime") ' \
' AND session_history.media_type = "track" ' \
' GROUP BY %s) AS t ' \
'GROUP BY t.grandparent_title ' \
'GROUP BY t.original_title, t.grandparent_title ' \
'ORDER BY %s DESC, started DESC ' \
'LIMIT %s ' % (time_range, group_by, sort_type, stats_count)
result = monitor_db.select(query)
@@ -501,7 +505,7 @@ class DataFactory(object):
return None
for item in result:
row = {'title': item['grandparent_title'],
row = {'title': item['original_title'] or item['grandparent_title'],
'total_plays': item['total_plays'],
'total_duration': item['total_duration'],
'users_watched': '',
@@ -529,7 +533,8 @@ class DataFactory(object):
elif stat == 'popular_music':
popular_music = []
try:
query = 'SELECT t.id, t.grandparent_title, t.grandparent_rating_key, t.grandparent_thumb, t.section_id, ' \
query = 'SELECT t.id, t.grandparent_title, t.original_title, ' \
't.grandparent_rating_key, t.grandparent_thumb, t.section_id, ' \
't.art, t.media_type, t.content_rating, t.labels, t.started, ' \
'COUNT(DISTINCT t.user_id) AS users_watched, ' \
'MAX(t.started) AS last_watch, COUNT(t.id) as total_plays, SUM(t.d) AS total_duration ' \
@@ -542,7 +547,7 @@ class DataFactory(object):
' >= datetime("now", "-%s days", "localtime") ' \
' AND session_history.media_type = "track" ' \
' GROUP BY %s) AS t ' \
'GROUP BY t.grandparent_title ' \
'GROUP BY t.original_title, t.grandparent_title ' \
'ORDER BY users_watched DESC, %s DESC, started DESC ' \
'LIMIT %s ' % (time_range, group_by, sort_type, stats_count)
result = monitor_db.select(query)
@@ -551,7 +556,7 @@ class DataFactory(object):
return None
for item in result:
row = {'title': item['grandparent_title'],
row = {'title': item['original_title'] or item['grandparent_title'],
'users_watched': item['users_watched'],
'rating_key': item['grandparent_rating_key'],
'last_play': item['last_watch'],
@@ -888,7 +893,7 @@ class DataFactory(object):
'video_decision, audio_decision, transcode_decision, width, height, container, ' \
'transcode_container, transcode_video_codec, transcode_audio_codec, transcode_audio_channels, ' \
'transcode_width, transcode_height, ' \
'session_history_metadata.media_type, title, grandparent_title ' \
'session_history_metadata.media_type, title, grandparent_title, original_title ' \
'FROM session_history_media_info ' \
'JOIN session_history ON session_history_media_info.id = session_history.id ' \
'JOIN session_history_metadata ON session_history_media_info.id = session_history_metadata.id ' \
@@ -909,7 +914,7 @@ class DataFactory(object):
'video_decision, audio_decision, transcode_decision, width, height, container, ' \
'transcode_container, transcode_video_codec, transcode_audio_codec, transcode_audio_channels, ' \
'transcode_width, transcode_height, ' \
'media_type, title, grandparent_title ' \
'media_type, title, grandparent_title, original_title ' \
'FROM sessions ' \
'WHERE session_key = ? %s' % user_cond
result = monitor_db.select(query, args=[session_key])
@@ -979,6 +984,7 @@ class DataFactory(object):
'media_type': item['media_type'],
'title': item['title'],
'grandparent_title': item['grandparent_title'],
'original_title': item['original_title'],
'current_session': 1 if session_key else 0,
'pre_tautulli': pre_tautulli
}
@@ -994,7 +1000,8 @@ class DataFactory(object):
'session_history_metadata.rating_key, session_history_metadata.parent_rating_key, ' \
'session_history_metadata.grandparent_rating_key, session_history_metadata.title, ' \
'session_history_metadata.parent_title, session_history_metadata.grandparent_title, ' \
'session_history_metadata.full_title, library_sections.section_name, ' \
'session_history_metadata.original_title, session_history_metadata.full_title, ' \
'library_sections.section_name, ' \
'session_history_metadata.media_index, session_history_metadata.parent_media_index, ' \
'session_history_metadata.section_id, session_history_metadata.thumb, ' \
'session_history_metadata.parent_thumb, session_history_metadata.grandparent_thumb, ' \
@@ -1043,6 +1050,7 @@ class DataFactory(object):
'parent_rating_key': item['parent_rating_key'],
'grandparent_rating_key': item['grandparent_rating_key'],
'grandparent_title': item['grandparent_title'],
'original_title': item['original_title'],
'parent_media_index': item['parent_media_index'],
'parent_title': item['parent_title'],
'media_index': item['media_index'],
@@ -1132,12 +1140,12 @@ class DataFactory(object):
return ip_address
def get_imgur_info(self, img=None, rating_key=None, width=None, height=None,
opacity=None, background=None, blur=None, fallback=None,
order_by=''):
def get_img_info(self, img=None, rating_key=None, width=None, height=None,
opacity=None, background=None, blur=None, fallback=None,
order_by='', service=None):
monitor_db = database.MonitorDatabase()
imgur_info = []
img_info = []
where_params = []
args = []
@@ -1174,35 +1182,69 @@ class DataFactory(object):
if order_by:
order_by = 'ORDER BY ' + order_by + ' DESC'
query = 'SELECT imgur_title, imgur_url FROM imgur_lookup ' \
'JOIN image_hash_lookup ON imgur_lookup.img_hash = image_hash_lookup.img_hash ' \
'%s %s' % (where, order_by)
if service == 'imgur':
query = 'SELECT imgur_title AS img_title, imgur_url AS img_url FROM imgur_lookup ' \
'JOIN image_hash_lookup ON imgur_lookup.img_hash = image_hash_lookup.img_hash ' \
'%s %s' % (where, order_by)
elif service == 'cloudinary':
query = 'SELECT cloudinary_title AS img_title, cloudinary_url AS img_url FROM cloudinary_lookup ' \
'JOIN image_hash_lookup ON cloudinary_lookup.img_hash = image_hash_lookup.img_hash ' \
'%s %s' % (where, order_by)
else:
logger.warn(u"Tautulli DataFactory :: Unable to execute database query for get_img_info: "
"service not provided.")
return img_info
try:
imgur_info = monitor_db.select(query, args=args)
img_info = monitor_db.select(query, args=args)
except Exception as e:
logger.warn(u"Tautulli DataFactory :: Unable to execute database query for get_imgur_info: %s." % e)
logger.warn(u"Tautulli DataFactory :: Unable to execute database query for get_img_info: %s." % e)
return imgur_info
return img_info
def set_imgur_info(self, img_hash=None, imgur_title=None, imgur_url=None, delete_hash=None):
def set_img_info(self, img_hash=None, img_title=None, img_url=None, delete_hash=None, service=None):
monitor_db = database.MonitorDatabase()
keys = {'img_hash': img_hash}
values = {'imgur_title': imgur_title,
'imgur_url': imgur_url,
'delete_hash': delete_hash}
monitor_db.upsert('imgur_lookup', key_dict=keys, value_dict=values)
if service == 'imgur':
table = 'imgur_lookup'
values = {'imgur_title': img_title,
'imgur_url': img_url,
'delete_hash': delete_hash}
elif service == 'cloudinary':
table = 'cloudinary_lookup'
values = {'cloudinary_title': img_title,
'cloudinary_url': img_url}
else:
logger.warn(u"Tautulli DataFactory :: Unable to execute database query for set_img_info: "
"service not provided.")
return
def delete_imgur_info(self, rating_key=None):
monitor_db.upsert(table, key_dict=keys, value_dict=values)
def delete_img_info(self, rating_key=None, service='', delete_all=False):
monitor_db = database.MonitorDatabase()
if not delete_all:
service = helpers.get_img_service()
if not rating_key and not delete_all:
logger.error(u"Tautulli DataFactory :: Unable to delete hosted images: rating_key not provided.")
return False
where = ''
args = []
log_msg = ''
if rating_key:
query = 'SELECT imgur_title, delete_hash, fallback FROM imgur_lookup ' \
'JOIN image_hash_lookup ON imgur_lookup.img_hash = image_hash_lookup.img_hash ' \
'WHERE rating_key = ? '
where = 'WHERE rating_key = ?'
args = [rating_key]
log_msg = ' for rating_key %s' % rating_key
if service.lower() == 'imgur':
# Delete from Imgur
query = 'SELECT imgur_title, delete_hash, fallback FROM imgur_lookup ' \
'JOIN image_hash_lookup ON imgur_lookup.img_hash = image_hash_lookup.img_hash %s' % where
results = monitor_db.select(query, args=args)
for imgur_info in results:
@@ -1211,15 +1253,35 @@ class DataFactory(object):
img_title=imgur_info['imgur_title'],
fallback=imgur_info['fallback'])
logger.info(u"Tautulli DataFactory :: Deleting Imgur info for rating_key %s from the database."
% rating_key)
logger.info(u"Tautulli DataFactory :: Deleting Imgur info%s from the database."
% log_msg)
result = monitor_db.action('DELETE FROM imgur_lookup WHERE img_hash '
'IN (SELECT img_hash FROM image_hash_lookup WHERE rating_key = ?)',
[rating_key])
'IN (SELECT img_hash FROM image_hash_lookup %s)' % where,
args)
return True if result else False
elif service.lower() == 'cloudinary':
# Delete from Cloudinary
query = 'SELECT cloudinary_title, rating_key, fallback FROM cloudinary_lookup ' \
'JOIN image_hash_lookup ON cloudinary_lookup.img_hash = image_hash_lookup.img_hash %s ' \
'GROUP BY rating_key' % where
results = monitor_db.select(query, args=args)
def get_poster_info(self, rating_key='', metadata=None):
for cloudinary_info in results:
helpers.delete_from_cloudinary(rating_key=cloudinary_info['rating_key'])
logger.info(u"Tautulli DataFactory :: Deleting Cloudinary info%s from the database."
% log_msg)
result = monitor_db.action('DELETE FROM cloudinary_lookup WHERE img_hash '
'IN (SELECT img_hash FROM image_hash_lookup %s)' % where,
args)
else:
logger.error(u"Tautulli DataFactory :: Unable to delete hosted images: invalid service '%s' provided."
% service)
return service
def get_poster_info(self, rating_key='', metadata=None, service=None):
poster_key = ''
if str(rating_key).isdigit():
poster_key = rating_key
@@ -1234,10 +1296,17 @@ class DataFactory(object):
poster_info = {}
if poster_key:
imgur_info = self.get_imgur_info(rating_key=poster_key, order_by='height', fallback='poster')
if imgur_info:
poster_info = {'poster_title': imgur_info[0]['imgur_title'],
'poster_url': imgur_info[0]['imgur_url']}
service = service or helpers.get_img_service()
if service:
img_info = self.get_img_info(rating_key=poster_key,
order_by='height',
fallback='poster',
service=service)
if img_info:
poster_info = {'poster_title': img_info[0]['img_title'],
'poster_url': img_info[0]['img_url'],
'img_service': service.capitalize()}
return poster_info
@@ -1398,7 +1467,7 @@ class DataFactory(object):
result = monitor_db.select(query=query.format('parent_rating_key', 'rating_key'),
args=[item['parent_rating_key']])
for item in result:
key = item['media_index']
key = item['media_index'] if item['media_index'] else item['title']
children.update({key: {'rating_key': item['rating_key']}})
key = item['parent_media_index'] if match_type == 'index' else item['parent_title']
@@ -1489,8 +1558,11 @@ class DataFactory(object):
if metadata:
# Create full_title
if metadata['media_type'] == 'episode' or metadata['media_type'] == 'track':
if metadata['media_type'] == 'episode':
full_title = '%s - %s' % (metadata['grandparent_title'], metadata['title'])
elif metadata['media_type'] == 'track':
full_title = '%s - %s' % (metadata['title'],
metadata['original_title'] or metadata['grandparent_title'])
else:
full_title = metadata['title']
@@ -1505,7 +1577,8 @@ class DataFactory(object):
# Update the session_history_metadata table
query = 'UPDATE session_history_metadata SET rating_key = ?, parent_rating_key = ?, ' \
'grandparent_rating_key = ?, title = ?, parent_title = ?, grandparent_title = ?, full_title = ?, ' \
'grandparent_rating_key = ?, title = ?, parent_title = ?, grandparent_title = ?, ' \
'original_title = ?, full_title = ?, ' \
'media_index = ?, parent_media_index = ?, section_id = ?, thumb = ?, parent_thumb = ?, ' \
'grandparent_thumb = ?, art = ?, media_type = ?, year = ?, originally_available_at = ?, ' \
'added_at = ?, updated_at = ?, last_viewed_at = ?, content_rating = ?, summary = ?, ' \
@@ -1514,7 +1587,8 @@ class DataFactory(object):
'WHERE rating_key = ?'
args = [metadata['rating_key'], metadata['parent_rating_key'], metadata['grandparent_rating_key'],
metadata['title'], metadata['parent_title'], metadata['grandparent_title'], full_title,
metadata['title'], metadata['parent_title'], metadata['grandparent_title'],
metadata['original_title'], full_title,
metadata['media_index'], metadata['parent_media_index'], metadata['section_id'], metadata['thumb'],
metadata['parent_thumb'], metadata['grandparent_thumb'], metadata['art'], metadata['media_type'],
metadata['year'], metadata['originally_available_at'], metadata['added_at'], metadata['updated_at'],

View File

@@ -14,6 +14,10 @@
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
import base64
import cloudinary
from cloudinary.api import delete_resources_by_tag
from cloudinary.uploader import upload
from cloudinary.utils import cloudinary_url
import datetime
from functools import wraps
import geoip2.database, geoip2.errors
@@ -28,7 +32,6 @@ import math
import maxminddb
from operator import itemgetter
import os
from ratelimit import rate_limited
import re
import socket
import sys
@@ -206,6 +209,9 @@ def now():
now = datetime.datetime.now()
return now.strftime("%Y-%m-%d %H:%M:%S")
def utc_now_iso():
utcnow = datetime.datetime.utcnow()
return utcnow.isoformat()
def human_duration(s, sig='dhms'):
@@ -705,17 +711,26 @@ def anon_url(*url):
return '' if None in url else '%s%s' % (plexpy.CONFIG.ANON_REDIRECT, ''.join(str(s) for s in url))
@rate_limited(450, 3600)
def get_img_service(include_self=False):
if plexpy.CONFIG.NOTIFY_UPLOAD_POSTERS == 1:
return 'imgur'
elif plexpy.CONFIG.NOTIFY_UPLOAD_POSTERS == 2 and include_self:
return 'self-hosted'
elif plexpy.CONFIG.NOTIFY_UPLOAD_POSTERS == 3:
return 'cloudinary'
else:
return None
def upload_to_imgur(img_data, img_title='', rating_key='', fallback=''):
""" Uploads an image to Imgur """
client_id = plexpy.CONFIG.IMGUR_CLIENT_ID
img_url = delete_hash = ''
if not client_id:
logger.error(u"Tautulli Helpers :: Cannot upload poster to Imgur. No Imgur client id specified in the settings.")
if not plexpy.CONFIG.IMGUR_CLIENT_ID:
logger.error(u"Tautulli Helpers :: Cannot upload image to Imgur. No Imgur client id specified in the settings.")
return img_url, delete_hash
headers = {'Authorization': 'Client-ID %s' % client_id}
headers = {'Authorization': 'Client-ID %s' % plexpy.CONFIG.IMGUR_CLIENT_ID}
data = {'image': base64.b64encode(img_data),
'title': img_title.encode('utf-8'),
'name': str(rating_key) + '.png',
@@ -743,9 +758,11 @@ def upload_to_imgur(img_data, img_title='', rating_key='', fallback=''):
def delete_from_imgur(delete_hash, img_title='', fallback=''):
""" Deletes an image from Imgur """
client_id = plexpy.CONFIG.IMGUR_CLIENT_ID
if not plexpy.CONFIG.IMGUR_CLIENT_ID:
logger.error(u"Tautulli Helpers :: Cannot delete image from Imgur. No Imgur client id specified in the settings.")
return False
headers = {'Authorization': 'Client-ID %s' % client_id}
headers = {'Authorization': 'Client-ID %s' % plexpy.CONFIG.IMGUR_CLIENT_ID}
response, err_msg, req_msg = request.request_response2('https://api.imgur.com/3/image/%s' % delete_hash, 'DELETE',
headers=headers)
@@ -761,6 +778,93 @@ def delete_from_imgur(delete_hash, img_title='', fallback=''):
return False
def upload_to_cloudinary(img_data, img_title='', rating_key='', fallback=''):
""" Uploads an image to Cloudinary """
img_url = ''
if not plexpy.CONFIG.CLOUDINARY_CLOUD_NAME or not plexpy.CONFIG.CLOUDINARY_API_KEY or not plexpy.CONFIG.CLOUDINARY_API_SECRET:
logger.error(u"Tautulli Helpers :: Cannot upload image to Cloudinary. Cloudinary settings not specified in the settings.")
return img_url
cloudinary.config(
cloud_name=plexpy.CONFIG.CLOUDINARY_CLOUD_NAME,
api_key=plexpy.CONFIG.CLOUDINARY_API_KEY,
api_secret=plexpy.CONFIG.CLOUDINARY_API_SECRET
)
try:
response = upload('data:image/png;base64,{}'.format(base64.b64encode(img_data)),
public_id='{}_{}'.format(fallback, rating_key),
tags=[fallback, str(rating_key)],
context={'title': img_title.encode('utf-8'), 'rating_key': str(rating_key), 'fallback': fallback})
logger.debug(u"Tautulli Helpers :: Image '{}' ({}) uploaded to Cloudinary.".format(img_title, fallback))
img_url = response.get('url', '')
except Exception as e:
logger.error(u"Tautulli Helpers :: Unable to upload image '{}' ({}) to Cloudinary: {}".format(img_title, fallback, e))
return img_url
def delete_from_cloudinary(rating_key):
""" Deletes an image from Cloudinary """
if not plexpy.CONFIG.CLOUDINARY_CLOUD_NAME or not plexpy.CONFIG.CLOUDINARY_API_KEY or not plexpy.CONFIG.CLOUDINARY_API_SECRET:
logger.error(u"Tautulli Helpers :: Cannot delete image from Cloudinary. Cloudinary settings not specified in the settings.")
return False
cloudinary.config(
cloud_name=plexpy.CONFIG.CLOUDINARY_CLOUD_NAME,
api_key=plexpy.CONFIG.CLOUDINARY_API_KEY,
api_secret=plexpy.CONFIG.CLOUDINARY_API_SECRET
)
delete_resources_by_tag(str(rating_key))
logger.debug(u"Tautulli Helpers :: Deleted images from Cloudinary with rating_key {}.".format(rating_key))
return True
def cloudinary_transform(rating_key=None, width=1000, height=1500, opacity=100, background='000000', blur=0,
img_format='png', img_title='', fallback=None):
url = ''
if not plexpy.CONFIG.CLOUDINARY_CLOUD_NAME or not plexpy.CONFIG.CLOUDINARY_API_KEY or not plexpy.CONFIG.CLOUDINARY_API_SECRET:
logger.error(u"Tautulli Helpers :: Cannot transform image on Cloudinary. Cloudinary settings not specified in the settings.")
return url
cloudinary.config(
cloud_name=plexpy.CONFIG.CLOUDINARY_CLOUD_NAME,
api_key=plexpy.CONFIG.CLOUDINARY_API_KEY,
api_secret=plexpy.CONFIG.CLOUDINARY_API_SECRET
)
img_options = {'format': img_format,
'fetch_format': 'auto',
'quality': 'auto',
'version': int(time.time()),
'secure': True}
if width != 1000:
img_options['width'] = str(width)
img_options['crop'] = 'fill'
if height != 1500:
img_options['height'] = str(height)
img_options['crop'] = 'fill'
if opacity != 100:
img_options['opacity'] = opacity
if background != '000000':
img_options['background'] = 'rgb:{}'.format(background)
if blur != 0:
img_options['effect'] = 'blur:{}'.format(blur * 100)
try:
url, options = cloudinary_url('{}_{}'.format(fallback, rating_key), **img_options)
logger.debug(u"Tautulli Helpers :: Image '{}' ({}) transformed on Cloudinary.".format(img_title, fallback))
except Exception as e:
logger.error(u"Tautulli Helpers :: Unable to transform image '{}' ({}) on Cloudinary: {}".format(img_title, fallback, e))
return url
def cache_image(url, image=None):
"""
Saves an image to the cache directory.
@@ -972,7 +1076,10 @@ def get_plexpy_url(hostname=None):
s.connect(('<broadcast>', 0))
hostname = s.getsockname()[0]
except socket.error:
hostname = socket.gethostbyname(socket.gethostname())
try:
hostname = socket.gethostbyname(socket.gethostname())
except socket.gaierror:
pass
if not hostname:
hostname = 'localhost'

View File

@@ -39,12 +39,13 @@ class HTTPHandler(object):
else:
self.urls = urls
self.headers = {'X-Plex-Device-Name': 'Tautulli',
'X-Plex-Product': 'Tautulli',
self.headers = {'X-Plex-Product': 'Tautulli',
'X-Plex-Version': plexpy.common.RELEASE,
'X-Plex-Platform': plexpy.common.PLATFORM,
'X-Plex-Platform-Version': plexpy.common.PLATFORM_VERSION,
'X-Plex-Client-Identifier': plexpy.CONFIG.PMS_UUID,
'X-Plex-Platform': plexpy.common.PLATFORM,
'X-Plex-Platform-Version': plexpy.common.PLATFORM_RELEASE,
'X-Plex-Device': 'Web',
'X-Plex-Device-Name': plexpy.common.PLATFORM_DEVICE_NAME
}
self.token = token

View File

@@ -862,13 +862,13 @@ class Libraries(object):
if str(section_id).isdigit():
query = 'SELECT session_history.id, session_history.media_type, ' \
'session_history.rating_key, session_history.parent_rating_key, session_history.grandparent_rating_key, ' \
'title, parent_title, grandparent_title, thumb, parent_thumb, grandparent_thumb, media_index, parent_media_index, ' \
'title, parent_title, grandparent_title, original_title, ' \
'thumb, parent_thumb, grandparent_thumb, media_index, parent_media_index, ' \
'year, started, user, content_rating, labels, section_id ' \
'FROM session_history_metadata ' \
'JOIN session_history ON session_history_metadata.id = session_history.id ' \
'WHERE section_id = ? ' \
'GROUP BY (CASE WHEN session_history.media_type = "track" THEN session_history.parent_rating_key ' \
' ELSE session_history.rating_key END) ' \
'GROUP BY session_history.rating_key ' \
'ORDER BY started DESC LIMIT ?'
result = monitor_db.select(query, args=[section_id, limit])
else:
@@ -893,6 +893,7 @@ class Libraries(object):
'title': row['title'],
'parent_title': row['parent_title'],
'grandparent_title': row['grandparent_title'],
'original_title': row['original_title'],
'thumb': thumb,
'media_index': row['media_index'],
'parent_media_index': row['parent_media_index'],

View File

@@ -86,7 +86,9 @@ def notify(newsletter_id=None, notify_action=None, **kwargs):
body = newsletter_config['body']
message = newsletter_config['message']
newsletter_agent = newsletters.get_agent_class(agent_id=newsletter_config['agent_id'],
newsletter_agent = newsletters.get_agent_class(newsletter_id=newsletter_id,
newsletter_id_name=newsletter_config['id_name'],
agent_id=newsletter_config['agent_id'],
config=newsletter_config['config'],
email_config=newsletter_config['email_config'],
subject=subject,
@@ -100,6 +102,7 @@ def notify(newsletter_id=None, notify_action=None, **kwargs):
subject=newsletter_agent.subject_formatted,
body=newsletter_agent.body_formatted,
message=newsletter_agent.message_formatted,
filename=newsletter_agent.filename_formatted,
start_date=newsletter_agent.start_date.format('YYYY-MM-DD'),
end_date=newsletter_agent.end_date.format('YYYY-MM-DD'),
start_time=newsletter_agent.start_time,
@@ -114,7 +117,7 @@ def notify(newsletter_id=None, notify_action=None, **kwargs):
return True
def set_notify_state(newsletter, notify_action, subject, body, message,
def set_notify_state(newsletter, notify_action, subject, body, message, filename,
start_date, end_date, start_time, end_time, newsletter_uuid):
if newsletter and notify_action:
@@ -133,7 +136,8 @@ def set_notify_state(newsletter, notify_action, subject, body, message,
'start_date': start_date,
'end_date': end_date,
'start_time': start_time,
'end_time': end_time}
'end_time': end_time,
'filename': filename}
db.upsert(table_name='newsletter_log', key_dict=keys, value_dict=values)
return db.last_insert_id()
@@ -149,20 +153,29 @@ def set_notify_success(newsletter_log_id):
db.upsert(table_name='newsletter_log', key_dict=keys, value_dict=values)
def get_newsletter(newsletter_uuid):
def get_newsletter(newsletter_uuid=None, newsletter_id_name=None):
db = database.MonitorDatabase()
result = db.select_single('SELECT newsletter_id, start_date, end_date FROM newsletter_log '
'WHERE uuid = ?', [newsletter_uuid])
if newsletter_uuid:
result = db.select_single('SELECT start_date, end_date, uuid, filename FROM newsletter_log '
'WHERE uuid = ?', [newsletter_uuid])
elif newsletter_id_name:
result = db.select_single('SELECT start_date, end_date, uuid, filename FROM newsletter_log '
'JOIN newsletters ON newsletters.id = newsletter_log.newsletter_id '
'WHERE id_name = ? AND notify_action != "test" '
'ORDER BY timestamp DESC LIMIT 1', [newsletter_id_name])
else:
result = None
if result:
newsletter_id = result['newsletter_id']
newsletter_uuid = result['uuid']
start_date = result['start_date']
end_date = result['end_date']
newsletter_file = result['filename'] or 'newsletter_%s-%s_%s.html' % (start_date.replace('-', ''),
end_date.replace('-', ''),
newsletter_uuid)
newsletter_file = 'newsletter_%s-%s_%s.html' % (start_date.replace('-', ''),
end_date.replace('-', ''),
newsletter_uuid)
newsletter_folder = plexpy.CONFIG.NEWSLETTER_DIR
newsletter_folder = plexpy.CONFIG.NEWSLETTER_DIR or os.path.join(plexpy.DATA_DIR, 'newsletters')
newsletter_file_fp = os.path.join(newsletter_folder, newsletter_file)
if newsletter_file in os.listdir(newsletter_folder):
@@ -173,4 +186,4 @@ def get_newsletter(newsletter_uuid):
except OSError as e:
logger.error(u"Tautulli NewsletterHandler :: Failed to retrieve newsletter '%s': %s" % (newsletter_uuid, e))
else:
logger.warn(u"Tautulli NewsletterHandler :: Newsletter '%s' file is missing." % newsletter_uuid)
logger.warn(u"Tautulli NewsletterHandler :: Newsletter file '%s' is missing." % newsletter_file)

View File

@@ -19,6 +19,7 @@ from itertools import groupby
from mako.lookup import TemplateLookup
from mako import exceptions
import os
import re
import plexpy
import common
@@ -63,12 +64,14 @@ def available_notification_actions():
return actions
def get_agent_class(agent_id=None, config=None, email_config=None, start_date=None, end_date=None,
subject=None, body=None, message=None):
def get_agent_class(newsletter_id=None, newsletter_id_name=None, agent_id=None, config=None, email_config=None,
start_date=None, end_date=None, subject=None, body=None, message=None):
if str(agent_id).isdigit():
agent_id = int(agent_id)
kwargs = {'config': config,
kwargs = {'newsletter_id': newsletter_id,
'newsletter_id_name': newsletter_id_name,
'config': config,
'email_config': email_config,
'start_date': start_date,
'end_date': end_date,
@@ -138,7 +141,9 @@ def get_newsletter_config(newsletter_id=None):
subject = result.pop('subject')
body = result.pop('body')
message = result.pop('message')
newsletter_agent = get_agent_class(agent_id=result['agent_id'], config=config, email_config=email_config,
newsletter_agent = get_agent_class(newsletter_id=newsletter_id, newsletter_id_name=result['id_name'],
agent_id=result['agent_id'],
config=config, email_config=email_config,
subject=subject, body=body, message=message)
except Exception as e:
logger.error(u"Tautulli Newsletters :: Failed to get newsletter config options: %s." % e)
@@ -176,6 +181,7 @@ def add_newsletter_config(agent_id=None, **kwargs):
values = {'agent_id': agent['id'],
'agent_name': agent['name'],
'agent_label': agent['label'],
'id_name': '',
'friendly_name': '',
'newsletter_config': json.dumps(agent_class.config),
'email_config': json.dumps(agent_class.email_config),
@@ -200,7 +206,7 @@ def set_newsletter_config(newsletter_id=None, agent_id=None, **kwargs):
if str(agent_id).isdigit():
agent_id = int(agent_id)
else:
logger.error(u"Tautulli Newsletters :: Unable to set exisiting newsletter: invalid agent_id %s."
logger.error(u"Tautulli Newsletters :: Unable to set existing newsletter: invalid agent_id %s."
% agent_id)
return False
@@ -223,13 +229,15 @@ def set_newsletter_config(newsletter_id=None, agent_id=None, **kwargs):
body = kwargs.pop('body')
message = kwargs.pop('message')
agent_class = get_agent_class(agent_id=agent['id'], config=newsletter_config, email_config=email_config,
agent_class = get_agent_class(agent_id=agent['id'],
config=newsletter_config, email_config=email_config,
subject=subject, body=body, message=message)
keys = {'id': newsletter_id}
values = {'agent_id': agent['id'],
'agent_name': agent['name'],
'agent_label': agent['label'],
'id_name': kwargs.get('id_name', ''),
'friendly_name': kwargs.get('friendly_name', ''),
'newsletter_config': json.dumps(agent_class.config),
'email_config': json.dumps(agent_class.email_config),
@@ -267,16 +275,22 @@ def send_newsletter(newsletter_id=None, subject=None, body=None, message=None, n
def serve_template(templatename, **kwargs):
interface_dir = os.path.join(str(plexpy.PROG_DIR), 'data/interfaces/')
template_dir = os.path.join(str(interface_dir), plexpy.CONFIG.NEWSLETTER_TEMPLATES)
if plexpy.CONFIG.NEWSLETTER_CUSTOM_DIR:
template_dir = plexpy.CONFIG.NEWSLETTER_CUSTOM_DIR
else:
interface_dir = os.path.join(str(plexpy.PROG_DIR), 'data/interfaces/')
template_dir = os.path.join(str(interface_dir), plexpy.CONFIG.NEWSLETTER_TEMPLATES)
if not plexpy.CONFIG.NEWSLETTER_INLINE_STYLES:
templatename = templatename.replace('.html', '.internal.html')
_hplookup = TemplateLookup(directories=[template_dir], default_filters=['unicode', 'h'])
try:
template = _hplookup.get_template(templatename)
return template.render(**kwargs)
return template.render(**kwargs), False
except:
return exceptions.html_error_template().render()
return exceptions.html_error_template().render(), True
def generate_newsletter_uuid():
@@ -299,22 +313,26 @@ class Newsletter(object):
'time_frame': 7,
'time_frame_units': 'days',
'formatted': 1,
'notifier_id': 0}
'notifier_id': 0,
'filename': '',
'save_only': 0}
_DEFAULT_EMAIL_CONFIG = EMAIL().return_default_config()
_DEFAULT_EMAIL_CONFIG['from_name'] = 'Tautulli Newsletter'
_DEFAULT_EMAIL_CONFIG['notifier_id'] = 0
_DEFAULT_SUBJECT = 'Tautulli Newsletter'
_DEFAULT_BODY = 'View the newsletter here: {newsletter_url}'
_DEFAULT_MESSAGE = ''
_TEMPLATE_MASTER = ''
_DEFAULT_FILENAME = 'newsletter_{newsletter_uuid}.html'
_TEMPLATE = ''
def __init__(self, config=None, email_config=None, start_date=None, end_date=None,
subject=None, body=None, message=None):
def __init__(self, newsletter_id=None, newsletter_id_name=None, config=None, email_config=None,
start_date=None, end_date=None, subject=None, body=None, message=None):
self.config = self.set_config(config=config, default=self._DEFAULT_CONFIG)
self.email_config = self.set_config(config=email_config, default=self._DEFAULT_EMAIL_CONFIG)
self.uuid = generate_newsletter_uuid()
self.newsletter_id = newsletter_id
self.newsletter_id_name = newsletter_id_name or ''
self.start_date = None
self.end_date = None
@@ -346,12 +364,19 @@ class Newsletter(object):
self.subject = subject or self._DEFAULT_SUBJECT
self.body = body or self._DEFAULT_BODY
self.message = message or self._DEFAULT_MESSAGE
self.filename = self.config['filename'] or self._DEFAULT_FILENAME
if not self.filename.endswith('.html'):
self.filename += '.html'
self.subject_formatted, self.body_formatted, self.message_formatted = self.build_text()
self.filename_formatted = self.build_filename()
self.data = {}
self.newsletter = None
self.is_preview = False
self.template_error = None
def set_config(self, config=None, default=None):
return self._validate_config(config=config, default=default)
@@ -391,19 +416,14 @@ class Newsletter(object):
'parameters': self.parameters,
'data': self.data}
def generate_newsletter(self, preview=False, master=False):
def generate_newsletter(self, preview=False):
if preview:
self.is_preview = True
if master:
template = self._TEMPLATE_MASTER
else:
template = self._TEMPLATE
self.retrieve_data()
return serve_template(
templatename=template,
newsletter_rendered, self.template_error = serve_template(
templatename=self._TEMPLATE,
uuid=self.uuid,
subject=self.subject_formatted,
body=self.body_formatted,
@@ -413,21 +433,50 @@ class Newsletter(object):
preview=self.is_preview
)
if self.template_error:
return newsletter_rendered
# Force Tautulli footer
if '<!-- FOOTER MESSAGE - DO NOT REMOVE -->' in newsletter_rendered:
newsletter_rendered = newsletter_rendered.replace(
'<!-- FOOTER MESSAGE - DO NOT REMOVE -->',
'Newsletter generated by <a href="https://tautulli.com" target="_blank" '
'style="text-decoration: underline;color: inherit;font-size: inherit;">Tautulli</a>.'
)
else:
msg = ('<div style="text-align: center;padding-top: 100px;padding-bottom: 100px;">'
'<p style="font-family: \'Open Sans\', Helvetica, Arial, sans-serif;color: #282A2D;'
'font-size: 18px;line-height: 30px;">'
'The Tautulli newsletter footer was removed from the newsletter template.<br>'
'Please leave the footer in place as it is unobtrusive and supports '
'<a href="https://tautulli.com" target="_blank">Tautulli</a>.<br>Thank you.'
'</p></div>')
newsletter_rendered = re.sub(r'(<body.*?>)', r'\1' + msg, newsletter_rendered)
return newsletter_rendered
def send(self):
self.newsletter = self.generate_newsletter()
if self.template_error:
logger.error(u"Tautulli Newsletters :: %s newsletter failed to render template. Newsletter not sent." % self.NAME)
return False
if not self._has_data():
logger.warn(u"Tautulli Newsletters :: %s newsletter has no data. Newsletter not sent." % self.NAME)
return False
self._save()
if self.config['save_only']:
return True
return self._send()
def _save(self):
newsletter_file = 'newsletter_%s-%s_%s.html' % (self.start_date.format('YYYYMMDD'),
self.end_date.format('YYYYMMDD'),
self.uuid)
newsletter_folder = plexpy.CONFIG.NEWSLETTER_DIR
newsletter_file = self.filename_formatted
newsletter_folder = plexpy.CONFIG.NEWSLETTER_DIR or os.path.join(plexpy.DATA_DIR, 'newsletters')
newsletter_file_fp = os.path.join(newsletter_folder, newsletter_file)
# In case the user has deleted it manually
@@ -439,26 +488,35 @@ class Newsletter(object):
for line in self.newsletter.encode('utf-8').splitlines():
if '<!-- IGNORE SAVE -->' not in line:
n_file.write(line + '\r\n')
#n_file.write(line.strip())
logger.info(u"Tautulli Newsletters :: %s newsletter saved to %s" % (self.NAME, newsletter_file))
logger.info(u"Tautulli Newsletters :: %s newsletter saved to '%s'" % (self.NAME, newsletter_file))
except OSError as e:
logger.error(u"Tautulli Newsletters :: Failed to save %s newsletter to %s: %s"
logger.error(u"Tautulli Newsletters :: Failed to save %s newsletter to '%s': %s"
% (self.NAME, newsletter_file, e))
def _send(self):
if self.config['formatted']:
newsletter_stripped = ''.join(l.strip() for l in self.newsletter.splitlines())
plaintext = 'HTML email support is required to view the newsletter.\n'
if plexpy.CONFIG.NEWSLETTER_SELF_HOSTED and plexpy.CONFIG.HTTP_BASE_URL:
plaintext += self._DEFAULT_BODY.format(**self.parameters)
if self.email_config['notifier_id']:
return send_notification(
notifier_id=self.email_config['notifier_id'],
subject=self.subject_formatted,
body=self.newsletter
body=newsletter_stripped,
plaintext=plaintext
)
else:
email = EMAIL(config=self.email_config)
return email.notify(
subject=self.subject_formatted,
body=self.newsletter
body=newsletter_stripped,
plaintext=plaintext
)
elif self.config['notifier_id']:
return send_notification(
@@ -475,7 +533,10 @@ class Newsletter(object):
def _build_params(self):
date_format = helpers.momentjs_to_arrow(plexpy.CONFIG.DATE_FORMAT)
base_url = plexpy.CONFIG.HTTP_BASE_URL or helpers.get_plexpy_url()
if plexpy.CONFIG.NEWSLETTER_SELF_HOSTED and plexpy.CONFIG.HTTP_BASE_URL:
base_url = plexpy.CONFIG.HTTP_BASE_URL + plexpy.HTTP_ROOT + 'newsletter/'
else:
base_url = helpers.get_plexpy_url() + '/newsletter/'
parameters = {
'server_name': plexpy.CONFIG.PMS_NAME,
@@ -484,8 +545,12 @@ class Newsletter(object):
'week_number': self.start_date.isocalendar()[1],
'newsletter_time_frame': self.config['time_frame'],
'newsletter_time_frame_units': self.config['time_frame_units'],
'newsletter_url': base_url.rstrip('/') + plexpy.HTTP_ROOT + 'newsletter/' + self.uuid,
'newsletter_uuid': self.uuid
'newsletter_url': base_url + self.uuid,
'newsletter_static_url': base_url + 'id/' + self.newsletter_id_name,
'newsletter_uuid': self.uuid,
'newsletter_id': self.newsletter_id,
'newsletter_id_name': self.newsletter_id_name,
'newsletter_password': plexpy.CONFIG.NEWSLETTER_PASSWORD
}
return parameters
@@ -529,6 +594,23 @@ class Newsletter(object):
return subject, body, message
def build_filename(self):
from notification_handler import CustomFormatter
custom_formatter = CustomFormatter()
try:
filename = custom_formatter.format(unicode(self.filename), **self.parameters)
except LookupError as e:
logger.error(
u"Tautulli Newsletter :: Unable to parse parameter %s in newsletter filename. Using fallback." % e)
filename = unicode(self._DEFAULT_FILENAME).format(**self.parameters)
except Exception as e:
logger.error(
u"Tautulli Newsletter :: Unable to parse custom newsletter subject: %s. Using fallback." % e)
filename = unicode(self._DEFAULT_FILENAME).format(**self.parameters)
return filename
def return_config_options(self):
return self._return_config_options()
@@ -554,7 +636,6 @@ class RecentlyAdded(Newsletter):
_DEFAULT_SUBJECT = 'Recently Added to {server_name}! ({end_date})'
_DEFAULT_BODY = 'View the newsletter here: {newsletter_url}'
_DEFAULT_MESSAGE = ''
_TEMPLATE_MASTER = 'recently_added_master.html'
_TEMPLATE = 'recently_added.html'
def _get_recently_added(self, media_type=None):
@@ -674,7 +755,7 @@ class RecentlyAdded(Newsletter):
return recently_added
def retrieve_data(self):
from notification_handler import get_imgur_info, set_hash_image_info
from notification_handler import get_img_info, set_hash_image_info
if not self.config['incl_libraries']:
logger.warn(u"Tautulli Newsletters :: Failed to retrieve %s newsletter data: no libraries selected." % self.NAME)
@@ -692,7 +773,7 @@ class RecentlyAdded(Newsletter):
artists = recently_added.get('artist', [])
albums = [a for artist in artists for a in artist['album']]
if self.is_preview or plexpy.CONFIG.NEWSLETTER_SELF_HOSTED:
if self.is_preview or helpers.get_img_service(include_self=True) == 'self-hosted':
for item in movies + shows + albums:
if item['media_type'] == 'album':
height = 150
@@ -711,11 +792,12 @@ class RecentlyAdded(Newsletter):
else:
item['art_hash'] = ''
item['poster_url'] = ''
item['thumb_url'] = ''
item['art_url'] = ''
item['poster_url'] = item['thumb_url'] # Keep for backwards compatibility
else:
# Upload posters and art to Imgur
elif helpers.get_img_service():
# Upload posters and art to image hosting service
for item in movies + shows + albums:
if item['media_type'] == 'album':
height = 150
@@ -724,20 +806,29 @@ class RecentlyAdded(Newsletter):
height = 225
fallback = 'poster'
imgur_info = get_imgur_info(
img_info = get_img_info(
img=item['thumb'], rating_key=item['rating_key'], title=item['title'],
width=150, height=height, fallback=fallback)
item['poster_url'] = imgur_info.get('imgur_url') or common.ONLINE_POSTER_THUMB
item['thumb_url'] = img_info.get('img_url') or common.ONLINE_POSTER_THUMB
imgur_info = get_imgur_info(
img_info = get_img_info(
img=item['art'], rating_key=item['rating_key'], title=item['title'],
width=500, height=280, opacity=25, background='282828', blur=3, fallback='art')
item['art_url'] = imgur_info.get('imgur_url')
item['art_url'] = img_info.get('img_url')
item['thumb_hash'] = ''
item['art_hash'] = ''
item['poster_url'] = item['thumb_url'] # Keep for backwards compatibility
else:
for item in movies + shows + albums:
item['thumb_hash'] = ''
item['art_hash'] = ''
item['thumb_url'] = ''
item['art_url'] = ''
item['poster_url'] = item['thumb_url'] # Keep for backwards compatibility
self.data['recently_added'] = recently_added
@@ -797,4 +888,4 @@ class RecentlyAdded(Newsletter):
}
]
return config_options + additional_config
return additional_config + config_options

View File

@@ -169,7 +169,7 @@ def notify_conditions(notify_action=None, stream_data=None, timeline_data=None):
user_devices = data_factory.get_user_devices(user_id=stream_data['user_id'])
return stream_data['machine_id'] not in user_devices
elif stream_data['media_type'] == 'movie' or stream_data['media_type'] == 'episode':
elif stream_data['media_type'] in ('movie', 'episode', 'clip'):
progress_percent = helpers.get_percent(stream_data['view_offset'], stream_data['duration'])
if notify_action == 'on_stop':
@@ -317,14 +317,16 @@ def notify_custom_conditions(notifier_id=None, parameters=None):
else:
evaluated_logic = all(evaluated_conditions[1:])
logger.debug(u"Tautulli NotificationHandler :: Custom condition evaluated to '%s'." % str(evaluated_logic))
logger.debug(u"Tautulli NotificationHandler :: Custom condition evaluated to '{}'. Conditions: {}.".format(
evaluated_logic, evaluated_conditions[1:]))
return evaluated_logic
return True
def notify(notifier_id=None, notify_action=None, stream_data=None, timeline_data=None, parameters=None, **kwargs):
logger.info(u"Tautulli NotificationHandler :: Preparing notifications for notifier_id %s." % notifier_id)
logger.info(u"Tautulli NotificationHandler :: Preparing notification for notifier_id %s." % notifier_id)
notifier_config = notifiers.get_notifier_config(notifier_id=notifier_id)
@@ -631,12 +633,15 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
notify_params['parent_title'])
else:
poster_thumb = ''
poster_key = ''
poster_title = ''
if plexpy.CONFIG.NOTIFY_UPLOAD_POSTERS == 1:
imgur_info = get_imgur_info(img=poster_thumb, rating_key=poster_key, title=poster_title, fallback='poster')
poster_info = {'poster_title': imgur_info['imgur_title'], 'poster_url': imgur_info['imgur_url']}
img_service = helpers.get_img_service(include_self=True)
if img_service not in (None, 'self-hosted'):
img_info = get_img_info(img=poster_thumb, rating_key=poster_key, title=poster_title, fallback='poster')
poster_info = {'poster_title': img_info['img_title'], 'poster_url': img_info['img_url']}
notify_params.update(poster_info)
elif plexpy.CONFIG.NOTIFY_UPLOAD_POSTERS == 2 and plexpy.CONFIG.HTTP_BASE_URL:
elif img_service == 'self-hosted' and plexpy.CONFIG.HTTP_BASE_URL:
img_hash = set_hash_image_info(img=poster_thumb, fallback='poster')
poster_info = {'poster_title': poster_title,
'poster_url': plexpy.CONFIG.HTTP_BASE_URL + plexpy.HTTP_ROOT + 'image/' + img_hash}
@@ -709,7 +714,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'server_machine_id': plexpy.CONFIG.PMS_IDENTIFIER,
'server_platform': plexpy.CONFIG.PMS_PLATFORM,
'server_version': plexpy.CONFIG.PMS_VERSION,
'action': notify_action.lstrip('on_'),
'action': notify_action.split('on_')[-1],
'week_number': arrow.now().isocalendar()[1],
'datestamp': arrow.now().format(date_format),
'timestamp': arrow.now().format(time_format),
@@ -739,6 +744,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'optimized_version': notify_params['optimized_version'],
'optimized_version_profile': notify_params['optimized_version_profile'],
'synced_version': notify_params['synced_version'],
'live': notify_params['live'],
'stream_local': notify_params['local'],
'stream_location': notify_params['location'],
'stream_bandwidth': notify_params['bandwidth'],
@@ -799,6 +805,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'artist_name': artist_name,
'album_name': album_name,
'track_name': track_name,
'track_artist': notify_params['original_title'] or notify_params['grandparent_title'],
'season_num': season_num,
'season_num00': season_num00,
'episode_num': episode_num,
@@ -876,6 +883,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'subtitle_language': notify_params['subtitle_language'],
'subtitle_language_code': notify_params['subtitle_language_code'],
'file': notify_params['file'],
'filename': os.path.basename(notify_params['file']),
'file_size': helpers.humanFileSize(notify_params['file_size']),
'indexes': notify_params['indexes'],
'section_id': notify_params['section_id'],
@@ -914,7 +922,7 @@ def build_server_notify_params(notify_action=None, **kwargs):
'server_platform': plexpy.CONFIG.PMS_PLATFORM,
'server_version': plexpy.CONFIG.PMS_VERSION,
'server_machine_id': plexpy.CONFIG.PMS_IDENTIFIER,
'action': notify_action.lstrip('on_'),
'action': notify_action.split('on_')[-1],
'datestamp': arrow.now().format(date_format),
'timestamp': arrow.now().format(time_format),
'unixtime': int(time.time()),
@@ -1076,49 +1084,109 @@ def format_group_index(group_keys):
return ','.join(num) or '0', ','.join(num00) or '00'
def get_imgur_info(img=None, rating_key=None, title='', width=600, height=1000,
opacity=100, background='000000', blur=0, fallback=None):
imgur_info = {'imgur_title': '', 'imgur_url': ''}
def get_img_info(img=None, rating_key=None, title='', width=1000, height=1500,
opacity=100, background='000000', blur=0, fallback=None):
img_info = {'img_title': '', 'img_url': ''}
image_info = {'img': img,
'rating_key': rating_key,
'width': width,
'height': height,
'opacity': opacity,
'background': background,
'blur': blur,
'fallback': fallback}
if not rating_key and not img:
return img_info
if rating_key and not img:
if fallback == 'art':
img = '/library/metadata/{}/art'.format(rating_key)
else:
img = '/library/metadata/{}/thumb'.format(rating_key)
img_split = img.split('/')
img = '/'.join(img_split[:5])
rating_key = rating_key or img_split[3]
service = helpers.get_img_service()
if service is None:
return img_info
elif service == 'cloudinary':
if fallback == 'cover':
w, h = 1000, 1000
elif fallback == 'art':
w, h = 1920, 1080
else:
w, h = 1000, 1500
image_info = {'img': img,
'rating_key': rating_key,
'width': w,
'height': h,
'opacity': 100,
'background': '000000',
'blur': 0,
'fallback': fallback}
else:
image_info = {'img': img,
'rating_key': rating_key,
'width': width,
'height': height,
'opacity': opacity,
'background': background,
'blur': blur,
'fallback': fallback}
# Try to retrieve poster info from the database
data_factory = datafactory.DataFactory()
database_imgur_info = data_factory.get_imgur_info(**image_info)
database_img_info = data_factory.get_img_info(service=service, **image_info)
if database_imgur_info:
imgur_info = database_imgur_info[0]
if database_img_info:
img_info = database_img_info[0]
elif not database_imgur_info and img:
elif not database_img_info and img:
pms_connect = pmsconnect.PmsConnect()
result = pms_connect.get_image(**image_info)
result = pms_connect.get_image(refresh=True, **image_info)
if result and result[0]:
imgur_url, delete_hash = helpers.upload_to_imgur(img_data=result[0],
img_title=title,
rating_key=rating_key,
fallback=fallback)
img_url = delete_hash = ''
if imgur_url:
if service == 'imgur':
img_url, delete_hash = helpers.upload_to_imgur(img_data=result[0],
img_title=title,
rating_key=rating_key,
fallback=fallback)
elif service == 'cloudinary':
img_url = helpers.upload_to_cloudinary(img_data=result[0],
img_title=title,
rating_key=rating_key,
fallback=fallback)
if img_url:
img_hash = set_hash_image_info(**image_info)
data_factory.set_imgur_info(img_hash=img_hash,
imgur_title=title,
imgur_url=imgur_url,
delete_hash=delete_hash)
data_factory.set_img_info(img_hash=img_hash,
img_title=title,
img_url=img_url,
delete_hash=delete_hash,
service=service)
imgur_info = {'imgur_title': title, 'imgur_url': imgur_url}
img_info = {'img_title': title, 'img_url': img_url}
return imgur_info
if img_info['img_url'] and service == 'cloudinary':
# Transform image using Cloudinary
image_info = {'rating_key': rating_key,
'width': width,
'height': height,
'opacity': opacity,
'background': background,
'blur': blur,
'fallback': fallback,
'img_title': title}
transformed_url = helpers.cloudinary_transform(**image_info)
if transformed_url:
img_info['img_url'] = transformed_url
return img_info
def set_hash_image_info(img=None, rating_key=None, width=600, height=1000,
def set_hash_image_info(img=None, rating_key=None, width=750, height=1000,
opacity=100, background='000000', blur=0, fallback=None):
if not rating_key and not img:
return fallback

View File

@@ -509,7 +509,7 @@ def add_notifier_config(agent_id=None, **kwargs):
'agent_name': agent['name'],
'agent_label': agent['label'],
'friendly_name': '',
'notifier_config': json.dumps(get_agent_class(agent_id=agent['id']).config),
'notifier_config': json.dumps(agent_class.config),
'custom_conditions': json.dumps(DEFAULT_CUSTOM_CONDITIONS),
'custom_conditions_logic': ''
}
@@ -540,7 +540,7 @@ def set_notifier_config(notifier_id=None, agent_id=None, **kwargs):
if str(agent_id).isdigit():
agent_id = int(agent_id)
else:
logger.error(u"Tautulli Notifiers :: Unable to set exisiting notifier: invalid agent_id %s."
logger.error(u"Tautulli Notifiers :: Unable to set existing notifier: invalid agent_id %s."
% agent_id)
return False
@@ -570,7 +570,7 @@ def set_notifier_config(notifier_id=None, agent_id=None, **kwargs):
'agent_name': agent['name'],
'agent_label': agent['label'],
'friendly_name': kwargs.get('friendly_name', ''),
'notifier_config': json.dumps(notifier_config),
'notifier_config': json.dumps(agent_class.config),
'custom_conditions': kwargs.get('custom_conditions', json.dumps(DEFAULT_CUSTOM_CONDITIONS)),
'custom_conditions_logic': kwargs.get('custom_conditions_logic', ''),
}
@@ -661,9 +661,9 @@ class PrettyMetadata(object):
poster_url = self.parameters['poster_url']
if not poster_url:
if self.media_type in ('artist', 'album', 'track'):
poster_url = 'http://tautulli.com/images/cover.png'
poster_url = common.ONLINE_COVER_THUMB
else:
poster_url = 'http://tautulli.com/images/poster.png'
poster_url = common.ONLINE_POSTER_THUMB
return poster_url
def get_provider_name(self, provider):
@@ -728,7 +728,7 @@ class PrettyMetadata(object):
elif self.media_type == 'album':
title = '%s - %s' % (self.parameters['artist_name'], self.parameters['album_name'])
elif self.media_type == 'track':
title = '%s - %s' % (self.parameters['artist_name'], self.parameters['track_name'])
title = '%s - %s' % (self.parameters['track_name'], self.parameters['track_artist'])
return title.encode("utf-8")
def get_description(self):
@@ -807,7 +807,7 @@ class Notifier(object):
if response is not None and response.status_code >= 400 and response.status_code < 500:
verify_msg = " Verify you notification agent settings are correct."
logger.error(u"Tautulli Notifiers :: {name} notification failed.{}".format(verify_msg, name=self.NAME))
logger.error(u"Tautulli Notifiers :: {name} notification failed.{msg}".format(msg=verify_msg, name=self.NAME))
if err_msg:
logger.error(u"Tautulli Notifiers :: {}".format(err_msg))
@@ -1145,7 +1145,8 @@ class DISCORD(Notifier):
plex_url = pretty_metadata.get_plex_url()
# Build Discord post attachment
attachment = {'title': title
attachment = {'title': title,
'timestamp': helpers.utc_now_iso()
}
if self.config['color']:
@@ -1295,12 +1296,21 @@ class EMAIL(Notifier):
def agent_notify(self, subject='', body='', action='', **kwargs):
if self.config['html_support']:
msg = MIMEMultipart('alternative')
msg.attach(MIMEText(bleach.clean(body, strip=True), 'plain', 'utf-8'))
msg.attach(MIMEText(body, 'html', 'utf-8'))
else:
msg = MIMEText(body, 'plain', 'utf-8')
plain = MIMEText(None, 'plain', 'utf-8')
plain.replace_header('Content-Transfer-Encoding', 'quoted-printable')
plain.set_payload(kwargs.get('plaintext', bleach.clean(body, strip=True)), 'utf-8')
html = MIMEText(body, 'html', 'utf-8')
msg = MIMEMultipart('alternative')
msg.attach(plain)
msg.attach(html)
else:
msg = MIMEText(None, 'plain', 'utf-8')
msg.replace_header('Content-Transfer-Encoding', 'quoted-printable')
msg.set_payload(body, 'utf-8')
msg['Message-ID'] = email.utils.make_msgid()
msg['Date'] = email.utils.formatdate(localtime=True)
msg['Subject'] = subject
msg['From'] = email.utils.formataddr((self.config['from_name'], self.config['from']))
@@ -1309,26 +1319,32 @@ class EMAIL(Notifier):
recipients = self.config['to'] + self.config['cc'] + self.config['bcc']
mailserver = None
success = False
try:
mailserver = smtplib.SMTP(self.config['smtp_server'], self.config['smtp_port'])
mailserver.ehlo()
if self.config['tls']:
mailserver.starttls()
mailserver.ehlo()
mailserver.ehlo()
if self.config['smtp_user']:
mailserver.login(str(self.config['smtp_user']), str(self.config['smtp_password']))
mailserver.sendmail(self.config['from'], recipients, msg.as_string())
mailserver.quit()
logger.info(u"Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
return True
success = True
except Exception as e:
logger.error(u"Tautulli Notifiers :: {name} notification failed: {e}".format(name=self.NAME, e=e))
return False
finally:
if mailserver:
mailserver.quit()
return success
def get_user_emails(self):
emails = {u['email']: u['friendly_name'] for u in users.Users().get_users() if u['email']}
@@ -1451,7 +1467,7 @@ class FACEBOOK(Notifier):
return facebook.auth_url(app_id=app_id,
canvas_url=redirect_uri,
perms=['user_managed_groups','publish_actions'])
perms=['publish_to_groups'])
def _get_credentials(self, code=''):
logger.info(u"Tautulli Notifiers :: Requesting access token from {name}.".format(name=self.NAME))
@@ -2795,24 +2811,53 @@ class PUSHOVER(Notifier):
return self.make_request('https://api.pushover.net/1/messages.json', headers=headers, data=data, files=files)
def get_sounds(self):
if self.config['api_token']:
params = {'token': self.config['api_token']}
sounds = {
'': '',
'alien': 'Alien Alarm (long)',
'bike': 'Bike',
'bugle': 'Bugle',
'cashregister': 'Cash Register',
'classical': 'Classical',
'climb': 'Climb (long)',
'cosmic': 'Cosmic',
'echo': 'Pushover Echo (long)',
'falling': 'Falling',
'gamelan': 'Gamelan',
'incoming': 'Incoming',
'intermission': 'Intermission',
'magic': 'Magic',
'mechanical': 'Mechanical',
'none': 'None (silent)',
'persistent': 'Persistent (long)',
'pianobar': 'Piano Bar',
'pushover': 'Pushover (default)',
'siren': 'Siren',
'spacealarm': 'Space Alarm',
'tugboat': 'Tug Boat',
'updown': 'Up Down (long)'
}
r = requests.get('https://api.pushover.net/1/sounds.json', params=params)
return sounds
if r.status_code == 200:
response_data = r.json()
sounds = response_data.get('sounds', {})
sounds.update({'': ''})
return sounds
else:
logger.error(u"Tautulli Notifiers :: Unable to retrieve {name} sounds list: "
u"[{r.status_code}] {r.reason}".format(name=self.NAME, r=r))
logger.debug(u"Tautulli Notifiers :: Request response: {}".format(request.server_message(r, True)))
return {'': ''}
else:
return {'': ''}
# if self.config['api_token']:
# params = {'token': self.config['api_token']}
#
# r = requests.get('https://api.pushover.net/1/sounds.json', params=params)
#
# if r.status_code == 200:
# response_data = r.json()
# sounds = response_data.get('sounds', {})
# sounds.update({'': ''})
# print sounds
# return sounds
# else:
# logger.error(u"Tautulli Notifiers :: Unable to retrieve {name} sounds list: "
# u"[{r.status_code}] {r.reason}".format(name=self.NAME, r=r))
# logger.debug(u"Tautulli Notifiers :: Request response: {}".format(request.server_message(r, True)))
# return {'': ''}
#
# else:
# return {'': ''}
def return_config_options(self):
config_option = [{'label': 'Pushover API Token',
@@ -2934,6 +2979,7 @@ class SCRIPTS(Notifier):
}
self.arg_overrides = ('python2', 'python3', 'python', 'pythonw', 'php', 'ruby', 'perl')
self.script_killed = False
def list_scripts(self):
scriptdir = self.config['script_folder']
@@ -2953,12 +2999,6 @@ class SCRIPTS(Notifier):
return scripts
def run_script(self, script):
def kill_script(process):
logger.warn(u"Tautulli Notifiers :: Script exceeded timeout limit of %d seconds. "
"Script killed." % self.config['timeout'])
process.kill()
self.script_killed = True
# Common environment variables
env = {'PLEX_URL': plexpy.CONFIG.PMS_URL,
'PLEX_TOKEN': plexpy.CONFIG.PMS_TOKEN,
@@ -2967,8 +3007,6 @@ class SCRIPTS(Notifier):
}
env.update(os.environ)
self.script_killed = False
output = error = ''
try:
process = subprocess.Popen(script,
stdin=subprocess.PIPE,
@@ -2978,7 +3016,7 @@ class SCRIPTS(Notifier):
env=env)
if self.config['timeout'] > 0:
timer = threading.Timer(self.config['timeout'], kill_script, (process,))
timer = threading.Timer(self.config['timeout'], self.kill_script, (process,))
else:
timer = None
@@ -3008,6 +3046,12 @@ class SCRIPTS(Notifier):
logger.info(u"Tautulli Notifiers :: Script notification sent.")
return True
def kill_script(self, process):
process.kill()
self.script_killed = True
logger.warn(u"Tautulli Notifiers :: Script exceeded timeout limit of %d seconds. "
"Script killed." % self.config['timeout'])
def agent_notify(self, subject='', body='', action='', **kwargs):
"""
Args:
@@ -3434,6 +3478,10 @@ class TWITTER(Notifier):
parameters = kwargs['parameters']
poster_url = parameters.get('poster_url','')
# Hack to add media type to attachment
if poster_url and not helpers.get_img_service():
poster_url += '.png'
if self.config['incl_subject']:
return self._send_tweet(subject + '\r\n' + body, attachment=poster_url)
else:

View File

@@ -49,6 +49,7 @@ def extract_plexivity_xml(xml=None):
grandparent_rating_key = helpers.get_xml_attr(a, 'grandparentRatingKey')
grandparent_thumb = helpers.get_xml_attr(a, 'grandparentThumb')
grandparent_title = helpers.get_xml_attr(a, 'grandparentTitle')
original_title = helpers.get_xml_attr(a, 'originalTitle')
guid = helpers.get_xml_attr(a, 'guid')
section_id = helpers.get_xml_attr(a, 'librarySectionID')
media_index = helpers.get_xml_attr(a, 'index')
@@ -180,9 +181,10 @@ def extract_plexivity_xml(xml=None):
'duration': duration,
'grandparent_rating_key': grandparent_rating_key,
'grandparent_thumb': grandparent_thumb,
'grandparent_title': grandparent_title,
'parent_title': parent_title,
'title': title,
'parent_title': parent_title,
'grandparent_title': grandparent_title,
'original_title': original_title,
'tagline': tagline,
'guid': guid,
'section_id': section_id,
@@ -339,6 +341,7 @@ def import_from_plexivity(database=None, table_name=None, import_ignore_interval
'title': row['title'],
'parent_title': extracted_xml['parent_title'],
'grandparent_title': row['grandparent_title'],
'original_title': extracted_xml['original_title'],
'full_title': row['full_title'],
'user_id': user_id,
'user': row['user'],
@@ -380,6 +383,7 @@ def import_from_plexivity(database=None, table_name=None, import_ignore_interval
'title': row['title'],
'parent_title': extracted_xml['parent_title'],
'grandparent_title': row['grandparent_title'],
'original_title': extracted_xml['original_title'],
'media_index': extracted_xml['media_index'],
'parent_media_index': extracted_xml['parent_media_index'],
'thumb': extracted_xml['thumb'],

View File

@@ -45,6 +45,7 @@ def extract_plexwatch_xml(xml=None):
duration = helpers.get_xml_attr(a, 'duration')
grandparent_thumb = helpers.get_xml_attr(a, 'grandparentThumb')
grandparent_title = helpers.get_xml_attr(a, 'grandparentTitle')
original_title = helpers.get_xml_attr(a, 'originalTitle')
guid = helpers.get_xml_attr(a, 'guid')
section_id = helpers.get_xml_attr(a, 'librarySectionID')
media_index = helpers.get_xml_attr(a, 'index')
@@ -172,9 +173,10 @@ def extract_plexwatch_xml(xml=None):
'art': art,
'duration': duration,
'grandparent_thumb': grandparent_thumb,
'grandparent_title': grandparent_title,
'parent_title': parent_title,
'title': title,
'parent_title': parent_title,
'grandparent_title': grandparent_title,
'original_title': original_title,
'tagline': tagline,
'guid': guid,
'section_id': section_id,
@@ -332,6 +334,7 @@ def import_from_plexwatch(database=None, table_name=None, import_ignore_interval
'title': row['title'],
'parent_title': extracted_xml['parent_title'],
'grandparent_title': row['grandparent_title'],
'original_title': extracted_xml['original_title'],
'full_title': row['full_title'],
'user_id': user_id,
'user': row['user'],
@@ -373,6 +376,7 @@ def import_from_plexwatch(database=None, table_name=None, import_ignore_interval
'title': row['title'],
'parent_title': extracted_xml['parent_title'],
'grandparent_title': row['grandparent_title'],
'original_title': extracted_xml['original_title'],
'media_index': extracted_xml['media_index'],
'parent_media_index': extracted_xml['parent_media_index'],
'thumb': extracted_xml['thumb'],

View File

@@ -512,6 +512,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(m, 'title'),
'parent_title': helpers.get_xml_attr(m, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(m, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(m, 'originalTitle'),
'sort_title': helpers.get_xml_attr(m, 'titleSort'),
'media_index': helpers.get_xml_attr(m, 'index'),
'parent_media_index': helpers.get_xml_attr(m, 'parentIndex'),
@@ -661,6 +662,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(metadata_main, 'originalTitle'),
'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -708,6 +710,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(metadata_main, 'originalTitle'),
'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -752,6 +755,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(metadata_main, 'originalTitle'),
'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -797,6 +801,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': 'Season %s' % helpers.get_xml_attr(metadata_main, 'parentIndex'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(metadata_main, 'originalTitle'),
'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -840,6 +845,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(metadata_main, 'originalTitle'),
'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -884,6 +890,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(metadata_main, 'originalTitle'),
'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -920,6 +927,8 @@ class PmsConnect(object):
elif metadata_type == 'track':
parent_rating_key = helpers.get_xml_attr(metadata_main, 'parentRatingKey')
album_details = self.get_metadata_details(parent_rating_key)
track_artist = helpers.get_xml_attr(metadata_main, 'originalTitle') or \
helpers.get_xml_attr(metadata_main, 'grandparentTitle')
metadata = {'media_type': metadata_type,
'section_id': section_id,
'library_name': library_name,
@@ -929,6 +938,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(metadata_main, 'originalTitle'),
'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -957,8 +967,8 @@ class PmsConnect(object):
'genres': album_details['genres'],
'labels': album_details['labels'],
'collections': album_details['collections'],
'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
helpers.get_xml_attr(metadata_main, 'title')),
'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'title'),
track_artist),
'children_count': helpers.get_xml_attr(metadata_main, 'leafCount')
}
@@ -972,6 +982,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(metadata_main, 'originalTitle'),
'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -1016,6 +1027,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(metadata_main, 'originalTitle'),
'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -1060,6 +1072,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(metadata_main, 'originalTitle'),
'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -1105,6 +1118,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(metadata_main, 'originalTitle'),
'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -1658,6 +1672,8 @@ class PmsConnect(object):
'optimized_version': int(helpers.get_xml_attr(stream_media_info, 'proxyType') == '42'),
'optimized_version_title': helpers.get_xml_attr(stream_media_info, 'title'),
'synced_version': 1 if sync_id else 0,
'live': int(helpers.get_xml_attr(session, 'live') == '1'),
'live_uuid': helpers.get_xml_attr(stream_media_info, 'uuid'),
'indexes': int(indexes == 'sd'),
'bif_thumb': bif_thumb,
'subtitles': 1 if subtitle_id and subtitle_selected else 0
@@ -1670,9 +1686,7 @@ class PmsConnect(object):
if not helpers.get_xml_attr(session, 'ratingKey').isdigit():
channel_stream = 1
clip_media = session.getElementsByTagName('Media')[0]
clip_part = clip_media.getElementsByTagName('Part')[0]
audio_channels = helpers.get_xml_attr(clip_media, 'audioChannels')
audio_channels = helpers.get_xml_attr(stream_media_info, 'audioChannels')
metadata_details = {'media_type': media_type,
'section_id': helpers.get_xml_attr(session, 'librarySectionID'),
'library_name': helpers.get_xml_attr(session, 'librarySectionTitle'),
@@ -1682,6 +1696,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(session, 'title'),
'parent_title': helpers.get_xml_attr(session, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(session, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(session, 'originalTitle'),
'sort_title': helpers.get_xml_attr(session, 'titleSort'),
'media_index': helpers.get_xml_attr(session, 'index'),
'parent_media_index': helpers.get_xml_attr(session, 'parentIndex'),
@@ -1710,18 +1725,17 @@ class PmsConnect(object):
'genres': [],
'labels': [],
'full_title': helpers.get_xml_attr(session, 'title'),
'container': helpers.get_xml_attr(clip_media, 'container') \
or helpers.get_xml_attr(clip_part, 'container'),
'height': helpers.get_xml_attr(clip_media, 'height'),
'width': helpers.get_xml_attr(clip_media, 'width'),
'video_codec': helpers.get_xml_attr(clip_media, 'videoCodec'),
'video_resolution': helpers.get_xml_attr(clip_media, 'videoResolution'),
'audio_codec': helpers.get_xml_attr(clip_media, 'audioCodec'),
'container': helpers.get_xml_attr(stream_media_info, 'container') \
or helpers.get_xml_attr(stream_media_parts_info, 'container'),
'height': helpers.get_xml_attr(stream_media_info, 'height'),
'width': helpers.get_xml_attr(stream_media_info, 'width'),
'video_codec': helpers.get_xml_attr(stream_media_info, 'videoCodec'),
'video_resolution': helpers.get_xml_attr(stream_media_info, 'videoResolution'),
'audio_codec': helpers.get_xml_attr(stream_media_info, 'audioCodec'),
'audio_channels': audio_channels,
'audio_channel_layout': common.AUDIO_CHANNELS.get(audio_channels, audio_channels),
'channel_icon': helpers.get_xml_attr(session, 'sourceIcon'),
'channel_title': helpers.get_xml_attr(session, 'sourceTitle'),
'live': int(helpers.get_xml_attr(session, 'live') == '1'),
'extra_type': helpers.get_xml_attr(session, 'extraType'),
'sub_type': helpers.get_xml_attr(session, 'subtype')
}
@@ -1790,13 +1804,12 @@ class PmsConnect(object):
next((p for p in source_media_part_streams if p['type'] == '3'), source_subtitle_details))
# Overrides for live sessions
if metadata_details.get('live') and transcode_session:
if stream_details['live'] and transcode_session:
stream_details['stream_container_decision'] = 'transcode'
stream_details['stream_container'] = transcode_details['transcode_container']
video_details['stream_video_decision'] = transcode_details['video_decision']
stream_details['stream_video_codec'] = transcode_details['transcode_video_codec']
stream_details['stream_video_resolution'] = metadata_details['video_resolution']
audio_details['stream_audio_decision'] = transcode_details['audio_decision']
stream_details['stream_audio_codec'] = transcode_details['transcode_audio_codec']
@@ -1994,6 +2007,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(m, 'title'),
'parent_title': helpers.get_xml_attr(m, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(m, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(m, 'originalTitle'),
'sort_title': helpers.get_xml_attr(m, 'titleSort'),
'media_index': helpers.get_xml_attr(m, 'index'),
'parent_media_index': helpers.get_xml_attr(m, 'parentIndex'),
@@ -2311,6 +2325,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(item, 'title'),
'parent_title': helpers.get_xml_attr(item, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(item, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(item, 'originalTitle'),
'sort_title': helpers.get_xml_attr(item, 'titleSort'),
'media_index': helpers.get_xml_attr(item, 'index'),
'parent_media_index': helpers.get_xml_attr(item, 'parentIndex'),
@@ -2435,8 +2450,8 @@ class PmsConnect(object):
return labels_list
def get_image(self, img=None, width=600, height=1000, opacity=None, background=None, blur=None,
img_format='png', clip=False, **kwargs):
def get_image(self, img=None, width=1000, height=1500, opacity=None, background=None, blur=None,
img_format='png', clip=False, refresh=False, **kwargs):
"""
Return image data as array.
Array contains the image content type and image binary
@@ -2450,10 +2465,13 @@ class PmsConnect(object):
Output: array
"""
width = width or 600
height = height or 1000
width = width or 1000
height = height or 1500
if img:
if refresh:
img = '{}/{}'.format(img.rstrip('/'), int(time.time()))
if clip:
params = {'url': '%s&%s' % (img, urllib.urlencode({'X-Plex-Token': self.token}))}
else:
@@ -2544,7 +2562,7 @@ class PmsConnect(object):
metadata = self.get_metadata_details(rating_key=rating_key)
search_results_list[metadata['media_type']].append(metadata)
output = {'results_count': sum(len(s) for s in search_results_list.items()),
output = {'results_count': sum(len(s) for s in search_results_list.values()),
'results_list': search_results_list
}
@@ -2646,7 +2664,7 @@ class PmsConnect(object):
child_title = helpers.get_xml_attr(item, 'title')
if child_rating_key:
key = int(child_index)
key = int(child_index) if child_index else child_title
children.update({key: {'rating_key': int(child_rating_key)}})
key = int(parent_index) if match_type == 'index' else parent_title
@@ -2658,9 +2676,9 @@ class PmsConnect(object):
key = 0 if match_type == 'index' else title
key_list = {key: {'rating_key': int(rating_key),
'children': parents},
'section_id': section_id,
'library_name': library_name
}
'section_id': section_id,
'library_name': library_name
}
return key_list

View File

@@ -201,9 +201,10 @@ def mask_session_info(list_of_dicts, mask_metadata=True):
'grandparent_thumb': common.DEFAULT_POSTER_THUMB,
'thumb': common.DEFAULT_POSTER_THUMB,
'bif_thumb': '',
'grandparent_title': 'Plex Media',
'parent_title': 'Plex Media',
'title': 'Plex Media',
'parent_title': 'Plex Media',
'grandparent_title': 'Plex Media',
'original_title': 'Plex Media',
'rating_key': '',
'parent_rating_key': '',
'grandparent_rating_key': '',

View File

@@ -521,7 +521,8 @@ class Users(object):
if str(user_id).isdigit():
query = 'SELECT session_history.id, session_history.media_type, ' \
'session_history.rating_key, session_history.parent_rating_key, session_history.grandparent_rating_key, ' \
'title, parent_title, grandparent_title, thumb, parent_thumb, grandparent_thumb, media_index, parent_media_index, ' \
'title, parent_title, grandparent_title, original_title, ' \
'thumb, parent_thumb, grandparent_thumb, media_index, parent_media_index, ' \
'year, started, user ' \
'FROM session_history_metadata ' \
'JOIN session_history ON session_history_metadata.id = session_history.id ' \
@@ -552,6 +553,7 @@ class Users(object):
'title': row['title'],
'parent_title': row['parent_title'],
'grandparent_title': row['grandparent_title'],
'original_title': row['original_title'],
'thumb': thumb,
'media_index': row['media_index'],
'parent_media_index': row['parent_media_index'],

View File

@@ -1,2 +1,2 @@
PLEXPY_BRANCH = "beta"
PLEXPY_RELEASE_VERSION = "v2.1.1-beta"
PLEXPY_RELEASE_VERSION = "v2.1.11-beta"

View File

@@ -131,7 +131,7 @@ def getVersion():
return None, 'origin', common.BRANCH
def checkGithub(auto_update=False):
def check_github(auto_update=False, notify=False):
plexpy.COMMITS_BEHIND = 0
# Get the latest version available from github
@@ -198,8 +198,11 @@ def checkGithub(auto_update=False):
plexpy.LATEST_RELEASE = release['tag_name']
plexpy.NOTIFY_QUEUE.put({'notify_action': 'on_plexpyupdate', 'plexpy_download_info': release,
'plexpy_update_commit': plexpy.LATEST_VERSION, 'plexpy_update_behind': plexpy.COMMITS_BEHIND})
if notify:
plexpy.NOTIFY_QUEUE.put({'notify_action': 'on_plexpyupdate',
'plexpy_download_info': release,
'plexpy_update_commit': plexpy.LATEST_VERSION,
'plexpy_update_behind': plexpy.COMMITS_BEHIND})
if auto_update:
logger.info('Running automatic update.')

View File

@@ -18,6 +18,7 @@ import json
import os
import shutil
import threading
import urllib
import cherrypy
from cherrypy.lib.static import serve_file, serve_download
@@ -56,7 +57,7 @@ import web_socket
from plexpy.api2 import API2
from plexpy.helpers import checked, addtoapi, get_ip, create_https_certificates, build_datatables_json
from plexpy.session import get_session_info, get_session_user_id, allow_session_user, allow_session_library
from plexpy.webauth import AuthController, requireAuth, member_of, name_is
from plexpy.webauth import AuthController, requireAuth, member_of
def serve_template(templatename, **kwargs):
@@ -273,8 +274,21 @@ class WebInterface(object):
@cherrypy.expose
@cherrypy.tools.json_out()
@requireAuth(member_of("admin"))
def return_sessions_url(self, **kwargs):
return plexpy.CONFIG.PMS_URL + '/status/sessions?X-Plex-Token=' + plexpy.CONFIG.PMS_TOKEN
def return_plex_xml_url(self, endpoint='', plextv=False, **kwargs):
kwargs['X-Plex-Token'] = plexpy.CONFIG.PMS_TOKEN
if plextv:
base_url = 'https://plex.tv'
else:
if plexpy.CONFIG.PMS_URL_OVERRIDE:
base_url = plexpy.CONFIG.PMS_URL_OVERRIDE
else:
base_url = plexpy.CONFIG.PMS_URL
if '{machine_id}' in endpoint:
endpoint = endpoint.format(machine_id=plexpy.CONFIG.PMS_IDENTIFIER)
return base_url + endpoint + '?' + urllib.urlencode(kwargs)
@cherrypy.expose
@requireAuth()
@@ -1614,6 +1628,7 @@ class WebInterface(object):
"full_title": "Game of Thrones - The Red Woman",
"grandparent_rating_key": 351,
"grandparent_title": "Game of Thrones",
"original_title": "",
"group_count": 1,
"group_ids": "1124",
"id": 1124,
@@ -1715,6 +1730,77 @@ class WebInterface(object):
return serve_template(templatename="stream_data.html", title="Stream Data", data=stream_data, user=user)
@cherrypy.expose
@cherrypy.tools.json_out()
@requireAuth()
@addtoapi('get_stream_data')
def get_stream_data_api(self, row_id=None, session_key=None, **kwargs):
""" 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
}
```
"""
data_factory = datafactory.DataFactory()
stream_data = data_factory.get_stream_details(row_id, session_key)
return stream_data
@cherrypy.expose
@requireAuth()
def get_ip_address_details(self, ip_address=None, **kwargs):
@@ -2739,6 +2825,9 @@ class WebInterface(object):
"group_history_tables": checked(plexpy.CONFIG.GROUP_HISTORY_TABLES),
"git_token": plexpy.CONFIG.GIT_TOKEN,
"imgur_client_id": plexpy.CONFIG.IMGUR_CLIENT_ID,
"cloudinary_cloud_name": plexpy.CONFIG.CLOUDINARY_CLOUD_NAME,
"cloudinary_api_key": plexpy.CONFIG.CLOUDINARY_API_KEY,
"cloudinary_api_secret": plexpy.CONFIG.CLOUDINARY_API_SECRET,
"cache_images": checked(plexpy.CONFIG.CACHE_IMAGES),
"pms_version": plexpy.CONFIG.PMS_VERSION,
"plexpy_auto_update": checked(plexpy.CONFIG.PLEXPY_AUTO_UPDATE),
@@ -2752,7 +2841,11 @@ class WebInterface(object):
"tvmaze_lookup": checked(plexpy.CONFIG.TVMAZE_LOOKUP),
"show_advanced_settings": plexpy.CONFIG.SHOW_ADVANCED_SETTINGS,
"newsletter_dir": plexpy.CONFIG.NEWSLETTER_DIR,
"newsletter_self_hosted": checked(plexpy.CONFIG.NEWSLETTER_SELF_HOSTED)
"newsletter_self_hosted": checked(plexpy.CONFIG.NEWSLETTER_SELF_HOSTED),
"newsletter_auth": plexpy.CONFIG.NEWSLETTER_AUTH,
"newsletter_password": plexpy.CONFIG.NEWSLETTER_PASSWORD,
"newsletter_inline_styles": checked(plexpy.CONFIG.NEWSLETTER_INLINE_STYLES),
"newsletter_custom_dir": plexpy.CONFIG.NEWSLETTER_CUSTOM_DIR
}
return serve_template(templatename="settings.html", title="Settings", config=config, kwargs=kwargs)
@@ -2774,7 +2867,7 @@ class WebInterface(object):
"allow_guest_access", "cache_images", "http_proxy", "http_basic_auth", "notify_concurrent_by_ip",
"history_table_activity", "plexpy_auto_update",
"themoviedb_lookup", "tvmaze_lookup", "http_plex_admin",
"newsletter_self_hosted"
"newsletter_self_hosted", "newsletter_inline_styles"
]
for checked_config in checked_configs:
if checked_config not in kwargs:
@@ -3145,7 +3238,7 @@ class WebInterface(object):
@requireAuth(member_of("admin"))
@addtoapi()
def set_notifier_config(self, notifier_id=None, agent_id=None, **kwargs):
""" Configure an exisitng notificaiton agent.
""" Configure an existing notification agent.
```
Required parameters:
@@ -3264,10 +3357,10 @@ class WebInterface(object):
return {'result': 'success', 'message': 'Notification queued.'}
else:
logger.debug(u"Unable to send %snotification, invalid notifier_id %s." % (test, notifier_id))
return {'result': 'success', 'message': 'Invalid notifier id %s.' % notifier_id}
return {'result': 'error', 'message': 'Invalid notifier id %s.' % notifier_id}
else:
logger.debug(u"Unable to send %snotification, no notifier_id received." % test)
return {'result': 'success', 'message': 'No notifier id received.'}
return {'result': 'error', 'message': 'No notifier id received.'}
@cherrypy.expose
@cherrypy.tools.json_out()
@@ -3404,7 +3497,7 @@ class WebInterface(object):
@requireAuth(member_of("admin"))
@addtoapi()
def set_mobile_device_config(self, mobile_device_id=None, **kwargs):
""" Configure an exisitng notificaiton agent.
""" Configure an existing notification agent.
```
Required parameters:
@@ -3699,7 +3792,7 @@ class WebInterface(object):
}
```
"""
versioncheck.checkGithub()
versioncheck.check_github()
if not plexpy.CURRENT_VERSION:
return {'result': 'error',
@@ -3959,13 +4052,20 @@ class WebInterface(object):
return
if rating_key and not img:
img = '/library/metadata/%s/thumb/1337' % rating_key
if fallback == 'art':
img = '/library/metadata/{}/art'.format(rating_key)
else:
img = '/library/metadata/{}/thumb'.format(rating_key)
img_string = img.rsplit('/', 1)[0] if '/library/metadata' in img else img
img_string = '{}{}{}{}{}{}'.format(img_string, width, height, opacity, background, blur)
img_split = img.split('/')
img = '/'.join(img_split[:5])
rating_key = rating_key or img_split[3]
fp = hashlib.md5(img_string).hexdigest()
fp += '.%s' % img_format # we want to be able to preview the thumbs
img_string = '{}.{}.{}.{}.{}.{}.{}.{}'.format(
plexpy.CONFIG.PMS_UUID, img, rating_key, width, height, opacity, background, blur, fallback)
img_hash = hashlib.sha256(img_string).hexdigest()
fp = '{}.{}'.format(img_hash, img_format) # we want to be able to preview the thumbs
c_dir = os.path.join(plexpy.CONFIG.CACHE_DIR, 'images')
ffp = os.path.join(c_dir, fp)
@@ -3991,7 +4091,8 @@ class WebInterface(object):
background=background,
blur=blur,
img_format=img_format,
clip=clip)
clip=clip,
refresh=refresh)
if result and result[0]:
cherrypy.response.headers['Content-type'] = result[1]
@@ -4020,7 +4121,16 @@ class WebInterface(object):
@cherrypy.expose
def image(self, *args, **kwargs):
if args:
img_hash = args[0]
cherrypy.response.headers['Cache-Control'] = 'max-age=3600' # 1 hour
if len(args) >= 2 and args[0] == 'images':
resource_dir = os.path.join(str(plexpy.PROG_DIR), 'data/interfaces/default/')
try:
return serve_file(path=os.path.join(resource_dir, *args), content_type='image/png')
except NotFound:
return
img_hash = args[0].split('.')[0]
if img_hash in ('poster', 'cover', 'art'):
if img_hash == 'poster':
@@ -4037,7 +4147,7 @@ class WebInterface(object):
if img_info:
kwargs.update(img_info)
return self.real_pms_image_proxy(**kwargs)
return self.real_pms_image_proxy(refresh=True, **kwargs)
return
@@ -4158,30 +4268,35 @@ class WebInterface(object):
@cherrypy.tools.json_out()
@requireAuth(member_of("admin"))
@addtoapi()
def delete_imgur_poster(self, rating_key='', **kwargs):
""" Delete the Imgur poster.
def delete_hosted_images(self, rating_key='', service='', delete_all=False, **kwargs):
""" Delete the images uploaded to image hosting services.
```
Required parameters:
None
Optional parameters:
rating_key (int): 1234
(Note: Must be the movie, show, season, artist, or album rating key)
Optional parameters:
None
service (str): 'imgur' or 'cloudinary'
delete_all (bool): 'true' to delete all images form the service
Returns:
json:
{"result": "success",
"message": "Deleted Imgur poster."}
"message": "Deleted hosted images from Imgur."}
```
"""
delete_all = (delete_all == 'true')
data_factory = datafactory.DataFactory()
result = data_factory.delete_imgur_info(rating_key=rating_key)
result = data_factory.delete_img_info(rating_key=rating_key, service=service, delete_all=delete_all)
if result:
return {'result': 'success', 'message': 'Deleted Imgur poster.'}
return {'result': 'success', 'message': 'Deleted hosted images from %s.' % result.capitalize()}
else:
return {'result': 'error', 'message': 'Failed to delete Imgur poster.'}
return {'result': 'error', 'message': 'Failed to delete hosted images.'}
@cherrypy.expose
@cherrypy.tools.json_out()
@@ -4527,6 +4642,7 @@ class WebInterface(object):
}
],
"media_type": "episode",
"original_title": "",
"originally_available_at": "2016-04-24",
"parent_media_index": "6",
"parent_rating_key": "153036",
@@ -4585,6 +4701,7 @@ class WebInterface(object):
"library_name": "",
"media_index": "1",
"media_type": "episode",
"original_title": "",
"parent_media_index": "6",
"parent_rating_key": "153036",
"parent_thumb": "/library/metadata/153036/thumb/1462175062",
@@ -4856,6 +4973,7 @@ class WebInterface(object):
"optimized_version_profile": "",
"optimized_version_title": "",
"originally_available_at": "2016-04-24",
"original_title": "",
"parent_media_index": "6",
"parent_rating_key": "153036",
"parent_thumb": "/library/metadata/153036/thumb/1503889210",
@@ -5455,7 +5573,7 @@ class WebInterface(object):
Returns:
json:
[{"id": 1,
"agent_id": 13,
"agent_id": 0,
"agent_name": "recently_added",
"agent_label": "Recently Added",
"friendly_name": "",
@@ -5515,15 +5633,24 @@ class WebInterface(object):
Returns:
json:
{"id": 1,
"agent_id": 13,
"agent_id": 0,
"agent_name": "recently_added",
"agent_label": "Recently Added",
"friendly_name": "",
"id_name": "",
"cron": "0 0 * * 1",
"active": 1
"config": {"time_frame": 7,
"time_frame_units": "days",
"incl_libraries": [1, 2]
"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": [{...}, ...],
@@ -5570,19 +5697,15 @@ class WebInterface(object):
@requireAuth(member_of("admin"))
@addtoapi()
def set_newsletter_config(self, newsletter_id=None, agent_id=None, **kwargs):
""" Configure an exisitng notificaiton agent.
""" Configure an existing newsletter agent.
```
Required parameters:
newsletter_id (int): The newsletter config to update
agent_id (int): The newsletter type of the newsletter
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 agent prefix:
e.g. For Recently Added: recently_added_last_days
recently_added_incl_movies
recently_added_incl_shows
recently_added_incl_artists
Pass all the config options for the agent with the 'newsletter_config_' and 'newsletter_email_' prefix.
Returns:
None
@@ -5600,7 +5723,6 @@ class WebInterface(object):
@cherrypy.expose
@cherrypy.tools.json_out()
@requireAuth(member_of("admin"))
@addtoapi("notify_newsletter")
def send_newsletter(self, newsletter_id=None, subject='', body='', message='', notify_action='', **kwargs):
""" Send a newsletter using Tautulli.
@@ -5640,7 +5762,29 @@ class WebInterface(object):
@cherrypy.expose
def newsletter(self, *args, **kwargs):
request_uri = cherrypy.request.wsgi_environ['REQUEST_URI']
if plexpy.CONFIG.NEWSLETTER_AUTH == 2:
redirect_uri = request_uri.replace('/newsletter', '/newsletter_auth')
raise cherrypy.HTTPRedirect(redirect_uri)
elif plexpy.CONFIG.NEWSLETTER_AUTH == 1 and plexpy.CONFIG.NEWSLETTER_PASSWORD:
if len(args) >= 2 and args[0] == 'image':
return self.newsletter_auth(*args, **kwargs)
elif kwargs.pop('key', None) == plexpy.CONFIG.NEWSLETTER_PASSWORD:
return self.newsletter_auth(*args, **kwargs)
else:
return serve_template(templatename="newsletter_auth.html",
title="Newsletter Login",
uri=request_uri)
else:
return self.newsletter_auth(*args, **kwargs)
@cherrypy.expose
@requireAuth()
def newsletter_auth(self, *args, **kwargs):
if args:
# Keep this for backwards compatibility for images through /newsletter/image
if len(args) >= 2 and args[0] == 'image':
if args[1] == 'images':
resource_dir = os.path.join(str(plexpy.PROG_DIR), 'data/interfaces/default/')
@@ -5649,10 +5793,17 @@ class WebInterface(object):
except NotFound:
return
return self.image(args[1], refresh=True)
return self.image(args[1])
newsletter_uuid = args[0]
newsletter = newsletter_handler.get_newsletter(newsletter_uuid=newsletter_uuid)
if len(args) >= 2 and args[0] == 'id':
newsletter_id_name = args[1]
newsletter_uuid = None
else:
newsletter_id_name = None
newsletter_uuid = args[0]
newsletter = newsletter_handler.get_newsletter(newsletter_uuid=newsletter_uuid,
newsletter_id_name=newsletter_id_name)
return newsletter
@cherrypy.expose
@@ -5666,12 +5817,14 @@ class WebInterface(object):
@cherrypy.expose
@requireAuth(member_of("admin"))
def real_newsletter(self, newsletter_id=None, start_date=None, end_date=None,
preview=False, master=False, raw=False, **kwargs):
preview=False, raw=False, **kwargs):
if newsletter_id and newsletter_id != 'None':
newsletter = newsletters.get_newsletter_config(newsletter_id=newsletter_id)
if newsletter:
newsletter_agent = newsletters.get_agent_class(agent_id=newsletter['agent_id'],
newsletter_agent = newsletters.get_agent_class(newsletter_id=newsletter_id,
newsletter_id_name=newsletter['id_name'],
agent_id=newsletter['agent_id'],
config=newsletter['config'],
start_date=start_date,
end_date=end_date,
@@ -5679,14 +5832,13 @@ class WebInterface(object):
body=newsletter['body'],
message=newsletter['message'])
preview = (preview == 'true')
master = (master == 'true')
raw = (raw == 'true')
if raw:
cherrypy.response.headers['Content-Type'] = 'application/json;charset=UTF-8'
return json.dumps(newsletter_agent.raw_data(preview=preview))
return newsletter_agent.generate_newsletter(preview=preview, master=master)
return newsletter_agent.generate_newsletter(preview=preview)
logger.error(u"Failed to retrieve newsletter: Invalid newsletter_id %s" % newsletter_id)
return "Failed to retrieve newsletter: invalid newsletter_id parameter"