Compare commits

...

81 Commits

Author SHA1 Message Date
JonnyWong16
331be52327 v2.1.12 2018-06-08 22:12:51 -07:00
JonnyWong16
6ece690e23 Fix grouping plays on graphs with new grouping logic 2018-06-08 18:20:51 -07:00
JonnyWong16
6b63a1399e Add collection tag to raw newsletter data 2018-06-08 17:42:20 -07:00
JonnyWong16
5c77cf652b Fix typo in Join notifier help text 2018-06-08 17:18:05 -07:00
JonnyWong16
d6f9a82edb Fix mutable default arguments 2018-06-07 18:51:52 -07:00
JonnyWong16
a887489666 Change notifier link sources to blank for disabled 2018-06-07 18:51:20 -07:00
JonnyWong16
8c0bcd0059 Ability to stop a stream using session key 2018-06-05 20:30:07 -07:00
JonnyWong16
c18ee81130 Fix typo in http handler 2018-06-03 12:25:15 -07:00
JonnyWong16
44428cc6e5 Update logger blacklist for newsleter/notifier configs 2018-06-03 11:01:58 -07:00
JonnyWong16
c19cc858bd v2.1.11-beta 2018-06-02 10:39:32 -07:00
JonnyWong16
668913fd60 Allow manual PMS URL override in config file for XML shortcuts 2018-06-02 09:55:39 -07:00
JonnyWong16
50c5407a46 Fix Monitor Remote Access checkbox (Plex server update chaged bool to int) 2018-06-02 09:32:05 -07:00
JonnyWong16
939755d3b7 Add Plex XML shortcuts to libraries, users, and sync headers 2018-06-02 08:58:55 -07:00
JonnyWong16
54f4696713 Refactor open Plex XML script 2018-06-02 08:53:53 -07:00
JonnyWong16
c85af521fe Add Plex XML shortcuts for metadata and server resources 2018-06-02 08:26:30 -07:00
JonnyWong16
917d19db85 Fix grouping or new plays not previously in history 2018-06-01 18:45:58 -07:00
JonnyWong16
7292f25eb9 Reword group play history setting 2018-05-31 09:10:44 -07:00
JonnyWong16
22a2ad4bc7 Fix progress percent in grouping logic 2018-05-31 08:34:58 -07:00
JonnyWong16
95e56f5ea5 Fix activity progress bar not updating in some cases 2018-05-31 08:33:50 -07:00
JonnyWong16
ed24232a0a Improve logic for grouping history items 2018-05-30 22:18:41 -07:00
JonnyWong16
15225faee7 Add filename to notification parameters 2018-05-30 21:52:18 -07:00
JonnyWong16
041a35a35a Fallback to title if missing index for update metadata 2018-05-30 21:46:31 -07:00
JonnyWong16
6d365c174a Update X-Plex headers 2018-05-30 21:39:45 -07:00
JonnyWong16
b5f2f55972 v2.1.10-beta 2018-05-28 17:22:16 -07:00
JonnyWong16
ac207260c8 Do not send newsletter if failed to render template 2018-05-27 23:02:56 -07:00
JonnyWong16
e93808381c Fix track listing layout on info pages 2018-05-27 22:43:56 -07:00
JonnyWong16
7acb8f7dc5 Fix artist summary not showing up on newsletter 2018-05-27 22:35:54 -07:00
JonnyWong16
ba9f4a1f9e Use track artist for music 2018-05-27 22:24:43 -07:00
JonnyWong16
8502c28e25 Fallback poster_key and poster_title for clip notification 2018-05-27 15:39:28 -07:00
JonnyWong16
10add90451 Merge pull request #1295 from samwiseg00/feature-add-timestamp-discord
Add timestamps for rich metadata info on discord
2018-05-27 14:47:53 -07:00
samwiseg00
ddb7fa04ca add timestamps for rich metadata info on discord 2018-05-27 17:44:35 -04:00
JonnyWong16
e21a13b7ff Revert "Hack to check for live tv stopped websocket event"
This reverts commit 1245b4fbd3.
2018-05-27 14:13:24 -07:00
JonnyWong16
1245b4fbd3 Hack to check for live tv stopped websocket event 2018-05-27 14:04:47 -07:00
JonnyWong16
94b00c75c2 Enable notifications for clip media type 2018-05-27 13:41:56 -07:00
JonnyWong16
2edcf26110 Use HTTPS for cloudinary urls 2018-05-27 13:07:18 -07:00
JonnyWong16
a9fdf73e8b Check live tv websocket event using key instead of rating key 2018-05-27 13:00:34 -07:00
JonnyWong16
4884cee309 Fix live tv stream resolution 2018-05-27 10:13:42 -07:00
JonnyWong16
b3c7256bcf Newsletter footer inherit styles 2018-05-26 17:29:21 -07:00
JonnyWong16
2c9a7ced13 Forgot product in session db write 2018-05-26 10:14:54 -07:00
JonnyWong16
aa365eb6a3 Improved checking of live tv session websocket events 2018-05-26 10:14:36 -07:00
JonnyWong16
2366a8811b Catch exception from failed SMTP connection 2018-05-25 12:19:46 -07:00
JonnyWong16
53aafbd19e Fix typo from d5bffc3 2018-05-25 12:18:29 -07:00
JonnyWong16
d5bffc374c Fallback to blank poster/art on newsletter if image hosting is disabled 2018-05-25 08:26:25 -07:00
JonnyWong16
5cd5c36d8c Actually add live notification parameter 2018-05-23 17:17:47 -07:00
JonnyWong16
7f9e8f6211 Clean up script.js 2018-05-23 17:13:20 -07:00
JonnyWong16
f743a817ba Update python-twitter to 3.4.1 2018-05-23 17:12:19 -07:00
JonnyWong16
8e4aba7ed4 v2.1.9 2018-05-21 09:07:12 -07:00
JonnyWong16
8c0ef75d4c Fix typos and some cleanup 2018-05-21 09:07:01 -07:00
JonnyWong16
76c4b3bb71 Add Live to notification parameter 2018-05-21 08:49:35 -07:00
JonnyWong16
112b1c7984 Refactor css pointer class 2018-05-20 17:04:55 -07:00
JonnyWong16
c22a2513e3 Update CONTRIBUTING.md 2018-05-19 09:12:13 -07:00
JonnyWong16
f336782fc1 v2.1.8-beta 2018-05-19 09:07:18 -07:00
JonnyWong16
c19afa06de Fallback to originally available at for episode number on info pages 2018-05-18 17:47:19 -07:00
JonnyWong16
e003850d31 Update Facebook permissions scope 2018-05-18 17:41:42 -07:00
JonnyWong16
23cf790079 Return proper status codes for API (Fixes Tautulli/Tautulli-Issues#82) 2018-05-18 17:41:23 -07:00
JonnyWong16
e7f930bd0f Check for Tautulli footer in newsletters 2018-05-17 10:31:55 -07:00
JonnyWong16
348707b6b9 Revert back to HTTP newsletter images from tautulli.com 2018-05-17 09:30:34 -07:00
JonnyWong16
7ad78b4536 Allow images through newsletter password auth 2018-05-17 08:40:58 -07:00
JonnyWong16
a408a62234 Check newsletter auth setting when checking guest access enabled 2018-05-17 08:34:36 -07:00
JonnyWong16
a1e9e7e87f Add newsletter password to newsletter parameters 2018-05-16 23:20:53 -07:00
JonnyWong16
fa99f6e684 Add self-hosted newsletter authentication metnods 2018-05-16 23:11:28 -07:00
JonnyWong16
11e9bd2d54 Fix incorrect <div> tag 2018-05-16 21:59:15 -07:00
JonnyWong16
50165af4b7 Update tautulli.com URLs to HTTPS 2018-05-15 20:38:25 -07:00
JonnyWong16
5dd22c23f2 Patch Twitter str encoding for Python 2 2018-05-15 08:44:13 -07:00
JonnyWong16
79b45c1c46 Auto quality when fetching cloudinary transform 2018-05-15 08:43:20 -07:00
JonnyWong16
af917c4915 Add session key to activity processor log messages 2018-05-14 09:03:18 -07:00
JonnyWong16
c3238b5a83 Fix Imgur database migration again 2018-05-14 09:02:32 -07:00
JonnyWong16
908dbc3243 v2.1.7-beta 2018-05-13 12:10:12 -07:00
JonnyWong16
14b6df8c25 Add newsletter commands to API docs 2018-05-13 11:46:58 -07:00
JonnyWong16
d3e53cb97f Add button to delete all Imgur/Cloudinary uploads 2018-05-13 11:29:59 -07:00
JonnyWong16
445eea5c1e Add plaintext message to newsletter email 2018-05-12 12:16:33 -07:00
JonnyWong16
c5918d7d6c Add option to use inline css styles 2018-05-11 21:43:26 -07:00
JonnyWong16
b8e025193e Add max-width to newsletter card titles 2018-05-11 21:23:19 -07:00
JonnyWong16
85772cdd83 Strip whitespace before sending newsletters 2018-05-11 19:18:05 -07:00
JonnyWong16
7f2bab3082 Switch to header styles for newsletter template 2018-05-11 18:27:31 -07:00
JonnyWong16
8185cc1c40 Keep /newsletter/image proxy for backwards compatibility 2018-05-11 09:40:47 -07:00
JonnyWong16
63bfe96124 Add get_stream_data to API 2018-05-11 08:08:26 -07:00
JonnyWong16
88b640f5e2 Self-hosted newsletter images to use /image endpoint instead of proxying through newsletter 2018-05-10 20:28:42 -07:00
JonnyWong16
26b2342956 Fix typo 2018-05-10 12:04:52 -07:00
JonnyWong16
883280be09 Add Linux distro to Google Analytics 2018-05-10 08:52:28 -07:00
JonnyWong16
7c76b0678a Log platform info on startup 2018-05-10 08:47:54 -07:00
57 changed files with 3561 additions and 2311 deletions

308
API.md
View File

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

View File

@@ -1,5 +1,73 @@
# Changelog # Changelog
## v2.1.12 (2018-06-08)
* Notifications:
* Change: Blank notification link source means disabled instead of default.
* Newsletters:
* New: Make collection tags available in the raw newsletter data for custom templates.
* API:
* New: Ability to terminate a stream using the session key.
## v2.1.11-beta (2018-06-02)
* Monitoring:
* Fix: Activity progress bar not updating in some cases.
* Fix: Monitory Remote Access setting disabled due to Plex Media Server API changes.
* Change: Improved logic for grouping history items without being successive plays.
* Notifications:
* New: Added filename to notification parameters.
* Other:
* Fix: Update metadata failing for tracks without track numbers.
## v2.1.10-beta (2018-05-28)
* Monitoring:
* Fix: Improved monitoring of live tv sessions.
* Change: Use track artist instead of album artist.
* Notifications:
* New: Added timestamp to Discord notification embeds. (Thanks @samwiseg00)
* New: Enable notifications for "clip" media types.
* Fix: Actually add the "live" notification parameter.
* Change: Update Twitter for 280 characters.
* Change: Use HTTPS url for Cloudinary images.
* Newsletters:
* Fix: Artist summaries not showing up on newsletter cards.
* Change: Do not send the newsletter if the template fails to render.
## v2.1.9 (2018-05-21)
* Notifications:
* New: Added "live" to notification parameters.
## v2.1.8-beta (2018-05-19)
* Newsletters:
* New: Added authentication options for self-hosted newsletters.
* Change: Check if the Tautulli footer has been removed in custom newsletter templates.
* Notifications:
* Fix: Cloudinary images not working for Twitter notifications.
* API:
* Fix: Return proper HTTP status codes for errors.
## v2.1.7-beta (2018-05-13)
* Newsletters:
* New: Option to toggle between inline or internal CSS style templates.
* New: Button to delete all uploaded images from Imgur/Cloudinary.
* Fix: Long titles overflowing the newsletter cards.
* Change: Self-hosted images on newsletters to use the /image endpoint instead of proxying through /newsletter/image.
* Change: Strip whitespace from newsletter for smaller file size before sending to email.
* API:
* New: Added get_stream_data command to API.
* New: Added newsletter API commands to documentation.
## v2.1.6-beta (2018-05-09) ## v2.1.6-beta (2018-05-09)
* Newsletters: * Newsletters:

View File

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

View File

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

View File

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

View File

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

View File

@@ -70,6 +70,7 @@ div.form-control .selectize-input {
background-color: #555; background-color: #555;
border-radius: 3px; border-radius: 3px;
transition: background-color .3s; transition: background-color .3s;
height: 32px !important;
} }
.react-selectize.root-node .react-selectize-control, .react-selectize.root-node .react-selectize-control,
.selectize-control.form-control .selectize-input { .selectize-control.form-control .selectize-input {
@@ -2934,6 +2935,7 @@ a .home-platforms-list-cover-face:hover
} }
.stacked-configs > li > span > a.toggle-left, .stacked-configs > li > span > a.toggle-left,
.stacked-configs > li > span > span.toggle-left { .stacked-configs > li > span > span.toggle-left {
float: left;
color: #444; color: #444;
padding-right: 8px; padding-right: 8px;
} }
@@ -2944,16 +2946,6 @@ a .home-platforms-list-cover-face:hover
.stacked-configs > li > span > span.active { .stacked-configs > li > span > span.active {
color: #f9be03; color: #f9be03;
} }
.stacked-configs > li.new-notification-agent,
.stacked-configs > li.notification-agent,
.stacked-configs > li.add-notification-agent,
.stacked-configs > li.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 > a.toggle-left,
.stacked-configs > li.mobile-device > span > span.toggle-left { .stacked-configs > li.mobile-device > span > span.toggle-left {
color: #999; color: #999;
@@ -3524,8 +3516,7 @@ a.no-highlight:hover {
} }
.login-logo { .login-logo {
margin: 0 auto 50px auto; margin: 0 auto 50px auto;
width: 340px; text-align: center;
height: 100px;
} }
.login-container .form-group { .login-container .form-group {
margin-bottom: 20px; margin-bottom: 20px;
@@ -4097,4 +4088,11 @@ a[data-tab-destination] {
margin-top: 10px !important; margin-top: 10px !important;
padding-top: 10px; padding-top: 10px;
border-top: 1px solid #444; 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="${grandparent_href}" title="${data['grandparent_title']}">${data['grandparent_title']}</a>
- <a href="${href}" title="${data['title']}">${data['title']}</a> - <a href="${href}" title="${data['title']}">${data['title']}</a>
% elif data['media_type'] == 'track': % elif data['media_type'] == 'track':
<a id="metadata-grandparent_title-${sk}" href="${grandparent_href}" title="${data['grandparent_title']}">${data['grandparent_title']}</a> <a id="metadata-title-${sk}" href="${href}" title="${data['title']}">${data['title']}</a>
- <a id="metadata-title-${sk}" href="${href}" title="${data['title']}">${data['title']}</a> - <a id="metadata-grandparent_title-${sk}" href="${grandparent_href}" title="${data['original_title'] or data['grandparent_title']}">${data['original_title'] or data['grandparent_title']}</a>
% elif data['media_type'] == 'photo': % elif data['media_type'] == 'photo':
<span title="${data['parent_title']}">${data['parent_title']}</span> <span title="${data['parent_title']}">${data['parent_title']}</span>
% elif data['media_type'] == 'clip': % elif data['media_type'] == 'clip':

View File

@@ -12,7 +12,7 @@
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div class="padded-header" id="current-activity-header"> <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> <small>
<span id="currentActivityHeader" style="display: none;"> <span id="currentActivityHeader" style="display: none;">
Streams: <span id="currentActivityHeader-streams"></span> | Streams: <span id="currentActivityHeader-streams"></span> |
@@ -236,7 +236,6 @@
<script src="${http_root}js/moment-with-locale.js"></script> <script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/jquery.scrollbar.min.js"></script> <script src="${http_root}js/jquery.scrollbar.min.js"></script>
<script src="${http_root}js/jquery.mousewheel.min.js"></script> <script src="${http_root}js/jquery.mousewheel.min.js"></script>
<script src="${http_root}js/jquery.tripleclick.min.js"></script>
<script> <script>
var date_format = 'YYYY-MM-DD'; var date_format = 'YYYY-MM-DD';
var time_format = 'hh:mm a'; var time_format = 'hh:mm a';
@@ -390,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)'); $('#background-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.art + '&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art&refresh=true)');
$('#metadata-grandparent_title-' + key) $('#metadata-grandparent_title-' + key)
.attr('href', 'info?rating_key=' + s.grandparent_rating_key) .attr('href', 'info?rating_key=' + s.grandparent_rating_key)
.attr('title', s.grandparent_title) .attr('title', s.original_title || s.grandparent_title)
.text(s.grandparent_title); .text(s.original_title || s.grandparent_title);
} }
// Update cover if album changed // Update cover if album changed
if (s.parent_rating_key !== instance.data('parent_rating_key')) { if (s.parent_rating_key !== instance.data('parent_rating_key')) {
@@ -406,7 +405,11 @@
.text(s.parent_title); .text(s.parent_title);
} }
// Update cover if track changed // Update cover if track changed
if (s.parent_rating_key !== instance.data('parent_rating_key')) { if (s.rating_key !== instance.data('rating_key')) {
$('#metadata-grandparent_title-' + key)
.attr('href', 'info?rating_key=' + s.grandparent_rating_key)
.attr('title', s.original_title || s.grandparent_title)
.text(s.original_title || s.grandparent_title);
$('#metadata-title-' + key) $('#metadata-title-' + key)
.attr('href', 'info?rating_key=' + s.rating_key) .attr('href', 'info?rating_key=' + s.rating_key)
.attr('title', s.title) .attr('title', s.title)
@@ -542,7 +545,7 @@
.attr('data-original-title', 'Transcoder Progress ' + s.transcode_progress + '%'); .attr('data-original-title', 'Transcoder Progress ' + s.transcode_progress + '%');
var progress_bar = $('#progress-bar-' + key); var progress_bar = $('#progress-bar-' + key);
progress_bar.data('state', s.state); 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); progress_bar.data('last_view_offset', s.view_offset).data('view_offset', s.view_offset);
} }
@@ -672,6 +675,7 @@
$.ajax({ $.ajax({
url: 'terminate_session', url: 'terminate_session',
data: { data: {
session_key: key,
session_id: session_id, session_id: session_id,
message: message message: message
}, },
@@ -690,10 +694,8 @@
}); });
}); });
$('#sessions-shortcut').on('tripleclick', function () { $('#sessions-xml').on('tripleclick', function () {
$.getJSON('return_sessions_url', function(sessions_url) { openPlexXML('/status/sessions');
window.open(sessions_url, '_blank');
});
}); });
% endif % endif
</script> </script>

View File

@@ -83,31 +83,31 @@ DOCUMENTATION :: END
<ul class="list-unstyled breadcrumb"> <ul class="list-unstyled breadcrumb">
% if data['media_type'] in ('movie', 'collection'): % if data['media_type'] in ('movie', 'collection'):
<li><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li> <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': % elif data['media_type'] == 'show':
<li><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li> <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': % 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 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><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': % 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="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 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><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': % elif data['media_type'] == 'artist':
<li><a href="library?section_id=${data['section_id']}">${data['library_name']}</a></li> <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': % 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 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><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': % 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="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 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><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 % endif
</ul> </ul>
</div> </div>
@@ -165,7 +165,7 @@ DOCUMENTATION :: END
<h1><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a></h1> <h1><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a></h1>
<h2>${data['title']}</h2> <h2>${data['title']}</h2>
% elif data['media_type'] == 'track': % elif data['media_type'] == 'track':
<h1><a href="info?rating_key=${data['grandparent_rating_key']}">${data['grandparent_title']}</a></h1> <h1><a href="info?rating_key=${data['grandparent_rating_key']}">${data['original_title'] or data['grandparent_title']}</a></h1>
<h2><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a> - ${data['title']}</h2> <h2><a href="info?rating_key=${data['parent_rating_key']}">${data['parent_title']}</a> - ${data['title']}</h2>
<h3 class="hidden-xs">T${data['media_index']}</h3> <h3 class="hidden-xs">T${data['media_index']}</h3>
% endif % endif
@@ -371,7 +371,11 @@ DOCUMENTATION :: END
<div class="col-md-12"> <div class="col-md-12">
<div class="table-card-header"> <div class="table-card-header">
<div class="header-bar"> <div class="header-bar">
% if data['media_type'] in ('artist', 'album', 'track'):
<span>Play History for <strong>${data['title']}</strong></span>
% else:
<span>Watch History for <strong>${data['title']}</strong></span> <span>Watch History for <strong>${data['title']}</strong></span>
% endif
</div> </div>
<div class="button-bar"> <div class="button-bar">
% if _session['user_group'] == 'admin': % if _session['user_group'] == 'admin':
@@ -502,7 +506,7 @@ DOCUMENTATION :: END
% elif data['media_type'] == 'album': % elif data['media_type'] == 'album':
${data['parent_title']}<br />${data['title']} ${data['parent_title']}<br />${data['title']}
% elif data['media_type'] == 'track': % elif data['media_type'] == 'track':
${data['grandparent_title']}<br />${data['title']}<br />${data['parent_title']} ${data['original_title'] or data['grandparent_title']}<br />${data['title']}<br />${data['parent_title']}
% endif % endif
</strong> </strong>
</p> </p>
@@ -699,6 +703,10 @@ DOCUMENTATION :: END
</script> </script>
% endif % endif
<script> <script>
$('.metadata-xml').on('tripleclick', function () {
openPlexXML("/library/metadata/${data['rating_key']}");
});
$("#airdate").html(moment($("#airdate").text()).format('MMM DD, YYYY')); $("#airdate").html(moment($("#airdate").text()).format('MMM DD, YYYY'));
$("#runtime").html(millisecondsToMinutes($("#runtime").text(), true)); $("#runtime").html(millisecondsToMinutes($("#runtime").text(), true));
$('div.art-face').animate({ opacity: 0.2 }, { duration: 1000 }); $('div.art-face').animate({ opacity: 0.2 }, { duration: 1000 });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
% if notifier: % if notifier:
<%! <%!
import json import json
from plexpy import helpers, notifiers, users from plexpy import notifiers, users
from plexpy.helpers import checked
available_notification_actions = notifiers.available_notification_actions() available_notification_actions = notifiers.available_notification_actions()
user_emails = [{'user': u['friendly_name'] or u['username'], 'email': u['email']} for u in users.Users().get_users() if u['email']] user_emails = [{'user': u['friendly_name'] or u['username'], 'email': u['email']} for u in users.Users().get_users() if u['email']]
@@ -70,7 +71,7 @@
% elif item['input_type'] == 'checkbox': % elif item['input_type'] == 'checkbox':
<div class="checkbox"> <div class="checkbox">
<label> <label>
<input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${helpers.checked(item['value'])}> ${item['label']} <input type="checkbox" data-id="${item['name']}" class="checkboxes" value="1" ${checked(item['value'])}> ${item['label']}
</label> </label>
<p class="help-block">${item['description'] | n}</p> <p class="help-block">${item['description'] | n}</p>
<input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}"> <input type="hidden" id="${item['name']}" name="${item['name']}" value="${item['value']}">
@@ -146,7 +147,7 @@
% for action in available_notification_actions: % for action in available_notification_actions:
<div class="checkbox"> <div class="checkbox">
<label> <label>
<input type="checkbox" data-id="${action['name']}" class="checkboxes" value="1" ${helpers.checked(notifier['actions'][action['name']])}> ${action['label']} <input type="checkbox" data-id="${action['name']}" class="checkboxes" value="1" ${checked(notifier['actions'][action['name']])}> ${action['label']}
</label> </label>
<p class="help-block">${action['description'] | n}</p> <p class="help-block">${action['description'] | n}</p>
<input type="hidden" id="${action['name']}" name="${action['name']}" value="${notifier['actions'][action['name']]}"> <input type="hidden" id="${action['name']}" name="${action['name']}" value="${notifier['actions'][action['name']]}">

View File

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

View File

@@ -115,9 +115,9 @@
</div> </div>
<div class="checkbox advanced-setting"> <div class="checkbox advanced-setting">
<label> <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> </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>
<div class="checkbox advanced-setting"> <div class="checkbox advanced-setting">
<label> <label>
@@ -646,11 +646,11 @@
<div role="tabpanel" class="tab-pane" id="tabs-plex_media_server"> <div role="tabpanel" class="tab-pane" id="tabs-plex_media_server">
<div class="padded-header"> <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>
<div class="form-group has-feedback" id="pms_ip_group"> <div class="form-group has-feedback" id="pms_ip_group">
<label for="pms_ip">Plex IP or Hostname</label> <label for="pms_ip">Plex IP Address or Hostname</label>
<div class="row"> <div class="row">
<div class="col-md-9" id="selectize-pms-ip-container"> <div class="col-md-9" id="selectize-pms-ip-container">
<div class="input-group"> <div class="input-group">
@@ -965,12 +965,46 @@
<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> <p class="help-block">Enable to host newsletters on your own domain. This will generate a link to an HTML page where you can view the newsletter.</p>
</div> </div>
<div id="self_host_newsletter_options" style="overlfow: hidden; display: ${'block' if config['newsletter_self_hosted'] == 'checked' else 'none'}"> <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"> <div class="form-group">
Note: The <span class="inline-pre">${http_root}newsletter</span> endpoint on your domain must be publicly accessible from the internet. <p class="help-block" id="self_host_newsletter_message">
</p> Note: The <span class="inline-pre">${http_root}newsletter</span> endpoint on your domain must be publicly accessible from the internet.
<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> </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>
<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"> <div class="form-group advanced-setting">
<label for="newsletter_dir">Custom Newsletter Templates Folder</label> <label for="newsletter_dir">Custom Newsletter Templates Folder</label>
<div class="row"> <div class="row">
@@ -998,21 +1032,30 @@
<label for="notify_upload_posters">Image Hosting</label> <label for="notify_upload_posters">Image Hosting</label>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<select class="form-control" id="notify_upload_posters" name="notify_upload_posters"> <div class="${'input-group' if config['notify_upload_posters'] in (1, 3) else ''}">
<option value="0" ${'selected' if config['notify_upload_posters'] == 0 else ''}>Disabled</option> <select class="form-control" id="notify_upload_posters" name="notify_upload_posters">
<option value="1" ${'selected' if config['notify_upload_posters'] == 1 else ''}>Imgur</option> <option value="0" ${'selected' if config['notify_upload_posters'] == 0 else ''}>Disabled</option>
<option value="3" ${'selected' if config['notify_upload_posters'] == 3 else ''}>Cloudinary</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> <option value="3" ${'selected' if config['notify_upload_posters'] == 3 else ''}>Cloudinary</option>
</select> <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>
</div> </div>
<p class="help-block">Select where to host Plex images for notifications and newsletters.</p> <p class="help-block">Select where to host Plex images for notifications and newsletters.</p>
</div> </div>
<div id="imgur_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 1 else 'block'}"> <div id="imgur_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 1 else 'block'}">
<p class="help-block" id="imgur_upload_message"> <div class="form-group">
You can register a new Imgur application <a href="${anon_url('https://api.imgur.com/oauth2/addclient')}" target="_blank">here</a>.<br> <p class="help-block" id="imgur_upload_message">
Warning: Imgur uploads are rate-limited and newsletters may exceed the limit. Please use Cloudinary for newsletters instead. You can register a new Imgur application <a href="${anon_url('https://api.imgur.com/oauth2/addclient')}" target="_blank">here</a>.<br>
</p> Warning: Imgur uploads are rate-limited and newsletters may exceed the limit. Please use Cloudinary for newsletters instead.
</p>
</div>
<div class="form-group"> <div class="form-group">
<label for="imgur_client_id">Imgur Client ID</label> <label for="imgur_client_id">Imgur Client ID</label>
<div class="row"> <div class="row">
@@ -1024,13 +1067,17 @@
</div> </div>
</div> </div>
<div id="self_host_image_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 2 else 'block'}"> <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> <div class="form-group">
<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> <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>
<div id="cloudinary_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 3 else 'block'}"> <div id="cloudinary_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 3 else 'block'}">
<p class="help-block" id="imgur_upload_message"> <div class="form-group">
You can sign up for Cloudinary <a href="${anon_url('https://cloudinary.com')}" target="_blank">here</a>.<br> <p class="help-block" id="imgur_upload_message">
</p> You can sign up for Cloudinary <a href="${anon_url('https://cloudinary.com')}" target="_blank">here</a>.<br>
</p>
</div>
<div class="form-group"> <div class="form-group">
<label for="cloudinary_cloud_name">Cloudinary Cloud Name</label> <label for="cloudinary_cloud_name">Cloudinary Cloud Name</label>
<div class="row"> <div class="row">
@@ -1221,7 +1268,7 @@
</span> </span>
</p> </p>
</div> </div>
<p class="form-group"> <div class="form-group">
<label>Registered Devices</label> <label>Registered Devices</label>
<p class="help-block">Register a new device using a QR code, or configure an existing device by clicking the settings icon on the right.</p> <p class="help-block">Register a new device using a QR code, or configure an existing device by clicking the settings icon on the right.</p>
<p id="app_api_msg" style="color: #eb8600;">The API must be enabled under <a data-tab-destination="tabs-web_interface" data-target="#api_enabled">Web Interface</a> to use the app.</p> <p id="app_api_msg" style="color: #eb8600;">The API must be enabled under <a data-tab-destination="tabs-web_interface" data-target="#api_enabled">Web Interface</a> to use the app.</p>
@@ -1376,7 +1423,7 @@
<div class="col-md-12"> <div class="col-md-12">
<ul class="stacked-configs list-unstyled"> <ul class="stacked-configs list-unstyled">
% for agent in sorted(available_notification_agents, key=lambda k: k['label'].lower()): % for agent in sorted(available_notification_agents, key=lambda k: k['label'].lower()):
<li class="new-notification-agent" data-id="${agent['id']}"> <li class="new-notification-agent pointer" data-id="${agent['id']}">
<span>${agent['label']}</span> <span>${agent['label']}</span>
</li> </li>
% endfor % endfor
@@ -1404,7 +1451,7 @@
<div class="col-md-12"> <div class="col-md-12">
<ul class="stacked-configs list-unstyled"> <ul class="stacked-configs list-unstyled">
% for agent in available_newsletter_agents: % 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> <span>${agent['label']}</span>
</li> </li>
% endfor % endfor
@@ -1746,6 +1793,7 @@
} }
function loadNotifierConfig(notifier_id) { function loadNotifierConfig(notifier_id) {
showMsg('<i class="fa fa-refresh fa-spin"></i>&nbsp; Loading Configuration', false);
$.ajax({ $.ajax({
url: 'get_notifier_config_modal', url: 'get_notifier_config_modal',
data: { notifier_id: notifier_id }, data: { notifier_id: notifier_id },
@@ -1753,6 +1801,7 @@
async: true, async: true,
complete: function (xhr, status) { complete: function (xhr, status) {
$("#notifier-config-modal").html(xhr.responseText).modal('show'); $("#notifier-config-modal").html(xhr.responseText).modal('show');
showMsg('<i class="fa fa-check"></i> Configuration Loaded', false, true, 2000);
} }
}); });
} }
@@ -1769,6 +1818,7 @@
} }
function loadNewsletterConfig(newsletter_id) { function loadNewsletterConfig(newsletter_id) {
showMsg('<i class="fa fa-refresh fa-spin"></i>&nbsp; Loading Configuration', false);
$.ajax({ $.ajax({
url: 'get_newsletter_config_modal', url: 'get_newsletter_config_modal',
data: { newsletter_id: newsletter_id }, data: { newsletter_id: newsletter_id },
@@ -1776,6 +1826,7 @@
async: true, async: true,
complete: function (xhr, status) { complete: function (xhr, status) {
$("#newsletter-config-modal").html(xhr.responseText).modal('show'); $("#newsletter-config-modal").html(xhr.responseText).modal('show');
showMsg('<i class="fa fa-check"></i> Configuration Loaded', false, true, 2000);
} }
}); });
} }
@@ -1792,6 +1843,7 @@
} }
function loadMobileDeviceConfig(mobile_device_id) { function loadMobileDeviceConfig(mobile_device_id) {
showMsg('<i class="fa fa-refresh fa-spin"></i>&nbsp; Loading Configuration', false);
$.ajax({ $.ajax({
url: 'get_mobile_device_config_modal', url: 'get_mobile_device_config_modal',
data: { mobile_device_id: mobile_device_id }, data: { mobile_device_id: mobile_device_id },
@@ -1799,6 +1851,7 @@
async: true, async: true,
complete: function (xhr, status) { complete: function (xhr, status) {
$("#mobile-device-config-modal").html(xhr.responseText).modal('show'); $("#mobile-device-config-modal").html(xhr.responseText).modal('show');
showMsg('<i class="fa fa-check"></i> Configuration Loaded', false, true, 2000);
} }
}); });
} }
@@ -2305,7 +2358,7 @@ $(document).ready(function() {
data: { pref: 'PublishServerOnPlexOnlineKey' }, data: { pref: 'PublishServerOnPlexOnlineKey' },
async: true, async: true,
success: function(data) { 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."); $("#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); $("#monitor_remote_access").attr("checked", false).attr("disabled", true);
} }
@@ -2440,6 +2493,7 @@ $(document).ready(function() {
$("#allow_guest_access").attr("disabled", false); $("#allow_guest_access").attr("disabled", false);
$("#allowGuestCheck").html(""); $("#allowGuestCheck").html("");
} }
newsletterPasswordEnabled();
} }
allowGuestAccessCheck(); allowGuestAccessCheck();
@@ -2543,7 +2597,7 @@ $(document).ready(function() {
var result = $.parseJSON(xhr.responseText); var result = $.parseJSON(xhr.responseText);
var msg = result.message; var msg = result.message;
$('#add-notifier-modal').modal('hide'); $('#add-notifier-modal').modal('hide');
if (result.result == 'success') { if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000); showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
loadNotifierConfig(result.notifier_id); loadNotifierConfig(result.notifier_id);
} else { } else {
@@ -2565,7 +2619,7 @@ $(document).ready(function() {
var result = $.parseJSON(xhr.responseText); var result = $.parseJSON(xhr.responseText);
var msg = result.message; var msg = result.message;
$('#add-newsletter-modal').modal('hide'); $('#add-newsletter-modal').modal('hide');
if (result.result == 'success') { if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000); showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
loadNewsletterConfig(result.newsletter_id); loadNewsletterConfig(result.newsletter_id);
} else { } else {
@@ -2606,11 +2660,37 @@ $(document).ready(function() {
} else { } else {
$('#cloudinary_upload_options').slideUp(); $('#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 () { $('#notify_upload_posters').change(function () {
imageUpload(); 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() { function baseURLSet() {
if ($('#http_base_url').val()) { if ($('#http_base_url').val()) {
$('.base-url-warning').hide(); $('.base-url-warning').hide();
@@ -2638,6 +2718,28 @@ $(document).ready(function() {
newsletterUploadEnabled(); newsletterUploadEnabled();
}); });
function newsletterPasswordEnabled() {
if ($('#newsletter_auth').val() === '1') {
$('#newsletter_password_option').slideDown();
} else {
$('#newsletter_password_option').slideUp();
}
if ($('#newsletter_auth').val() === '2' && !($('#allow_guest_access').is(':checked'))) {
$('.newsletter-guest-access-warning').show();
} else {
$('.newsletter-guest-access-warning').hide();
}
}
newsletterPasswordEnabled();
$('#newsletter_auth').change(function () {
newsletterPasswordEnabled();
});
$('#allow_guest_access').click(function () {
newsletterPasswordEnabled();
})
$('body').on('click', 'a[data-tab-destination]', function () { $('body').on('click', 'a[data-tab-destination]', function () {
var tab = $(this).data('tab-destination'); var tab = $(this).data('tab-destination');
$("a[href=#" + tab + "]").click(); $("a[href=#" + tab + "]").click();
@@ -2651,6 +2753,10 @@ $(document).ready(function() {
body_container.animate({scrollTop: scroll_pos}); body_container.animate({scrollTop: scroll_pos});
} }
}); });
$('#resources-xml').on('tripleclick', function () {
openPlexXML('/api/resources', true, {includeHttps: 1});
});
}); });
</script> </script>
</%def> </%def>

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,9 +13,9 @@
service = get_img_service(include_self=True) service = get_img_service(include_self=True)
if service == 'self-hosted' and plexpy.CONFIG.HTTP_BASE_URL: if service == 'self-hosted' and plexpy.CONFIG.HTTP_BASE_URL:
base_url_image = plexpy.CONFIG.HTTP_BASE_URL + plexpy.HTTP_ROOT + 'newsletter/image/' base_url_image = plexpy.CONFIG.HTTP_BASE_URL + plexpy.HTTP_ROOT + 'image/'
elif service and service != 'self-hosted' and preview: elif preview and service and service != 'self-hosted':
base_url_image = 'newsletter/image/' base_url_image = 'image/'
else: else:
base_url_image = '' base_url_image = ''
@@ -68,17 +68,14 @@
width: 100%; 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 { .container {
display: block; display: block;
margin: 0 auto !important; margin: 0 auto !important;
/* makes it centered */
max-width: 1042px; max-width: 1042px;
padding: 10px; padding: 10px;
width: 1042px; width: 1042px;
} }
/* This should also be a block element, so that it will fill 100% of the .container */
.content { .content {
box-sizing: border-box; box-sizing: border-box;
display: block; display: block;
@@ -172,88 +169,9 @@
text-decoration: underline; 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 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 { .mb5 {
margin-bottom: 5px; margin-bottom: 5px;
} }
@@ -263,9 +181,6 @@
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
} }
.clear {
clear: both;
}
.preheader { .preheader {
color: transparent; color: transparent;
@@ -324,6 +239,7 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
/* ------------------------------------- /* -------------------------------------
MEDIA SECTIONS MEDIA SECTIONS
------------------------------------- */ ------------------------------------- */
@@ -378,6 +294,7 @@
font-size: 20px; font-size: 20px;
text-transform: uppercase; text-transform: uppercase;
} }
/* ------------------------------------- /* -------------------------------------
MEDIA CARDS MEDIA CARDS
------------------------------------- */ ------------------------------------- */
@@ -480,21 +397,21 @@
padding-left: 5px; padding-left: 5px;
} }
.card-info-footer .badge-container { .card-info-footer .badge-container {
max-width: 265px; max-width: 260px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.card-info-footer .star-rating-container { .card-info-footer .star-rating-container {
width: 60px; width: 65px;
vertical-align: bottom;
padding-right: 5px;
} }
.card-info-footer .star-rating { .card-info-footer .star-rating {
margin-left: 4px;
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1rem; line-height: 1rem;
width: 0.5rem; width: 0.5rem;
display: inline-block; display: inline-block;
vertical-align: bottom;
} }
.star-rating.full { .star-rating.full {
color: #E5A00D; color: #E5A00D;
@@ -505,6 +422,7 @@
.badge { .badge {
display: inline-block; display: inline-block;
min-width: 10px; min-width: 10px;
margin-right: 4px;
padding: 3px 7px; padding: 3px 7px;
font-size: 11px; font-size: 11px;
line-height: 1; line-height: 1;
@@ -609,21 +527,12 @@
line-height: inherit !important; line-height: inherit !important;
text-decoration: none !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> </style>
</head> </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: % 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 --> <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: % 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 --> <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 -->
@@ -632,19 +541,13 @@
<tr> <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;"> <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;"> <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> <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: % 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 --> <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 --> <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 --> </div> <!-- IGNORE SAVE -->
% endif % 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;"> <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> <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;"> <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;"> <div class="header" style="width: 100%;height: 90px;text-align: center;">
@@ -654,7 +557,6 @@
<div class="dates" style="color: #aaaaaa;font-size: 20px;text-align: center;">${parameters['start_date']} - ${parameters['end_date']}</div> <div class="dates" style="color: #aaaaaa;font-size: 20px;text-align: center;">${parameters['start_date']} - ${parameters['end_date']}</div>
</td> </td>
</tr> </tr>
% if message: % if message:
<tr> <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;"> <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;">
@@ -663,7 +565,6 @@
</td> </td>
</tr> </tr>
% endif % endif
% if recently_added.get('movie'): % if recently_added.get('movie'):
<tr> <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;"> <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;">
@@ -679,109 +580,105 @@
<tr> <tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;"> <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%;"> <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):
% for movie_a, movie_b in grouper(recently_added['movie'], 2): <tr>
<tr> <td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<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%;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;"> <tr>
<tbody> % for movie in (movie_a, movie_b):
<tr> % if movie:
% for movie in (movie_a, movie_b): % if not movie_b:
% if movie: <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>
% if not movie_b: % endif
<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> <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;">
% endif <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);">
<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;"> <tr>
<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);"> <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;">
<tbody> <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> <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;"> <td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<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);"> <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;">
<tbody> <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;">
<tr> </a>
<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> </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;"> </tr>
<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%;"> </table>
<tbody> </td>
<tr> <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;">
<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;"> <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%;">
<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> <tr>
</td> <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;">
</tr> <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>
<tr> </td>
<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%;"> </tr>
% if movie['tagline']: <tr>
<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;"> <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%;">
<em>${movie['tagline']}</em> % if movie['tagline']:
</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;">
% endif <em>${movie['tagline']}</em>
<p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;color: #ffffff;"> </p>
${movie['summary'][:450] + (movie['summary'][450:] and '...')} % endif
</p> <p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;color: #ffffff;">
</td> ${movie['summary'][:450] + (movie['summary'][450:] and '...')}
</tr> </p>
<tr> </td>
<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;"> </tr>
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;"> <tr>
<tbody> <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;">
<tr> <table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<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;"> <tr>
% if movie['year']: <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;">
<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> <table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
% endif <tr>
% if movie['duration']: % if movie['year']:
<% duration = int(int(movie['duration'])/60000) %> <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>
<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
% endif % if movie['duration']:
% if movie['genres']: <% duration = int(int(movie['duration'])/60000) %>
% for genre in movie['genres'][:2]: <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>
<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> % endif
% endfor % if movie['genres']:
% endif % for genre in movie['genres'][:]:
</td> <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>
% if movie['rating']: % endfor
<% rating = int(round(float(movie['rating']) / 2)) %> % endif
<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;"> </tr>
% for _ in range(rating): </table>
<span class="star-rating full" style="font-size: 0.8rem;line-height: 1rem;width: 0.5rem;display: inline-block;color: #E5A00D;">&#9733;</span> </td>
% endfor % if movie['rating']:
% for _ in range(5-rating): <% rating = int(round(float(movie['rating']) / 2)) %>
<span class="star-rating empty" style="font-size: 0.8rem;line-height: 1rem;width: 0.5rem;display: inline-block;color: #aaaaaa;">&#9734;</span> <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;">
% endfor <table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
</td> <tr>
% endif % for _ in range(rating):
</tr> <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>
</tbody> % endfor
</table> % for _ in range(5-rating):
</td> <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>
</tr> % endfor
</tbody> </tr>
</table>
</td>
% endif
</tr>
</table> </table>
</td> </td>
</tr> </tr>
</tbody> </table>
</table> </td>
</td> </tr>
% if not movie_b: </table>
<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> </td>
% endif % if not movie_b:
% endif <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>
% endfor % endif
</tr> % endif
</tbody> % endfor
</table> </tr>
</td> </table>
</tr> </td>
% endfor </tr>
</tbody> % endfor
</table> </table>
</td> </td>
</tr> </tr>
@@ -803,142 +700,137 @@
<tr> <tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;"> <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%;"> <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):
% for show_a, show_b in grouper(recently_added['show'], 2): <tr>
<tr> <td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<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%;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;"> <tr>
<tbody> % for show in (show_a, show_b):
<tr> % if show:
% for show in (show_a, show_b): % if not show_b:
% if show: <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>
% if not show_b: % endif
<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:
if show['season_count'] == 1: link_rating_key = show['season'][0]['episode'][0]['rating_key']
if show['season'][0]['episode_count'] == 1: else:
link_rating_key = show['season'][0]['episode'][0]['rating_key'] link_rating_key = show['season'][0]['episode'][0]['parent_rating_key']
else: else:
link_rating_key = show['season'][0]['episode'][0]['parent_rating_key'] link_rating_key = show['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);">
<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;"> <tr>
<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);"> <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;">
<tbody> <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> <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;"> <td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<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);"> <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;">
<tbody> <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;">
<tr> </a>
<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> </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;"> </tr>
<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%;"> </table>
<tbody> </td>
<tr> <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;">
<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;"> <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%;">
<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> <tr>
</td> <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;">
</tr> <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>
<tr> </td>
<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%;"> </tr>
<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;"> <tr>
% if show['season_count'] > 1: <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%;">
<em>${show['season_count']} seasons /</em> <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 % endif
<% total_show_episodes = sum(s['episode_count'] for s in show['season']) %> % if show['duration']:
<em>${total_show_episodes} episode${'s' if total_show_episodes > 1 else ''}</em> <% duration = int(int(show['duration'])/60000) %>
</p> <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>
<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;"> % endif
% for i, season in enumerate(show['season'][:8]): % if show['genres']:
Season ${season['media_index']} &middot; % for genre in show['genres'][:2]:
% if season['episode_count'] == 1: <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>
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
% endfor % 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 % endif
</p> </tr>
</td> </table>
</tr> </td>
<tr> % if show['rating']:
<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;"> <% rating = int(round(float(show['rating']) / 2)) %>
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;"> <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;">
<tbody> <table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr> <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;"> % for _ in range(rating):
% if show['year']: <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>
<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> % endfor
% endif % for _ in range(5-rating):
% if show['duration']: <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>
<% duration = int(int(show['duration'])/60000) %> % endfor
<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> </tr>
% endif </table>
% if show['genres']: </td>
% for genre in show['genres'][:2]: % endif
<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> </tr>
% 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>
</table> </table>
</td> </td>
</tr> </tr>
</tbody> </table>
</table> </td>
</td> </tr>
% if not show_b: </table>
<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> </td>
% endif % if not show_b:
% endif <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>
% endfor % endif
</tr> % endif
</tbody> % endfor
</table> </tr>
</td> </table>
</tr> </td>
% endfor </tr>
</tbody> % endfor
</table> </table>
</td> </td>
</tr> </tr>
@@ -960,124 +852,114 @@
<tr> <tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;"> <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%;"> <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):
% for album_a, album_b in grouper([a for artist in recently_added['artist'] for a in artist['album']], 2): <tr>
<tr> <td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<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%;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;"> <tr>
<tbody> % for album in (album_a, album_b):
<tr> % if album:
% for album in (album_a, album_b): % if not album_b:
% if album: <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>
% if not album_b: % endif
<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> <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;">
% endif <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);">
<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;"> <tr>
<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);"> <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;">
<tbody> <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> <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;"> <td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<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);"> <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;">
<tbody> <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;">
<tr> </a>
<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> </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;"> </tr>
<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%;"> </table>
<tbody> </td>
<tr> <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;">
<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;"> <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%;">
<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> <tr>
</td> <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;">
</tr> <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>
<tr> </td>
<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;"> </tr>
<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;"> <tr>
<em>${album['parent_title']} &middot; ${album['track_count']} track${'s' if album['track_count'] > 1 else ''}</em> <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> <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 artist['title'].lower() != 'various artists': <em>${album['parent_title']} &middot; ${album['track_count']} track${'s' if album['track_count'] > 1 else ''}</em>
<p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;color: #ffffff;"> </p>
${album['summary'][:200] + (album['summary'][200:] and '...')} % if album['parent_title'].lower() != 'various artists':
</p> <p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;color: #ffffff;">
% endif ${album['summary'][:200] + (album['summary'][200:] and '...')}
</td> </p>
</tr> % endif
<tr> </td>
<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;"> </tr>
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;"> <tr>
<tbody> <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;">
<tr> <table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<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;"> <tr>
% if album['year']: <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;">
<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> <table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
% endif <tr>
% if album['genres']: % if album['year']:
% for genre in album['genres'][:2]: <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>
<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> % endif
% endfor % if album['genres']:
% endif % for genre in album['genres'][:2]:
</td> <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>
% if album['rating']: % endfor
<% rating = int(round(float(album['rating']) / 2)) %> % endif
<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;"> </tr>
% for _ in range(rating): </table>
<span class="star-rating full" style="font-size: 0.8rem;line-height: 1rem;width: 0.5rem;display: inline-block;color: #E5A00D;">&#9733;</span> </td>
% endfor % if album['rating']:
% for _ in range(5-rating): <% rating = int(round(float(album['rating']) / 2)) %>
<span class="star-rating empty" style="font-size: 0.8rem;line-height: 1rem;width: 0.5rem;display: inline-block;color: #aaaaaa;">&#9734;</span> <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;">
% endfor <table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
</td> <tr>
% endif % for _ in range(rating):
</tr> <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>
</tbody> % endfor
</table> % for _ in range(5-rating):
</td> <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>
</tr> % endfor
</tbody> </tr>
</table>
</td>
% endif
</tr>
</table> </table>
</td> </td>
</tr> </tr>
</tbody> </table>
</table> </td>
</td> </tr>
% if not album_b: </table>
<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> </td>
% endif % if not album_b:
% endif <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>
% endfor % endif
</tr> % endif
</tbody> % endfor
</table> </tr>
</td> </table>
</tr> </td>
% endfor </tr>
</tbody> % endfor
</table> </table>
</td> </td>
</tr> </tr>
% endif % endif
<tr> <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%;"> <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="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;"> <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> </div>
<!-- END FOOTER -->
</td> </td>
</tr> </tr>
<!-- END MAIN CONTENT AREA -->
</table> </table>
<!-- END CENTERED WHITE CONTAINER -->
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -13,9 +13,9 @@
service = get_img_service(include_self=True) service = get_img_service(include_self=True)
if service == 'self-hosted' and plexpy.CONFIG.HTTP_BASE_URL: if service == 'self-hosted' and plexpy.CONFIG.HTTP_BASE_URL:
base_url_image = plexpy.CONFIG.HTTP_BASE_URL + plexpy.HTTP_ROOT + 'newsletter/image/' base_url_image = plexpy.CONFIG.HTTP_BASE_URL + plexpy.HTTP_ROOT + 'image/'
elif service and service != 'self-hosted' and preview: elif preview and service and service != 'self-hosted':
base_url_image = 'newsletter/image/' base_url_image = 'image/'
else: else:
base_url_image = '' base_url_image = ''
@@ -68,17 +68,14 @@
width: 100%; 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 { .container {
display: block; display: block;
margin: 0 auto !important; margin: 0 auto !important;
/* makes it centered */
max-width: 1042px; max-width: 1042px;
padding: 10px; padding: 10px;
width: 1042px; width: 1042px;
} }
/* This should also be a block element, so that it will fill 100% of the .container */
.content { .content {
box-sizing: border-box; box-sizing: border-box;
display: block; display: block;
@@ -172,88 +169,9 @@
text-decoration: underline; 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 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 { .mb5 {
margin-bottom: 5px; margin-bottom: 5px;
} }
@@ -263,9 +181,6 @@
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
} }
.clear {
clear: both;
}
.preheader { .preheader {
color: transparent; color: transparent;
@@ -324,6 +239,7 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
/* ------------------------------------- /* -------------------------------------
MEDIA SECTIONS MEDIA SECTIONS
------------------------------------- */ ------------------------------------- */
@@ -378,6 +294,7 @@
font-size: 20px; font-size: 20px;
text-transform: uppercase; text-transform: uppercase;
} }
/* ------------------------------------- /* -------------------------------------
MEDIA CARDS MEDIA CARDS
------------------------------------- */ ------------------------------------- */
@@ -451,6 +368,7 @@
line-height: 1.2rem; line-height: 1.2rem;
font-size: 0.9rem; font-size: 0.9rem;
padding: 5px; padding: 5px;
max-width: 320px;
} }
.card-info-title a { .card-info-title a {
text-decoration: none; text-decoration: none;
@@ -480,21 +398,21 @@
padding-left: 5px; padding-left: 5px;
} }
.card-info-footer .badge-container { .card-info-footer .badge-container {
max-width: 265px; max-width: 260px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.card-info-footer .star-rating-container { .card-info-footer .star-rating-container {
width: 60px; width: 65px;
vertical-align: bottom;
padding-right: 5px;
} }
.card-info-footer .star-rating { .card-info-footer .star-rating {
margin-left: 4px;
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1rem; line-height: 1rem;
width: 0.5rem; width: 0.5rem;
display: inline-block; display: inline-block;
vertical-align: bottom;
} }
.star-rating.full { .star-rating.full {
color: #E5A00D; color: #E5A00D;
@@ -505,6 +423,7 @@
.badge { .badge {
display: inline-block; display: inline-block;
min-width: 10px; min-width: 10px;
margin-right: 4px;
padding: 3px 7px; padding: 3px 7px;
font-size: 11px; font-size: 11px;
line-height: 1; line-height: 1;
@@ -609,21 +528,12 @@
line-height: inherit !important; line-height: inherit !important;
text-decoration: none !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> </style>
</head> </head>
<body class=""> <body>
% if preview and service: % 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 --> <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: % 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 --> <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 -->
@@ -632,19 +542,13 @@
<tr> <tr>
<td class="container"> <td class="container">
<div class="content"> <div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<span class="preheader">Tautulli Newsletter - ${subject}</span> <span class="preheader">Tautulli Newsletter - ${subject}</span>
% if base_url and not preview: % if base_url and not preview:
<div class="view-full"> <!-- IGNORE SAVE --> <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 --> <a href="${base_url + uuid}" title="View full newsletter" target="_blank">Click here to view the full newsletter.</a> <!-- IGNORE SAVE -->
</div> <!-- IGNORE SAVE --> </div> <!-- IGNORE SAVE -->
% endif % endif
<table border="0" cellpadding="3" cellspacing="0" class="main"> <table border="0" cellpadding="3" cellspacing="0" class="main">
<!-- START MAIN CONTENT AREA -->
<tr> <tr>
<td class="wrapper"> <td class="wrapper">
<div class="header"> <div class="header">
@@ -654,7 +558,6 @@
<div class="dates">${parameters['start_date']} - ${parameters['end_date']}</div> <div class="dates">${parameters['start_date']} - ${parameters['end_date']}</div>
</td> </td>
</tr> </tr>
% if message: % if message:
<tr> <tr>
<td class="wrapper"> <td class="wrapper">
@@ -663,7 +566,6 @@
</td> </td>
</tr> </tr>
% endif % endif
% if recently_added.get('movie'): % if recently_added.get('movie'):
<tr> <tr>
<td class="wrapper"> <td class="wrapper">
@@ -679,109 +581,105 @@
<tr> <tr>
<td> <td>
<table border="0" cellpadding="0" cellspacing="0"> <table border="0" cellpadding="0" cellspacing="0">
<tbody> % for movie_a, movie_b in grouper(recently_added['movie'], 2):
% for movie_a, movie_b in grouper(recently_added['movie'], 2): <tr>
<tr> <td>
<td> <table border="0" cellpadding="0" cellspacing="0" width="100%">
<table border="0" cellpadding="0" cellspacing="0" width="100%"> <tr>
<tbody> % for movie in (movie_a, movie_b):
<tr> % if movie:
% for movie in (movie_a, movie_b): % if not movie_b:
% if movie: <td align="center" valign="top" class="card-instance pad"></td>
% if not movie_b: % endif
<td align="center" valign="top" class="card-instance pad"></td> <td align="center" valign="top" class="card-instance movie">
% endif <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']});">
<td align="center" valign="top" class="card-instance movie"> <tr>
<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']});"> <td class="card-poster-container">
<tbody> <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> <tr>
<td class="card-poster-container"> <td>
<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']})"> <a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${movie['rating_key']}" title="${movie['title']}" target="_blank">
<tbody> <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">
<tr> </a>
<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> </td>
<td class="card-info-container"> </tr>
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table"> </table>
<tbody> </td>
<tr> <td class="card-info-container">
<td class="card-info-title nowrap"> <table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
<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> <tr>
</td> <td class="card-info-title nowrap">
</tr> <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>
<tr> </td>
<td class="card-info-body"> </tr>
% if movie['tagline']: <tr>
<p class="nowrap mb5"> <td class="card-info-body">
<em>${movie['tagline']}</em> % if movie['tagline']:
</p> <p class="nowrap mb5">
% endif <em>${movie['tagline']}</em>
<p> </p>
${movie['summary'][:450] + (movie['summary'][450:] and '...')} % endif
</p> <p>
</td> ${movie['summary'][:450] + (movie['summary'][450:] and '...')}
</tr> </p>
<tr> </td>
<td class="card-info-footer nowrap"> </tr>
<table border="0" cellpadding="0" cellspacing="0"> <tr>
<tbody> <td class="card-info-footer nowrap">
<tr> <table border="0" cellpadding="0" cellspacing="0">
<td class="badge-container"> <tr>
% if movie['year']: <td class="badge-container">
<span class="badge" title="${movie['year']}">${movie['year']}</span> <table border="0" cellpadding="0" cellspacing="0">
% endif <tr>
% if movie['duration']: % if movie['year']:
<% duration = int(int(movie['duration'])/60000) %> <td class="badge" title="${movie['year']}">${movie['year']}</td>
<span class="badge" title="${duration} mins">${duration} mins</span> % endif
% endif % if movie['duration']:
% if movie['genres']: <% duration = int(int(movie['duration'])/60000) %>
% for genre in movie['genres'][:2]: <td class="badge" title="${duration} mins">${duration} mins</td>
<span class="badge" title="${genre}">${genre}</span> % endif
% endfor % if movie['genres']:
% endif % for genre in movie['genres'][:]:
</td> <td class="badge" title="${genre}">${genre}</td>
% if movie['rating']: % endfor
<% rating = int(round(float(movie['rating']) / 2)) %> % endif
<td class="star-rating-container" title="${int(float(movie['rating'])/0.1)}%" align="right"> </tr>
% for _ in range(rating): </table>
<span class="star-rating full">&#9733;</span> </td>
% endfor % if movie['rating']:
% for _ in range(5-rating): <% rating = int(round(float(movie['rating']) / 2)) %>
<span class="star-rating empty">&#9734;</span> <td class="star-rating-container" title="${int(float(movie['rating'])/0.1)}%" align="right">
% endfor <table border="0" cellpadding="0" cellspacing="0">
</td> <tr>
% endif % for _ in range(rating):
</tr> <td class="star-rating full">&#9733;</td>
</tbody> % endfor
</table> % for _ in range(5-rating):
</td> <td class="star-rating empty">&#9734;</td>
</tr> % endfor
</tbody> </tr>
</table>
</td>
% endif
</tr>
</table> </table>
</td> </td>
</tr> </tr>
</tbody> </table>
</table> </td>
</td> </tr>
% if not movie_b: </table>
<td align="center" valign="top" class="card-instance pad"></td> </td>
% endif % if not movie_b:
% endif <td align="center" valign="top" class="card-instance pad"></td>
% endfor % endif
</tr> % endif
</tbody> % endfor
</table> </tr>
</td> </table>
</tr> </td>
% endfor </tr>
</tbody> % endfor
</table> </table>
</td> </td>
</tr> </tr>
@@ -803,142 +701,137 @@
<tr> <tr>
<td> <td>
<table border="0" cellpadding="0" cellspacing="0"> <table border="0" cellpadding="0" cellspacing="0">
<tbody> % for show_a, show_b in grouper(recently_added['show'], 2):
% for show_a, show_b in grouper(recently_added['show'], 2): <tr>
<tr> <td>
<td> <table border="0" cellpadding="0" cellspacing="0" width="100%">
<table border="0" cellpadding="0" cellspacing="0" width="100%"> <tr>
<tbody> % for show in (show_a, show_b):
<tr> % if show:
% for show in (show_a, show_b): % if not show_b:
% if show: <td align="center" valign="top" class="card-instance pad"></td>
% if not show_b: % endif
<td align="center" valign="top" class="card-instance pad"></td> <%
% endif if show['season_count'] == 1:
<% if show['season'][0]['episode_count'] == 1:
if show['season_count'] == 1: link_rating_key = show['season'][0]['episode'][0]['rating_key']
if show['season'][0]['episode_count'] == 1: else:
link_rating_key = show['season'][0]['episode'][0]['rating_key'] link_rating_key = show['season'][0]['episode'][0]['parent_rating_key']
else: else:
link_rating_key = show['season'][0]['episode'][0]['parent_rating_key'] link_rating_key = show['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']});">
<td align="center" valign="top" class="card-instance show"> <tr>
<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']});"> <td class="card-poster-container">
<tbody> <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> <tr>
<td class="card-poster-container"> <td>
<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']})"> <a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${link_rating_key}" title="${show['title']}" target="_blank">
<tbody> <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">
<tr> </a>
<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> </td>
<td class="card-info-container"> </tr>
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table"> </table>
<tbody> </td>
<tr> <td class="card-info-container">
<td class="card-info-title nowrap"> <table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
<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> <tr>
</td> <td class="card-info-title nowrap">
</tr> <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>
<tr> </td>
<td class="card-info-body"> </tr>
<p class="nowrap mb5"> <tr>
% if show['season_count'] > 1: <td class="card-info-body">
<em>${show['season_count']} seasons /</em> <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 % endif
<% total_show_episodes = sum(s['episode_count'] for s in show['season']) %> % if show['duration']:
<em>${total_show_episodes} episode${'s' if total_show_episodes > 1 else ''}</em> <% duration = int(int(show['duration'])/60000) %>
</p> <td class="badge" title="${duration} mins">${duration} mins</td>
<p class="nowrap mb5"> % endif
% for i, season in enumerate(show['season'][:8]): % if show['genres']:
Season ${season['media_index']} &middot; % for genre in show['genres'][:2]:
% if season['episode_count'] == 1: <td class="badge" title="${genre}">${genre}</td>
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
% endfor % 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 % endif
</p> </tr>
</td> </table>
</tr> </td>
<tr> % if show['rating']:
<td class="card-info-footer nowrap"> <% rating = int(round(float(show['rating']) / 2)) %>
<table border="0" cellpadding="0" cellspacing="0"> <td class="star-rating-container" title="${int(float(show['rating'])/0.1)}%" align="right">
<tbody> <table border="0" cellpadding="0" cellspacing="0">
<tr> <tr>
<td class="badge-container"> % for _ in range(rating):
% if show['year']: <td class="star-rating full">&#9733;</td>
<span class="badge" title="${show['year']}">${show['year']}</span> % endfor
% endif % for _ in range(5-rating):
% if show['duration']: <td class="star-rating empty">&#9734;</td>
<% duration = int(int(show['duration'])/60000) %> % endfor
<span class="badge" title="${duration} mins">${duration} mins</span> </tr>
% endif </table>
% if show['genres']: </td>
% for genre in show['genres'][:2]: % endif
<span class="badge" title="${genre}">${genre}</span> </tr>
% 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>
</table> </table>
</td> </td>
</tr> </tr>
</tbody> </table>
</table> </td>
</td> </tr>
% if not show_b: </table>
<td align="center" valign="top" class="card-instance pad"></td> </td>
% endif % if not show_b:
% endif <td align="center" valign="top" class="card-instance pad"></td>
% endfor % endif
</tr> % endif
</tbody> % endfor
</table> </tr>
</td> </table>
</tr> </td>
% endfor </tr>
</tbody> % endfor
</table> </table>
</td> </td>
</tr> </tr>
@@ -960,124 +853,114 @@
<tr> <tr>
<td> <td>
<table border="0" cellpadding="0" cellspacing="0"> <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):
% for album_a, album_b in grouper([a for artist in recently_added['artist'] for a in artist['album']], 2): <tr>
<tr> <td>
<td> <table border="0" cellpadding="0" cellspacing="0" width="100%">
<table border="0" cellpadding="0" cellspacing="0" width="100%"> <tr>
<tbody> % for album in (album_a, album_b):
<tr> % if album:
% for album in (album_a, album_b): % if not album_b:
% if album: <td align="center" valign="top" class="card-instance pad"></td>
% if not album_b: % endif
<td align="center" valign="top" class="card-instance pad"></td> <td align="center" valign="top" class="card-instance album">
% endif <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']});">
<td align="center" valign="top" class="card-instance album"> <tr>
<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']});"> <td class="card-poster-container">
<tbody> <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> <tr>
<td class="card-poster-container"> <td>
<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']})"> <a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${album['rating_key']}" title="${album['title']}" target="_blank">
<tbody> <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">
<tr> </a>
<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> </td>
<td class="card-info-container"> </tr>
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table"> </table>
<tbody> </td>
<tr> <td class="card-info-container">
<td class="card-info-title nowrap"> <table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
<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> <tr>
</td> <td class="card-info-title nowrap">
</tr> <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>
<tr> </td>
<td class="card-info-body"> </tr>
<p class="nowrap mb5"> <tr>
<em>${album['parent_title']} &middot; ${album['track_count']} track${'s' if album['track_count'] > 1 else ''}</em> <td class="card-info-body">
</p> <p class="nowrap mb5">
% if artist['title'].lower() != 'various artists': <em>${album['parent_title']} &middot; ${album['track_count']} track${'s' if album['track_count'] > 1 else ''}</em>
<p> </p>
${album['summary'][:200] + (album['summary'][200:] and '...')} % if album['parent_title'].lower() != 'various artists':
</p> <p>
% endif ${album['summary'][:200] + (album['summary'][200:] and '...')}
</td> </p>
</tr> % endif
<tr> </td>
<td class="card-info-footer nowrap"> </tr>
<table border="0" cellpadding="0" cellspacing="0"> <tr>
<tbody> <td class="card-info-footer nowrap">
<tr> <table border="0" cellpadding="0" cellspacing="0">
<td class="badge-container"> <tr>
% if album['year']: <td class="badge-container">
<span class="badge" title="${album['year']}">${album['year']}</span> <table border="0" cellpadding="0" cellspacing="0">
% endif <tr>
% if album['genres']: % if album['year']:
% for genre in album['genres'][:2]: <td class="badge" title="${album['year']}">${album['year']}</td>
<span class="badge" title="${genre}">${genre}</span> % endif
% endfor % if album['genres']:
% endif % for genre in album['genres'][:2]:
</td> <td class="badge" title="${genre}">${genre}</td>
% if album['rating']: % endfor
<% rating = int(round(float(album['rating']) / 2)) %> % endif
<td class="star-rating-container" title="${int(float(album['rating'])/0.1)}%" align="right"> </tr>
% for _ in range(rating): </table>
<span class="star-rating full">&#9733;</span> </td>
% endfor % if album['rating']:
% for _ in range(5-rating): <% rating = int(round(float(album['rating']) / 2)) %>
<span class="star-rating empty">&#9734;</span> <td class="star-rating-container" title="${int(float(album['rating'])/0.1)}%" align="right">
% endfor <table border="0" cellpadding="0" cellspacing="0">
</td> <tr>
% endif % for _ in range(rating):
</tr> <td class="star-rating full">&#9733;</td>
</tbody> % endfor
</table> % for _ in range(5-rating):
</td> <td class="star-rating empty">&#9734;</td>
</tr> % endfor
</tbody> </tr>
</table>
</td>
% endif
</tr>
</table> </table>
</td> </td>
</tr> </tr>
</tbody> </table>
</table> </td>
</td> </tr>
% if not album_b: </table>
<td align="center" valign="top" class="card-instance pad"></td> </td>
% endif % if not album_b:
% endif <td align="center" valign="top" class="card-instance pad"></td>
% endfor % endif
</tr> % endif
</tbody> % endfor
</table> </tr>
</td> </table>
</tr> </td>
% endfor </tr>
</tbody> % endfor
</table> </table>
</td> </td>
</tr> </tr>
% endif % endif
<tr> <tr>
<td class="footer"> <td class="footer">
<!-- START FOOTER -->
<div class="footer-bar"></div> <div class="footer-bar"></div>
<div class="content-block powered-by"> <div class="content-block powered-by">
Newsletter generated by <a href="http://tautulli.com" target="_blank">Tautulli</a>. <!-- FOOTER MESSAGE - DO NOT REMOVE -->
</div> </div>
<!-- END FOOTER -->
</td> </td>
</tr> </tr>
<!-- END MAIN CONTENT AREA -->
</table> </table>
<!-- END CENTERED WHITE CONTAINER -->
</div> </div>
</td> </td>
</tr> </tr>

View File

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

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env python #!/usr/bin/env python
import errno import errno
import os import os
import re
import tempfile import tempfile
from hashlib import md5 from hashlib import md5
@@ -47,7 +46,7 @@ class _FileCache(object):
path = self._GetPath(key) path = self._GetPath(key)
if not path.startswith(self._root_directory): if not path.startswith(self._root_directory):
raise _FileCacheError('%s does not appear to live under %s' % raise _FileCacheError('%s does not appear to live under %s' %
(path, self._root_directory )) (path, self._root_directory))
if os.path.exists(path): if os.path.exists(path):
os.remove(path) os.remove(path)
@@ -101,61 +100,3 @@ class _FileCache(object):
def _GetPrefix(self, hashed_key): def _GetPrefix(self, hashed_key):
return os.path.sep.join(hashed_key[0:_FileCache.DEPTH]) 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): def message(self):
'''Returns the first argument used to construct this error.''' '''Returns the first argument used to construct this error.'''
return self.args[0] 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): def __ne__(self, other):
return not self.__eq__(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): def AsJsonString(self):
""" Returns the TwitterModel as a JSON string based on key/value """ Returns the TwitterModel as a JSON string based on key/value
pairs returned from the AsDict() method. """ pairs returned from the AsDict() method. """
@@ -78,11 +85,14 @@ class TwitterModel(object):
""" """
json_data = data.copy()
if kwargs: if kwargs:
for key, val in kwargs.items(): 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): class Media(TwitterModel):
@@ -93,11 +103,14 @@ class Media(TwitterModel):
self.param_defaults = { self.param_defaults = {
'display_url': None, 'display_url': None,
'expanded_url': None, 'expanded_url': None,
'ext_alt_text': None,
'id': None, 'id': None,
'media_url': None, 'media_url': None,
'media_url_https': None, 'media_url_https': None,
'sizes': None,
'type': None, 'type': None,
'url': None, 'url': None,
'video_info': None,
} }
for (param, default) in self.param_defaults.items(): for (param, default) in self.param_defaults.items():
@@ -172,8 +185,10 @@ class DirectMessage(TwitterModel):
self.param_defaults = { self.param_defaults = {
'created_at': None, 'created_at': None,
'id': None, 'id': None,
'recipient': None,
'recipient_id': None, 'recipient_id': None,
'recipient_screen_name': None, 'recipient_screen_name': None,
'sender': None,
'sender_id': None, 'sender_id': None,
'sender_screen_name': None, 'sender_screen_name': None,
'text': None, 'text': None,
@@ -181,6 +196,10 @@ class DirectMessage(TwitterModel):
for (param, default) in self.param_defaults.items(): for (param, default) in self.param_defaults.items():
setattr(self, param, kwargs.get(param, default)) 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): def __repr__(self):
if self.text and len(self.text) > 140: if self.text and len(self.text) > 140:
@@ -206,7 +225,7 @@ class Trend(TwitterModel):
'query': None, 'query': None,
'timestamp': None, 'timestamp': None,
'url': None, 'url': None,
'volume': None, 'tweet_volume': None,
} }
for (param, default) in self.param_defaults.items(): for (param, default) in self.param_defaults.items():
@@ -218,6 +237,10 @@ class Trend(TwitterModel):
self.timestamp, self.timestamp,
self.url) self.url)
@property
def volume(self):
return self.tweet_volume
class Hashtag(TwitterModel): class Hashtag(TwitterModel):
@@ -259,12 +282,12 @@ class UserStatus(TwitterModel):
""" A class representing the UserStatus structure. This is an abbreviated """ A class representing the UserStatus structure. This is an abbreviated
form of the twitter.User object. """ form of the twitter.User object. """
connections = {'following': False, _connections = {'following': False,
'followed_by': False, 'followed_by': False,
'following_received': False, 'following_received': False,
'following_requested': False, 'following_requested': False,
'blocking': False, 'blocking': False,
'muting': False} 'muting': False}
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.param_defaults = { self.param_defaults = {
@@ -284,10 +307,19 @@ class UserStatus(TwitterModel):
setattr(self, param, kwargs.get(param, default)) setattr(self, param, kwargs.get(param, default))
if 'connections' in kwargs: if 'connections' in kwargs:
for param in self.connections: for param in self._connections:
if param in kwargs['connections']: if param in kwargs['connections']:
setattr(self, param, True) 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): def __repr__(self):
connections = [param for param in self.connections if getattr(self, param)] connections = [param for param in self.connections if getattr(self, param)]
return "UserStatus(ID={uid}, ScreenName={sn}, Connections=[{conn}])".format( return "UserStatus(ID={uid}, ScreenName={sn}, Connections=[{conn}])".format(
@@ -307,11 +339,14 @@ class User(TwitterModel):
'default_profile': None, 'default_profile': None,
'default_profile_image': None, 'default_profile_image': None,
'description': None, 'description': None,
'email': None,
'favourites_count': None, 'favourites_count': None,
'followers_count': None, 'followers_count': None,
'following': None,
'friends_count': None, 'friends_count': None,
'geo_enabled': None, 'geo_enabled': None,
'id': None, 'id': None,
'id_str': None,
'lang': None, 'lang': None,
'listed_count': None, 'listed_count': None,
'location': None, 'location': None,
@@ -319,12 +354,16 @@ class User(TwitterModel):
'notifications': None, 'notifications': None,
'profile_background_color': None, 'profile_background_color': None,
'profile_background_image_url': None, 'profile_background_image_url': None,
'profile_background_image_url_https': None,
'profile_background_tile': None, 'profile_background_tile': None,
'profile_banner_url': None, 'profile_banner_url': None,
'profile_image_url': None, 'profile_image_url': None,
'profile_image_url_https': None,
'profile_link_color': None, 'profile_link_color': None,
'profile_sidebar_border_color': None,
'profile_sidebar_fill_color': None, 'profile_sidebar_fill_color': None,
'profile_text_color': None, 'profile_text_color': None,
'profile_use_background_image': None,
'protected': None, 'protected': None,
'screen_name': None, 'screen_name': None,
'status': None, 'status': None,
@@ -333,6 +372,8 @@ class User(TwitterModel):
'url': None, 'url': None,
'utc_offset': None, 'utc_offset': None,
'verified': None, 'verified': None,
'withheld_in_countries': None,
'withheld_scope': None,
} }
for (param, default) in self.param_defaults.items(): for (param, default) in self.param_defaults.items():
@@ -365,6 +406,7 @@ class Status(TwitterModel):
'current_user_retweet': None, 'current_user_retweet': None,
'favorite_count': None, 'favorite_count': None,
'favorited': None, 'favorited': None,
'full_text': None,
'geo': None, 'geo': None,
'hashtags': None, 'hashtags': None,
'id': None, 'id': None,
@@ -377,6 +419,9 @@ class Status(TwitterModel):
'media': None, 'media': None,
'place': None, 'place': None,
'possibly_sensitive': None, 'possibly_sensitive': None,
'quoted_status': None,
'quoted_status_id': None,
'quoted_status_id_str': None,
'retweet_count': None, 'retweet_count': None,
'retweeted': None, 'retweeted': None,
'retweeted_status': None, 'retweeted_status': None,
@@ -395,6 +440,11 @@ class Status(TwitterModel):
for (param, default) in self.param_defaults.items(): for (param, default) in self.param_defaults.items():
setattr(self, param, kwargs.get(param, default)) setattr(self, param, kwargs.get(param, default))
if kwargs.get('full_text', None):
self.tweet_mode = 'extended'
else:
self.tweet_mode = 'compatibility'
@property @property
def created_at_in_seconds(self): def created_at_in_seconds(self):
""" Get the time this status message was posted, in seconds since """ 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 string: A string representation of this twitter.Status instance with
the ID of status, username and datetime. the ID of status, username and datetime.
""" """
if self.tweet_mode == 'extended':
text = self.full_text
else:
text = self.text
if self.user: if self.user:
return "Status(ID={0}, ScreenName={1}, Created={2}, Text={3!r})".format( return "Status(ID={0}, ScreenName={1}, Created={2}, Text={3!r})".format(
self.id, self.id,
self.user.screen_name, self.user.screen_name,
self.created_at, self.created_at,
self.text) text)
else: else:
return u"Status(ID={0}, Created={1}, Text={2!r})".format( return u"Status(ID={0}, Created={1}, Text={2!r})".format(
self.id, self.id,
self.created_at, self.created_at,
self.text) text)
@classmethod @classmethod
def NewFromJsonDict(cls, data, **kwargs): def NewFromJsonDict(cls, data, **kwargs):
@@ -439,17 +493,25 @@ class Status(TwitterModel):
current_user_retweet = None current_user_retweet = None
hashtags = None hashtags = None
media = None media = None
quoted_status = None
retweeted_status = None retweeted_status = None
urls = None urls = None
user = None user = None
user_mentions = 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: if 'user' in data:
user = User.NewFromJsonDict(data['user']) user = User.NewFromJsonDict(data['user'])
if 'retweeted_status' in data: if 'retweeted_status' in data:
retweeted_status = Status.NewFromJsonDict(data['retweeted_status']) retweeted_status = Status.NewFromJsonDict(data['retweeted_status'])
if 'current_user_retweet' in data: if 'current_user_retweet' in data:
current_user_retweet = data['current_user_retweet']['id'] 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 'entities' in data:
if 'urls' in data['entities']: if 'urls' in data['entities']:
@@ -470,6 +532,7 @@ class Status(TwitterModel):
current_user_retweet=current_user_retweet, current_user_retweet=current_user_retweet,
hashtags=hashtags, hashtags=hashtags,
media=media, media=media,
quoted_status=quoted_status,
retweeted_status=retweeted_status, retweeted_status=retweeted_status,
urls=urls, urls=urls,
user=user, user=user,

View File

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

View File

@@ -97,6 +97,7 @@ class RateLimit(object):
and a dictionary of limit, remaining, and reset will be returned. and a dictionary of limit, remaining, and reset will be returned.
""" """
self.__dict__['resources'] = {}
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
@staticmethod @staticmethod
@@ -117,10 +118,12 @@ class RateLimit(object):
for non_std_endpoint in NON_STANDARD_ENDPOINTS: for non_std_endpoint in NON_STANDARD_ENDPOINTS:
if re.match(non_std_endpoint.regex, resource): if re.match(non_std_endpoint.regex, resource):
return non_std_endpoint.resource return non_std_endpoint.resource
else: return resource
return resource
def set_unknown_limit(self, url, limit, remaining, reset): 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 """ If a resource family is unknown, add it to the object's
dictionary. This is to deal with new endpoints being added to dictionary. This is to deal with new endpoints being added to
the API, but not necessarily to the information returned by the API, but not necessarily to the information returned by
@@ -146,13 +149,18 @@ class RateLimit(object):
""" """
endpoint = self.url_to_resource(url) endpoint = self.url_to_resource(url)
resource_family = endpoint.split('/')[1] resource_family = endpoint.split('/')[1]
self.__dict__['resources'].update( new_endpoint = {endpoint: {
{resource_family: { "limit": enf_type('limit', int, limit),
endpoint: { "remaining": enf_type('remaining', int, remaining),
"limit": limit, "reset": enf_type('reset', int, reset)
"remaining": remaining, }}
"reset": 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): def get_limit(self, url):
""" Gets a EndpointRateLimit object for the given url. """ Gets a EndpointRateLimit object for the given url.
@@ -181,35 +189,3 @@ class RateLimit(object):
return EndpointRateLimit(family_rates['limit'], return EndpointRateLimit(family_rates['limit'],
family_rates['remaining'], family_rates['remaining'],
family_rates['reset']) 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 # encoding: utf-8
from __future__ import unicode_literals
import mimetypes import mimetypes
import os import os
import re 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 import requests
from tempfile import NamedTemporaryFile
from twitter import TwitterError 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 = [ TLDS = [
"ac", "ad", "ae", "af", "ag", "ai", "al", "am", "an", "ao", "aq", "ar", "ac", "ad", "ae", "af", "ag", "ai", "al", "am", "an", "ao", "aq", "ar",
@@ -138,7 +158,14 @@ TLDS = [
"淡马锡", "游戏", "点看", "移动", "组织机构", "网址", "网店", "网络", "谷歌", "集团", "淡马锡", "游戏", "点看", "移动", "组织机构", "网址", "网店", "网络", "谷歌", "集团",
"飞利浦", "餐厅", "닷넷", "닷컴", "삼성", "onion"] "飞利浦", "餐厅", "닷넷", "닷컴", "삼성", "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): 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. Expected length of the status message as an integer.
""" """
replaced_chars = 0 status_length = 0
status_length = len(status) if isinstance(status, bytes):
match = re.findall(URL_REGEXP, status) status = unicode(status)
if len(match) >= 1: for word in re.split(r'\s', status):
replaced_chars = len(''.join(match)) if is_url(word):
status_length = status_length - replaced_chars + (short_url_length * len(match)) 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 return status_length
@@ -171,16 +205,14 @@ def is_url(text):
Returns: Returns:
Boolean of whether the text should be treated as a URL or not. Boolean of whether the text should be treated as a URL or not.
""" """
if re.findall(URL_REGEXP, text): return bool(re.findall(URL_REGEXP, text))
return True
else:
return False
def http_to_file(http): def http_to_file(http):
data_file = NamedTemporaryFile() data_file = NamedTemporaryFile()
req = requests.get(http, stream=True) 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 return data_file
@@ -200,7 +232,8 @@ def parse_media_file(passed_media):
'image/gif', 'image/gif',
'image/bmp', 'image/bmp',
'image/webp'] '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, # 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 # 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 not hasattr(passed_media, 'read'):
if passed_media.startswith('http'): if passed_media.startswith('http'):
data_file = http_to_file(passed_media) data_file = http_to_file(passed_media)
filename = os.path.basename(passed_media) filename = os.path.basename(urlparse(passed_media).path)
else: else:
data_file = open(os.path.realpath(passed_media), 'rb') data_file = open(os.path.realpath(passed_media), 'rb')
filename = os.path.basename(passed_media) 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, # Otherwise, if a file object was passed in the first place,
# create the standard reference to media_file (i.e., rename it to fp). # create the standard reference to media_file (i.e., rename it to fp).
else: else:
if passed_media.mode != 'rb': if passed_media.mode not in ['rb', 'rb+', 'w+b']:
raise TwitterError({'message': 'File mode must be "rb".'}) raise TwitterError('File mode must be "rb" or "rb+"')
filename = os.path.basename(passed_media.name) filename = os.path.basename(passed_media.name)
data_file = passed_media data_file = passed_media
@@ -226,16 +259,17 @@ def parse_media_file(passed_media):
try: try:
data_file.seek(0) data_file.seek(0)
except: except Exception as e:
pass pass
media_type = mimetypes.guess_type(os.path.basename(filename))[0] media_type = mimetypes.guess_type(os.path.basename(filename))[0]
if media_type in img_formats and file_size > 5 * 1048576: if media_type is not None:
raise TwitterError({'message': 'Images must be less than 5MB.'}) if media_type in img_formats and file_size > 5 * 1048576:
elif media_type in video_formats and file_size > 15 * 1048576: raise TwitterError({'message': 'Images must be less than 5MB.'})
raise TwitterError({'message': 'Videos must be less than 15MB.'}) elif media_type in video_formats and file_size > 15 * 1048576:
elif media_type not in img_formats and media_type not in video_formats: raise TwitterError({'message': 'Videos must be less than 15MB.'})
raise TwitterError({'message': 'Media type could not be determined.'}) 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 return data_file, filename, file_size, media_type
@@ -263,3 +297,18 @@ def enf_type(field, _type, val):
raise TwitterError({ raise TwitterError({
'message': '"{0}" must be type {1}'.format(field, _type.__name__) '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

@@ -42,6 +42,7 @@ import datafactory
import libraries import libraries
import logger import logger
import mobile_app import mobile_app
import newsletters
import newsletter_handler import newsletter_handler
import notification_handler import notification_handler
import notifiers import notifiers
@@ -152,6 +153,17 @@ def initialize(config_file):
logger.initLogger(console=not QUIET, log_dir=CONFIG.LOG_DIR, logger.initLogger(console=not QUIET, log_dir=CONFIG.LOG_DIR,
verbose=VERBOSE) 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: if not CONFIG.BACKUP_DIR:
CONFIG.BACKUP_DIR = os.path.join(DATA_DIR, 'backups') CONFIG.BACKUP_DIR = os.path.join(DATA_DIR, 'backups')
if not os.path.exists(CONFIG.BACKUP_DIR): if not os.path.exists(CONFIG.BACKUP_DIR):
@@ -191,6 +203,7 @@ def initialize(config_file):
logger.error(u"Could not perform upgrades: %s" % e) logger.error(u"Could not perform upgrades: %s" % e)
# Add notifier configs to logger blacklist # Add notifier configs to logger blacklist
newsletters.blacklist_logger()
notifiers.blacklist_logger() notifiers.blacklist_logger()
mobile_app.blacklist_logger() mobile_app.blacklist_logger()
@@ -505,11 +518,12 @@ def dbcheck():
# sessions table :: This is a temp table that logs currently active sessions # sessions table :: This is a temp table that logs currently active sessions
c_db.execute( c_db.execute(
'CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, session_key INTEGER, ' 'CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, session_key INTEGER, session_id TEXT, '
'transcode_key TEXT, rating_key INTEGER, section_id INTEGER, media_type TEXT, started INTEGER, stopped INTEGER, ' '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, ' '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, ' '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, ' 'thumb TEXT, parent_thumb TEXT, grandparent_thumb TEXT, year INTEGER, '
'parent_rating_key INTEGER, grandparent_rating_key INTEGER, ' 'parent_rating_key INTEGER, grandparent_rating_key INTEGER, '
'view_offset INTEGER DEFAULT 0, duration INTEGER, video_decision TEXT, audio_decision TEXT, ' 'view_offset INTEGER DEFAULT 0, duration INTEGER, video_decision TEXT, audio_decision TEXT, '
@@ -529,6 +543,7 @@ def dbcheck():
'transcode_hw_decoding INTEGER, transcode_hw_encoding INTEGER, ' 'transcode_hw_decoding INTEGER, transcode_hw_encoding INTEGER, '
'optimized_version INTEGER, optimized_version_profile TEXT, optimized_version_title TEXT, ' 'optimized_version INTEGER, optimized_version_profile TEXT, optimized_version_title TEXT, '
'synced_version INTEGER, synced_version_profile 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, ' '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)' 'write_attempts INTEGER DEFAULT 0, raw_stream_info TEXT)'
) )
@@ -569,8 +584,9 @@ def dbcheck():
c_db.execute( c_db.execute(
'CREATE TABLE IF NOT EXISTS session_history_metadata (id INTEGER PRIMARY KEY, ' 'CREATE TABLE IF NOT EXISTS session_history_metadata (id INTEGER PRIMARY KEY, '
'rating_key INTEGER, parent_rating_key INTEGER, grandparent_rating_key INTEGER, ' '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, ' 'title TEXT, parent_title TEXT, grandparent_title TEXT, original_title TEXT, full_title TEXT, '
'parent_media_index INTEGER, section_id INTEGER, thumb TEXT, parent_thumb TEXT, grandparent_thumb 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, ' '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, ' '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, ' 'duration INTEGER DEFAULT 0, guid TEXT, directors TEXT, writers TEXT, actors TEXT, genres TEXT, studio TEXT, '
@@ -1053,6 +1069,36 @@ def dbcheck():
'ALTER TABLE sessions ADD COLUMN watched INTEGER DEFAULT 0' '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 session_id FROM sessions')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table sessions.")
c_db.execute(
'ALTER TABLE sessions ADD COLUMN session_id 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 # Upgrade session_history table from earlier versions
try: try:
c_db.execute('SELECT reference_id FROM session_history') c_db.execute('SELECT reference_id FROM session_history')
@@ -1139,6 +1185,15 @@ def dbcheck():
'ALTER TABLE session_history_metadata ADD COLUMN labels TEXT' '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 # Upgrade session_history_media_info table from earlier versions
try: try:
c_db.execute('SELECT transcode_decision FROM session_history_media_info') c_db.execute('SELECT transcode_decision FROM session_history_media_info')
@@ -1712,8 +1767,8 @@ def dbcheck():
for row in result: for row in result:
img_hash = notification_handler.set_hash_image_info( img_hash = notification_handler.set_hash_image_info(
rating_key=row['rating_key'], width=1000, height=1500, fallback='poster') rating_key=row['rating_key'], width=1000, height=1500, fallback='poster')
data_factory.set_img_info(img_hash=img_hash, imgur_title=row['poster_title'], data_factory.set_img_info(img_hash=img_hash, img_title=row['poster_title'],
imgur_url=row['poster_url'], delete_hash=row['delete_hash'], img_url=row['poster_url'], delete_hash=row['delete_hash'],
service='imgur') service='imgur')
db.action('DROP TABLE poster_urls') db.action('DROP TABLE poster_urls')
@@ -1812,7 +1867,8 @@ def initialize_tracker():
'appVersion': common.RELEASE, 'appVersion': common.RELEASE,
'appId': plexpy.INSTALL_TYPE, 'appId': plexpy.INSTALL_TYPE,
'appInstallerId': plexpy.CONFIG.GIT_BRANCH, '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, 'userLanguage': plexpy.SYS_LANGUAGE,
'documentEncoding': plexpy.SYS_ENCODING, 'documentEncoding': plexpy.SYS_ENCODING,
'noninteractive': True 'noninteractive': True

View File

@@ -226,7 +226,11 @@ class ActivityHandler(object):
db_session = ap.get_session_by_key(session_key=self.get_session_key()) db_session = ap.get_session_by_key(session_key=self.get_session_key())
this_state = self.timeline['state'] 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 we already have this session in the temp table, check for state changes
if db_session: if db_session:
@@ -235,10 +239,11 @@ class ActivityHandler(object):
func=force_stop_stream, args=[self.get_session_key()], minutes=5) func=force_stop_stream, args=[self.get_session_key()], minutes=5)
last_state = db_session['state'] last_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 # 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 # Update the session state and viewOffset
if this_state == 'playing': if this_state == 'playing':
# Update the session in our temp session table # Update the session in our temp session table

View File

@@ -15,18 +15,13 @@
from collections import defaultdict from collections import defaultdict
import json import json
import threading
import time import time
import re
import plexpy import plexpy
import database import database
import datafactory import helpers
import libraries import libraries
import log_reader
import logger import logger
import notification_handler
import notifiers
import pmsconnect import pmsconnect
import users import users
@@ -39,6 +34,7 @@ class ActivityProcessor(object):
def write_session(self, session=None, notify=True): def write_session(self, session=None, notify=True):
if session: if session:
values = {'session_key': session.get('session_key', ''), values = {'session_key': session.get('session_key', ''),
'session_id': session.get('session_id', ''),
'transcode_key': session.get('transcode_key', ''), 'transcode_key': session.get('transcode_key', ''),
'section_id': session.get('section_id', ''), 'section_id': session.get('section_id', ''),
'rating_key': session.get('rating_key', ''), 'rating_key': session.get('rating_key', ''),
@@ -50,6 +46,7 @@ class ActivityProcessor(object):
'title': session.get('title', ''), 'title': session.get('title', ''),
'parent_title': session.get('parent_title', ''), 'parent_title': session.get('parent_title', ''),
'grandparent_title': session.get('grandparent_title', ''), 'grandparent_title': session.get('grandparent_title', ''),
'original_title': session.get('original_title', ''),
'full_title': session.get('full_title', ''), 'full_title': session.get('full_title', ''),
'media_index': session.get('media_index', ''), 'media_index': session.get('media_index', ''),
'parent_media_index': session.get('parent_media_index', ''), 'parent_media_index': session.get('parent_media_index', ''),
@@ -60,6 +57,7 @@ class ActivityProcessor(object):
'friendly_name': session.get('friendly_name', ''), 'friendly_name': session.get('friendly_name', ''),
'ip_address': session.get('ip_address', ''), 'ip_address': session.get('ip_address', ''),
'player': session.get('player', ''), 'player': session.get('player', ''),
'product': session.get('product', ''),
'platform': session.get('platform', ''), 'platform': session.get('platform', ''),
'parent_rating_key': session.get('parent_rating_key', ''), 'parent_rating_key': session.get('parent_rating_key', ''),
'grandparent_rating_key': session.get('grandparent_rating_key', ''), 'grandparent_rating_key': session.get('grandparent_rating_key', ''),
@@ -114,7 +112,9 @@ class ActivityProcessor(object):
'stream_audio_channels': session.get('stream_audio_channels', ''), 'stream_audio_channels': session.get('stream_audio_channels', ''),
'stream_subtitle_decision': session.get('stream_subtitle_decision', ''), 'stream_subtitle_decision': session.get('stream_subtitle_decision', ''),
'stream_subtitle_codec': session.get('stream_subtitle_codec', ''), '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), 'raw_stream_info': json.dumps(session),
'stopped': int(time.time()) 'stopped': int(time.time())
} }
@@ -180,8 +180,9 @@ class ActivityProcessor(object):
if str(session['rating_key']).isdigit() and session['media_type'] in ('movie', 'episode', 'track'): if str(session['rating_key']).isdigit() and session['media_type'] in ('movie', 'episode', 'track'):
logging_enabled = True logging_enabled = True
else: else:
logger.debug(u"Tautulli ActivityProcessor :: ratingKey %s not logged. Does not meet logging criteria. " logger.debug(u"Tautulli ActivityProcessor :: Session %s ratingKey %s not logged. "
u"Media type is '%s'" % (session['rating_key'], session['media_type'])) u"Does not meet logging criteria. Media type is '%s'" %
(session['session_key'], session['rating_key'], session['media_type']))
return session['id'] return session['id']
if str(session['paused_counter']).isdigit(): if str(session['paused_counter']).isdigit():
@@ -193,15 +194,16 @@ class ActivityProcessor(object):
if (session['media_type'] == 'movie' or session['media_type'] == 'episode') and \ if (session['media_type'] == 'movie' or session['media_type'] == 'episode') and \
(real_play_time < int(plexpy.CONFIG.LOGGING_IGNORE_INTERVAL)): (real_play_time < int(plexpy.CONFIG.LOGGING_IGNORE_INTERVAL)):
logging_enabled = False logging_enabled = False
logger.debug(u"Tautulli ActivityProcessor :: Play duration for ratingKey %s is %s secs which is less than %s " logger.debug(u"Tautulli ActivityProcessor :: Play duration for session %s ratingKey %s is %s secs "
u"seconds, so we're not logging it." % u"which is less than %s seconds, so we're not logging it." %
(session['rating_key'], str(real_play_time), plexpy.CONFIG.LOGGING_IGNORE_INTERVAL)) (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 not is_import and session['media_type'] == 'track':
if real_play_time < 15 and session['duration'] >= 30: if real_play_time < 15 and session['duration'] >= 30:
logging_enabled = False 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" % 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: elif is_import and import_ignore_interval:
if (session['media_type'] == 'movie' or session['media_type'] == 'episode') and \ if (session['media_type'] == 'movie' or session['media_type'] == 'episode') and \
(real_play_time < int(import_ignore_interval)): (real_play_time < int(import_ignore_interval)):
@@ -266,14 +268,15 @@ class ActivityProcessor(object):
self.db.upsert(table_name='session_history', key_dict=keys, value_dict=values) 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 # 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 \ query = 'SELECT id, rating_key, view_offset, user_id, reference_id FROM session_history ' \
WHERE user_id = ? ORDER BY id DESC LIMIT 2 ' '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) result = self.db.select(query=query, args=args)
new_session = prev_session = None new_session = prev_session = None
prev_progress_percent = media_watched_percent = 0
# Get the last insert row id # Get the last insert row id
last_id = self.db.last_insert_id() last_id = self.db.last_insert_id()
@@ -290,11 +293,23 @@ class ActivityProcessor(object):
'user_id': result[1]['user_id'], 'user_id': result[1]['user_id'],
'reference_id': result[1]['reference_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 = ? ' 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: if prev_session is None and new_session is None:
args = [last_id, last_id] 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']] args = [prev_session['reference_id'], new_session['id']]
else: else:
args = [new_session['id'], new_session['id']] args = [new_session['id'], new_session['id']]
@@ -394,6 +409,7 @@ class ActivityProcessor(object):
'title': session['title'], 'title': session['title'],
'parent_title': session['parent_title'], 'parent_title': session['parent_title'],
'grandparent_title': session['grandparent_title'], 'grandparent_title': session['grandparent_title'],
'original_title': session['original_title'],
'full_title': session['full_title'], 'full_title': session['full_title'],
'media_index': metadata['media_index'], 'media_index': metadata['media_index'],
'parent_media_index': metadata['parent_media_index'], 'parent_media_index': metadata['parent_media_index'],
@@ -451,6 +467,16 @@ class ActivityProcessor(object):
return None return None
def get_session_by_id(self, session_id=None):
if session_id:
session = self.db.select_single('SELECT * FROM sessions '
'WHERE session_id = ? ',
args=[session_id])
if session:
return session
return None
def set_session_state(self, session_key=None, state=None, **kwargs): def set_session_state(self, session_key=None, state=None, **kwargs):
if str(session_key).isdigit(): if str(session_key).isdigit():
values = {} values = {}

View File

@@ -37,6 +37,8 @@ import logger
import mobile_app import mobile_app
import notification_handler import notification_handler
import notifiers import notifiers
import newsletter_handler
import newsletters
import users import users
@@ -443,6 +445,51 @@ class API2:
return 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): def _api_make_md(self):
""" Tries to make a API.md to simplify the api docs. """ """ Tries to make a API.md to simplify the api docs. """
@@ -564,6 +611,7 @@ General optional parameters:
# if we fail to generate the output fake an error # if we fail to generate the output fake an error
except Exception as e: except Exception as e:
logger.api_exception(u'Tautulli APIv2 :: ' + traceback.format_exc()) logger.api_exception(u'Tautulli APIv2 :: ' + traceback.format_exc())
cherrypy.response.status = 500
out['message'] = traceback.format_exc() out['message'] = traceback.format_exc()
out['result'] = 'error' out['result'] = 'error'
@@ -573,6 +621,7 @@ General optional parameters:
out = xmltodict.unparse(out, pretty=True) out = xmltodict.unparse(out, pretty=True)
except Exception as e: except Exception as e:
logger.api_error(u'Tautulli APIv2 :: Failed to parse xml result') logger.api_error(u'Tautulli APIv2 :: Failed to parse xml result')
cherrypy.response.status = 500
try: try:
out['message'] = e out['message'] = e
out['result'] = 'error' out['result'] = 'error'
@@ -613,6 +662,7 @@ General optional parameters:
result = call(**self._api_kwargs) result = call(**self._api_kwargs)
except Exception as e: except Exception as e:
logger.api_error(u'Tautulli APIv2 :: Failed to run %s with %s: %s' % (self._api_cmd, self._api_kwargs, 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: if self._api_debug:
cherrypy.request.show_tracebacks = True cherrypy.request.show_tracebacks = True
# Reraise the exception so the traceback hits the browser # Reraise the exception so the traceback hits the browser
@@ -657,4 +707,7 @@ General optional parameters:
if ret.get('result'): if ret.get('result'):
self._api_result_type = ret.pop('result', None) 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)) 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 # Identify Our Application
PLATFORM = platform.system() 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 BRANCH = version.PLEXPY_BRANCH
RELEASE = version.PLEXPY_RELEASE_VERSION 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_USER_THUMB = "interfaces/default/images/gravatar-default-80x80.png"
DEFAULT_POSTER_THUMB = "interfaces/default/images/poster.png" DEFAULT_POSTER_THUMB = "interfaces/default/images/poster.png"
DEFAULT_COVER_THUMB = "interfaces/default/images/cover.png" DEFAULT_COVER_THUMB = "interfaces/default/images/cover.png"
DEFAULT_ART = "interfaces/default/images/art.png" DEFAULT_ART = "interfaces/default/images/art.png"
ONLINE_POSTER_THUMB = "http://tautulli.com/images/poster.png" ONLINE_POSTER_THUMB = "https://tautulli.com/images/poster.png"
ONLINE_COVER_THUMB = "http://tautulli.com/images/cover.png" ONLINE_COVER_THUMB = "https://tautulli.com/images/cover.png"
ONLINE_ART = "http://tautulli.com/images/art.png" ONLINE_ART = "https://tautulli.com/images/art.png"
MEDIA_TYPE_HEADERS = { MEDIA_TYPE_HEADERS = {
'movie': 'Movies', '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', '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': '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': '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 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 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'}, {'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': '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': '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 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', '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': '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'}, {'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', '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': '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': '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': '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': '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.'}, {'name': 'Rating Key', 'type': 'int', 'value': 'rating_key', 'description': 'The unique identifier for the movie, episode, or track.'},
@@ -529,6 +535,7 @@ NEWSLETTER_PARAMETERS = [
{'name': 'Newsletter UUID', 'type': 'str', 'value': 'newsletter_uuid', 'description': 'The unique identifier for the newsletter.'}, {'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', '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 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_TOKEN': (str, 'PMS', ''),
'PMS_SSL': (int, 'PMS', 0), 'PMS_SSL': (int, 'PMS', 0),
'PMS_URL': (str, 'PMS', ''), 'PMS_URL': (str, 'PMS', ''),
'PMS_URL_OVERRIDE': (str, 'PMS', ''),
'PMS_URL_MANUAL': (int, 'PMS', 0), 'PMS_URL_MANUAL': (int, 'PMS', 0),
'PMS_USE_BIF': (int, 'PMS', 0), 'PMS_USE_BIF': (int, 'PMS', 0),
'PMS_UUID': (str, 'PMS', ''), 'PMS_UUID': (str, 'PMS', ''),
@@ -312,7 +313,10 @@ _CONFIG_DEFINITIONS = {
'MONITOR_REMOTE_ACCESS': (int, 'Monitoring', 0), 'MONITOR_REMOTE_ACCESS': (int, 'Monitoring', 0),
'MONITORING_INTERVAL': (int, 'Monitoring', 60), 'MONITORING_INTERVAL': (int, 'Monitoring', 60),
'MONITORING_USE_WEBSOCKET': (int, 'Monitoring', 0), 'MONITORING_USE_WEBSOCKET': (int, 'Monitoring', 0),
'NEWSLETTER_AUTH': (int, 'Newsletter', 0),
'NEWSLETTER_PASSWORD': (str, 'Newsletter', ''),
'NEWSLETTER_CUSTOM_DIR': (str, 'Newsletter', ''), 'NEWSLETTER_CUSTOM_DIR': (str, 'Newsletter', ''),
'NEWSLETTER_INLINE_STYLES': (int, 'Newsletter', 1),
'NEWSLETTER_TEMPLATES': (str, 'Newsletter', 'newsletters'), 'NEWSLETTER_TEMPLATES': (str, 'Newsletter', 'newsletters'),
'NEWSLETTER_DIR': (str, 'Newsletter', ''), 'NEWSLETTER_DIR': (str, 'Newsletter', ''),
'NEWSLETTER_SELF_HOSTED': (int, 'Newsletter', 0), 'NEWSLETTER_SELF_HOSTED': (int, 'Newsletter', 0),

View File

@@ -65,7 +65,7 @@ class DataFactory(object):
columns = [ columns = [
'session_history.reference_id', 'session_history.reference_id',
'session_history.id', 'session_history.id',
'started AS date', 'MAX(started) AS date',
'MIN(started) AS started', 'MIN(started) AS started',
'MAX(stopped) AS stopped', 'MAX(stopped) AS stopped',
'SUM(CASE WHEN stopped > 0 THEN (stopped - started) ELSE 0 END) - \ '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.title',
'session_history_metadata.parent_title', 'session_history_metadata.parent_title',
'session_history_metadata.grandparent_title', 'session_history_metadata.grandparent_title',
'session_history_metadata.original_title',
'session_history_metadata.year', 'session_history_metadata.year',
'session_history_metadata.media_index', 'session_history_metadata.media_index',
'session_history_metadata.parent_media_index', 'session_history_metadata.parent_media_index',
@@ -132,6 +133,7 @@ class DataFactory(object):
'title', 'title',
'parent_title', 'parent_title',
'grandparent_title', 'grandparent_title',
'original_title',
'year', 'year',
'media_index', 'media_index',
'parent_media_index', 'parent_media_index',
@@ -233,6 +235,7 @@ class DataFactory(object):
'title': item['parent_title'], 'title': item['parent_title'],
'parent_title': item['parent_title'], 'parent_title': item['parent_title'],
'grandparent_title': item['grandparent_title'], 'grandparent_title': item['grandparent_title'],
'original_title': item['original_title'],
'year': item['year'], 'year': item['year'],
'media_index': item['media_index'], 'media_index': item['media_index'],
'parent_media_index': item['parent_media_index'], 'parent_media_index': item['parent_media_index'],
@@ -480,7 +483,8 @@ class DataFactory(object):
elif stat == 'top_music': elif stat == 'top_music':
top_music = [] top_music = []
try: 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, ' \ '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 ' \ '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) - ' \ 'FROM (SELECT *, SUM(CASE WHEN stopped > 0 THEN (stopped - started) - ' \
@@ -492,7 +496,7 @@ class DataFactory(object):
' >= datetime("now", "-%s days", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \
' AND session_history.media_type = "track" ' \ ' AND session_history.media_type = "track" ' \
' GROUP BY %s) AS t ' \ ' GROUP BY %s) AS t ' \
'GROUP BY t.grandparent_title ' \ 'GROUP BY t.original_title, t.grandparent_title ' \
'ORDER BY %s DESC, started DESC ' \ 'ORDER BY %s DESC, started DESC ' \
'LIMIT %s ' % (time_range, group_by, sort_type, stats_count) 'LIMIT %s ' % (time_range, group_by, sort_type, stats_count)
result = monitor_db.select(query) result = monitor_db.select(query)
@@ -501,7 +505,7 @@ class DataFactory(object):
return None return None
for item in result: for item in result:
row = {'title': item['grandparent_title'], row = {'title': item['original_title'] or item['grandparent_title'],
'total_plays': item['total_plays'], 'total_plays': item['total_plays'],
'total_duration': item['total_duration'], 'total_duration': item['total_duration'],
'users_watched': '', 'users_watched': '',
@@ -529,7 +533,8 @@ class DataFactory(object):
elif stat == 'popular_music': elif stat == 'popular_music':
popular_music = [] popular_music = []
try: 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, ' \ 't.art, t.media_type, t.content_rating, t.labels, t.started, ' \
'COUNT(DISTINCT t.user_id) AS users_watched, ' \ '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 ' \ '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") ' \ ' >= datetime("now", "-%s days", "localtime") ' \
' AND session_history.media_type = "track" ' \ ' AND session_history.media_type = "track" ' \
' GROUP BY %s) AS t ' \ ' 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 ' \ 'ORDER BY users_watched DESC, %s DESC, started DESC ' \
'LIMIT %s ' % (time_range, group_by, sort_type, stats_count) 'LIMIT %s ' % (time_range, group_by, sort_type, stats_count)
result = monitor_db.select(query) result = monitor_db.select(query)
@@ -551,7 +556,7 @@ class DataFactory(object):
return None return None
for item in result: for item in result:
row = {'title': item['grandparent_title'], row = {'title': item['original_title'] or item['grandparent_title'],
'users_watched': item['users_watched'], 'users_watched': item['users_watched'],
'rating_key': item['grandparent_rating_key'], 'rating_key': item['grandparent_rating_key'],
'last_play': item['last_watch'], 'last_play': item['last_watch'],
@@ -888,7 +893,7 @@ class DataFactory(object):
'video_decision, audio_decision, transcode_decision, width, height, container, ' \ 'video_decision, audio_decision, transcode_decision, width, height, container, ' \
'transcode_container, transcode_video_codec, transcode_audio_codec, transcode_audio_channels, ' \ 'transcode_container, transcode_video_codec, transcode_audio_codec, transcode_audio_channels, ' \
'transcode_width, transcode_height, ' \ '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 ' \ 'FROM session_history_media_info ' \
'JOIN session_history ON session_history_media_info.id = session_history.id ' \ '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 ' \ '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, ' \ 'video_decision, audio_decision, transcode_decision, width, height, container, ' \
'transcode_container, transcode_video_codec, transcode_audio_codec, transcode_audio_channels, ' \ 'transcode_container, transcode_video_codec, transcode_audio_codec, transcode_audio_channels, ' \
'transcode_width, transcode_height, ' \ 'transcode_width, transcode_height, ' \
'media_type, title, grandparent_title ' \ 'media_type, title, grandparent_title, original_title ' \
'FROM sessions ' \ 'FROM sessions ' \
'WHERE session_key = ? %s' % user_cond 'WHERE session_key = ? %s' % user_cond
result = monitor_db.select(query, args=[session_key]) result = monitor_db.select(query, args=[session_key])
@@ -979,6 +984,7 @@ class DataFactory(object):
'media_type': item['media_type'], 'media_type': item['media_type'],
'title': item['title'], 'title': item['title'],
'grandparent_title': item['grandparent_title'], 'grandparent_title': item['grandparent_title'],
'original_title': item['original_title'],
'current_session': 1 if session_key else 0, 'current_session': 1 if session_key else 0,
'pre_tautulli': pre_tautulli '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.rating_key, session_history_metadata.parent_rating_key, ' \
'session_history_metadata.grandparent_rating_key, session_history_metadata.title, ' \ 'session_history_metadata.grandparent_rating_key, session_history_metadata.title, ' \
'session_history_metadata.parent_title, session_history_metadata.grandparent_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.media_index, session_history_metadata.parent_media_index, ' \
'session_history_metadata.section_id, session_history_metadata.thumb, ' \ 'session_history_metadata.section_id, session_history_metadata.thumb, ' \
'session_history_metadata.parent_thumb, session_history_metadata.grandparent_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'], 'parent_rating_key': item['parent_rating_key'],
'grandparent_rating_key': item['grandparent_rating_key'], 'grandparent_rating_key': item['grandparent_rating_key'],
'grandparent_title': item['grandparent_title'], 'grandparent_title': item['grandparent_title'],
'original_title': item['original_title'],
'parent_media_index': item['parent_media_index'], 'parent_media_index': item['parent_media_index'],
'parent_title': item['parent_title'], 'parent_title': item['parent_title'],
'media_index': item['media_index'], 'media_index': item['media_index'],
@@ -1215,52 +1223,64 @@ class DataFactory(object):
monitor_db.upsert(table, key_dict=keys, value_dict=values) monitor_db.upsert(table, key_dict=keys, value_dict=values)
def delete_img_info(self, rating_key=None, service=None): def delete_img_info(self, rating_key=None, service='', delete_all=False):
monitor_db = database.MonitorDatabase() monitor_db = database.MonitorDatabase()
if rating_key: if not delete_all:
service = service or helpers.get_img_service() service = helpers.get_img_service()
if service == 'imgur': if not rating_key and not delete_all:
# 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 ' \
'WHERE rating_key = ? '
args = [rating_key]
results = monitor_db.select(query, args=args)
for imgur_info in results:
if imgur_info['delete_hash']:
helpers.delete_from_imgur(delete_hash=imgur_info['delete_hash'],
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)
result = monitor_db.action('DELETE FROM imgur_lookup WHERE img_hash '
'IN (SELECT img_hash FROM image_hash_lookup WHERE rating_key = ?)',
[rating_key])
elif service == 'cloudinary':
# Delete from Cloudinary
helpers.delete_from_cloudinary(rating_key=rating_key)
logger.info(u"Tautulli DataFactory :: Deleting Cloudinary info for rating_key %s from the database."
% rating_key)
result = monitor_db.action('DELETE FROM cloudinary_lookup WHERE img_hash '
'IN (SELECT img_hash FROM image_hash_lookup WHERE rating_key = ?)',
[rating_key])
else:
logger.error(u"Tautulli DataFactory :: Unable to delete hosted images: invalid service '%s' provided."
% service)
return service
else:
logger.error(u"Tautulli DataFactory :: Unable to delete hosted images: rating_key not provided.") logger.error(u"Tautulli DataFactory :: Unable to delete hosted images: rating_key not provided.")
return False return False
where = ''
args = []
log_msg = ''
if 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:
if imgur_info['delete_hash']:
helpers.delete_from_imgur(delete_hash=imgur_info['delete_hash'],
img_title=imgur_info['imgur_title'],
fallback=imgur_info['fallback'])
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 %s)' % where,
args)
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)
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): def get_poster_info(self, rating_key='', metadata=None, service=None):
poster_key = '' poster_key = ''
if str(rating_key).isdigit(): if str(rating_key).isdigit():
@@ -1447,7 +1467,7 @@ class DataFactory(object):
result = monitor_db.select(query=query.format('parent_rating_key', 'rating_key'), result = monitor_db.select(query=query.format('parent_rating_key', 'rating_key'),
args=[item['parent_rating_key']]) args=[item['parent_rating_key']])
for item in result: 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']}}) children.update({key: {'rating_key': item['rating_key']}})
key = item['parent_media_index'] if match_type == 'index' else item['parent_title'] key = item['parent_media_index'] if match_type == 'index' else item['parent_title']
@@ -1538,8 +1558,11 @@ class DataFactory(object):
if metadata: if metadata:
# Create full_title # 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']) 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: else:
full_title = metadata['title'] full_title = metadata['title']
@@ -1554,7 +1577,8 @@ class DataFactory(object):
# Update the session_history_metadata table # Update the session_history_metadata table
query = 'UPDATE session_history_metadata SET rating_key = ?, parent_rating_key = ?, ' \ 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 = ?, ' \ 'media_index = ?, parent_media_index = ?, section_id = ?, thumb = ?, parent_thumb = ?, ' \
'grandparent_thumb = ?, art = ?, media_type = ?, year = ?, originally_available_at = ?, ' \ 'grandparent_thumb = ?, art = ?, media_type = ?, year = ?, originally_available_at = ?, ' \
'added_at = ?, updated_at = ?, last_viewed_at = ?, content_rating = ?, summary = ?, ' \ 'added_at = ?, updated_at = ?, last_viewed_at = ?, content_rating = ?, summary = ?, ' \
@@ -1563,7 +1587,8 @@ class DataFactory(object):
'WHERE rating_key = ?' 'WHERE rating_key = ?'
args = [metadata['rating_key'], metadata['parent_rating_key'], metadata['grandparent_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['media_index'], metadata['parent_media_index'], metadata['section_id'], metadata['thumb'],
metadata['parent_thumb'], metadata['grandparent_thumb'], metadata['art'], metadata['media_type'], metadata['parent_thumb'], metadata['grandparent_thumb'], metadata['art'], metadata['media_type'],
metadata['year'], metadata['originally_available_at'], metadata['added_at'], metadata['updated_at'], metadata['year'], metadata['originally_available_at'], metadata['added_at'], metadata['updated_at'],

View File

@@ -50,7 +50,9 @@ class Graphs(object):
'SUM(CASE WHEN media_type = "episode" THEN 1 ELSE 0 END) AS tv_count, ' \ 'SUM(CASE WHEN media_type = "episode" THEN 1 ELSE 0 END) AS tv_count, ' \
'SUM(CASE WHEN media_type = "movie" THEN 1 ELSE 0 END) AS movie_count, ' \ 'SUM(CASE WHEN media_type = "movie" THEN 1 ELSE 0 END) AS movie_count, ' \
'SUM(CASE WHEN media_type = "track" THEN 1 ELSE 0 END) AS music_count ' \ 'SUM(CASE WHEN media_type = "track" THEN 1 ELSE 0 END) AS music_count ' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \ 'FROM (SELECT * FROM session_history ' \
'GROUP BY date(started, "unixepoch", "localtime"), %s) ' \
'AS session_history ' \
'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \ 'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'GROUP BY date_played ' \ 'GROUP BY date_played ' \
'ORDER BY started ASC' % (group_by, time_range, user_cond) 'ORDER BY started ASC' % (group_by, time_range, user_cond)
@@ -147,7 +149,9 @@ class Graphs(object):
'SUM(CASE WHEN media_type = "episode" THEN 1 ELSE 0 END) AS tv_count, ' \ 'SUM(CASE WHEN media_type = "episode" THEN 1 ELSE 0 END) AS tv_count, ' \
'SUM(CASE WHEN media_type = "movie" THEN 1 ELSE 0 END) AS movie_count, ' \ 'SUM(CASE WHEN media_type = "movie" THEN 1 ELSE 0 END) AS movie_count, ' \
'SUM(CASE WHEN media_type = "track" THEN 1 ELSE 0 END) AS music_count ' \ 'SUM(CASE WHEN media_type = "track" THEN 1 ELSE 0 END) AS music_count ' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \ 'FROM (SELECT * FROM session_history ' \
'GROUP BY strftime("%%w", datetime(started, "unixepoch", "localtime")), %s) ' \
'AS session_history ' \
'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \ 'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'GROUP BY dayofweek ' \ 'GROUP BY dayofweek ' \
'ORDER BY daynumber' % (group_by, time_range, user_cond) 'ORDER BY daynumber' % (group_by, time_range, user_cond)
@@ -245,7 +249,9 @@ class Graphs(object):
'SUM(CASE WHEN media_type = "episode" THEN 1 ELSE 0 END) AS tv_count, ' \ 'SUM(CASE WHEN media_type = "episode" THEN 1 ELSE 0 END) AS tv_count, ' \
'SUM(CASE WHEN media_type = "movie" THEN 1 ELSE 0 END) AS movie_count, ' \ 'SUM(CASE WHEN media_type = "movie" THEN 1 ELSE 0 END) AS movie_count, ' \
'SUM(CASE WHEN media_type = "track" THEN 1 ELSE 0 END) AS music_count ' \ 'SUM(CASE WHEN media_type = "track" THEN 1 ELSE 0 END) AS music_count ' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \ 'FROM (SELECT * FROM session_history ' \
'GROUP BY strftime("%%H", datetime(started, "unixepoch", "localtime")) , %s) ' \
'AS session_history ' \
'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \ 'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s days", "localtime") %s' \
'GROUP BY hourofday ' \ 'GROUP BY hourofday ' \
'ORDER BY hourofday' % (group_by, time_range, user_cond) 'ORDER BY hourofday' % (group_by, time_range, user_cond)
@@ -335,7 +341,9 @@ class Graphs(object):
'SUM(CASE WHEN media_type = "episode" THEN 1 ELSE 0 END) AS tv_count, ' \ 'SUM(CASE WHEN media_type = "episode" THEN 1 ELSE 0 END) AS tv_count, ' \
'SUM(CASE WHEN media_type = "movie" THEN 1 ELSE 0 END) AS movie_count, ' \ 'SUM(CASE WHEN media_type = "movie" THEN 1 ELSE 0 END) AS movie_count, ' \
'SUM(CASE WHEN media_type = "track" THEN 1 ELSE 0 END) AS music_count ' \ 'SUM(CASE WHEN media_type = "track" THEN 1 ELSE 0 END) AS music_count ' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \ 'FROM (SELECT * FROM session_history ' \
'GROUP BY strftime("%%Y-%%m", datetime(started, "unixepoch", "localtime")), %s) ' \
'AS session_history ' \
'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s months", "localtime") %s' \ 'WHERE datetime(started, "unixepoch", "localtime") >= datetime("now", "-%s months", "localtime") %s' \
'GROUP BY strftime("%%Y-%%m", datetime(started, "unixepoch", "localtime")) ' \ 'GROUP BY strftime("%%Y-%%m", datetime(started, "unixepoch", "localtime")) ' \
'ORDER BY datestring DESC LIMIT %s' % (group_by, time_range, user_cond, time_range) 'ORDER BY datestring DESC LIMIT %s' % (group_by, time_range, user_cond, time_range)
@@ -591,7 +599,9 @@ class Graphs(object):
'THEN 1 ELSE 0 END) AS ds_count, ' \ 'THEN 1 ELSE 0 END) AS ds_count, ' \
'SUM(CASE WHEN session_history_media_info.transcode_decision = "transcode" ' \ 'SUM(CASE WHEN session_history_media_info.transcode_decision = "transcode" ' \
'THEN 1 ELSE 0 END) AS tc_count ' \ 'THEN 1 ELSE 0 END) AS tc_count ' \
'FROM (SELECT * FROM session_history GROUP BY %s) AS session_history ' \ 'FROM (SELECT * FROM session_history ' \
'GROUP BY date(session_history.started, "unixepoch", "localtime"), %s) ' \
'AS session_history ' \
'JOIN session_history_media_info ON session_history.id = session_history_media_info.id ' \ 'JOIN session_history_media_info ON session_history.id = session_history_media_info.id ' \
'WHERE (datetime(started, "unixepoch", "localtime") >= ' \ 'WHERE (datetime(started, "unixepoch", "localtime") >= ' \
'datetime("now", "-%s days", "localtime")) AND ' \ 'datetime("now", "-%s days", "localtime")) AND ' \

View File

@@ -209,6 +209,9 @@ def now():
now = datetime.datetime.now() now = datetime.datetime.now()
return now.strftime("%Y-%m-%d %H:%M:%S") 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'): def human_duration(s, sig='dhms'):
@@ -835,7 +838,10 @@ def cloudinary_transform(rating_key=None, width=1000, height=1500, opacity=100,
) )
img_options = {'format': img_format, img_options = {'format': img_format,
'version': int(time.time())} 'fetch_format': 'auto',
'quality': 'auto',
'version': int(time.time()),
'secure': True}
if width != 1000: if width != 1000:
img_options['width'] = str(width) img_options['width'] = str(width)
@@ -947,7 +953,7 @@ def parse_condition_logic_string(s, num_cond=0):
""" """
valid_tokens = re.compile(r'(\(|\)|and|or)') valid_tokens = re.compile(r'(\(|\)|and|or)')
conditions_pattern = re.compile(r'{\d+}') conditions_pattern = re.compile(r'{\d+}')
tokens = [x.strip() for x in re.split(valid_tokens, s.lower()) if x.strip()] tokens = [x.strip() for x in re.split(valid_tokens, s.lower()) if x.strip()]
stack = [[]] stack = [[]]
@@ -958,7 +964,7 @@ def parse_condition_logic_string(s, num_cond=0):
close_bracket_next = False close_bracket_next = False
nest_and = 0 nest_and = 0
nest_nest_and = 0 nest_nest_and = 0
for i, x in enumerate(tokens): for i, x in enumerate(tokens):
if open_bracket_next and x == '(': if open_bracket_next and x == '(':
stack[-1].append([]) stack[-1].append([])
@@ -969,7 +975,7 @@ def parse_condition_logic_string(s, num_cond=0):
close_bracket_next = False close_bracket_next = False
if nest_and: if nest_and:
nest_nest_and += 1 nest_nest_and += 1
elif close_bracket_next and x == ')': elif close_bracket_next and x == ')':
stack.pop() stack.pop()
if not stack: if not stack:
@@ -998,7 +1004,7 @@ def parse_condition_logic_string(s, num_cond=0):
if nest_and > nest_nest_and: if nest_and > nest_nest_and:
stack.pop() stack.pop()
nest_and -= 1 nest_and -= 1
elif bool_next and x == 'and' and i < len(tokens)-1: elif bool_next and x == 'and' and i < len(tokens)-1:
stack[-1].append([]) stack[-1].append([])
stack.append(stack[-1][-1]) stack.append(stack[-1][-1])
@@ -1009,7 +1015,7 @@ def parse_condition_logic_string(s, num_cond=0):
open_bracket_next = True open_bracket_next = True
close_bracket_next = False close_bracket_next = False
nest_and += 1 nest_and += 1
elif bool_next and x == 'or' and i < len(tokens)-1: elif bool_next and x == 'or' and i < len(tokens)-1:
stack[-1].append(x) stack[-1].append(x)
cond_next = True cond_next = True

View File

@@ -39,12 +39,13 @@ class HTTPHandler(object):
else: else:
self.urls = urls self.urls = urls
self.headers = {'X-Plex-Device-Name': 'Tautulli', self.headers = {'X-Plex-Product': 'Tautulli',
'X-Plex-Product': 'Tautulli',
'X-Plex-Version': plexpy.common.RELEASE, '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-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 self.token = token
@@ -178,5 +179,5 @@ class HTTPHandler(object):
return output return output
except Exception as e: except Exception as e:
logger.warn(u"Failed format response from uri %s to %s error %s" % (self.uri, self.response_type, e)) logger.warn(u"Failed format response from uri %s to %s error %s" % (self.uri, self.output_format, e))
return None return None

View File

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

View File

@@ -28,6 +28,7 @@ import traceback
import plexpy import plexpy
import helpers import helpers
from plexpy.config import _BLACKLIST_KEYS, _WHITELIST_KEYS
# These settings are for file logging only # These settings are for file logging only
FILENAME = "tautulli.log" FILENAME = "tautulli.log"
@@ -48,6 +49,20 @@ logger_plex_websocket = logging.getLogger("plex_websocket")
# Global queue for multiprocessing logging # Global queue for multiprocessing logging
queue = None queue = None
def blacklist_config(config):
blacklist = set()
blacklist_keys = ['HOOK', 'APIKEY', 'KEY', 'PASSWORD', 'TOKEN']
for key, value in config.iteritems():
if isinstance(value, basestring) and len(value.strip()) > 5 and \
key.upper() not in _WHITELIST_KEYS and (key.upper() in blacklist_keys or
any(bk in key.upper() for bk in _BLACKLIST_KEYS)):
blacklist.add(value.strip())
_BLACKLIST_WORDS.update(blacklist)
class NoThreadFilter(logging.Filter): class NoThreadFilter(logging.Filter):
""" """
Log filter for the current thread Log filter for the current thread

View File

@@ -138,7 +138,5 @@ def set_last_seen(device_token=None):
def blacklist_logger(): def blacklist_logger():
devices = get_mobile_devices() devices = get_mobile_devices()
for d in devices:
blacklist = set(d['device_token'] for d in devices) logger.blacklist_config(d)
logger._BLACKLIST_WORDS.update(blacklist)

View File

@@ -19,6 +19,7 @@ from itertools import groupby
from mako.lookup import TemplateLookup from mako.lookup import TemplateLookup
from mako import exceptions from mako import exceptions
import os import os
import re
import plexpy import plexpy
import common import common
@@ -195,6 +196,7 @@ def add_newsletter_config(agent_id=None, **kwargs):
newsletter_id = db.last_insert_id() newsletter_id = db.last_insert_id()
logger.info(u"Tautulli Newsletters :: Added new newsletter agent: %s (newsletter_id %s)." logger.info(u"Tautulli Newsletters :: Added new newsletter agent: %s (newsletter_id %s)."
% (agent['label'], newsletter_id)) % (agent['label'], newsletter_id))
blacklist_logger()
return newsletter_id return newsletter_id
except Exception as e: except Exception as e:
logger.warn(u"Tautulli Newsletters :: Unable to add newsletter agent: %s." % e) logger.warn(u"Tautulli Newsletters :: Unable to add newsletter agent: %s." % e)
@@ -205,7 +207,7 @@ def set_newsletter_config(newsletter_id=None, agent_id=None, **kwargs):
if str(agent_id).isdigit(): if str(agent_id).isdigit():
agent_id = int(agent_id) agent_id = int(agent_id)
else: 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) % agent_id)
return False return False
@@ -253,6 +255,7 @@ def set_newsletter_config(newsletter_id=None, agent_id=None, **kwargs):
logger.info(u"Tautulli Newsletters :: Updated newsletter agent: %s (newsletter_id %s)." logger.info(u"Tautulli Newsletters :: Updated newsletter agent: %s (newsletter_id %s)."
% (agent['label'], newsletter_id)) % (agent['label'], newsletter_id))
newsletter_handler.schedule_newsletters(newsletter_id=newsletter_id) newsletter_handler.schedule_newsletters(newsletter_id=newsletter_id)
blacklist_logger()
return True return True
except Exception as e: except Exception as e:
logger.warn(u"Tautulli Newsletters :: Unable to update newsletter agent: %s." % e) logger.warn(u"Tautulli Newsletters :: Unable to update newsletter agent: %s." % e)
@@ -273,6 +276,17 @@ def send_newsletter(newsletter_id=None, subject=None, body=None, message=None, n
logger.debug(u"Tautulli Newsletters :: Notification requested but no newsletter_id received.") logger.debug(u"Tautulli Newsletters :: Notification requested but no newsletter_id received.")
def blacklist_logger():
db = database.MonitorDatabase()
notifiers = db.select('SELECT newsletter_config, email_config FROM newsletters')
for n in notifiers:
config = json.loads(n['newsletter_config'] or '{}')
logger.blacklist_config(config)
email_config = json.loads(n['email_config'] or '{}')
logger.blacklist_config(email_config)
def serve_template(templatename, **kwargs): def serve_template(templatename, **kwargs):
if plexpy.CONFIG.NEWSLETTER_CUSTOM_DIR: if plexpy.CONFIG.NEWSLETTER_CUSTOM_DIR:
template_dir = plexpy.CONFIG.NEWSLETTER_CUSTOM_DIR template_dir = plexpy.CONFIG.NEWSLETTER_CUSTOM_DIR
@@ -280,13 +294,16 @@ def serve_template(templatename, **kwargs):
interface_dir = os.path.join(str(plexpy.PROG_DIR), 'data/interfaces/') interface_dir = os.path.join(str(plexpy.PROG_DIR), 'data/interfaces/')
template_dir = os.path.join(str(interface_dir), plexpy.CONFIG.NEWSLETTER_TEMPLATES) 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']) _hplookup = TemplateLookup(directories=[template_dir], default_filters=['unicode', 'h'])
try: try:
template = _hplookup.get_template(templatename) template = _hplookup.get_template(templatename)
return template.render(**kwargs) return template.render(**kwargs), False
except: except:
return exceptions.html_error_template().render() return exceptions.html_error_template().render(), True
def generate_newsletter_uuid(): def generate_newsletter_uuid():
@@ -319,7 +336,6 @@ class Newsletter(object):
_DEFAULT_BODY = 'View the newsletter here: {newsletter_url}' _DEFAULT_BODY = 'View the newsletter here: {newsletter_url}'
_DEFAULT_MESSAGE = '' _DEFAULT_MESSAGE = ''
_DEFAULT_FILENAME = 'newsletter_{newsletter_uuid}.html' _DEFAULT_FILENAME = 'newsletter_{newsletter_uuid}.html'
_TEMPLATE_MASTER = ''
_TEMPLATE = '' _TEMPLATE = ''
def __init__(self, newsletter_id=None, newsletter_id_name=None, config=None, email_config=None, def __init__(self, newsletter_id=None, newsletter_id_name=None, config=None, email_config=None,
@@ -373,6 +389,7 @@ class Newsletter(object):
self.newsletter = None self.newsletter = None
self.is_preview = False self.is_preview = False
self.template_error = None
def set_config(self, config=None, default=None): def set_config(self, config=None, default=None):
return self._validate_config(config=config, default=default) return self._validate_config(config=config, default=default)
@@ -412,19 +429,14 @@ class Newsletter(object):
'parameters': self.parameters, 'parameters': self.parameters,
'data': self.data} 'data': self.data}
def generate_newsletter(self, preview=False, master=False): def generate_newsletter(self, preview=False):
if preview: if preview:
self.is_preview = True self.is_preview = True
if master:
template = self._TEMPLATE_MASTER
else:
template = self._TEMPLATE
self.retrieve_data() self.retrieve_data()
return serve_template( newsletter_rendered, self.template_error = serve_template(
templatename=template, templatename=self._TEMPLATE,
uuid=self.uuid, uuid=self.uuid,
subject=self.subject_formatted, subject=self.subject_formatted,
body=self.body_formatted, body=self.body_formatted,
@@ -434,9 +446,36 @@ class Newsletter(object):
preview=self.is_preview 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): def send(self):
self.newsletter = self.generate_newsletter() 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(): if not self._has_data():
logger.warn(u"Tautulli Newsletters :: %s newsletter has no data. Newsletter not sent." % self.NAME) logger.warn(u"Tautulli Newsletters :: %s newsletter has no data. Newsletter not sent." % self.NAME)
return False return False
@@ -462,6 +501,7 @@ class Newsletter(object):
for line in self.newsletter.encode('utf-8').splitlines(): for line in self.newsletter.encode('utf-8').splitlines():
if '<!-- IGNORE SAVE -->' not in line: if '<!-- IGNORE SAVE -->' not in line:
n_file.write(line + '\r\n') 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: except OSError as e:
@@ -470,18 +510,26 @@ class Newsletter(object):
def _send(self): def _send(self):
if self.config['formatted']: 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']: if self.email_config['notifier_id']:
return send_notification( return send_notification(
notifier_id=self.email_config['notifier_id'], notifier_id=self.email_config['notifier_id'],
subject=self.subject_formatted, subject=self.subject_formatted,
body=self.newsletter body=newsletter_stripped,
plaintext=plaintext
) )
else: else:
email = EMAIL(config=self.email_config) email = EMAIL(config=self.email_config)
return email.notify( return email.notify(
subject=self.subject_formatted, subject=self.subject_formatted,
body=self.newsletter body=newsletter_stripped,
plaintext=plaintext
) )
elif self.config['notifier_id']: elif self.config['notifier_id']:
return send_notification( return send_notification(
@@ -514,7 +562,8 @@ class Newsletter(object):
'newsletter_static_url': base_url + 'id/' + self.newsletter_id_name, 'newsletter_static_url': base_url + 'id/' + self.newsletter_id_name,
'newsletter_uuid': self.uuid, 'newsletter_uuid': self.uuid,
'newsletter_id': self.newsletter_id, 'newsletter_id': self.newsletter_id,
'newsletter_id_name': self.newsletter_id_name 'newsletter_id_name': self.newsletter_id_name,
'newsletter_password': plexpy.CONFIG.NEWSLETTER_PASSWORD
} }
return parameters return parameters
@@ -600,7 +649,6 @@ class RecentlyAdded(Newsletter):
_DEFAULT_SUBJECT = 'Recently Added to {server_name}! ({end_date})' _DEFAULT_SUBJECT = 'Recently Added to {server_name}! ({end_date})'
_DEFAULT_BODY = 'View the newsletter here: {newsletter_url}' _DEFAULT_BODY = 'View the newsletter here: {newsletter_url}'
_DEFAULT_MESSAGE = '' _DEFAULT_MESSAGE = ''
_TEMPLATE_MASTER = 'recently_added_master.html'
_TEMPLATE = 'recently_added.html' _TEMPLATE = 'recently_added.html'
def _get_recently_added(self, media_type=None): def _get_recently_added(self, media_type=None):
@@ -757,8 +805,9 @@ class RecentlyAdded(Newsletter):
else: else:
item['art_hash'] = '' item['art_hash'] = ''
item['poster_url'] = '' item['thumb_url'] = ''
item['art_url'] = '' item['art_url'] = ''
item['poster_url'] = item['thumb_url'] # Keep for backwards compatibility
elif helpers.get_img_service(): elif helpers.get_img_service():
# Upload posters and art to image hosting service # Upload posters and art to image hosting service
@@ -774,7 +823,7 @@ class RecentlyAdded(Newsletter):
img=item['thumb'], rating_key=item['rating_key'], title=item['title'], img=item['thumb'], rating_key=item['rating_key'], title=item['title'],
width=150, height=height, fallback=fallback) width=150, height=height, fallback=fallback)
item['poster_url'] = img_info.get('img_url') or common.ONLINE_POSTER_THUMB item['thumb_url'] = img_info.get('img_url') or common.ONLINE_POSTER_THUMB
img_info = get_img_info( img_info = get_img_info(
img=item['art'], rating_key=item['rating_key'], title=item['title'], img=item['art'], rating_key=item['rating_key'], title=item['title'],
@@ -784,6 +833,15 @@ class RecentlyAdded(Newsletter):
item['thumb_hash'] = '' item['thumb_hash'] = ''
item['art_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 self.data['recently_added'] = recently_added

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']) user_devices = data_factory.get_user_devices(user_id=stream_data['user_id'])
return stream_data['machine_id'] not in user_devices 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']) progress_percent = helpers.get_percent(stream_data['view_offset'], stream_data['duration'])
if notify_action == 'on_stop': if notify_action == 'on_stop':
@@ -326,7 +326,7 @@ def notify_custom_conditions(notifier_id=None, parameters=None):
def notify(notifier_id=None, notify_action=None, stream_data=None, timeline_data=None, parameters=None, **kwargs): 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) notifier_config = notifiers.get_notifier_config(notifier_id=notifier_id)
@@ -633,6 +633,8 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
notify_params['parent_title']) notify_params['parent_title'])
else: else:
poster_thumb = '' poster_thumb = ''
poster_key = ''
poster_title = ''
img_service = helpers.get_img_service(include_self=True) img_service = helpers.get_img_service(include_self=True)
if img_service not in (None, 'self-hosted'): if img_service not in (None, 'self-hosted'):
@@ -742,6 +744,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'optimized_version': notify_params['optimized_version'], 'optimized_version': notify_params['optimized_version'],
'optimized_version_profile': notify_params['optimized_version_profile'], 'optimized_version_profile': notify_params['optimized_version_profile'],
'synced_version': notify_params['synced_version'], 'synced_version': notify_params['synced_version'],
'live': notify_params['live'],
'stream_local': notify_params['local'], 'stream_local': notify_params['local'],
'stream_location': notify_params['location'], 'stream_location': notify_params['location'],
'stream_bandwidth': notify_params['bandwidth'], 'stream_bandwidth': notify_params['bandwidth'],
@@ -802,6 +805,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'artist_name': artist_name, 'artist_name': artist_name,
'album_name': album_name, 'album_name': album_name,
'track_name': track_name, 'track_name': track_name,
'track_artist': notify_params['original_title'] or notify_params['grandparent_title'],
'season_num': season_num, 'season_num': season_num,
'season_num00': season_num00, 'season_num00': season_num00,
'episode_num': episode_num, 'episode_num': episode_num,
@@ -879,6 +883,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'subtitle_language': notify_params['subtitle_language'], 'subtitle_language': notify_params['subtitle_language'],
'subtitle_language_code': notify_params['subtitle_language_code'], 'subtitle_language_code': notify_params['subtitle_language_code'],
'file': notify_params['file'], 'file': notify_params['file'],
'filename': os.path.basename(notify_params['file']),
'file_size': helpers.humanFileSize(notify_params['file_size']), 'file_size': helpers.humanFileSize(notify_params['file_size']),
'indexes': notify_params['indexes'], 'indexes': notify_params['indexes'],
'section_id': notify_params['section_id'], 'section_id': notify_params['section_id'],

View File

@@ -62,7 +62,6 @@ import mobile_app
import pmsconnect import pmsconnect
import request import request
import users import users
from plexpy.config import _BLACKLIST_KEYS, _WHITELIST_KEYS
BROWSER_NOTIFIERS = {} BROWSER_NOTIFIERS = {}
@@ -420,7 +419,7 @@ def get_notifiers(notifier_id=None, notify_action=None):
db = database.MonitorDatabase() db = database.MonitorDatabase()
result = db.select('SELECT id, agent_id, agent_name, agent_label, friendly_name, %s FROM notifiers %s' result = db.select('SELECT id, agent_id, agent_name, agent_label, friendly_name, %s FROM notifiers %s'
% (', '.join(notify_actions), where), args=args) % (', '.join(notify_actions), where), args=args)
for item in result: for item in result:
item['active'] = int(any([item.pop(k) for k in item.keys() if k in notify_actions])) item['active'] = int(any([item.pop(k) for k in item.keys() if k in notify_actions]))
@@ -509,7 +508,7 @@ def add_notifier_config(agent_id=None, **kwargs):
'agent_name': agent['name'], 'agent_name': agent['name'],
'agent_label': agent['label'], 'agent_label': agent['label'],
'friendly_name': '', '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': json.dumps(DEFAULT_CUSTOM_CONDITIONS),
'custom_conditions_logic': '' 'custom_conditions_logic': ''
} }
@@ -540,7 +539,7 @@ def set_notifier_config(notifier_id=None, agent_id=None, **kwargs):
if str(agent_id).isdigit(): if str(agent_id).isdigit():
agent_id = int(agent_id) agent_id = int(agent_id)
else: 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) % agent_id)
return False return False
@@ -570,7 +569,7 @@ def set_notifier_config(notifier_id=None, agent_id=None, **kwargs):
'agent_name': agent['name'], 'agent_name': agent['name'],
'agent_label': agent['label'], 'agent_label': agent['label'],
'friendly_name': kwargs.get('friendly_name', ''), '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': kwargs.get('custom_conditions', json.dumps(DEFAULT_CUSTOM_CONDITIONS)),
'custom_conditions_logic': kwargs.get('custom_conditions_logic', ''), 'custom_conditions_logic': kwargs.get('custom_conditions_logic', ''),
} }
@@ -612,17 +611,9 @@ def blacklist_logger():
db = database.MonitorDatabase() db = database.MonitorDatabase()
notifiers = db.select('SELECT notifier_config FROM notifiers') notifiers = db.select('SELECT notifier_config FROM notifiers')
blacklist = set()
blacklist_keys = ['hook', 'key', 'password', 'token']
for n in notifiers: for n in notifiers:
config = json.loads(n['notifier_config'] or '{}') config = json.loads(n['notifier_config'] or '{}')
for key, value in config.iteritems(): logger.blacklist_config(config)
if isinstance(value, basestring) and len(value.strip()) > 5 and \
key.upper() not in _WHITELIST_KEYS and (key.upper() in blacklist_keys or any(bk in key.upper() for bk in _BLACKLIST_KEYS)):
blacklist.add(value.strip())
logger._BLACKLIST_WORDS.update(blacklist)
class PrettyMetadata(object): class PrettyMetadata(object):
@@ -661,9 +652,9 @@ class PrettyMetadata(object):
poster_url = self.parameters['poster_url'] poster_url = self.parameters['poster_url']
if not poster_url: if not poster_url:
if self.media_type in ('artist', 'album', 'track'): if self.media_type in ('artist', 'album', 'track'):
poster_url = 'http://tautulli.com/images/cover.png' poster_url = common.ONLINE_COVER_THUMB
else: else:
poster_url = 'http://tautulli.com/images/poster.png' poster_url = common.ONLINE_POSTER_THUMB
return poster_url return poster_url
def get_provider_name(self, provider): def get_provider_name(self, provider):
@@ -682,13 +673,13 @@ class PrettyMetadata(object):
provider_name = 'Trakt.tv' provider_name = 'Trakt.tv'
elif provider == 'lastfm': elif provider == 'lastfm':
provider_name = 'Last.fm' provider_name = 'Last.fm'
else: # else:
if self.media_type == 'movie': # if self.media_type == 'movie':
provider_name = 'IMDb' # provider_name = 'IMDb'
elif self.media_type in ('show', 'season', 'episode'): # elif self.media_type in ('show', 'season', 'episode'):
provider_name = 'TheTVDB' # provider_name = 'TheTVDB'
elif self.media_type in ('artist', 'album', 'track'): # elif self.media_type in ('artist', 'album', 'track'):
provider_name = 'Last.fm' # provider_name = 'Last.fm'
return provider_name return provider_name
def get_provider_link(self, provider=None): def get_provider_link(self, provider=None):
@@ -697,13 +688,13 @@ class PrettyMetadata(object):
provider_link = self.get_plex_url() provider_link = self.get_plex_url()
elif provider: elif provider:
provider_link = self.parameters.get(provider + '_url', '') provider_link = self.parameters.get(provider + '_url', '')
else: # else:
if self.media_type == 'movie': # if self.media_type == 'movie':
provider_link = self.parameters.get('imdb_url', '') # provider_link = self.parameters.get('imdb_url', '')
elif self.media_type in ('show', 'season', 'episode'): # elif self.media_type in ('show', 'season', 'episode'):
provider_link = self.parameters.get('thetvdb_url', '') # provider_link = self.parameters.get('thetvdb_url', '')
elif self.media_type in ('artist', 'album', 'track'): # elif self.media_type in ('artist', 'album', 'track'):
provider_link = self.parameters.get('lastfm_url', '') # provider_link = self.parameters.get('lastfm_url', '')
return provider_link return provider_link
def get_caption(self, provider): def get_caption(self, provider):
@@ -711,6 +702,7 @@ class PrettyMetadata(object):
return 'View on ' + provider_name return 'View on ' + provider_name
def get_title(self, divider='-'): def get_title(self, divider='-'):
title = ''
if self.media_type == 'movie': if self.media_type == 'movie':
title = '%s (%s)' % (self.parameters['title'], self.parameters['year']) title = '%s (%s)' % (self.parameters['title'], self.parameters['year'])
elif self.media_type == 'show': elif self.media_type == 'show':
@@ -728,7 +720,7 @@ class PrettyMetadata(object):
elif self.media_type == 'album': elif self.media_type == 'album':
title = '%s - %s' % (self.parameters['artist_name'], self.parameters['album_name']) title = '%s - %s' % (self.parameters['artist_name'], self.parameters['album_name'])
elif self.media_type == 'track': 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") return title.encode("utf-8")
def get_description(self): def get_description(self):
@@ -807,7 +799,7 @@ class Notifier(object):
if response is not None and response.status_code >= 400 and response.status_code < 500: if response is not None and response.status_code >= 400 and response.status_code < 500:
verify_msg = " Verify you notification agent settings are correct." 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: if err_msg:
logger.error(u"Tautulli Notifiers :: {}".format(err_msg)) logger.error(u"Tautulli Notifiers :: {}".format(err_msg))
@@ -1145,7 +1137,8 @@ class DISCORD(Notifier):
plex_url = pretty_metadata.get_plex_url() plex_url = pretty_metadata.get_plex_url()
# Build Discord post attachment # Build Discord post attachment
attachment = {'title': title attachment = {'title': title,
'timestamp': helpers.utc_now_iso()
} }
if self.config['color']: if self.config['color']:
@@ -1250,7 +1243,7 @@ class DISCORD(Notifier):
{'label': 'Movie Link Source', {'label': 'Movie Link Source',
'value': self.config['movie_provider'], 'value': self.config['movie_provider'],
'name': 'discord_movie_provider', 'name': 'discord_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank for default.<br>' 'description': 'Select the source for movie links on the info cards. Leave blank to disable.<br>'
'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.', 'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers() 'select_options': PrettyMetadata().get_movie_providers()
@@ -1258,7 +1251,7 @@ class DISCORD(Notifier):
{'label': 'TV Show Link Source', {'label': 'TV Show Link Source',
'value': self.config['tv_provider'], 'value': self.config['tv_provider'],
'name': 'discord_tv_provider', 'name': 'discord_tv_provider',
'description': 'Select the source for tv show links on the info cards. Leave blank for default.<br>' 'description': 'Select the source for tv show links on the info cards. Leave blank to disable.<br>'
'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.', 'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers() 'select_options': PrettyMetadata().get_tv_providers()
@@ -1266,7 +1259,7 @@ class DISCORD(Notifier):
{'label': 'Music Link Source', {'label': 'Music Link Source',
'value': self.config['music_provider'], 'value': self.config['music_provider'],
'name': 'discord_music_provider', 'name': 'discord_music_provider',
'description': 'Select the source for music links on the info cards. Leave blank for default.', 'description': 'Select the source for music links on the info cards. Leave blank to disable.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers() 'select_options': PrettyMetadata().get_music_providers()
} }
@@ -1295,11 +1288,19 @@ class EMAIL(Notifier):
def agent_notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if self.config['html_support']: if self.config['html_support']:
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 = MIMEMultipart('alternative')
msg.attach(MIMEText(bleach.clean(body, strip=True), 'plain', 'utf-8')) msg.attach(plain)
msg.attach(MIMEText(body, 'html', 'utf-8')) msg.attach(html)
else: else:
msg = MIMEText(body, 'plain', 'utf-8') 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['Message-ID'] = email.utils.make_msgid()
msg['Date'] = email.utils.formatdate(localtime=True) msg['Date'] = email.utils.formatdate(localtime=True)
@@ -1310,10 +1311,11 @@ class EMAIL(Notifier):
recipients = self.config['to'] + self.config['cc'] + self.config['bcc'] recipients = self.config['to'] + self.config['cc'] + self.config['bcc']
mailserver = None
success = False success = False
mailserver = smtplib.SMTP(self.config['smtp_server'], self.config['smtp_port'])
try: try:
mailserver = smtplib.SMTP(self.config['smtp_server'], self.config['smtp_port'])
mailserver.ehlo() mailserver.ehlo()
if self.config['tls']: if self.config['tls']:
@@ -1324,14 +1326,15 @@ class EMAIL(Notifier):
mailserver.login(str(self.config['smtp_user']), str(self.config['smtp_password'])) mailserver.login(str(self.config['smtp_user']), str(self.config['smtp_password']))
mailserver.sendmail(self.config['from'], recipients, msg.as_string()) mailserver.sendmail(self.config['from'], recipients, msg.as_string())
logger.info(u"Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
success = True success = True
except Exception as e: except Exception as e:
logger.error(u"Tautulli Notifiers :: {name} notification failed: {e}".format(name=self.NAME, e=e)) logger.error(u"Tautulli Notifiers :: {name} notification failed: {e}".format(name=self.NAME, e=e))
finally: finally:
mailserver.quit() if mailserver:
logger.info(u"Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME)) mailserver.quit()
return success return success
@@ -1456,7 +1459,7 @@ class FACEBOOK(Notifier):
return facebook.auth_url(app_id=app_id, return facebook.auth_url(app_id=app_id,
canvas_url=redirect_uri, canvas_url=redirect_uri,
perms=['user_managed_groups','publish_actions']) perms=['publish_to_groups'])
def _get_credentials(self, code=''): def _get_credentials(self, code=''):
logger.info(u"Tautulli Notifiers :: Requesting access token from {name}.".format(name=self.NAME)) logger.info(u"Tautulli Notifiers :: Requesting access token from {name}.".format(name=self.NAME))
@@ -1589,7 +1592,7 @@ class FACEBOOK(Notifier):
{'label': 'Movie Link Source', {'label': 'Movie Link Source',
'value': self.config['movie_provider'], 'value': self.config['movie_provider'],
'name': 'facebook_movie_provider', 'name': 'facebook_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank for default.<br>' 'description': 'Select the source for movie links on the info cards. Leave blank to disable.<br>'
'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.', 'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers() 'select_options': PrettyMetadata().get_movie_providers()
@@ -1597,7 +1600,7 @@ class FACEBOOK(Notifier):
{'label': 'TV Show Link Source', {'label': 'TV Show Link Source',
'value': self.config['tv_provider'], 'value': self.config['tv_provider'],
'name': 'facebook_tv_provider', 'name': 'facebook_tv_provider',
'description': 'Select the source for tv show links on the info cards. Leave blank for default.<br>' 'description': 'Select the source for tv show links on the info cards. Leave blank to disable.<br>'
'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.', 'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers() 'select_options': PrettyMetadata().get_tv_providers()
@@ -1605,7 +1608,7 @@ class FACEBOOK(Notifier):
{'label': 'Music Link Source', {'label': 'Music Link Source',
'value': self.config['music_provider'], 'value': self.config['music_provider'],
'name': 'facebook_music_provider', 'name': 'facebook_music_provider',
'description': 'Select the source for music links on the info cards. Leave blank for default.', 'description': 'Select the source for music links on the info cards. Leave blank to disable.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers() 'select_options': PrettyMetadata().get_music_providers()
} }
@@ -1925,7 +1928,7 @@ class HIPCHAT(Notifier):
{'label': 'Movie Link Source', {'label': 'Movie Link Source',
'value': self.config['movie_provider'], 'value': self.config['movie_provider'],
'name': 'hipchat_movie_provider', 'name': 'hipchat_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank for default.<br>' 'description': 'Select the source for movie links on the info cards. Leave blank to disable.<br>'
'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.', 'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers() 'select_options': PrettyMetadata().get_movie_providers()
@@ -1933,7 +1936,7 @@ class HIPCHAT(Notifier):
{'label': 'TV Show Link Source', {'label': 'TV Show Link Source',
'value': self.config['tv_provider'], 'value': self.config['tv_provider'],
'name': 'hipchat_tv_provider', 'name': 'hipchat_tv_provider',
'description': 'Select the source for tv show links on the info cards. Leave blank for default.<br>' 'description': 'Select the source for tv show links on the info cards. Leave blank to disable.<br>'
'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.', 'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers() 'select_options': PrettyMetadata().get_tv_providers()
@@ -1941,7 +1944,7 @@ class HIPCHAT(Notifier):
{'label': 'Music Link Source', {'label': 'Music Link Source',
'value': self.config['music_provider'], 'value': self.config['music_provider'],
'name': 'hipchat_music_provider', 'name': 'hipchat_music_provider',
'description': 'Select the source for music links on the info cards. Leave blank for default.', 'description': 'Select the source for music links on the info cards. Leave blank to disable.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers() 'select_options': PrettyMetadata().get_music_providers()
} }
@@ -2133,7 +2136,7 @@ class JOIN(Notifier):
{'label': 'Movie Link Source', {'label': 'Movie Link Source',
'value': self.config['movie_provider'], 'value': self.config['movie_provider'],
'name': 'join_movie_provider', 'name': 'join_movie_provider',
'description': 'Select the source for movie links in the notificaation. Leave blank for default.<br>' 'description': 'Select the source for movie links in the notification. Leave blank to disable.<br>'
'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.', 'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers() 'select_options': PrettyMetadata().get_movie_providers()
@@ -2141,7 +2144,7 @@ class JOIN(Notifier):
{'label': 'TV Show Link Source', {'label': 'TV Show Link Source',
'value': self.config['tv_provider'], 'value': self.config['tv_provider'],
'name': 'join_tv_provider', 'name': 'join_tv_provider',
'description': 'Select the source for tv show links in the notificaation. Leave blank for default.<br>' 'description': 'Select the source for tv show links in the notification. Leave blank to disable.<br>'
'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.', 'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers() 'select_options': PrettyMetadata().get_tv_providers()
@@ -2149,7 +2152,7 @@ class JOIN(Notifier):
{'label': 'Music Link Source', {'label': 'Music Link Source',
'value': self.config['music_provider'], 'value': self.config['music_provider'],
'name': 'join_music_provider', 'name': 'join_music_provider',
'description': 'Select the source for music links in the notificaation. Leave blank for default.', 'description': 'Select the source for music links in the notification. Leave blank to disable.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers() 'select_options': PrettyMetadata().get_music_providers()
} }
@@ -2439,7 +2442,8 @@ class PLEX(Notifier):
else: else:
return request.request_content(url) return request.request_content(url)
def _sendjson(self, host, method, params={}): def _sendjson(self, host, method, params=None):
params = params or {}
data = [{'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}] data = [{'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}]
headers = {'Content-Type': 'application/json'} headers = {'Content-Type': 'application/json'}
url = host + '/jsonrpc' url = host + '/jsonrpc'
@@ -2917,7 +2921,7 @@ class PUSHOVER(Notifier):
{'label': 'Movie Link Source', {'label': 'Movie Link Source',
'value': self.config['movie_provider'], 'value': self.config['movie_provider'],
'name': 'pushover_movie_provider', 'name': 'pushover_movie_provider',
'description': 'Select the source for movie links in the notification. Leave blank for default.<br>' 'description': 'Select the source for movie links in the notification. Leave blank to disable.<br>'
'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.', 'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers() 'select_options': PrettyMetadata().get_movie_providers()
@@ -2925,7 +2929,7 @@ class PUSHOVER(Notifier):
{'label': 'TV Show Link Source', {'label': 'TV Show Link Source',
'value': self.config['tv_provider'], 'value': self.config['tv_provider'],
'name': 'pushover_tv_provider', 'name': 'pushover_tv_provider',
'description': 'Select the source for tv show links in the notification. Leave blank for default.<br>' 'description': 'Select the source for tv show links in the notification. Leave blank to disable.<br>'
'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.', 'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers() 'select_options': PrettyMetadata().get_tv_providers()
@@ -2933,7 +2937,7 @@ class PUSHOVER(Notifier):
{'label': 'Music Link Source', {'label': 'Music Link Source',
'value': self.config['music_provider'], 'value': self.config['music_provider'],
'name': 'pushover_music_provider', 'name': 'pushover_music_provider',
'description': 'Select the source for music links in the notification. Leave blank for default.', 'description': 'Select the source for music links in the notification. Leave blank to disable.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers() 'select_options': PrettyMetadata().get_music_providers()
} }
@@ -3292,7 +3296,7 @@ class SLACK(Notifier):
{'label': 'Movie Link Source', {'label': 'Movie Link Source',
'value': self.config['movie_provider'], 'value': self.config['movie_provider'],
'name': 'slack_movie_provider', 'name': 'slack_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank for default.<br>' 'description': 'Select the source for movie links on the info cards. Leave blank to disable.<br>'
'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.', 'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers() 'select_options': PrettyMetadata().get_movie_providers()
@@ -3300,7 +3304,7 @@ class SLACK(Notifier):
{'label': 'TV Show Link Source', {'label': 'TV Show Link Source',
'value': self.config['tv_provider'], 'value': self.config['tv_provider'],
'name': 'slack_tv_provider', 'name': 'slack_tv_provider',
'description': 'Select the source for tv show links on the info cards. Leave blank for default.<br>' 'description': 'Select the source for tv show links on the info cards. Leave blank to disable.<br>'
'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.', 'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers() 'select_options': PrettyMetadata().get_tv_providers()
@@ -3308,7 +3312,7 @@ class SLACK(Notifier):
{'label': 'Music Link Source', {'label': 'Music Link Source',
'value': self.config['music_provider'], 'value': self.config['music_provider'],
'name': 'slack_music_provider', 'name': 'slack_music_provider',
'description': 'Select the source for music links on the info cards. Leave blank for default.', 'description': 'Select the source for music links on the info cards. Leave blank to disable.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers() 'select_options': PrettyMetadata().get_music_providers()
} }
@@ -3468,7 +3472,7 @@ class TWITTER(Notifier):
poster_url = parameters.get('poster_url','') poster_url = parameters.get('poster_url','')
# Hack to add media type to attachment # Hack to add media type to attachment
if poster_url: if poster_url and not helpers.get_img_service():
poster_url += '.png' poster_url += '.png'
if self.config['incl_subject']: if self.config['incl_subject']:
@@ -3542,7 +3546,8 @@ class XBMC(Notifier):
else: else:
return request.request_content(url) return request.request_content(url)
def _sendjson(self, host, method, params={}): def _sendjson(self, host, method, params=None):
params = params or {}
data = [{'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}] data = [{'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}]
headers = {'Content-Type': 'application/json'} headers = {'Content-Type': 'application/json'}
url = host + '/jsonrpc' url = host + '/jsonrpc'
@@ -3703,7 +3708,7 @@ class ZAPIER(Notifier):
{'label': 'Movie Link Source', {'label': 'Movie Link Source',
'value': self.config['movie_provider'], 'value': self.config['movie_provider'],
'name': 'zapier_movie_provider', 'name': 'zapier_movie_provider',
'description': 'Select the source for movie links in the notification. Leave blank for default.<br>' 'description': 'Select the source for movie links in the notification. Leave blank to disable.<br>'
'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.', 'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers() 'select_options': PrettyMetadata().get_movie_providers()
@@ -3711,7 +3716,7 @@ class ZAPIER(Notifier):
{'label': 'TV Show Link Source', {'label': 'TV Show Link Source',
'value': self.config['tv_provider'], 'value': self.config['tv_provider'],
'name': 'zapier_tv_provider', 'name': 'zapier_tv_provider',
'description': 'Select the source for tv show links in the notification. Leave blank for default.<br>' 'description': 'Select the source for tv show links in the notification. Leave blank to disable.<br>'
'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.', 'Note: 3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers() 'select_options': PrettyMetadata().get_tv_providers()
@@ -3719,7 +3724,7 @@ class ZAPIER(Notifier):
{'label': 'Music Link Source', {'label': 'Music Link Source',
'value': self.config['music_provider'], 'value': self.config['music_provider'],
'name': 'zapier_music_provider', 'name': 'zapier_music_provider',
'description': 'Select the source for music links in the notification. Leave blank for default.', 'description': 'Select the source for music links in the notification. Leave blank to disable.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers() 'select_options': PrettyMetadata().get_music_providers()
} }

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import time
import urllib import urllib
import plexpy import plexpy
import activity_processor
import common import common
import helpers import helpers
import http_handler import http_handler
@@ -482,6 +483,7 @@ class PmsConnect(object):
actors = [] actors = []
genres = [] genres = []
labels = [] labels = []
collections = []
if m.getElementsByTagName('Director'): if m.getElementsByTagName('Director'):
for director in m.getElementsByTagName('Director'): for director in m.getElementsByTagName('Director'):
@@ -503,6 +505,10 @@ class PmsConnect(object):
for label in m.getElementsByTagName('Label'): for label in m.getElementsByTagName('Label'):
labels.append(helpers.get_xml_attr(label, 'tag')) labels.append(helpers.get_xml_attr(label, 'tag'))
if m.getElementsByTagName('Collection'):
for collection in m.getElementsByTagName('Collection'):
collections.append(helpers.get_xml_attr(collection, 'tag'))
recent_item = {'media_type': helpers.get_xml_attr(m, 'type'), recent_item = {'media_type': helpers.get_xml_attr(m, 'type'),
'section_id': helpers.get_xml_attr(m, 'librarySectionID'), 'section_id': helpers.get_xml_attr(m, 'librarySectionID'),
'library_name': helpers.get_xml_attr(m, 'librarySectionTitle'), 'library_name': helpers.get_xml_attr(m, 'librarySectionTitle'),
@@ -512,6 +518,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(m, 'title'), 'title': helpers.get_xml_attr(m, 'title'),
'parent_title': helpers.get_xml_attr(m, 'parentTitle'), 'parent_title': helpers.get_xml_attr(m, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(m, 'grandparentTitle'), 'grandparent_title': helpers.get_xml_attr(m, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(m, 'originalTitle'),
'sort_title': helpers.get_xml_attr(m, 'titleSort'), 'sort_title': helpers.get_xml_attr(m, 'titleSort'),
'media_index': helpers.get_xml_attr(m, 'index'), 'media_index': helpers.get_xml_attr(m, 'index'),
'parent_media_index': helpers.get_xml_attr(m, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(m, 'parentIndex'),
@@ -539,6 +546,7 @@ class PmsConnect(object):
'actors': actors, 'actors': actors,
'genres': genres, 'genres': genres,
'labels': labels, 'labels': labels,
'collections': collections,
'full_title': helpers.get_xml_attr(m, 'title'), 'full_title': helpers.get_xml_attr(m, 'title'),
'child_count': helpers.get_xml_attr(m, 'childCount') 'child_count': helpers.get_xml_attr(m, 'childCount')
} }
@@ -661,6 +669,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'), 'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'), 'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'), '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'), 'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'), 'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -708,6 +717,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'), 'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'), 'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'), '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'), 'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'), 'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -752,6 +762,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'), 'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'), 'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'), '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'), 'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'), 'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -797,6 +808,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'), 'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': 'Season %s' % helpers.get_xml_attr(metadata_main, 'parentIndex'), 'parent_title': 'Season %s' % helpers.get_xml_attr(metadata_main, 'parentIndex'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'), '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'), 'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'), 'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -840,6 +852,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'), 'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'), 'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'), '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'), 'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'), 'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -884,6 +897,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'), 'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'), 'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'), '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'), 'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'), 'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -920,6 +934,8 @@ class PmsConnect(object):
elif metadata_type == 'track': elif metadata_type == 'track':
parent_rating_key = helpers.get_xml_attr(metadata_main, 'parentRatingKey') parent_rating_key = helpers.get_xml_attr(metadata_main, 'parentRatingKey')
album_details = self.get_metadata_details(parent_rating_key) 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, metadata = {'media_type': metadata_type,
'section_id': section_id, 'section_id': section_id,
'library_name': library_name, 'library_name': library_name,
@@ -929,6 +945,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'), 'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'), 'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'), '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'), 'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'), 'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -957,8 +974,8 @@ class PmsConnect(object):
'genres': album_details['genres'], 'genres': album_details['genres'],
'labels': album_details['labels'], 'labels': album_details['labels'],
'collections': album_details['collections'], 'collections': album_details['collections'],
'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'grandparentTitle'), 'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'title'),
helpers.get_xml_attr(metadata_main, 'title')), track_artist),
'children_count': helpers.get_xml_attr(metadata_main, 'leafCount') 'children_count': helpers.get_xml_attr(metadata_main, 'leafCount')
} }
@@ -972,6 +989,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'), 'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'), 'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'), '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'), 'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'), 'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -1016,6 +1034,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'), 'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'), 'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'), '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'), 'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'), 'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -1060,6 +1079,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'), 'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'), 'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'), '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'), 'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'), 'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -1105,6 +1125,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(metadata_main, 'title'), 'title': helpers.get_xml_attr(metadata_main, 'title'),
'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'), 'parent_title': helpers.get_xml_attr(metadata_main, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(metadata_main, 'grandparentTitle'), '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'), 'sort_title': helpers.get_xml_attr(metadata_main, 'titleSort'),
'media_index': helpers.get_xml_attr(metadata_main, 'index'), 'media_index': helpers.get_xml_attr(metadata_main, 'index'),
'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(metadata_main, 'parentIndex'),
@@ -1658,6 +1679,8 @@ class PmsConnect(object):
'optimized_version': int(helpers.get_xml_attr(stream_media_info, 'proxyType') == '42'), 'optimized_version': int(helpers.get_xml_attr(stream_media_info, 'proxyType') == '42'),
'optimized_version_title': helpers.get_xml_attr(stream_media_info, 'title'), 'optimized_version_title': helpers.get_xml_attr(stream_media_info, 'title'),
'synced_version': 1 if sync_id else 0, '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'), 'indexes': int(indexes == 'sd'),
'bif_thumb': bif_thumb, 'bif_thumb': bif_thumb,
'subtitles': 1 if subtitle_id and subtitle_selected else 0 'subtitles': 1 if subtitle_id and subtitle_selected else 0
@@ -1670,9 +1693,7 @@ class PmsConnect(object):
if not helpers.get_xml_attr(session, 'ratingKey').isdigit(): if not helpers.get_xml_attr(session, 'ratingKey').isdigit():
channel_stream = 1 channel_stream = 1
clip_media = session.getElementsByTagName('Media')[0] audio_channels = helpers.get_xml_attr(stream_media_info, 'audioChannels')
clip_part = clip_media.getElementsByTagName('Part')[0]
audio_channels = helpers.get_xml_attr(clip_media, 'audioChannels')
metadata_details = {'media_type': media_type, metadata_details = {'media_type': media_type,
'section_id': helpers.get_xml_attr(session, 'librarySectionID'), 'section_id': helpers.get_xml_attr(session, 'librarySectionID'),
'library_name': helpers.get_xml_attr(session, 'librarySectionTitle'), 'library_name': helpers.get_xml_attr(session, 'librarySectionTitle'),
@@ -1682,6 +1703,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(session, 'title'), 'title': helpers.get_xml_attr(session, 'title'),
'parent_title': helpers.get_xml_attr(session, 'parentTitle'), 'parent_title': helpers.get_xml_attr(session, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(session, 'grandparentTitle'), 'grandparent_title': helpers.get_xml_attr(session, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(session, 'originalTitle'),
'sort_title': helpers.get_xml_attr(session, 'titleSort'), 'sort_title': helpers.get_xml_attr(session, 'titleSort'),
'media_index': helpers.get_xml_attr(session, 'index'), 'media_index': helpers.get_xml_attr(session, 'index'),
'parent_media_index': helpers.get_xml_attr(session, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(session, 'parentIndex'),
@@ -1710,18 +1732,17 @@ class PmsConnect(object):
'genres': [], 'genres': [],
'labels': [], 'labels': [],
'full_title': helpers.get_xml_attr(session, 'title'), 'full_title': helpers.get_xml_attr(session, 'title'),
'container': helpers.get_xml_attr(clip_media, 'container') \ 'container': helpers.get_xml_attr(stream_media_info, 'container') \
or helpers.get_xml_attr(clip_part, 'container'), or helpers.get_xml_attr(stream_media_parts_info, 'container'),
'height': helpers.get_xml_attr(clip_media, 'height'), 'height': helpers.get_xml_attr(stream_media_info, 'height'),
'width': helpers.get_xml_attr(clip_media, 'width'), 'width': helpers.get_xml_attr(stream_media_info, 'width'),
'video_codec': helpers.get_xml_attr(clip_media, 'videoCodec'), 'video_codec': helpers.get_xml_attr(stream_media_info, 'videoCodec'),
'video_resolution': helpers.get_xml_attr(clip_media, 'videoResolution'), 'video_resolution': helpers.get_xml_attr(stream_media_info, 'videoResolution'),
'audio_codec': helpers.get_xml_attr(clip_media, 'audioCodec'), 'audio_codec': helpers.get_xml_attr(stream_media_info, 'audioCodec'),
'audio_channels': audio_channels, 'audio_channels': audio_channels,
'audio_channel_layout': common.AUDIO_CHANNELS.get(audio_channels, audio_channels), 'audio_channel_layout': common.AUDIO_CHANNELS.get(audio_channels, audio_channels),
'channel_icon': helpers.get_xml_attr(session, 'sourceIcon'), 'channel_icon': helpers.get_xml_attr(session, 'sourceIcon'),
'channel_title': helpers.get_xml_attr(session, 'sourceTitle'), 'channel_title': helpers.get_xml_attr(session, 'sourceTitle'),
'live': int(helpers.get_xml_attr(session, 'live') == '1'),
'extra_type': helpers.get_xml_attr(session, 'extraType'), 'extra_type': helpers.get_xml_attr(session, 'extraType'),
'sub_type': helpers.get_xml_attr(session, 'subtype') 'sub_type': helpers.get_xml_attr(session, 'subtype')
} }
@@ -1790,13 +1811,12 @@ class PmsConnect(object):
next((p for p in source_media_part_streams if p['type'] == '3'), source_subtitle_details)) next((p for p in source_media_part_streams if p['type'] == '3'), source_subtitle_details))
# Overrides for live sessions # 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_decision'] = 'transcode'
stream_details['stream_container'] = transcode_details['transcode_container'] stream_details['stream_container'] = transcode_details['transcode_container']
video_details['stream_video_decision'] = transcode_details['video_decision'] video_details['stream_video_decision'] = transcode_details['video_decision']
stream_details['stream_video_codec'] = transcode_details['transcode_video_codec'] 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'] audio_details['stream_audio_decision'] = transcode_details['audio_decision']
stream_details['stream_audio_codec'] = transcode_details['transcode_audio_codec'] stream_details['stream_audio_codec'] = transcode_details['transcode_audio_codec']
@@ -1906,7 +1926,7 @@ class PmsConnect(object):
return session_output return session_output
def terminate_session(self, session_id='', message=''): def terminate_session(self, session_key='', session_id='', message=''):
""" """
Terminates a streaming session. Terminates a streaming session.
@@ -1914,10 +1934,22 @@ class PmsConnect(object):
""" """
message = message or 'The server owner has ended the stream.' message = message or 'The server owner has ended the stream.'
if session_key and not session_id:
ap = activity_processor.ActivityProcessor()
session = ap.get_session_by_key(session_key=session_key)
session_id = session['session_id']
elif session_id and not session_key:
ap = activity_processor.ActivityProcessor()
session = ap.get_session_by_id(session_id=session_id)
session_key = session['session_key']
if session_id: if session_id:
logger.info(u"Tautulli Pmsconnect :: Terminating session %s (session_id %s)." % (session_key, session_id))
result = self.get_sessions_terminate(session_id=session_id, reason=urllib.quote_plus(message)) result = self.get_sessions_terminate(session_id=session_id, reason=urllib.quote_plus(message))
return result return result
else: else:
logger.warn(u"Tautulli Pmsconnect :: Failed to terminate session %s. Missing session_id." % session_key)
return False return False
def get_item_children(self, rating_key='', get_grandchildren=False): def get_item_children(self, rating_key='', get_grandchildren=False):
@@ -1994,6 +2026,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(m, 'title'), 'title': helpers.get_xml_attr(m, 'title'),
'parent_title': helpers.get_xml_attr(m, 'parentTitle'), 'parent_title': helpers.get_xml_attr(m, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(m, 'grandparentTitle'), 'grandparent_title': helpers.get_xml_attr(m, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(m, 'originalTitle'),
'sort_title': helpers.get_xml_attr(m, 'titleSort'), 'sort_title': helpers.get_xml_attr(m, 'titleSort'),
'media_index': helpers.get_xml_attr(m, 'index'), 'media_index': helpers.get_xml_attr(m, 'index'),
'parent_media_index': helpers.get_xml_attr(m, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(m, 'parentIndex'),
@@ -2311,6 +2344,7 @@ class PmsConnect(object):
'title': helpers.get_xml_attr(item, 'title'), 'title': helpers.get_xml_attr(item, 'title'),
'parent_title': helpers.get_xml_attr(item, 'parentTitle'), 'parent_title': helpers.get_xml_attr(item, 'parentTitle'),
'grandparent_title': helpers.get_xml_attr(item, 'grandparentTitle'), 'grandparent_title': helpers.get_xml_attr(item, 'grandparentTitle'),
'original_title': helpers.get_xml_attr(item, 'originalTitle'),
'sort_title': helpers.get_xml_attr(item, 'titleSort'), 'sort_title': helpers.get_xml_attr(item, 'titleSort'),
'media_index': helpers.get_xml_attr(item, 'index'), 'media_index': helpers.get_xml_attr(item, 'index'),
'parent_media_index': helpers.get_xml_attr(item, 'parentIndex'), 'parent_media_index': helpers.get_xml_attr(item, 'parentIndex'),
@@ -2649,7 +2683,7 @@ class PmsConnect(object):
child_title = helpers.get_xml_attr(item, 'title') child_title = helpers.get_xml_attr(item, 'title')
if child_rating_key: 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)}}) children.update({key: {'rating_key': int(child_rating_key)}})
key = int(parent_index) if match_type == 'index' else parent_title key = int(parent_index) if match_type == 'index' else parent_title
@@ -2661,9 +2695,9 @@ class PmsConnect(object):
key = 0 if match_type == 'index' else title key = 0 if match_type == 'index' else title
key_list = {key: {'rating_key': int(rating_key), key_list = {key: {'rating_key': int(rating_key),
'children': parents}, 'children': parents},
'section_id': section_id, 'section_id': section_id,
'library_name': library_name 'library_name': library_name
} }
return key_list 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, 'grandparent_thumb': common.DEFAULT_POSTER_THUMB,
'thumb': common.DEFAULT_POSTER_THUMB, 'thumb': common.DEFAULT_POSTER_THUMB,
'bif_thumb': '', 'bif_thumb': '',
'grandparent_title': 'Plex Media',
'parent_title': 'Plex Media',
'title': 'Plex Media', 'title': 'Plex Media',
'parent_title': 'Plex Media',
'grandparent_title': 'Plex Media',
'original_title': 'Plex Media',
'rating_key': '', 'rating_key': '',
'parent_rating_key': '', 'parent_rating_key': '',
'grandparent_rating_key': '', 'grandparent_rating_key': '',

View File

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

View File

@@ -1,2 +1,2 @@
PLEXPY_BRANCH = "beta" PLEXPY_BRANCH = "master"
PLEXPY_RELEASE_VERSION = "v2.1.6-beta" PLEXPY_RELEASE_VERSION = "v2.1.12"

View File

@@ -18,6 +18,7 @@ import json
import os import os
import shutil import shutil
import threading import threading
import urllib
import cherrypy import cherrypy
from cherrypy.lib.static import serve_file, serve_download from cherrypy.lib.static import serve_file, serve_download
@@ -56,7 +57,7 @@ import web_socket
from plexpy.api2 import API2 from plexpy.api2 import API2
from plexpy.helpers import checked, addtoapi, get_ip, create_https_certificates, build_datatables_json 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.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): def serve_template(templatename, **kwargs):
@@ -247,23 +248,23 @@ class WebInterface(object):
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@requireAuth(member_of("admin")) @requireAuth(member_of("admin"))
@addtoapi() @addtoapi()
def terminate_session(self, session_id=None, message=None, **kwargs): def terminate_session(self, session_key=None, session_id=None, message=None, **kwargs):
""" Add a new notification agent. """ Stop a streaming session.
``` ```
Required parameters: Required parameters:
session_id (str): The id of the session to terminate session_key (int): The session key of the session to terminate, OR
message (str): A custom message to send to the client session_id (str): The session id of the session to terminate
Optional parameters: Optional parameters:
None message (str): A custom message to send to the client
Returns: Returns:
None None
``` ```
""" """
pms_connect = pmsconnect.PmsConnect() pms_connect = pmsconnect.PmsConnect()
result = pms_connect.terminate_session(session_id=session_id, message=message) result = pms_connect.terminate_session(session_key=session_key, session_id=session_id, message=message)
if result: if result:
return {'result': 'success', 'message': 'Session terminated.'} return {'result': 'success', 'message': 'Session terminated.'}
@@ -273,8 +274,21 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@requireAuth(member_of("admin")) @requireAuth(member_of("admin"))
def return_sessions_url(self, **kwargs): def return_plex_xml_url(self, endpoint='', plextv=False, **kwargs):
return plexpy.CONFIG.PMS_URL + '/status/sessions?X-Plex-Token=' + plexpy.CONFIG.PMS_TOKEN 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 @cherrypy.expose
@requireAuth() @requireAuth()
@@ -1614,6 +1628,7 @@ class WebInterface(object):
"full_title": "Game of Thrones - The Red Woman", "full_title": "Game of Thrones - The Red Woman",
"grandparent_rating_key": 351, "grandparent_rating_key": 351,
"grandparent_title": "Game of Thrones", "grandparent_title": "Game of Thrones",
"original_title": "",
"group_count": 1, "group_count": 1,
"group_ids": "1124", "group_ids": "1124",
"id": 1124, "id": 1124,
@@ -1715,6 +1730,77 @@ class WebInterface(object):
return serve_template(templatename="stream_data.html", title="Stream Data", data=stream_data, user=user) 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 @cherrypy.expose
@requireAuth() @requireAuth()
def get_ip_address_details(self, ip_address=None, **kwargs): def get_ip_address_details(self, ip_address=None, **kwargs):
@@ -2756,6 +2842,9 @@ class WebInterface(object):
"show_advanced_settings": plexpy.CONFIG.SHOW_ADVANCED_SETTINGS, "show_advanced_settings": plexpy.CONFIG.SHOW_ADVANCED_SETTINGS,
"newsletter_dir": plexpy.CONFIG.NEWSLETTER_DIR, "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 "newsletter_custom_dir": plexpy.CONFIG.NEWSLETTER_CUSTOM_DIR
} }
@@ -2778,7 +2867,7 @@ class WebInterface(object):
"allow_guest_access", "cache_images", "http_proxy", "http_basic_auth", "notify_concurrent_by_ip", "allow_guest_access", "cache_images", "http_proxy", "http_basic_auth", "notify_concurrent_by_ip",
"history_table_activity", "plexpy_auto_update", "history_table_activity", "plexpy_auto_update",
"themoviedb_lookup", "tvmaze_lookup", "http_plex_admin", "themoviedb_lookup", "tvmaze_lookup", "http_plex_admin",
"newsletter_self_hosted" "newsletter_self_hosted", "newsletter_inline_styles"
] ]
for checked_config in checked_configs: for checked_config in checked_configs:
if checked_config not in kwargs: if checked_config not in kwargs:
@@ -3149,7 +3238,7 @@ class WebInterface(object):
@requireAuth(member_of("admin")) @requireAuth(member_of("admin"))
@addtoapi() @addtoapi()
def set_notifier_config(self, notifier_id=None, agent_id=None, **kwargs): def set_notifier_config(self, notifier_id=None, agent_id=None, **kwargs):
""" Configure an exisitng notificaiton agent. """ Configure an existing notification agent.
``` ```
Required parameters: Required parameters:
@@ -3268,10 +3357,10 @@ class WebInterface(object):
return {'result': 'success', 'message': 'Notification queued.'} return {'result': 'success', 'message': 'Notification queued.'}
else: else:
logger.debug(u"Unable to send %snotification, invalid notifier_id %s." % (test, notifier_id)) 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: else:
logger.debug(u"Unable to send %snotification, no notifier_id received." % test) 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.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@@ -3408,7 +3497,7 @@ class WebInterface(object):
@requireAuth(member_of("admin")) @requireAuth(member_of("admin"))
@addtoapi() @addtoapi()
def set_mobile_device_config(self, mobile_device_id=None, **kwargs): def set_mobile_device_config(self, mobile_device_id=None, **kwargs):
""" Configure an exisitng notificaiton agent. """ Configure an existing notification agent.
``` ```
Required parameters: Required parameters:
@@ -4032,6 +4121,15 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
def image(self, *args, **kwargs): def image(self, *args, **kwargs):
if args: if args:
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] img_hash = args[0].split('.')[0]
if img_hash in ('poster', 'cover', 'art'): if img_hash in ('poster', 'cover', 'art'):
@@ -4049,7 +4147,7 @@ class WebInterface(object):
if img_info: if img_info:
kwargs.update(img_info) kwargs.update(img_info)
return self.real_pms_image_proxy(**kwargs) return self.real_pms_image_proxy(refresh=True, **kwargs)
return return
@@ -4170,16 +4268,18 @@ class WebInterface(object):
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@requireAuth(member_of("admin")) @requireAuth(member_of("admin"))
@addtoapi() @addtoapi()
def delete_hosted_images(self, rating_key='', service='', **kwargs): def delete_hosted_images(self, rating_key='', service='', delete_all=False, **kwargs):
""" Delete the images uploaded to image hosting services. """ Delete the images uploaded to image hosting services.
``` ```
Required parameters: Required parameters:
None
Optional parameters:
rating_key (int): 1234 rating_key (int): 1234
(Note: Must be the movie, show, season, artist, or album rating key) (Note: Must be the movie, show, season, artist, or album rating key)
Optional parameters: service (str): 'imgur' or 'cloudinary'
service (str): imgur or cloudinary delete_all (bool): 'true' to delete all images form the service
(Note: Defaults to service in Image Hosting setting)
Returns: Returns:
json: json:
@@ -4188,8 +4288,10 @@ class WebInterface(object):
``` ```
""" """
delete_all = (delete_all == 'true')
data_factory = datafactory.DataFactory() data_factory = datafactory.DataFactory()
result = data_factory.delete_img_info(rating_key=rating_key, service=service) result = data_factory.delete_img_info(rating_key=rating_key, service=service, delete_all=delete_all)
if result: if result:
return {'result': 'success', 'message': 'Deleted hosted images from %s.' % result.capitalize()} return {'result': 'success', 'message': 'Deleted hosted images from %s.' % result.capitalize()}
@@ -4540,6 +4642,7 @@ class WebInterface(object):
} }
], ],
"media_type": "episode", "media_type": "episode",
"original_title": "",
"originally_available_at": "2016-04-24", "originally_available_at": "2016-04-24",
"parent_media_index": "6", "parent_media_index": "6",
"parent_rating_key": "153036", "parent_rating_key": "153036",
@@ -4598,6 +4701,7 @@ class WebInterface(object):
"library_name": "", "library_name": "",
"media_index": "1", "media_index": "1",
"media_type": "episode", "media_type": "episode",
"original_title": "",
"parent_media_index": "6", "parent_media_index": "6",
"parent_rating_key": "153036", "parent_rating_key": "153036",
"parent_thumb": "/library/metadata/153036/thumb/1462175062", "parent_thumb": "/library/metadata/153036/thumb/1462175062",
@@ -4869,6 +4973,7 @@ class WebInterface(object):
"optimized_version_profile": "", "optimized_version_profile": "",
"optimized_version_title": "", "optimized_version_title": "",
"originally_available_at": "2016-04-24", "originally_available_at": "2016-04-24",
"original_title": "",
"parent_media_index": "6", "parent_media_index": "6",
"parent_rating_key": "153036", "parent_rating_key": "153036",
"parent_thumb": "/library/metadata/153036/thumb/1503889210", "parent_thumb": "/library/metadata/153036/thumb/1503889210",
@@ -5468,7 +5573,7 @@ class WebInterface(object):
Returns: Returns:
json: json:
[{"id": 1, [{"id": 1,
"agent_id": 13, "agent_id": 0,
"agent_name": "recently_added", "agent_name": "recently_added",
"agent_label": "Recently Added", "agent_label": "Recently Added",
"friendly_name": "", "friendly_name": "",
@@ -5528,15 +5633,24 @@ class WebInterface(object):
Returns: Returns:
json: json:
{"id": 1, {"id": 1,
"agent_id": 13, "agent_id": 0,
"agent_name": "recently_added", "agent_name": "recently_added",
"agent_label": "Recently Added", "agent_label": "Recently Added",
"friendly_name": "", "friendly_name": "",
"id_name": "",
"cron": "0 0 * * 1", "cron": "0 0 * * 1",
"active": 1 "active": 1,
"config": {"time_frame": 7, "subject": "Recently Added to {server_name}! ({end_date})",
"time_frame_units": "days", "body": "View the newsletter here: {newsletter_url}",
"incl_libraries": [1, 2] "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": {...}, "email_config": {...},
"config_options": [{...}, ...], "config_options": [{...}, ...],
@@ -5583,19 +5697,15 @@ class WebInterface(object):
@requireAuth(member_of("admin")) @requireAuth(member_of("admin"))
@addtoapi() @addtoapi()
def set_newsletter_config(self, newsletter_id=None, agent_id=None, **kwargs): def set_newsletter_config(self, newsletter_id=None, agent_id=None, **kwargs):
""" Configure an exisitng notificaiton agent. """ Configure an existing newsletter agent.
``` ```
Required parameters: Required parameters:
newsletter_id (int): The newsletter config to update newsletter_id (int): The newsletter config to update
agent_id (int): The newsletter type of the newsletter agent_id (int): The newsletter type of the newsletter
Optional parameters: Optional parameters:
Pass all the config options for the agent with the agent prefix: Pass all the config options for the agent with the 'newsletter_config_' and 'newsletter_email_' prefix.
e.g. For Recently Added: recently_added_last_days
recently_added_incl_movies
recently_added_incl_shows
recently_added_incl_artists
Returns: Returns:
None None
@@ -5613,7 +5723,6 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@requireAuth(member_of("admin")) @requireAuth(member_of("admin"))
@addtoapi("notify_newsletter")
def send_newsletter(self, newsletter_id=None, subject='', body='', message='', notify_action='', **kwargs): def send_newsletter(self, newsletter_id=None, subject='', body='', message='', notify_action='', **kwargs):
""" Send a newsletter using Tautulli. """ Send a newsletter using Tautulli.
@@ -5653,7 +5762,29 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
def newsletter(self, *args, **kwargs): 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: if args:
# Keep this for backwards compatibility for images through /newsletter/image
if len(args) >= 2 and args[0] == 'image': if len(args) >= 2 and args[0] == 'image':
if args[1] == 'images': if args[1] == 'images':
resource_dir = os.path.join(str(plexpy.PROG_DIR), 'data/interfaces/default/') resource_dir = os.path.join(str(plexpy.PROG_DIR), 'data/interfaces/default/')
@@ -5662,8 +5793,7 @@ class WebInterface(object):
except NotFound: except NotFound:
return return
cherrypy.response.headers['Cache-Control'] = 'max-age=2592000' # 30 days return self.image(args[1])
return self.image(args[1], refresh=True)
if len(args) >= 2 and args[0] == 'id': if len(args) >= 2 and args[0] == 'id':
newsletter_id_name = args[1] newsletter_id_name = args[1]
@@ -5687,7 +5817,7 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
@requireAuth(member_of("admin")) @requireAuth(member_of("admin"))
def real_newsletter(self, newsletter_id=None, start_date=None, end_date=None, 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': if newsletter_id and newsletter_id != 'None':
newsletter = newsletters.get_newsletter_config(newsletter_id=newsletter_id) newsletter = newsletters.get_newsletter_config(newsletter_id=newsletter_id)
@@ -5702,14 +5832,13 @@ class WebInterface(object):
body=newsletter['body'], body=newsletter['body'],
message=newsletter['message']) message=newsletter['message'])
preview = (preview == 'true') preview = (preview == 'true')
master = (master == 'true')
raw = (raw == 'true') raw = (raw == 'true')
if raw: if raw:
cherrypy.response.headers['Content-Type'] = 'application/json;charset=UTF-8' cherrypy.response.headers['Content-Type'] = 'application/json;charset=UTF-8'
return json.dumps(newsletter_agent.raw_data(preview=preview)) 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) logger.error(u"Failed to retrieve newsletter: Invalid newsletter_id %s" % newsletter_id)
return "Failed to retrieve newsletter: invalid newsletter_id parameter" return "Failed to retrieve newsletter: invalid newsletter_id parameter"