Compare commits

...

338 Commits

Author SHA1 Message Date
JonnyWong16
bc2f73d686 v2.1.43 2020-02-03 18:27:41 -08:00
JonnyWong16
645c2ecef6 Use Authorization header for GitHub API 2020-02-03 18:11:56 -08:00
JonnyWong16
4d4a8ca3b2 Trim MaxMind License Key when saving 2020-01-28 10:38:21 -08:00
JonnyWong16
a98bc45c10 Fix HDR for direct stream video 2020-01-24 20:14:48 -08:00
JonnyWong16
b9d5e49a71 Make verbose logging an advanced config option (Fixes Tautulli/Tautulli-Issues#205)
* Verbose logging is enabled by default
* Make toggleVerbose stick
2020-01-20 20:49:37 -08:00
JonnyWong16
e90390be67 Fix plural episodes and albums in newsletter templates (Fixes Tautulli/Tautulli-Issues#203) 2020-01-20 20:08:45 -08:00
JonnyWong16
8ef671c9cb Fix Telegram notification character limit (Closes #1361) 2020-01-20 19:56:38 -08:00
JonnyWong16
8829516cae Fix season 0 number not showing in tables 2020-01-20 19:23:16 -08:00
JonnyWong16
d8e8dfbd45 Revert "Match session user using user_id instead of username"
This reverts commit 6380de3e6c.
2020-01-19 16:43:34 -08:00
JonnyWong16
0b6d9a4890 Add email address log filter 2020-01-19 16:03:10 -08:00
JonnyWong16
55ffd68023 Refactor log filter 2020-01-19 16:02:56 -08:00
JonnyWong16
6380de3e6c Match session user using user_id instead of username 2020-01-19 16:00:36 -08:00
JonnyWong16
d6220a921a Log dynamic range to history and show in stream info modal 2020-01-18 11:46:43 -08:00
JonnyWong16
10e421b9d4 Add color and dynamic range to notification parameters 2020-01-15 12:19:43 -08:00
JonnyWong16
90056bcce2 Indicate HDR content on activity cards 2020-01-15 11:38:50 -08:00
JonnyWong16
4740d0fbf3 Change webhook request data if not Content-Type header is not application/json 2020-01-14 17:36:25 -08:00
JonnyWong16
13374c9ded Fix Windows and macOS platform capitalization 2020-01-14 17:35:35 -08:00
JonnyWong16
2a03be1905 Fix typo in CHANGELOG.md 2020-01-04 17:49:27 -08:00
JonnyWong16
c8f132a750 v2.1.42 2020-01-04 17:46:03 -08:00
JonnyWong16
4d0c4bf4f4 Update GeoLite2 database help text to leave blank for default 2020-01-04 12:03:44 -08:00
JonnyWong16
e321c5b197 Fix GeoLite2 update log message to correct number of days from setting 2020-01-04 11:50:48 -08:00
JonnyWong16
badbfdc4c1 Disable uninstall GeoLite2 database button when not installed 2020-01-04 10:52:11 -08:00
JonnyWong16
7d71086a41 Verify MaxMind license key and GeoLite2 database path before allowing install 2020-01-02 21:28:11 -08:00
JonnyWong16
0e1764755a Change save setting callbacks 2020-01-02 21:25:15 -08:00
JonnyWong16
c31d3ffd6c Download from MaxMind using urllib3 using certifi 2020-01-01 10:10:52 -08:00
JonnyWong16
0cba3988aa Update certifi to 2019.11.28 2020-01-01 10:10:05 -08:00
JonnyWong16
a7a9ed8628 v2.1.41 2019-12-30 14:33:10 -08:00
JonnyWong16
6af96332fa Use tar.extractall instead of tar.extract 2019-12-30 14:29:19 -08:00
JonnyWong16
e334a0fc8b Change GeoLite2 tar extraction 2019-12-30 14:19:12 -08:00
JonnyWong16
54bbbb36a6 Fix Geolite2 db extraction on Windows 2019-12-30 13:58:56 -08:00
JonnyWong16
b8ef56574a v2.1.40 2019-12-30 10:24:46 -08:00
JonnyWong16
bc491628d4 Add links to 3rd Party APIs guide 2019-12-29 15:03:22 -08:00
JonnyWong16
629800c239 Change metadata to links for lookup help text 2019-12-29 11:05:21 -08:00
JonnyWong16
8bf876f88c Add note to save the MaxMind license key 2019-12-28 11:09:52 -08:00
JonnyWong16
a81ad6d73e Set maximum GeoLite2 database update interval to 30 days 2019-12-28 11:06:04 -08:00
JonnyWong16
02220209e3 Add default activity refresh interval to setting help text 2019-12-27 09:48:38 -08:00
JonnyWong16
9450a1434d Remove line break from Python version in startup logs 2019-12-25 14:10:35 -08:00
JonnyWong16
c358693fb2 Fix GeoLite2 update scheduler 2019-12-25 14:10:16 -08:00
JonnyWong16
e7b3d768ce Prevent installing GeoLite2 database without license key 2019-12-25 14:07:59 -08:00
JonnyWong16
ee91da2ff1 Force reinstall of GeoLite2 database 2019-12-25 13:43:12 -08:00
JonnyWong16
0428df8e3f Fix GeoLite2 update button for previous installs 2019-12-25 13:31:46 -08:00
JonnyWong16
d4fee1d701 Fix GeoLite2 last updated time for previous installs 2019-12-25 13:23:33 -08:00
JonnyWong16
3b44a3afd2 Add setting for GeoLite2 database update interval 2019-12-25 12:51:59 -08:00
JonnyWong16
7ee1c51810 Go to correct setting for installing GeoLite2 database 2019-12-25 12:40:58 -08:00
JonnyWong16
f958de2de6 Move 3rd Party API settings to new tab 2019-12-25 11:41:53 -08:00
JonnyWong16
b83eb2e763 Add auto-updating of GeoLite2 database 2019-12-25 11:17:45 -08:00
JonnyWong16
41c9369b43 Change GeoIP database install using license key 2019-12-23 23:58:36 -08:00
JonnyWong16
554be92c39 Update go to setting links 2019-12-23 22:30:49 -08:00
JonnyWong16
7486a50c33 Calculate months for graphs using datetime 2019-12-12 09:12:29 -08:00
JonnyWong16
1e777b1a1b Add logging for notify_action (trigger) 2019-12-11 11:35:30 -08:00
JonnyWong16
b7bb159630 Fix regex IP match in hostname resolution 2019-12-10 10:07:34 -08:00
JonnyWong16
6e0a0d51b5 v2.1.39 2019-12-08 12:12:24 -08:00
JonnyWong16
55aad4e6ee Fix integrity check before database backup 2019-11-29 23:14:33 -08:00
JonnyWong16
d1e401cb0c Remove default subject and body for notify API command 2019-11-24 22:35:58 -08:00
JonnyWong16
018479fae9 Fix favicons in setup wizard 2019-11-24 15:03:36 -08:00
JonnyWong16
1c18e72539 Enable allow Plex admin by default 2019-11-20 21:51:52 -08:00
JonnyWong16
779e710045 Fix setup wizard height 2019-11-20 21:51:36 -08:00
JonnyWong16
089a981f6e Check for database corruption when making backup 2019-11-20 19:03:44 -08:00
JonnyWong16
3b24bbee5f Add web server HTTP username and password to setup wizard 2019-11-20 18:50:17 -08:00
JonnyWong16
f9a597bed9 Use bit.ly link for PayPal in FUNDING file 2019-11-17 21:54:47 -08:00
JonnyWong16
a637b3bb24 v2.1.38 2019-11-17 21:45:43 -08:00
JonnyWong16
3e520820d8 Update pytz to 2019.3 2019-11-17 21:13:21 -08:00
JonnyWong16
3a71929821 Remove issues template 2019-11-13 19:44:33 -08:00
JonnyWong16
a08629c503 Add GitHub sponsor 2019-11-13 19:40:26 -08:00
JonnyWong16
cfc30c1234 Add optional headers parameter for webhooks in notify API command 2019-11-11 15:41:11 -08:00
JonnyWong16
ddbd486500 Add custom headers to Webhook notification agent 2019-11-11 15:17:49 -08:00
JonnyWong16
f5794a5bae Improve resolving hostnames 2019-11-02 10:38:14 -07:00
JonnyWong16
bb1bf87fe2 Fix homepage recently watched not showing grouped history 2019-10-27 16:06:19 -07:00
JonnyWong16
acc59523e0 Prevent dismissing the changelog modal 2019-10-11 22:55:23 -07:00
JonnyWong16
38d7ea16b4 Fix typo in changelogs 2019-10-11 20:37:26 -07:00
JonnyWong16
6e3147c5f5 v2.1.37 2019-10-11 20:26:54 -07:00
JonnyWong16
1b09f225ff Remove blank line 2019-10-11 20:14:25 -07:00
JonnyWong16
3cf8c4f8a8 Fix rare case when HTTP_ROOT is None when retrieving URL 2019-10-11 20:11:13 -07:00
JonnyWong16
30be4b473f Log individual custom conditions 2019-10-11 19:54:48 -07:00
JonnyWong16
6908034a86 Add ability to delete MusicBrainz lookup info 2019-10-06 16:50:26 -07:00
JonnyWong16
cba43f675a Save MusicBrainz lookups in the database 2019-10-06 15:16:19 -07:00
JonnyWong16
6ff826bc3a Fix Last.fm URLs linking to the artist page instead of the album page 2019-10-05 23:58:17 -07:00
JonnyWong16
c7afd10ec0 Add notification parameters for MusicBrainz lookup 2019-10-05 23:57:48 -07:00
JonnyWong16
b39d5174f9 Add 3rd party MusicBrains lookup setting 2019-10-05 23:44:13 -07:00
JonnyWong16
501bc0ab3f Add musicbrainzngs v0.7dev 2019-10-05 23:27:16 -07:00
JonnyWong16
688d28b5ea Limit Discord description to 2048 characters 2019-10-05 23:26:41 -07:00
JonnyWong16
27d2c7b078 v2.1.36-beta 2019-10-05 21:59:14 -07:00
JonnyWong16
2fb12ccf65 Fix updating activity card when rating key changes for the same session key (Fixes Tautulli/Tautulli-Issues#96) 2019-10-05 21:48:11 -07:00
JonnyWong16
cb92d159c1 Add parent_guid and grandparent_guid to get_activity and get_metadata API commands 2019-10-05 20:21:15 -07:00
JonnyWong16
64bdf4237c Separate progressive vs. interlaced video on stream type graphs 2019-09-27 13:44:08 -07:00
JonnyWong16
fd7b4ec7e3 Get srouce video_full_resolution from Plex metadata 2019-09-27 13:26:55 -07:00
JonnyWong16
57eb57d4d7 Use video_full_resolution in stream data modal 2019-09-27 13:04:40 -07:00
JonnyWong16
7974e9505b Save session video_scan_type and video_full_resolution to the database 2019-09-27 13:04:17 -07:00
JonnyWong16
7498fb37b5 Update database with video_scan_type and video_full_resolution 2019-09-27 13:03:34 -07:00
JonnyWong16
2cc3e88e6c Fix Albums typo on media info table 2019-09-26 20:14:48 -07:00
JonnyWong16
5fd8cfeb80 v2.1.35-beta 2019-09-24 18:09:05 -07:00
JonnyWong16
b295566a4e Clear metadata cache before retrieving new sessions 2019-09-19 21:01:37 -07:00
JonnyWong16
e0943a2d55 Clear metadata cache on startup check for active sessions 2019-09-19 20:55:19 -07:00
JonnyWong16
3015740c3e Merge pull request #1354 from samwiseg0/fix/blank_audio
Fix blank audio on activity card when changing audio streams during direct play
2019-09-19 19:40:14 -07:00
JonnyWong16
ec9ff2f803 Merge pull request #1353 from samwiseg0/fix/video_scan_type
Add video scan type and standardize video resolution. Fixes Tautulli/Tautulli-Issues#194
2019-09-19 19:39:40 -07:00
JonnyWong16
ec8aae9122 Fix Tautulli logging out after saving settings and restarting 2019-09-19 19:37:31 -07:00
samwiseg0
52e608cc43 Fix blank audio on activity card when changing audio streams 2019-09-19 22:29:44 -04:00
samwiseg0
8213f270e5 Move full resolution outside if statement 2019-09-19 22:26:43 -04:00
JonnyWong16
7085042b0d Merge pull request #1352 from samwiseg0/fix/watch_stats
Add year to SQL query for movie watch stats
2019-09-19 19:04:06 -07:00
JonnyWong16
6a411d2458 Merge pull request #1351 from samwiseg0/fix/release_date
Fix release date to be a string vs integer
2019-09-19 19:03:57 -07:00
JonnyWong16
38e2fbabb8 Merge pull request #1350 from samwiseg0/fix/lin_hw_decode
Fix hardware decode not reflected in GUI
2019-09-19 19:03:48 -07:00
samwiseg0
85709f754a Update API docs to reflect changes 2019-09-19 21:22:37 -04:00
samwiseg0
623a1e8a91 Update webui to utilize video full resolution 2019-09-19 21:22:02 -04:00
samwiseg0
de69945ebe Define notification parameters 2019-09-19 20:57:08 -04:00
samwiseg0
7095fa6ac6 Remove overrides 2019-09-19 20:53:18 -04:00
samwiseg0
a59e8298fd Use video_full_resolution for optimized versions in the activity card 2019-09-19 20:50:17 -04:00
samwiseg0
2737d52279 Set the full resolution of the source video and stream video 2019-09-19 20:47:39 -04:00
samwiseg0
0ac1ad4386 Create video_scan_type and stream_video_scan_type 2019-09-19 20:46:38 -04:00
samwiseg0
2db328ac31 Standardize videoResolution to be lowercase 2019-09-19 20:45:53 -04:00
samwiseg0
b6de4ad054 Add year to SQL query watch stats. Fixes Tautulli/Tautulli-Issues#195 2019-09-16 23:44:48 -04:00
samwiseg0
cfea7164b7 Fix release date to be a str vs int 2019-09-16 22:50:31 -04:00
samwiseg0
7e7e5a6be4 Add nvdec for linux decode support. Fixes Tautulli/Tautulli-Issues#193 2019-09-16 21:30:09 -04:00
samwiseg0
df57f4c009 Remove duplicate nvenc 2019-09-16 21:24:57 -04:00
JonnyWong16
c2185c4ce5 Fix notificaiton parameter prefix and suffix not being substituted correctly 2019-09-07 16:37:38 -07:00
JonnyWong16
08714436c3 v2.1.34 2019-09-03 21:46:48 -07:00
JonnyWong16
f65f5d07c0 Add product to get_history API command 2019-09-03 19:52:54 -07:00
JonnyWong16
a9b10c4560 Add Product column to history tables 2019-09-03 19:52:33 -07:00
JonnyWong16
589fbd3158 Add TVMaze and TMDB IDs to notification parameters after lookup 2019-08-27 19:54:54 -07:00
JonnyWong16
0ffc8c5d19 Update JWT secret instead of UUID 2019-08-24 22:00:31 -07:00
JonnyWong16
7498617b74 Flag update JWT UUID after restarting 2019-08-24 21:13:39 -07:00
JonnyWong16
f21d505ab8 Force logout all clients when changing the admin password 2019-08-24 20:49:56 -07:00
JonnyWong16
7b16af0585 Fix verifying PMS with unpublished hostnames (Fixes Tautulli/Tautulli-Issues#190) 2019-08-16 21:27:34 -07:00
JonnyWong16
a83108282a Fix add title to searchable media info fields 2019-08-14 22:50:23 -07:00
JonnyWong16
1c4d01d6ec Fix libraries/users table respect grouping setting 2019-08-09 19:15:53 -07:00
JonnyWong16
22e6d4067d Missing space for episode titles on tables 2019-08-09 19:01:27 -07:00
JonnyWong16
1046b29c1a v2.1.33 2019-07-27 08:44:26 -07:00
JonnyWong16
d6127e28f3 Remove email sort 2019-07-22 09:03:20 -07:00
JonnyWong16
25a949356d Allow seraching by email address in dropdown menu 2019-07-21 09:46:17 -07:00
JonnyWong16
72a012b817 Update Plex OAuth headers 2019-07-18 11:39:16 -07:00
JonnyWong16
f439bd639c Make sure config has name and value when masking passwords 2019-07-10 21:37:32 -07:00
JonnyWong16
91476a420a Mask notifier and newsletter config passwords (Fixes Tautulli/Tautulli-Issues#172) 2019-07-08 23:50:48 -07:00
JonnyWong16
96c0f9cad5 Add if Docker container to platform 2019-07-08 22:30:58 -07:00
JonnyWong16
df50559495 Fix overwriting version number with None 2019-07-02 08:58:56 -07:00
JonnyWong16
6d35bd7947 v2.1.32 2019-06-26 19:14:30 -07:00
JonnyWong16
d27356bbba Fix timezone error with newsletter scheduler because QNAP devices use a stupid "local" timezone (Fixes Tautulli/Tautulli-Issues#183) 2019-06-26 19:11:33 -07:00
JonnyWong16
3054a824ce v2.1.31 2019-06-24 21:45:57 -07:00
JonnyWong16
3b22b6a3f7 v2.1.31-beta 2019-06-13 22:14:42 -07:00
JonnyWong16
e4be5a716f Fix unable to view database status when auth is disabled 2019-06-13 21:57:05 -07:00
JonnyWong16
13579b8140 Change default database synchronous mode to normal 2019-06-09 10:48:59 -07:00
JonnyWong16
b11437b86b Fix synced content showing incorrect stream info 2019-05-12 17:30:04 -07:00
JonnyWong16
4b744a5acd v2.1.30-beta 2019-05-11 10:46:45 -07:00
JonnyWong16
db5be89710 Merge branch 'master' into nightly 2019-05-11 10:45:34 -07:00
JonnyWong16
46e77b074a Add database status link in configuration table 2019-05-11 10:28:53 -07:00
JonnyWong16
f1376fb2ca Add database integrity check to status API 2019-05-11 10:28:52 -07:00
JonnyWong16
2635569e76 v2.1.29 2019-05-11 09:10:37 -07:00
JonnyWong16
4743538de8 Add status API command 2019-05-11 09:02:18 -07:00
JonnyWong16
235d6f259e Fix missing comma in creating session_history table 2019-05-10 17:54:47 -07:00
JonnyWong16
049a30adbc Update certifi to 2019.03.09 2019-05-02 20:19:42 -07:00
JonnyWong16
e63606f195 Update pytz to 2019.1 2019-05-02 20:13:52 -07:00
JonnyWong16
eea1cf1d2c Schedule jobs using UTC to prevent tiggers in the past 2019-05-02 20:01:39 -07:00
JonnyWong16
43b350a1cc Improve timezone fallback 2019-05-02 20:00:32 -07:00
JonnyWong16
87a77f0522 Fix queue tables displaying intervals greater than 24 hours 2019-04-27 17:03:04 -07:00
JonnyWong16
ce4bfd56c2 Fix scheduler table displaying intervals greater than 24 hours 2019-04-27 16:58:23 -07:00
JonnyWong16
81d443f34c Reorder config 2019-04-27 16:13:25 -07:00
JonnyWong16
4c5602c77c Merge pull request #1336 from abiacco/pms_notify_interval
Adds ability to change notification/check interval for new plex server version
2019-04-27 16:10:49 -07:00
JonnyWong16
feab4115ee Add secure and relayed to notification parameters 2019-04-27 15:54:46 -07:00
JonnyWong16
eae842b09a Store websocket rating key to compare against when mismatched with sessions XML (Fixes Tautulli/Tautulli-Issues#174) 2019-04-27 15:48:31 -07:00
JonnyWong16
9b42bb240a Add secure and relayed to the database 2019-04-27 15:40:33 -07:00
JonnyWong16
d3a7229c00 Fix removing session metadata cache exception IOError to OSError 2019-04-27 10:32:56 -07:00
JonnyWong16
133335a3fb Check section_id > 0 when returning library details 2019-04-27 10:16:11 -07:00
JonnyWong16
32c05136bd Add PLEX_USER_TOKEN to script environment variables 2019-04-18 10:22:04 -07:00
Anthony Biacco
b8d4bb7d69 remove old notify_interval block 2019-04-15 13:45:32 -06:00
JonnyWong16
fc457585fb Update plexpy/webserve.py
Co-Authored-By: abiacco <abiacco@handll.com>
2019-04-15 13:08:04 -06:00
JonnyWong16
74c07167e1 Update plexpy/webserve.py
Co-Authored-By: abiacco <abiacco@handll.com>
2019-04-15 13:07:46 -06:00
JonnyWong16
beabd7bb0f Update plexpy/__init__.py
Co-Authored-By: abiacco <abiacco@handll.com>
2019-04-15 13:07:34 -06:00
JonnyWong16
9a6106f10e Update plexpy/config.py
Co-Authored-By: abiacco <abiacco@handll.com>
2019-04-15 13:07:14 -06:00
JonnyWong16
75949a8dd2 Update plexpy/__init__.py
Co-Authored-By: abiacco <abiacco@handll.com>
2019-04-15 13:07:07 -06:00
JonnyWong16
b92b057ab9 Update data/interfaces/default/settings.html
Co-Authored-By: abiacco <abiacco@handll.com>
2019-04-15 13:06:02 -06:00
JonnyWong16
7d11e1de2d v2.1.29-beta 2019-04-14 13:43:58 -07:00
JonnyWong16
36aa4a6be3 Update API docs 2019-04-13 23:14:41 -07:00
JonnyWong16
24ed63e07c Add undelete button to edit library/user modal 2019-04-13 22:56:32 -07:00
JonnyWong16
f41ed9953a Improve API response codes 2019-04-13 22:53:56 -07:00
JonnyWong16
6970231687 Log script return status code 2019-04-13 22:01:03 -07:00
JonnyWong16
ea036aa354 Fix typo in notifier config triggers 2019-04-13 18:05:40 -07:00
JonnyWong16
d0a7c2f92c Fix typo in notification parameters list 2019-04-10 18:32:54 -07:00
JonnyWong16
f07acd839b Missing closing div tag in c1fd798 2019-04-07 14:22:49 -07:00
JonnyWong16
c1fd798fe9 Add prefix and suffix text modifiers to notifications 2019-04-04 19:21:26 -07:00
JonnyWong16
2f8d2f23fe Add user IP table column IDs 2019-03-30 13:22:01 -07:00
JonnyWong16
b65a30263e Fix user IP address last seen 2019-03-30 12:10:30 -07:00
JonnyWong16
766e33df0e Add getPlexHeaders function 2019-03-26 09:10:09 -07:00
JonnyWong16
68df0f07c8 Return API result error when unauthenticaed 2019-03-21 10:22:34 -07:00
JonnyWong16
819829554b Use global port and root when getting self URL 2019-03-21 08:50:28 -07:00
JonnyWong16
18a38b16b1 Fix a9169d2 2019-03-20 08:58:30 -07:00
JonnyWong16
a9169d2b53 Fix terminate stream when both session_key and session_id are provided 2019-03-20 08:49:15 -07:00
JonnyWong16
76b9b3e474 Improve terminate stream error messages 2019-03-18 11:57:08 -07:00
JonnyWong16
00405f0b18 Change wording in activity header 2019-03-18 10:42:02 -07:00
JonnyWong16
9dfeccdaed Fix update metadata serach only returning 3 results 2019-03-17 18:19:11 -07:00
JonnyWong16
b6d044fe8f Fix API search not using the limit parameter 2019-03-17 17:59:18 -07:00
JonnyWong16
e949b1486e v2.1.28 2019-03-10 14:47:26 -07:00
JonnyWong16
8e1b6efc51 Reword unable to delete from Cloudinary log message 2019-03-10 14:32:45 -07:00
JonnyWong16
00012ffe09 Merge pull request #1344 from Jabcob/patch-1
Update init.systemd
2019-03-10 14:28:01 -07:00
Jabcob
bcf6b4de77 Update init.systemd
Edited user creation and directory ownership instructions in the configuration notes for clarity. Current version may give novice users the impression that the ownership command is only executed on CentOS/Fedora.
2019-03-09 15:39:26 -06:00
JonnyWong16
b1516e9963 Improve mass delete from Cloudinary 2019-03-08 17:49:28 -08:00
JonnyWong16
231de3a7a5 Merge pull request #1343 from samwiseg0/fix/relayed_int
Fix relayed to be an integer vs string
2019-03-06 18:33:05 -08:00
samwiseg0
b611ea659e Fix relayed to be an integer vs string 2019-03-06 17:02:12 -08:00
JonnyWong16
6e9f299c19 Add secure/insecure icon to activity card 2019-03-05 21:55:13 -08:00
JonnyWong16
61fac10079 v2.1.27-beta 2019-03-03 15:30:42 -08:00
JonnyWong16
536e8add17 Encode email exception to unicode 2019-03-03 14:10:05 -08:00
JonnyWong16
cb81bcac57 Return default blank content type (Fixes Tautulli/Tautulli-Issues#165) 2019-03-03 13:39:45 -08:00
JonnyWong16
5dd7806c0e Combine recently added manually due to change in Plex recently added API 2019-03-02 18:42:43 -08:00
JonnyWong16
2a707fc512 Fix typo in f6f5df3 2019-02-24 14:39:07 -08:00
JonnyWong16
469e54a22c Add current release/version to update_check API 2019-02-24 14:36:21 -08:00
JonnyWong16
f6f5df3d1e Add ability to dismiss browser warning 2019-02-24 14:27:34 -08:00
JonnyWong16
ae0960d2e2 Merge pull request #1341 from Arcanemagus/systemd-restart-note
Add Systemd auto restart policy
2019-02-24 14:00:30 -08:00
Landon Abney
a646cc36a1 Enable by default
With the protections from an infinite restart loop in place this should 
be safe to enable by default.
2019-02-24 13:58:19 -08:00
Landon Abney
b243ac5f5c Add burst failure protection
If the process fails 3 times within 90 seconds of a start attempt 
consider it permanently failed and stop all further attempts to restart 
it automatically.
2019-02-24 13:55:33 -08:00
JonnyWong16
bca7744bc5 Remove unicode from Email notification failed error message 2019-02-24 12:51:27 -08:00
JonnyWong16
2fc826c88f Fix some local variable references (Also Fixes Tautulli/Tautulli-Issues#155) 2019-02-24 12:34:55 -08:00
JonnyWong16
6397b1e5a7 Show tags in notification text preview modal 2019-02-24 11:18:18 -08:00
Landon Abney
85b9a47a0d Add note on how to restart automatically
If Tautulli ever crashes due to a failure of some sort the policies 
mentioned here will automatically restart it, with the caveat that they 
will _always_ restart it, even if it is going to crash right away again!
2019-02-22 23:09:34 -08:00
JonnyWong16
5749ab7c92 Remove execute permision from systemd init script (Fixes Tautulli/Tautulli-Issues#149) 2019-02-22 19:50:06 -08:00
JonnyWong16
dcb56cfd20 Add message to complete setup wizard 2019-02-20 21:38:13 -08:00
JonnyWong16
90849f9196 Remove crypto donation 2019-02-20 20:56:50 -08:00
JonnyWong16
5b77cab575 Update Patreon URL 2019-02-20 20:55:59 -08:00
JonnyWong16
6a21d7690a Improve data sanitation (Fixes Tautulli/Tautulli-Issues#161) 2019-02-20 18:35:04 -08:00
JonnyWong16
037e983350 Change PMS beta update check URL 2019-02-19 21:20:34 -08:00
Anthony Biacco
7f0cdb6f26 add config variable PMS_UPDATE_NOTIFY_INTERVAL and settings interface to be to change how often we notify about a new plex version 2018-12-12 10:18:11 -07:00
JonnyWong16
aa023f0166 v2.1.26 2018-12-01 15:50:04 -08:00
JonnyWong16
571b5461c0 Fix stream info graph modal history table (Fixes Tautulli/Tautulli-Issues#142) 2018-12-01 15:30:16 -08:00
JonnyWong16
a749b71f7f Fix activity resume after buffering 2018-12-01 15:17:33 -08:00
JonnyWong16
ac259214f7 Merge pull request #1333 from samwiseg00/add/user_email
Add user_email parameter for notifications
2018-11-04 11:49:42 -08:00
JonnyWong16
e11803685c Fix API error when missing cmd 2018-11-04 11:49:13 -08:00
samwiseg00
e4c3601312 Add user_email parameter for notifications 2018-11-04 14:18:34 -05:00
JonnyWong16
56a91de2c4 v2.1.25 2018-11-03 16:23:21 -07:00
JonnyWong16
e2d217a981 Merge pull request #1332 from samwiseg00/add/livetv_image
Add live TV images for current activity
2018-11-03 12:12:10 -07:00
samwiseg00
b484f27724 Add logic for live tv in current activity 2018-11-03 14:56:53 -04:00
samwiseg00
eb04a2e579 Fix poster image failback 2018-11-03 14:55:56 -04:00
samwiseg00
c66d8ecd5f Add new images for live tv activity 2018-11-03 14:55:30 -04:00
JonnyWong16
79b5f3c36f Merge pull request #1331 from samwiseg00/fix/pms_video_codec
Override * in video codecs
2018-11-02 19:59:48 -07:00
samwiseg00
4a78424b75 Override * in video codecs 2018-11-01 22:38:15 -04:00
JonnyWong16
4f78d0c98a Missing " in settings alert error 2018-10-31 17:49:33 -07:00
JonnyWong16
91b84b4437 Placeholder "-" for burn subtitle codec 2018-10-29 20:47:02 -07:00
JonnyWong16
8a9b3dc782 Override * in audio codecs 2018-10-29 20:46:37 -07:00
JonnyWong16
944df6cec1 v2.1.24-beta 2018-10-29 18:48:05 -07:00
JonnyWong16
354d46e940 Merge pull request #1330 from Arcanemagus/pytz-2018.6
Update pytz to 2018.6
2018-10-24 17:10:22 -04:00
Landon Abney
71cb2d9c4c Update pytz to 2018.6
Update the pytz library files to those from the 2018.6 release.
2018-10-24 12:48:53 -07:00
JonnyWong16
c08cec40cb Log colon 2018-10-23 22:45:28 -07:00
JonnyWong16
dee544c951 Update tzlocal to v1.5.1 2018-10-23 22:40:49 -07:00
JonnyWong16
f42581a5a6 Merge pull request #1329 from samwiseg00/add/timezone_log
Determine system timezone, log the timezone, and display it in the web ui config table
2018-10-23 22:05:06 -07:00
samwiseg00
fdc9f165a4 Add system timezone to the web configuration table 2018-10-23 21:55:32 -07:00
samwiseg00
8fff796700 Log system timezone on startup 2018-10-23 21:55:03 -07:00
samwiseg00
c64a115d29 Add logic to determine system timezone 2018-10-23 21:54:32 -07:00
JonnyWong16
d731ad851c Add queued tasks modal 2018-10-22 22:01:16 -07:00
JonnyWong16
c098175ba9 Merge pull request #1328 from samwiseg00/fix/on_change
Fix transcode change creating invalid sessions in the DB
2018-10-19 19:48:41 -07:00
JonnyWong16
d728f7a11e Version graphs to bypass cache 2018-10-19 18:58:26 -07:00
samwiseg00
7271e66ab8 Fix transcode change creating invalid sessions in the DB 2018-10-18 22:19:36 -04:00
JonnyWong16
5c54283df7 Merge pull request #1326 from samwiseg00/update/telegram_limit
Update character limit for telegram to reflect API changes.
2018-10-16 18:00:28 -07:00
samwiseg00
86b1d0f51d Update character limit for telegram to reflect API changes. 2018-10-16 16:23:56 -04:00
JonnyWong16
f2fedb182c Make local storage unique to Tautulli instance 2018-10-15 18:20:16 -07:00
JonnyWong16
1bce850765 v2.1.23-beta 2018-10-14 09:23:50 -07:00
JonnyWong16
ebe5c3168f Fix minor jquery expression error 2018-10-14 09:15:55 -07:00
JonnyWong16
6e4fa3ef63 Save state of history media type toggle 2018-10-13 22:12:08 -07:00
JonnyWong16
ec7afcdbc4 Fix default local storage chart visibility 2018-10-13 21:54:22 -07:00
JonnyWong16
0f2e25ba72 Save state of homepage recently added type 2018-10-13 21:51:26 -07:00
JonnyWong16
115b05ee7f Reword buffer threshold setting 2018-10-13 21:33:58 -07:00
JonnyWong16
85b4116491 Force buffer threshold to 10 2018-10-13 21:33:43 -07:00
JonnyWong16
863bb4033c Merge pull request #1325 from samwiseg00/change/buffer_threshhold
Change the default buffer threshold and bump the version number
2018-10-13 21:11:10 -07:00
samwiseg00
92672ddda8 Bump version & change default buffer from 3 to 10 2018-10-14 00:08:02 -04:00
JonnyWong16
018356b85e Save home stats config to local storage instead of server 2018-10-13 20:27:08 -07:00
JonnyWong16
d93390f8ed Change home stats type to 'plays' or 'duration' 2018-10-13 20:26:42 -07:00
JonnyWong16
e36be32b8e Set local storage before loading graphs 2018-10-13 20:22:36 -07:00
JonnyWong16
0e0fb2e2b8 Save graph config to local storage instead of server 2018-10-13 18:07:26 -07:00
JonnyWong16
be0144bbe1 Add button for recently added videos on homepage 2018-10-13 17:36:58 -07:00
JonnyWong16
0d30df6853 Add Other Video libraries to newsletters 2018-10-13 17:24:58 -07:00
JonnyWong16
77460f7617 Change type to media_type 2018-10-13 17:24:42 -07:00
JonnyWong16
c70cc535e5 Add library agent to database 2018-10-13 17:23:36 -07:00
JonnyWong16
16733bbe04 Revert column graph widths 2018-10-13 15:57:02 -07:00
JonnyWong16
1686b70c1c Show remote app device token and id 2018-10-13 15:43:19 -07:00
JonnyWong16
1ef4fd294a Save graph visibility state 2018-10-13 15:42:36 -07:00
JonnyWong16
83a4dfc0de Merge pull request #1324 from Arcanemagus/too-fast-buffer
Don't double notify on fast buffer triggers
2018-10-11 18:12:26 -07:00
samwiseg00
2eb82e8732 Change default buffering threshold for new installs 2018-10-11 16:55:45 -04:00
Landon Abney
67f70fab90 Don't double notify on fast buffer triggers
If two buffer notifications come in at the same second right at the cusp 
of the notification trigger the difference between the current and last 
trigger would be 0, causing it to send two notifications.

Change the initial value to `None` to prevent this from happening.
2018-10-11 13:29:21 -07:00
JonnyWong16
fb2362be24 Merge pull request #1323 from samwiseg00/fix/transcode_change
Fix transcode decision change for some clients
2018-10-10 21:09:05 -07:00
samwiseg00
612bf079de Fix transcode decision change for some clients 2018-10-10 16:46:40 -04:00
JonnyWong16
a88047eb9c Merge pull request #1322 from samwiseg00/fix/buffering_state_activity
Fix client buffering identification in certain scenarios
2018-10-09 20:45:57 -07:00
JonnyWong16
7bdef05a45 Fix download API commands 2018-10-09 08:27:48 -07:00
samwiseg00
1a46e09928 Fix client buffering identification in certain scenarios 2018-10-08 10:54:09 -04:00
JonnyWong16
4302c4bc0d Reverse sorting when retriving old rating key list from database 2018-10-06 20:15:19 -07:00
JonnyWong16
3b0f31c112 Merge pull request #1318 from Sheigutn/view-offset-fix
Don't overwrite view offset when processing session history
2018-10-06 18:59:35 -07:00
JonnyWong16
a976d65e9c Lock down some settings for Docker container 2018-10-06 14:19:01 -07:00
Florian Böhm
40559471cf Remove code to update view offset for every websocket event 2018-10-06 11:01:13 +02:00
JonnyWong16
6bb6e27378 Merge pull request #1321 from samwiseg00/add/notify_state_change
Add the ability to notify on transcode decision state change
2018-10-05 21:07:08 -07:00
JonnyWong16
03751abc0e v2.1.22 2018-10-05 21:04:17 -07:00
samwiseg00
8ab5d88db5 Create transcode decision columns for new DBs 2018-10-05 23:18:33 -04:00
samwiseg00
d80919140b Upgrade existing DB for transcode decision 2018-10-05 23:18:18 -04:00
samwiseg00
1e3a347782 Populate NULL text fields after a DB update 2018-10-05 23:18:00 -04:00
samwiseg00
a6e8372d47 Add transcode decision change to notifiers 2018-10-05 23:17:28 -04:00
samwiseg00
ce59692089 Fix typo in the comments 2018-10-05 23:17:06 -04:00
samwiseg00
df76a02478 Account for changing transcode decisions from websocket events 2018-10-05 23:16:49 -04:00
JonnyWong16
a94207691f Improve OAuth polling 2018-09-30 21:05:12 -07:00
JonnyWong16
dbc53ca710 Fix websocket not connecting after setup wizard 2018-09-29 15:32:13 -07:00
JonnyWong16
4c9ddbd8b7 Fix incorrectly showing 127.0.0.1 server in setup wizard 2018-09-29 15:31:55 -07:00
JonnyWong16
045c69f5d8 Catch exception when retrieiving data for notifier configs 2018-09-28 18:21:04 -07:00
JonnyWong16
71ae314c46 Make sure proxy handler priority is before auth handler (Fixes Tautulli/Tautulli-Issues#123) 2018-09-27 18:05:28 -07:00
JonnyWong16
c8575bbc0f v2.1.21 2018-09-21 18:16:48 -07:00
Florian Böhm
af3944734f Fix for usage of wrong view offset field when serializing to session_history
Also add code to update view offset in sessions table more often
2018-09-20 01:17:39 +02:00
JonnyWong16
f1b3a6f7b6 Merge pull request #1316 from Arcanemagus/fix_content_rating_type
Fix the type of the Content Rating notification parameter (Fixes Tautulli/Tautulli-Issues#122)
2018-09-19 12:49:31 -07:00
Landon Abney
8a94f6d63a Fix the type of the Content Rating notification parameter
The "Content Rating" notification parameter was incorrectly marked as an
integer, leading to all values being cast to the number 0. This made it
so every single content rating was the same value in conditions.
2018-09-19 12:46:28 -07:00
JonnyWong16
9b8fb73a7a Merge pull request #1312 from samwiseg00/add/init_distro
Add chown instructions per major distros
2018-09-19 08:41:05 -07:00
JonnyWong16
67c333e86e Add X-Plex-Token log filter 2018-09-16 10:24:07 -07:00
JonnyWong16
cfa0b20419 Fix music showing as pre-tautulli in stream info (Fixes Tautulli/Tautulli-Issues#120) 2018-09-16 09:56:32 -07:00
samwiseg00
4b2930c890 Add chown instructions per major distros 2018-09-15 16:54:49 -03:00
JonnyWong16
d98565ea12 Merge pull request #1309 from Sheigutn/refresh-image-patch
Move refresh image button to right div for track results
2018-09-15 10:28:08 -07:00
Florian Böhm
471f7c184a Replace album-item with cover-item
Also add missing quotation mark in artist cover div
2018-09-12 21:52:57 +02:00
Florian Böhm
3d4a5e6547 Move refresh image span to right div 2018-09-12 15:45:49 +02:00
JonnyWong16
382322d5e7 Always format notification subject 2018-09-11 18:08:40 -07:00
JonnyWong16
c0ae25611b Merge pull request #1152 from wilmardo/execute-permission-init-scripts
Adds execute permission to fedora.centos and systemd init-scripts
2018-09-11 17:45:45 -07:00
JonnyWong16
f025533582 Merge pull request #1308 from ldumont/fix_systemd_group
Fix typo in systemd group value
2018-09-10 08:29:49 -07:00
Loïc Dumont
fd28e5183a Fix typo in systemd group value 2018-09-10 07:35:45 +02:00
JonnyWong16
185099f183 Check for alternative reverse proxy headers 2018-09-09 10:57:14 -07:00
JonnyWong16
cd6289046e Fallback directories to data dir 2018-09-08 23:16:14 -07:00
JonnyWong16
955dc795ff Update javascript uuidv4 function 2018-09-06 23:23:55 -07:00
JonnyWong16
1b772e60a9 Add browser warning for IE/Edge 2018-09-06 22:51:01 -07:00
JonnyWong16
c6f4c17a81 Remove polling flag 2018-09-05 17:45:51 -07:00
JonnyWong16
1e68a81fe1 Stop polling if OAuth popup closed 2018-09-05 17:44:04 -07:00
JonnyWong16
4944ce1ca0 v2.1.20 2018-09-05 08:55:20 -07:00
JonnyWong16
f04873446a v2.1.20-beta 2018-09-02 18:06:45 -07:00
JonnyWong16
505b6b616e Add session_id parameter to get_activity API command 2018-09-02 11:27:34 -07:00
JonnyWong16
87dd43d699 Merge pull request #1305 from samwiseg00/fix/systemd_init
Change init script group value to be compatible with CentOS
2018-09-02 11:21:18 -07:00
samwiseg00
a48ebef9ae Change init script group value 2018-08-31 13:27:15 -04:00
JonnyWong16
e40483525b Redirect root to http_root 2018-08-27 21:41:11 -07:00
JonnyWong16
ebc563fd26 Remove unused pnotify css from login page 2018-08-27 21:39:40 -07:00
JonnyWong16
5bb3e189fe Update API docs 2018-08-27 21:27:09 -07:00
JonnyWong16
ed08df5224 Try getting missing parent_rating_key from parent_thumb first 2018-08-27 21:06:52 -07:00
JonnyWong16
ae2584b6f6 Helper function for splitting script args 2018-08-27 20:56:32 -07:00
JonnyWong16
f0e2355231 Log folder/file location on startup 2018-08-27 16:05:39 -07:00
JonnyWong16
878c48b491 Fix video and audio not showing on activity cards after refresh 2018-08-26 15:36:21 -07:00
JonnyWong16
ecfbb4de9b Fetch missing parent rating key when season is hidden 2018-08-24 22:02:21 -07:00
JonnyWong16
731af75c54 Merge pull request #1304 from samwiseg00/feature/add_env
Add TAUTULLI_PUBLIC_URL to environment variables
2018-08-24 08:07:21 -07:00
samwiseg00
9817da6012 Add TAUTULLI_PUBLIC_URL to environment variables 2018-08-24 02:59:34 -04:00
JonnyWong16
dd3f75f154 Add return_hash to pms_image_proxy API 2018-08-23 19:12:22 -07:00
JonnyWong16
1eee03fa8f Merge pull request #1303 from samwiseg00/feature/add_timestamp
Add UTC timestamp to notification params + OCD + Change discord timestamp function
2018-08-22 18:35:20 -07:00
samwiseg00
02af6c4e6c Change discord timestamp function to metadata params 2018-08-22 00:22:01 -04:00
samwiseg00
b8a9c4f5b7 Add UTC ISO time to notification handler 2018-08-22 00:21:49 -04:00
samwiseg00
0b227dc69e PEP8 functions in helpers 2018-08-22 00:18:54 -04:00
JonnyWong16
8228018dd0 Add sending notification log message 2018-08-20 14:49:00 -07:00
JonnyWong16
5c3086a049 Chnage get_notify_text_preview to POST 2018-08-19 15:08:27 -07:00
Wilmar
634e003bb7 Adds execute permission to fedora.centos and systemd init-scripts 2017-11-21 01:09:54 +01:00
713 changed files with 13120 additions and 8519 deletions

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
github: JonnyWong16
patreon: Tautulli
custom: ["https://bit.ly/2InPp15"]

72
API.md
View File

@@ -1,9 +1,15 @@
# API Reference
The API is still pretty new and needs some serious cleaning up on the backend but should be reasonably functional. There are no error codes yet.
## General structure
The API endpoint is `http://ip:port + HTTP_ROOT + /api/v2?apikey=$apikey&cmd=$command`
The API endpoint is
```
http://IP_ADDRESS:PORT + [/HTTP_ROOT] + /api/v2?apikey=$apikey&cmd=$command
```
Example:
```
http://localhost:8181/api/v2?apikey=66198313a092496b8a725867d2223b5f&cmd=get_metadata&rating_key=153037
```
Response example (default `json`)
```
@@ -168,7 +174,7 @@ Delete the 3rd party API lookup info.
```
Required parameters:
rating_key (int): 1234
(Note: Must be the movie, show, or artist rating key)
(Note: Must be the movie, show, artist, album, or track rating key)
Optional parameters:
None
@@ -354,7 +360,8 @@ Required parameters:
None
Optional parameters:
None
session_key (int): Session key for the session info to return, OR
session_id (str): Session ID for the session info to return
Returns:
json:
@@ -409,6 +416,7 @@ Returns:
"Drama",
"Fantasy"
],
"grandparent_guid": "com.plexapp.agents.thetvdb://121361?lang=en",
"grandparent_rating_key": "1219",
"grandparent_thumb": "/library/metadata/1219/thumb/1503306930",
"grandparent_title": "Game of Thrones",
@@ -436,6 +444,7 @@ Returns:
"optimized_version_title": "",
"originally_available_at": "2016-04-24",
"original_title": "",
"parent_guid": "com.plexapp.agents.thetvdb://121361/6?lang=en",
"parent_media_index": "6",
"parent_rating_key": "153036",
"parent_thumb": "/library/metadata/153036/thumb/1503889210",
@@ -500,7 +509,9 @@ Returns:
"stream_video_language": "",
"stream_video_language_code": "",
"stream_video_ref_frames": "4",
"stream_video_full_resolution": "1080p",
"stream_video_resolution": "1080",
"stream_video_scan_type": "progressive",
"stream_video_width": "1920",
"studio": "HBO",
"subtitle_codec": "",
@@ -553,12 +564,14 @@ Returns:
"video_decision": "direct play",
"video_frame_rate": "23.976",
"video_framerate": "24p",
"video_full_resolution": "1080p",
"video_height": "1078",
"video_language": "",
"video_language_code": "",
"video_profile": "high",
"video_ref_frames": "4",
"video_resolution": "1080",
"video_scan_type": "progressive",
"video_width": "1920",
"view_offset": "1000",
"width": "1920",
@@ -693,8 +706,9 @@ Returns:
"parent_title": "",
"paused_counter": 0,
"percent_complete": 84,
"platform": "Chrome",
"player": "Plex Web (Chrome)",
"platform": "Windows",
"product": "Plex for Windows",
"player": "Castle-PC",
"rating_key": 4348,
"reference_id": 1123,
"session_key": null,
@@ -726,7 +740,7 @@ Required parameters:
Optional parameters:
grouping (int): 0 or 1
time_range (str): The time range to calculate statistics, '30'
stats_type (int): 0 for plays, 1 for duration
stats_type (str): plays or duration
stats_count (str): The number of top items to list, '5'
Returns:
@@ -826,6 +840,7 @@ Required parameters:
None
Optional parameters:
grouping (int): 0 or 1
order_column (str): "library_thumb", "section_name", "section_type", "count", "parent_count",
"child_count", "last_accessed", "last_played", "plays", "duration"
order_dir (str): "desc" or "asc"
@@ -886,6 +901,7 @@ Returns:
json:
{"child_count": null,
"count": 887,
"deleted_section": 0,
"do_notify": 1,
"do_notify_created": 1,
"keep_history": 1,
@@ -1100,6 +1116,7 @@ Returns:
"Drama",
"Fantasy"
],
"grandparent_guid": "com.plexapp.agents.thetvdb://121361?lang=en",
"grandparent_rating_key": "1219",
"grandparent_thumb": "/library/metadata/1219/thumb/1462175063",
"grandparent_title": "Game of Thrones",
@@ -1140,7 +1157,9 @@ Returns:
"video_language_code": "",
"video_profile": "high",
"video_ref_frames": "4",
"video_width": "1920"
"video_scan_type": "progressive",
"video_width": "1920",
"selected": 0
},
{
"audio_bitrate": "384",
@@ -1153,7 +1172,8 @@ Returns:
"audio_profile": "",
"audio_sample_rate": "48000",
"id": "511664",
"type": "2"
"type": "2",
"selected": 1
},
{
"id": "511953",
@@ -1164,13 +1184,15 @@ Returns:
"subtitle_language": "English",
"subtitle_language_code": "eng",
"subtitle_location": "external",
"type": "3"
"type": "3",
"selected": 1
}
]
}
],
"video_codec": "h264",
"video_framerate": "24p",
"video_full_resolution": "1080p",
"video_profile": "high",
"video_resolution": "1080",
"width": "1920"
@@ -1179,6 +1201,7 @@ Returns:
"media_type": "episode",
"original_title": "",
"originally_available_at": "2016-04-24",
"parent_guid": "com.plexapp.agents.thetvdb://121361/6?lang=en",
"parent_media_index": "6",
"parent_rating_key": "153036",
"parent_thumb": "/library/metadata/153036/thumb/1462175062",
@@ -1765,7 +1788,7 @@ Returns:
### get_recently_added
Get all items that where recelty added to plex.
Get all items that where recently added to plex.
```
Required parameters:
@@ -1773,7 +1796,7 @@ Required parameters:
Optional parameters:
start (str): The item number to start at
type (str): The media type: movie, show, artist
media_type (str): The media type: movie, show, artist
section_id (str): The id of the Plex library section
Returns:
@@ -2330,6 +2353,7 @@ Required parameters:
None
Optional parameters:
grouping (int): 0 or 1
order_column (str): "user_thumb", "friendly_name", "last_seen", "ip_address", "platform",
"player", "last_played", "plays", "duration"
order_dir (str): "desc" or "asc"
@@ -2435,7 +2459,8 @@ Required parameters:
body (str): The body of the message
Optional parameters:
None
headers (str): The JSON headers for webhook notifications
script_args (str): The arguments for script notifications
Returns:
None
@@ -2496,6 +2521,7 @@ Optional parameters:
img_format (str): png
fallback (str): "poster", "cover", "art"
refresh (bool): True or False whether to refresh the image cache
return_hash (bool): True or False to return the self-hosted image hash instead of the image
Returns:
None
@@ -2633,6 +2659,24 @@ Returns:
```
### status
Get the current status of Tautulli.
```
Required parameters:
None
Optional parameters:
check (str): database
Returns:
json:
{"result": "success",
"message": "Ok",
}
```
### terminate_session
Stop a streaming session.

View File

@@ -1,5 +1,337 @@
# Changelog
## v2.1.43 (2020-02-03)
* Monitoring:
* New: Added HDR indicator on activity card.
* New: Added dynamic range to history steam info modal.
* Notifications:
* Fix: Webhook notification body sent as incorrect data type when Content-Type header is overridden.
* Fix: Telegram notification character limit incorrect for unicode characters.
* New: Added color and dynamic range notification parameters.
* Newsletters:
* Fix: Episodes and Albums plural spelling on recently added newsletter section headers.
* UI:
* Fix: Windows and macOS platform capitalization.
* Fix: Season number 0 not shown for episodes on history tables.
* Other:
* Change: Mask email addresses in logs.
* Change: Update deprecated GitHub access token URL parameter to Authorization header.
## v2.1.42 (2020-01-04)
* Other:
* Fix: SSL certificate error when installing GeoLite2 database.
* Change: Verify MaxMind license key and GeoLite2 database path before installing.
* Change: Disable GeoLite2 database uninstall button when it is not installed.
## v2.1.41 (2019-12-30)
* Other:
* Fix: Failing to extract the GeoLite2 database on Windows.
## v2.1.40 (2019-12-30)
* UI:
* Change: Moved 3rd Party API settings to new tab in the settings.
* Graphs:
* Change: Improve calculating month ranges for Play Totals graphs.
* Other:
* Fix: Failing to verify a Plex Media Server using a hostname.
* Change: A license key is now required to install the MaxMind GeoLite2 database for IP geolocation. Please follow the guide in the wiki to reinstall the GeoLite2 database.
* Change: The GeoLite2 database will now automatically update periodically if installed.
## v2.1.39 (2019-12-08)
* UI:
* New: Added creating admin username and password to setup wizard.
* API:
* Change: Remove default notification subject and body for notify API command.
* Other:
* Change: Check for database corruption when making backup.
## v2.1.38 (2019-11-17)
* Notifications:
* New: Added custom JSON headers to the webhook notification agent.
* UI:
* Fix: Homepage recently watched card not showing grouped history.
* Other:
* New: Added GitHub sponsor donation option.
* Change: Improve resolving hostnames.
## v2.1.37 (2019-10-11)
* Notifications:
* Fix: Last.fm URLs linking to artist page instead of the album page.
* New: Added option for MusicBrainz lookup for music notifications. Option must be enabled under 3rd Party APIs in the settings.
* New: Added MusicBrainz ID and MusicBrainz URL notification parameters.
* Change: Automatically truncate Discord description summary to 2048 characters.
## v2.1.36-beta (2019-10-05)
* Monitoring:
* Fix: Activity card title not updating after pre-rolls or auto-play.
* History:
* Fix: Display correct interlaced or progressive video scan type on stream data modal.
* Graphs:
* New: Separate interlaced and progressive video scan type on source and stream resolution graphs.
* API:
* New: Added parent_guid and grandparent_guid to get_activity and get_metadata commands.
## v2.1.35-beta (2019-09-24)
* Monitoring:
* Fix: Audio shown as blank on activity cards when changing audio tracks during direct play.
* Fix: Display correct interlaced or progressive video scan type on activity cards.
* New: Added flag for Nvidia hardware decoding on activity cards.
* Notifications:
* Fix: Notification parameter prefix and suffix were not substituted correctly.
* Fix: Release Date notification parameter was incorrectly casted to an integer instead of a string.
* New: Added video scan type and full resolution notification parameters.
* UI:
* Fix: Movies with the same title but different year being grouped on the homepage stats cards.
* API:
* New: Added video scan type and full resolution values to get_activity command.
* Other:
* Fix: Tautulli logging out every time after saving settings and restarting.
## v2.1.34 (2019-09-03)
* History:
* New: Added Product column to history tables.
* Notifications:
* Fix: IMDB/TMDb/TVDB/TVmaze ID notification parameters showing blank values after lookup.
* UI:
* Fix: Libraries and Users tables did not respect the group history setting.
* API:
* Fix: Title field was not searchable in get_library_media_info command.
* New: Added grouping option to get_libraries_table and get_users_table commands.
* New: Added product value to get_history command.
* Other:
* Fix: Could not verify Plex Media Server with unpublished hostnames.
* Change: Automatically logout all Tautulli instances when changing the admin password.
## v2.1.33 (2019-07-27)
* Notifications:
* Change: Mask notification agent password fields.
* Change: Enable searching by email address in dropdown menu.
* Other:
* Fix: Version number being overwritten with "None" which prevented updating in some instances.
* Change: Update Plex OAuth request headers.
## v2.1.32 (2019-06-26)
* Newsletters:
* Fix: Newsletter scheduler issue for QNAP devices using an invalid "local" timezone preventing Tautulli from starting.
## v2.1.31 (2019-06-24)
* No additional changes from v2.1.31-beta.
## v2.1.31-beta (2019-06-13)
* Monitoring:
* Fix: Synced content showing incorrect stream info.
* Other:
* Fix: Unable to view database status when authentication is enabled.
* Change: Default database synchronous mode changed to prevent database corruption. Database response may be slower.
## v2.1.30-beta (2019-05-11)
* Monitoring:
* Fix: Activity crashing with Plex's Artist TV feature.
* New: Added setting for Plex Media Server Update Check Interval. (Thanks @abiacco)
* Notifications:
* New: Added secure and relayed connection notification parameters.
* New: Added PLEX_USER_TOKEN to script environment variables.
* Change: Schedule notifications using UTC to prevent missing notifications due to misconfigured timezones.
* API:
* New: Added status API command to check the status of Tautulli.
## v2.1.29 (2019-05-11)
* No additional changes from v2.1.29-beta.
## v2.1.29-beta (2019-04-14)
* Monitoring:
* Change: "Required Bandwidth" changed to "Reserved Bandwidth" in order to match the Plex dashboard.
* Notifications:
* New: Added prefix and suffix notification text modifiers. See the "Notification Text Modifiers" help modal for details.
* UI:
* New: Added "Undelete" button to the edit library and edit user modals.
* Fix: User IP address history table showing incorrect "Last Seen" values.
* API:
* Fix: Search API only returning 3 results.
* Fix: Terminate stream API failing when both session_key and session_id were provided.
* Change: Improved API response HTTP status codes and error messages.
## v2.1.28 (2019-03-10)
* Monitoring:
* New: Added secure/insecure connection icon on the activity cards. Requires Plex Media Server v1.15+.
* Other:
* Change: Improved mass deleting of all images from Cloudinary. Requires all previous images on Cloudinary to be manually tagged with "tautulli". New uploads are automatically tagged.
## v2.1.27-beta (2019-03-03)
* Monitoring:
* Fix: Error when playing synced optimized versions.
* Change: Show message to complete the setup wizard instead of error communicating with server message.
* Change: URL changed on Plex.tv for Plex Media Server beta updates.
* Notifications:
* New: Show the media type exclusion tags in the text preview modal.
* Fix: Unicode error in the Email notification failed response message.
* Fix: Error when a notification agent response is missing the "Content-Type" header.
* UI:
* Fix: Usernames were not being sanitized in dropdown selectors.
* Change: Different display of "All" recently added items on the homepage due to change in the Plex Media Server v1.15+ API.
* API:
* New: Added current Tautulli version to update_check API response.
* Change: API no longer returns sanitized HTML response data.
* Other:
* New: Added auto-restart to systemd init script.
* Fix: Patreon donation URL.
* Remove: Crypto donation options.
## v2.1.26 (2018-12-01)
* Monitoring:
* Fix: Resume event not being triggered after buffering.
* Notifications:
* New: Added user email as a notification parameter.
* Graphs:
* Fix: History model showing no results for stream info graph.
* API:
* Fix: API returning error when missing a cmd.
## v2.1.25 (2018-11-03)
* Monitoring:
* Fix: Audio and video codec showing up as * on the activity cards.
* New: Poster and background image on the activity cards for live TV.
* UI:
* Fix: Alert message for invalid Tautulli Public Domain setting.
## v2.1.24-beta (2018-10-29)
* Monitoring:
* Fix: Transcode change events creating invalid sessions in the database.
* Notifications:
* Change: Update Telegram character limit to 1024.
* History:
* Fix: Save history table states separately for multiple Tautulli instances.
* Graphs:
* Fix: Save graphs states separately for multiple Tautulli instances.
* Change: Version graphs to bypass browser cache.
* UI:
* New: Added queued tasks modals to the scheduled tasks table for debugging.
* Other:
* Change: Updated timezone info and display in configuration table.
## v2.1.23-beta (2018-10-14)
* Monitoring:
* Fix: Buffer events not being triggered properly.
* Fix: Watched progress sometimes not saved correctly. (Thanks @Sheigutn)
* Notifications:
* New: Added notification trigger for transcode decision change.
* Fix: Multiple buffer notifications being triggered within the same second.
* Change: Default buffer notification threshold changed to 10 for buffer thresholds less than 10.
* Newsletter:
* New: Added Other Video libraries to the newsletter.
* Homepage:
* New: Added Other Video type to recently added on the homepage.
* Change: Save homepage recently added media type toggle state.
* Change: Save homepage stats config to local storage instead of the server.
* History:
* Change: Save history table media type toggle state.
* Graphs:
* Change: Save series visibility state when toggling the legend.
* Change: Save graph config to local storage instead of the server.
* UI:
* New: Show the remote app device token and id in the edit device modal.
* Change: Lock certain settings if using the Tautulli docker container.
* API:
* Fix: download_config, download_database, download_log, and download_plex_log API commands not working.
* Change: get_recently_added command 'type' parameter renamed to 'media_type'. Backwards compatibility is maintained.
* Change: get_home_stats command 'stats_type' parameter change to string 'plays' or 'duration'. Backwards compatibility is maintained.
## v2.1.22 (2018-10-05)
* Notifications:
* Fix: Notification agent settings not loading when failed to retrieve some data.
* UI:
* Fix: Incorrectly showing localhost server in the setup wizard.
* Other:
* Fix: Incorrect redirect to HTTP when HTTPS proxy header is present.
* Fix: Websocket not connecting automatically after the setup wizard.
## v2.1.21 (2018-09-21)
* Notifications:
* Fix: Content Rating notification condition always evaluating to True. (Thanks @Arcanemagus)
* Fix: Script arguments not showing substituted values in the notification logs.
* UI:
* New: Unsupported browser warning when using IE or Edge.
* Fix: Misaligned refresh image icon in album search results. (Thanks @Sheigutn)
* Fix: Music history showing as pre-Tautulli in stream info modal.
* Other:
* Fix: Typo in Systemd init script group value. (Thanks @ldumont)
* Fix: Execute permissions in Fedora/CentOS and Systemd init scripts. (Thanks @wilmardo)
* Fix: Systemd init script instructions per Linux distro. (Thanks @samwiseg00)
* Change: Fallback to Tautulli data directory if logs/backup/cache/newsletter directories are not writable.
* Change: Check for alternative reverse proxy headers if X-Forwarded-Host is missing.
## v2.1.20 (2018-09-05)
* No additional changes from v2.1.20-beta.
## v2.1.20-beta (2018-09-02)
* Monitoring:
* Fix: Fetch messing season info when "Hide Seasons" is enabled for a show.
* Fix: Video and Audio details sometimes missing on activity cards.
* Notifications:
* New: Added UTC timestamp to notification parameters. (Thanks @samwiseg00)
* New: Added TAUTULLI_PUBLIC_URL to script environment variables. (Thanks @samwiseg00)
* UI:
* Change: Automatically redirect '/' to HTTP root if enabled.
* API:
* New: Added return_hash parameter to pms_image_proxy command.
* New: Added session_id parameter to get_activity command.
* Other:
* Change: Linux systemd startup script to use the "tautulli" group permission. (Thanks @samwiseg00)
## v2.1.19-beta (2018-08-19)
* Notifications:

View File

@@ -1,41 +0,0 @@
<!---
Reporting Issues:
* To ensure that a developer has enough information to work with please include all of the information below.
Please provide as much detail as possible. Screenshots can be very useful to see the problem.
* Use proper markdown syntax to structure your post (i.e. code/log in code blocks).
See: https://help.github.com/articles/basic-writing-and-formatting-syntax/
* Include a link to your **FULL** log file that has the error(not just a few lines!).
Please use [Gist](http://gist.github.com) or [Pastebin](http://pastebin.com/).
Feature Requests:
* Feature requests are handled on FeatHub: http://feathub.com/Tautulli/Tautulli
* Do not post them on the GitHub issues tracker.
-->
**Version:**
**Branch:**
**Commit hash:**
**Operating system:**
**Python version:**
**What you did?**
**What happened?**
**What you expected?**
**How can we reproduce your issue?**
<!-- Provide a detailed step-by-step. -->
**What are your (relevant) settings?**
**Link to logs:**
<!--
Close your issue when it's solved! If you found the solution yourself please comment so that others benefit from it.
-->

View File

@@ -28,9 +28,12 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lib'))
import argparse
import datetime
import locale
import pytz
import signal
import time
import tzlocal
import plexpy
from plexpy import config, database, logger, webstart
@@ -103,8 +106,19 @@ def main():
plexpy.QUIET = True
# Do an intial setup of the logger.
logger.initLogger(console=not plexpy.QUIET, log_dir=False,
verbose=plexpy.VERBOSE)
# Require verbose for pre-initilization to see critical errors
logger.initLogger(console=not plexpy.QUIET, log_dir=False, verbose=True)
try:
plexpy.SYS_TIMEZONE = tzlocal.get_localzone()
except (pytz.UnknownTimeZoneError, LookupError, ValueError) as e:
logger.error("Could not determine system timezone: %s" % e)
plexpy.SYS_TIMEZONE = pytz.UTC
plexpy.SYS_UTC_OFFSET = datetime.datetime.now(plexpy.SYS_TIMEZONE).strftime('%z')
if os.getenv('TAUTULLI_DOCKER', False) == 'True':
plexpy.DOCKER = True
if args.dev:
plexpy.DEV = True
@@ -220,31 +234,19 @@ def main():
plexpy.CONFIG.ENABLE_HTTPS = False
# Try to start the server. Will exit here is address is already in use.
web_config = {
'http_port': plexpy.HTTP_PORT,
'http_host': plexpy.CONFIG.HTTP_HOST,
'http_root': plexpy.CONFIG.HTTP_ROOT,
'http_environment': plexpy.CONFIG.HTTP_ENVIRONMENT,
'http_proxy': plexpy.CONFIG.HTTP_PROXY,
'enable_https': plexpy.CONFIG.ENABLE_HTTPS,
'https_cert': plexpy.CONFIG.HTTPS_CERT,
'https_cert_chain': plexpy.CONFIG.HTTPS_CERT_CHAIN,
'https_key': plexpy.CONFIG.HTTPS_KEY,
'http_username': plexpy.CONFIG.HTTP_USERNAME,
'http_password': plexpy.CONFIG.HTTP_PASSWORD,
'http_basic_auth': plexpy.CONFIG.HTTP_BASIC_AUTH
}
webstart.initialize(web_config)
webstart.start()
# Windows system tray icon
if os.name == 'nt' and plexpy.CONFIG.WIN_SYS_TRAY:
plexpy.win_system_tray()
logger.info("Tautulli is ready!")
# Open webbrowser
if plexpy.CONFIG.LAUNCH_BROWSER and not args.nolaunch and not plexpy.DEV:
plexpy.launch_browser(plexpy.CONFIG.HTTP_HOST, plexpy.HTTP_PORT,
plexpy.HTTP_ROOT)
# Windows system tray icon
if os.name == 'nt' and plexpy.CONFIG.WIN_SYS_TRAY:
plexpy.win_system_tray()
# Wait endlessy for a signal to happen
while True:
if not plexpy.SIGNAL:

View File

@@ -209,7 +209,7 @@ ${next.modalIncludes()}
</div>
</div>
% else:
<div id="donate-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="crypto-donate-modal">
<div id="donate-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="donate-modal">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
@@ -229,21 +229,26 @@ ${next.modalIncludes()}
</div>
<ul id="donation_type" class="nav nav-pills" role="tablist" style="display: flex; justify-content: center; margin: 10px 0;">
<li class="active"><a href="#patreon-donation" role="tab" data-toggle="tab">Patreon</a></li>
<li><a href="#github-donation" role="tab" data-toggle="tab">GitHub</a></li>
<li><a href="#paypal-donation" role="tab" data-toggle="tab">PayPal</a></li>
<li><a href="#crypto-donation" role="tab" data-toggle="tab" class="crypto-donation" data-coin="bitcoin" data-name="Bitcoin" data-address="3FdfJAyNWU15Sf11U9FTgPHuP1hPz32eEN">Bitcoin</a></li>
<li><a href="#crypto-donation" role="tab" data-toggle="tab" class="crypto-donation" data-coin="bitcoincash" data-name="Bitcoin Cash" data-address="1H2atabxAQGaFAWYQEiLkXKSnK9CZZvt2n">Bitcoin Cash</a></li>
<li><a href="#crypto-donation" role="tab" data-toggle="tab" class="crypto-donation" data-coin="ethereum" data-name="Ethereum" data-address="0x77ae4c2b8de1a1ccfa93553db39971da58c873d3">Ethereum</a></li>
<li><a href="#crypto-donation" role="tab" data-toggle="tab" class="crypto-donation" data-coin="litecoin" data-name="Litecoin" data-address="LWpPmUqQYHBhMV83XSCsHzPmKLhJt6r57J">Litecoin</a></li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="patreon-donation" style="text-align: center">
<p>
Click the button below to continue to Patreon.
</p>
<a href="${anon_url('https://www.patreon.com/bePatron?u=10078609')}" target="_blank">
<a href="${anon_url('https://www.patreon.com/join/tautulli')}" target="_blank">
<img src="images/become_a_patron_button.png" alt="Become a Patron" height="40">
</a>
</div>
<div role="tabpanel" class="tab-pane" id="github-donation" style="text-align: center">
<p>
Click the button below to continue to GitHub.
</p>
<a href="${anon_url('https://github.com/sponsors/JonnyWong16')}" target="_blank" class="btn btn-sm btn-default" style="font-weight: 600;">
<i class="fa fa-heart fa-sm" style="color: #ea4aaa;"></i>&nbsp; Sponsor
</a>
</div>
<div role="tabpanel" class="tab-pane" id="paypal-donation" style="text-align: center">
<p>
Click the button below to continue to PayPal.
@@ -252,12 +257,6 @@ ${next.modalIncludes()}
<img src="images/gold-rect-paypal-34px.png" alt="PayPal">
</a>
</div>
<div role="tabpanel" class="tab-pane" id="crypto-donation">
<label>QR Code</label>
<pre id="crypto_qr_code" style="text-align: center"></pre>
<label><span id="crypto_type_label"></span> Address</label>
<pre id="crypto_address" style="text-align: center"></pre>
</div>
</div>
</div>
<div class="modal-footer">
@@ -293,7 +292,6 @@ ${next.modalIncludes()}
<script src="${http_root}js/pnotify.custom.min.js"></script>
<script src="${http_root}js/platform.min.js"></script>
<script src="${http_root}js/script.js${cache_param}"></script>
<script src="${http_root}js/jquery.qrcode.min.js"></script>
<script src="${http_root}js/jquery.tripleclick.min.js"></script>
% if _session['user_group'] == 'admin' and BROWSER_NOTIFIERS:
<script src="${http_root}js/ajaxNotifications.js"></script>
@@ -365,16 +363,6 @@ ${next.modalIncludes()}
checkUpdate(function () { $('#nav-update').html('<i class="fa fa-fw fa-arrow-alt-circle-up"></i> Check for Updates'); });
});
$('#donation_type a.crypto-donation').on('shown.bs.tab', function () {
var crypto_coin = $(this).data('coin');
var crypto_name = $(this).data('name');
var crypto_address = $(this).data('address')
$('#crypto_qr_code').empty().qrcode({
text: crypto_coin + ":" + crypto_address
});
$('#crypto_type_label').html(crypto_name);
$('#crypto_address').html(crypto_address);
});
% endif
$('.dropdown-toggle').click(function (e) {

View File

@@ -35,7 +35,7 @@ DOCUMENTATION :: END
</tr>
<tr>
<td>Database File:</td>
<td><a class="no-highlight" href="download_database" data-toggle="tooltip" data-placement="right" title="Download Database">${plexpy.DB_FILE}</a></td>
<td><a class="no-highlight" href="download_database" data-toggle="tooltip" data-placement="right" title="Download Database">${plexpy.DB_FILE}</a> | <a class="no-highlight" href="status/database">Status</a></td>
</tr>
<tr>
<td>Log File:</td>
@@ -53,14 +53,6 @@ DOCUMENTATION :: END
<td>Newsletter Directory:</td>
<td>${plexpy.CONFIG.NEWSLETTER_DIR}</td>
</tr>
<tr>
<td>GeoLite2 Database:</td>
% if plexpy.CONFIG.GEOIP_DB:
<td>${plexpy.CONFIG.GEOIP_DB} | <a class="no-highlight" href="#" id="reinstall_geoip_db">Reinstall / Update</a> | <a class="no-highlight" href="#" id="uninstall_geoip_db">Uninstall</a></td>
% else:
<td><a class="no-highlight" href="#" id="install_geoip_db">Click here to install the GeoLite2 database.</a></td>
% endif
</tr>
% if plexpy.ARGS:
<tr>
<td>Arguments:</td>
@@ -69,7 +61,11 @@ DOCUMENTATION :: END
% endif
<tr>
<td>Platform:</td>
<td>${common.PLATFORM} ${common.PLATFORM_RELEASE} (${common.PLATFORM_VERSION + (' - {}'.format(common.PLATFORM_LINUX_DISTRO) if common.PLATFORM_LINUX_DISTRO else '')})</td>
<td>${'[Docker] ' if plexpy.DOCKER else ''}${common.PLATFORM} ${common.PLATFORM_RELEASE} (${common.PLATFORM_VERSION + (' - {}'.format(common.PLATFORM_LINUX_DISTRO) if common.PLATFORM_LINUX_DISTRO else '')})</td>
</tr>
<tr>
<td>System Timezone:</td>
<td>${plexpy.SYS_TIMEZONE.zone} (${'UTC{}'.format(plexpy.SYS_UTC_OFFSET)})
</tr>
<tr>
<td>Python Version:</td>
@@ -98,22 +94,6 @@ DOCUMENTATION :: END
<script>
$(document).ready(function () {
$("#install_geoip_db, #reinstall_geoip_db").click(function () {
var msg = 'Are you sure you want to install the GeoLite2 database?<br /><br />' +
'The database is used to lookup IP address geolocation info.<br />' +
'The database will be downloaded from <a href="${anon_url("https://dev.maxmind.com/geoip/geoip2/geolite2/")}" target="_blank">MaxMind</a>, <br />' +
'and requires <strong>100MB</strong> of free space to install in your Tautulli directory.<br />'
var url = 'install_geoip_db';
confirmAjaxCall(url, msg, null, 'Installing GeoLite2 database.', getConfigurationTable);
});
$("#uninstall_geoip_db").click(function () {
var msg = 'Are you sure you want to uninstall the GeoLite2 database?<br /><br />' +
'You will not be able to lookup IP address geolocation info.';
var url = 'uninstall_geoip_db';
confirmAjaxCall(url, msg, null, 'Uninstalling GeoLite2 database.', getConfigurationTable);
});
$('.guidelines-modal-link').on('click', function (e) {
e.preventDefault();
$('#guidelines-type').text($(this).data('id'))

View File

@@ -676,7 +676,9 @@ textarea.form-control:focus {
color: #F9AA03;
margin: 5px 40px 5px 0;
}
.form-control[readonly] {
.form-control[disabled],
.form-control[readonly],
fieldset[disabled] .form-control {
background-color: #555;
}
.form-control[readonly]:focus {
@@ -2151,6 +2153,10 @@ div.advanced-setting {
li.advanced-setting {
border-left: 1px solid #cc7b19;
}
.docker-setting {
color: #cc7b19;
margin-left: 10px;
}
.user-info-wrapper {
}
.user-info-poster-face {
@@ -3361,6 +3367,34 @@ pre::-webkit-scrollbar-thumb {
.notification-params tr:nth-child(even) td {
background-color: rgba(255,255,255,0.010);
}
.activity-queue {
width: 100%;
margin-top: 10px;
background-color: #282828;
}
.activity-queue th {
padding-left: 10px;
height: 30px;
}
.activity-queue th:first-child {
width: 268px;
}
.activity-queue th:nth-child(2) {
width: 125px;
}
.activity-queue th:nth-child(3) {
width: 175px;
}
.activity-queue td {
height: 25px;
padding: 5px 10px;
}
.activity-queue tr:nth-child(odd) td {
background-color: rgba(255,255,255,0.035);
}
.activity-queue tr:nth-child(even) td {
background-color: rgba(255,255,255,0.010);
}
#days-selection label,
#months-selection label {
margin-bottom: 0;
@@ -4162,4 +4196,21 @@ a[data-tab-destination] {
}
.fa-blank {
visibility: hidden;
}
#browser-warning {
height: 25px;
width: 100%;
background: #cc7b19;
text-align: center;
font-weight: bold;
padding: 2px 10px;
position: absolute;
top: 0;
z-index: 9999;
}
.help-block li {
margin-top: 0;
color: #737373;
}

View File

@@ -80,7 +80,9 @@ DOCUMENTATION :: END
data-rating_key="${data['rating_key']}" data-parent_rating_key="${data['parent_rating_key']}" data-grandparent_rating_key="${data['grandparent_rating_key']}">
<div class="dashboard-activity-container">
<%
if data['channel_stream'] == 0:
if data['live'] == 1:
background_url = 'images/art-live.png'
elif data['channel_stream'] == 0:
background_url = 'pms_image_proxy?img=' + data['art'] + '&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art&refresh=true'
else:
if (data['art'] and data['art'].startswith('http')) or (data['thumb'] and data['thumb'].startswith('http')):
@@ -93,7 +95,9 @@ DOCUMENTATION :: END
% if data['media_type'] == 'track':
<div id="poster-${sk}-bg" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['parent_thumb']}&width=300&height=300&opacity=60&background=282828&blur=3&fallback=cover&refresh=true);"></div>
% endif
% if data['channel_stream'] == 0:
% if data['live'] == 1:
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(images/poster-live.png);"></div>
% elif data['channel_stream'] == 0:
% if data['media_type'] == 'movie':
<a id="poster-url-${sk}" href="${href}" title="${data['title']}">
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['thumb']}&width=300&height=450&fallback=poster&refresh=true);"></div>
@@ -113,7 +117,7 @@ DOCUMENTATION :: END
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(pms_image_proxy?img=${data['parent_thumb'] or data['thumb']}&width=300&height=450&fallback=poster&refresh=true);"></div>
% endif
% else:
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(images/art.png);"></div>
<div id="poster-${sk}" class="dashboard-activity-poster" style="background-image: url(images/poster.png);"></div>
% endif
% else:
% if data['channel_icon'].startswith('http'):
@@ -214,7 +218,7 @@ DOCUMENTATION :: END
% if data['stream_container_decision'] == 'transcode':
Transcode (${data['container'].upper()} <i class="fa fa-long-arrow-right"></i> ${data['stream_container'].upper()})
% else:
Direct Play (${data['container'].upper()})
Direct Play (${data['stream_container'].upper()})
% endif
</div>
</li>
@@ -222,17 +226,24 @@ DOCUMENTATION :: END
<li class="dashboard-activity-info-item">
<div class="sub-heading">Video</div>
<div class="sub-value" id="video_decision-${sk}">
% if data['media_type'] in ('movie', 'episode', 'clip'):
% if data['media_type'] in ('movie', 'episode', 'clip') and data['stream_video_decision']:
<%
if data['video_dynamic_range'] == 'HDR':
video_dynamic_range = ' ' + data['video_dynamic_range']
stream_video_dynamic_range = ' ' + data['stream_video_dynamic_range']
else:
video_dynamic_range = stream_video_dynamic_range = ''
%>
% if data['stream_video_decision'] == 'transcode':
<%
hw_d = ' (HW)' if data['transcode_hw_decoding'] else ''
hw_e = ' (HW)' if data['transcode_hw_encoding'] else ''
%>
Transcode (${data['video_codec'].upper()}${hw_d} ${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])} <i class="fa fa-long-arrow-right"></i> ${data['stream_video_codec'].upper()}${hw_e} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])})
Transcode (${data['video_codec'].upper()}${hw_d} ${data['video_full_resolution']}${video_dynamic_range} <i class="fa fa-long-arrow-right"></i> ${data['stream_video_codec'].upper()}${hw_e} ${data['stream_video_full_resolution']}${stream_video_dynamic_range})
% elif data['stream_video_decision'] == 'copy':
Direct Stream (${data['stream_video_codec'].upper()} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])})
Direct Stream (${data['stream_video_codec'].upper()} ${data['stream_video_full_resolution']}${stream_video_dynamic_range})
% else:
Direct Play (${data['video_codec'].upper()} ${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])})
Direct Play (${data['stream_video_codec'].upper()} ${data['stream_video_full_resolution']}${stream_video_dynamic_range})
% endif
% elif data['media_type'] == 'photo':
Direct Play (${data['width']}x${data['height']})
@@ -244,12 +255,14 @@ DOCUMENTATION :: END
<li class="dashboard-activity-info-item">
<div class="sub-heading">Audio</div>
<div class="sub-value" id="audio_decision-${sk}">
% if data['stream_audio_decision'] == 'transcode':
Transcode (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()} <i class="fa fa-long-arrow-right"></i> ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% elif data['stream_audio_decision'] == 'copy':
Direct Stream (${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% else:
Direct Play (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()})
% if data['stream_audio_decision']:
% if data['stream_audio_decision'] == 'transcode':
Transcode (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()} <i class="fa fa-long-arrow-right"></i> ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% elif data['stream_audio_decision'] == 'copy':
Direct Stream (${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% else:
Direct Play (${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% endif
% endif
</div>
</li>
@@ -266,7 +279,7 @@ DOCUMENTATION :: END
% elif data['stream_subtitle_decision'] == 'burn':
Burn (${data['subtitle_codec'].upper()})
% else:
Direct Play (${data['stream_subtitle_codec'].upper() if data['synced_version'] else data['subtitle_codec'].upper()})
Direct Play (${data['subtitle_codec'].upper() if data['synced_version'] else data['stream_subtitle_codec'].upper()})
% endif
% else:
None
@@ -279,10 +292,17 @@ DOCUMENTATION :: END
<li class="dashboard-activity-info-item">
<div class="sub-heading">Location</div>
<div class="sub-value time-right">
% if data['secure'] is not None:
% if data['secure']:
<span data-toggle="tooltip" title="Secure Connection"><i class="fa fa-lock"></i></span>
% else:
<span data-toggle="tooltip" title="Insecure Connection"><i class="fa fa-unlock"></i></span>
% endif
% endif
<span id="location-${sk}">${data['location'].upper()}</span>:
% if data['ip_address'] != 'N/A':
<span class="ip-container"><span class="ip-address">${data['ip_address']}</span></span>
% if data['relay']:
% if data['relayed']:
<span data-toggle="tooltip" title="Plex Relay"><i class="fa fa-exclamation-circle"></i></span>
% else:
<a href="#" class="external_ip-modal" data-toggle="modal" data-target="#ip-info-modal" data-ip="${data['ip_address']}">
@@ -313,7 +333,7 @@ DOCUMENTATION :: END
bw = str(bw) + ' kbps'
%>
<span id="stream-bandwidth-${sk}">${bw}</span>
<span id="streaming-brain-${sk}" data-toggle="tooltip" title="Streaming Brain Estimate (Required Bandwidth)"><i class="fa fa-info-circle"></i></span>
<span id="streaming-brain-${sk}" data-toggle="tooltip" title="Streaming Brain Estimate (Reserved Bandwidth)"><i class="fa fa-info-circle"></i></span>
% elif data['synced_version'] == 1 or data['channel_stream'] == 1:
<span id="stream-bandwidth-${sk}">None</span>
% else:

View File

@@ -21,6 +21,7 @@ parent_count Returns the parent item count for the library.
child_count Returns the child item count for the library.
do_notify Returns bool value for whether to send notifications for the library.
keep_history Returns bool value for whether to keep history for the library.
deleted_section Returns bool value for whether the library is marked as deleted.
DOCUMENTATION :: END
</%doc>
@@ -59,6 +60,12 @@ DOCUMENTATION :: END
<p class="help-block">DANGER ZONE! Click the purge button to remove all history logged for this library. This is permanent!</p>
</div>
% endif
% if data['deleted_section']:
<div class="form-group">
<button class="btn btn-bright" id="undelete-library">Undelete</button>
<p class="help-block">Click to re-add the library to the Tautulli libraries list.</p>
</div>
% endif
</fieldset>
</div>
<div class="modal-footer">
@@ -100,6 +107,12 @@ DOCUMENTATION :: END
confirmAjaxCall(url, msg, { section_id: '${data["section_id"]}' }, null, function () { location.reload(); });
});
$('#undelete-library').click(function () {
var msg = 'Are you sure you want to undelete this user?';
var url = 'undelete_library';
confirmAjaxCall(url, msg, { section_id: '${data["section_id"]}' }, null, function () { location.reload(); });
});
$(document).ready(function() {
// Move #confirm-modal to parent container
if (!($('#edit-library-modal').next().is('#confirm-modal-purge'))) {

View File

@@ -21,6 +21,7 @@ is_restricted Returns bool value for whether the user account is restricte
do_notify Returns bool value for whether to send notifications for the user.
keep_history Returns bool value for whether to keep history for the user.
allow_guest Returns bool value for whether to allow guest access for the user.
deleted_user Returns bool value for whether the user is marked as deleted.
DOCUMENTATION :: END
</%doc>
@@ -74,6 +75,12 @@ DOCUMENTATION :: END
<p class="help-block">DANGER ZONE! Click the purge button to remove all history logged for this user. This is permanent!</p>
</div>
% endif
% if data['deleted_user']:
<div class="form-group">
<button class="btn btn-bright" id="undelete-user">Undelete</button>
<p class="help-block">Click to re-add the user to the Tautulli users list.</p>
</div>
% endif
</fieldset>
</div>
<div class="modal-footer">
@@ -122,6 +129,12 @@ DOCUMENTATION :: END
confirmAjaxCall(url, msg, { user_id: '${data["user_id"]}' }, null, function () { location.reload(); });
});
$('#undelete-user').click(function () {
var msg = 'Are you sure you want to undelete this user?';
var url = 'undelete_user';
confirmAjaxCall(url, msg, { user_id: '${data["user_id"]}' }, null, function () { location.reload(); });
});
$(document).ready(function() {
// Move #confirm-modal-purge to parent container
if (!($('#edit-user-modal').next().is('#confirm-modal-purge'))) {

View File

@@ -21,137 +21,109 @@
</label>
</div>
<div class="btn-group" style="margin-right: 2px;" data-toggle="buttons" id="yaxis-selection">
% if config['graph_type'] == 'duration':
<label class="btn btn-dark">
<input type="radio" name="yaxis-options" id="yaxis-count" value="plays" autocomplete="off"> Play Count
</label>
<label class="btn btn-dark active">
<input type="radio" name="yaxis-options" id="yaxis-duration" value="duration" autocomplete="off" checked> Play Duration
</label>
% else:
<label class="btn btn-dark active">
<input type="radio" name="yaxis-options" id="yaxis-count" value="plays" autocomplete="off" checked> Play Count
<input type="radio" name="yaxis-options" id="yaxis-plays" value="plays" autocomplete="off"> Play Count
</label>
<label class="btn btn-dark">
<input type="radio" name="yaxis-options" id="yaxis-duration" value="duration" autocomplete="off"> Play Duration
</label>
% endif
</div>
<div class="input-group pull-right" style="width: 1px;" id="days-selection">
<span class="input-group-addon btn-dark inactive">Last</span>
<input type="number" class="form-control number-input" name="graph-days" id="graph-days" value="${config['graph_days']}" min="1" data-default="7" data-toggle="tooltip" title="Min: 1 day" />
<input type="number" class="form-control number-input" name="graph-days" id="graph-days" value="30" min="1" data-default="7" data-toggle="tooltip" title="Min: 1 day" />
<span class="input-group-addon btn-dark inactive">days</span>
</div>
<div class="input-group pull-right" style="width: 1px;" id="months-selection">
<span class="input-group-addon btn-dark inactive">Last</span>
<input type="number" class="form-control number-input" name="graph-months" id="graph-months" value="${config['graph_months']}" min="1" data-default="12" data-toggle="tooltip" title="Min: 1 month" />
<input type="number" class="form-control number-input" name="graph-months" id="graph-months" value="12" min="1" data-default="12" data-toggle="tooltip" title="Min: 1 month" />
<span class="input-group-addon btn-dark inactive">months</span>
</div>
</div>
</div>
<div class='table-card-back'>
<ul class="nav nav-pills" role="tablist" id="graph-tabs">
% if config['graph_tab'] == 'tabs-3':
<li role="presentation"><a href="#tabs-1" aria-controls="tabs-1" data-toggle="tab" role="tab">Plays by period</a></li>
<li role="presentation"><a href="#tabs-2" aria-controls="tabs-2" data-toggle="tab" role="tab">Stream Info</a></li>
<li role="presentation" class="active"><a href="#tabs-3" aria-controls="tabs-3" data-toggle="tab" role="tab">Play Totals</a></li>
% elif config['graph_tab'] == 'tabs-2':
<li role="presentation"><a href="#tabs-1" aria-controls="tabs-1" data-toggle="tab" role="tab">Plays by period</a></li>
<li role="presentation" class="active"><a href="#tabs-2" aria-controls="tabs-2" data-toggle="tab" role="tab">Stream Info</a></li>
<li role="presentation"><a href="#tabs-3" aria-controls="tabs-3" data-toggle="tab" role="tab">Play Totals</a></li>
% else:
<li role="presentation" class="active"><a href="#tabs-1" aria-controls="tabs-1" data-toggle="tab" role="tab">Plays by period</a></li>
<li role="presentation"><a href="#tabs-1" aria-controls="tabs-1" data-toggle="tab" role="tab">Plays by Period</a></li>
<li role="presentation"><a href="#tabs-2" aria-controls="tabs-2" data-toggle="tab" role="tab">Stream Info</a></li>
<li role="presentation"><a href="#tabs-3" aria-controls="tabs-3" data-toggle="tab" role="tab">Play Totals</a></li>
% endif
</ul>
<div class="tab-content">
% if config['graph_tab'] != 'tabs-2' and config['graph_tab'] != 'tabs-3':
<div role="tabpanel" class="tab-pane active" id="tabs-1">
% else:
<div role="tabpanel" class="tab-pane" id="tabs-1">
% endif
<div class="row">
<div class="col-md-12">
<h4><i class="fa fa-history"></i> Daily <span class="yaxis-text">Play count</span> <small>Last <span class="days">30</span> days</small></h4>
<p class="help-block">
The total play count or duration of tv, movies, and music played per day. Click a graph point to open up a list of items played for that specific date.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="chart_div_plays_by_day">
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...</div>
<br>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<h4><i class="fa fa-calendar"></i> <span class="yaxis-text">Play count</span> by day of week <small>Last <span class="days">30</span> days</small></h4>
<p class="help-block">
The combined total of tv, movies, and music played per day of the week.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="chart_div_plays_by_dayofweek" style="float: left;">
<div class="graphs-load">
<i class="fa fa-refresh fa-spin"></i> Loading chart...
</div>
<br>
</div>
</div>
</div>
<div class="col-md-6">
<h4><i class="fa fa-clock-o"></i> <span class="yaxis-text">Play count</span> by hour of day <small>Last <span class="days">30</span> days</small></h4>
<p class="help-block">
The combined total of tv, movies, and music played per hour of the day.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="chart_div_plays_by_hourofday">
<div class="graphs-load">
<i class="fa fa-refresh fa-spin"></i> Loading chart...
</div>
<br>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<h4><i class="fa fa-television"></i> <span class="yaxis-text">Play count</span> by top 10 platforms <small>Last <span class="days">30</span> days</small></h4>
<p class="help-block">
The combined total of tv, movies, and music played by top 10 most active platforms.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="chart_div_plays_by_platform" style="float: left;">
<div class="graphs-load">
<i class="fa fa-refresh fa-spin"></i> Loading chart...
</div>
<br>
</div>
</div>
</div>
<div class="col-md-6">
<h4><i class="fa fa-user"></i> <span class="yaxis-text">Play count</span> by top 10 users <small>Last <span class="days">30</span> days</small></h4>
<p class="help-block">
The combined total of tv, movies, and music played by top 10 most active users.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="chart_div_plays_by_user">
<div class="graphs-load">
<i class="fa fa-refresh fa-spin"></i> Loading chart...
</div>
<br>
</div>
<div class="row">
<div class="col-md-12">
<h4><i class="fa fa-history"></i> Daily <span class="yaxis-text">Play count</span> <small>Last <span class="days">30</span> days</small></h4>
<p class="help-block">
The total play count or duration of tv, movies, and music played per day. Click a graph point to open up a list of items played for that specific date.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="graph_plays_by_day">
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...</div>
<br>
</div>
</div>
</div>
</div>
% if config['graph_tab'] == 'tabs-2':
<div role="tabpanel" class="tab-pane active" id="tabs-2">
% else:
<div class="row">
<div class="col-md-6">
<h4><i class="fa fa-calendar"></i> <span class="yaxis-text">Play count</span> by day of week <small>Last <span class="days">30</span> days</small></h4>
<p class="help-block">
The combined total of tv, movies, and music played per day of the week.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="graph_plays_by_dayofweek" style="float: left;">
<div class="graphs-load">
<i class="fa fa-refresh fa-spin"></i> Loading chart...
</div>
<br>
</div>
</div>
</div>
<div class="col-md-6">
<h4><i class="fa fa-clock-o"></i> <span class="yaxis-text">Play count</span> by hour of day <small>Last <span class="days">30</span> days</small></h4>
<p class="help-block">
The combined total of tv, movies, and music played per hour of the day.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="graph_plays_by_hourofday">
<div class="graphs-load">
<i class="fa fa-refresh fa-spin"></i> Loading chart...
</div>
<br>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<h4><i class="fa fa-television"></i> <span class="yaxis-text">Play count</span> by top 10 platforms <small>Last <span class="days">30</span> days</small></h4>
<p class="help-block">
The combined total of tv, movies, and music played by top 10 most active platforms.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="graph_plays_by_platform" style="float: left;">
<div class="graphs-load">
<i class="fa fa-refresh fa-spin"></i> Loading chart...
</div>
<br>
</div>
</div>
</div>
<div class="col-md-6">
<h4><i class="fa fa-user"></i> <span class="yaxis-text">Play count</span> by top 10 users <small>Last <span class="days">30</span> days</small></h4>
<p class="help-block">
The combined total of tv, movies, and music played by top 10 most active users.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="graph_plays_by_user">
<div class="graphs-load">
<i class="fa fa-refresh fa-spin"></i> Loading chart...
</div>
<br>
</div>
</div>
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-2">
% endif
<div class="row">
<div class="col-md-12">
<h4><i class="fa fa-video-camera"></i> Daily Stream type breakdown <small>Last <span class="days">30</span> days</small></h4>
@@ -159,7 +131,7 @@
The total play count or duration of tv, movies, and music by the transcode decision. Click a graph point to open up a list of items played for that specific date.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="chart_div_plays_by_stream_type">
<div class="watch-chart" id="graph_plays_by_stream_type">
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...</div>
<br>
</div>
@@ -173,7 +145,7 @@
The combined total of tv and movies by their original resolution (pre-transcoding).
</p>
<div class="graphs-instance">
<div class="watch-chart" id="chart_div_plays_by_source_resolution" style="float: left;">
<div class="watch-chart" id="graph_plays_by_source_resolution" style="float: left;">
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...
</div>
<br>
@@ -186,7 +158,7 @@
The combined total of tv and movies by their streamed resolution (post-transcoding).
</p>
<div class="graphs-instance">
<div class="watch-chart" id="chart_div_plays_by_stream_resolution">
<div class="watch-chart" id="graph_plays_by_stream_resolution">
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...
</div>
<br>
@@ -201,7 +173,7 @@
The combined total of tv, movies, and music by platform and stream type.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="chart_div_plays_by_platform_by_stream_type" style="float: left;">
<div class="watch-chart" id="graph_plays_by_platform_by_stream_type" style="float: left;">
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...
</div>
<br>
@@ -214,7 +186,7 @@
The combined total of tv, movies, and music by user and stream type.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="chart_div_plays_by_user_by_stream_type" style="float: left;">
<div class="watch-chart" id="graph_plays_by_user_by_stream_type" style="float: left;">
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...
</div>
<br>
@@ -223,12 +195,7 @@
</div>
</div>
</div>
% if config['graph_tab'] == 'tabs-3':
<div role="tabpanel" class="tab-pane active" id="tabs-3">
% else:
<div role="tabpanel" class="tab-pane" id="tabs-3">
% endif
<div class="row">
<div class="col-md-12">
<h4><i class="fa fa-calendar"></i> Plays by month <small>Last <span class="months">12</span> months</small></h4>
@@ -236,7 +203,7 @@
The combined total of tv, movies, and music by month.
</p>
<div class="graphs-instance">
<div class="watch-chart" id="chart_div_plays_by_month">
<div class="watch-chart" id="graph_plays_by_month">
<div class="graphs-load"><i class="fa fa-refresh fa-spin"></i> Loading chart...
</div>
<br>
@@ -266,7 +233,7 @@
<script src="${http_root}js/dataTables.bootstrap.pagination.js"></script>
<script>
var selected_user_id = null
var selected_user_id = null;
// Modal popup dialog
function selectHandler(selectedDate, selectedSeries) {
@@ -311,27 +278,61 @@
console.log("Failed to retrieve history modal data.");
}
}
function getGraphVisibility(chart_name, data_series) {
var chart_key = 'HighCharts_' + chart_name;
var chart_visibility = JSON.parse(getLocalStorage(chart_key, null)) || [];
chart_visibility = chart_visibility.reduce(function(obj, s) {
obj[s.name] = s.visible;
return obj;
}, {});
return data_series.map(function(s) {
var obj = Object.assign({}, s);
obj.visible = (chart_visibility[s.name] !== false);
return obj
});
}
function setGraphVisibility(chart_name, data_series, series_name) {
var chart_key = 'HighCharts_' + chart_name;
var chart_visibility = data_series.map(function(s) {
return {name: s.name, visible: (s.name === series_name) ? !s.visible : s.visible}
});
setLocalStorage(chart_key, JSON.stringify(chart_visibility));
}
</script>
<script src="${http_root}js/graphs/plays_by_day.js"></script>
<script src="${http_root}js/graphs/plays_by_dayofweek.js"></script>
<script src="${http_root}js/graphs/plays_by_hourofday.js"></script>
<script src="${http_root}js/graphs/plays_by_platform.js"></script>
<script src="${http_root}js/graphs/plays_by_user.js"></script>
<script src="${http_root}js/graphs/plays_by_stream_type.js"></script>
<script src="${http_root}js/graphs/plays_by_source_resolution.js"></script>
<script src="${http_root}js/graphs/plays_by_stream_resolution.js"></script>
<script src="${http_root}js/graphs/plays_by_platform_by_stream_type.js"></script>
<script src="${http_root}js/graphs/plays_by_user_by_stream_type.js"></script>
<script src="${http_root}js/graphs/plays_by_month.js"></script>
<script src="${http_root}js/graphs/plays_by_day.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_dayofweek.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_hourofday.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_platform.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_user.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_stream_type.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_source_resolution.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_stream_resolution.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_platform_by_stream_type.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_user_by_stream_type.js${cache_param}"></script>
<script src="${http_root}js/graphs/plays_by_month.js${cache_param}"></script>
<script>
$(document).ready(function () {
// Initial values for graph from config
var yaxis = "${config['graph_type']}";
var current_day_range = ${config['graph_days']};
var current_month_range = ${config['graph_months']};
var current_tab = "${'#' + config['graph_tab']}";
// Initial values for graph from local storage
var yaxis = getLocalStorage('graph_type', 'plays');
var current_day_range = getLocalStorage('graph_days', 30);
var current_month_range = getLocalStorage('graph_months', 12);
var current_tab = '#' + getLocalStorage('graph_tab', 'tabs-1');
$('#yaxis-' + yaxis).prop('checked', true);
$('#yaxis-' + yaxis).closest('label').addClass('active');
$('#graph-days').val(current_day_range);
$('#graph-months').val(current_month_range);
$('#graph-tabs a[href="' + current_tab + '"]').closest('li').addClass('active');
$(current_tab).addClass('active');
$('.days').html(current_day_range);
$('.months').html(current_month_range);
@@ -352,9 +353,6 @@
}
});
var music_visible = (${config['music_logging_enable']} == 1 ? true : false);
function dataSecondsToHours(data) {
$.each(data.series, function (i, series) {
series.data = $.map(series.data, function (value) {
@@ -379,8 +377,8 @@
$.each(data.categories, function (i, day) {
dateArray.push(moment(day, 'YYYY-MM-DD').valueOf());
// Highlight the weekend
if ((moment(day, 'YYYY-MM-DD').format('ddd') == 'Sat') ||
(moment(day, 'YYYY-MM-DD').format('ddd') == 'Sun')) {
if ((moment(day, 'YYYY-MM-DD').format('ddd') === 'Sat') ||
(moment(day, 'YYYY-MM-DD').format('ddd') === 'Sun')) {
hc_plays_by_day_options.xAxis.plotBands.push({
from: i-0.5,
to: i+0.5,
@@ -391,8 +389,7 @@
if (yaxis === 'duration') { dataSecondsToHours(data); }
hc_plays_by_day_options.yAxis.min = 0;
hc_plays_by_day_options.xAxis.categories = dateArray;
hc_plays_by_day_options.series = data.series;
hc_plays_by_day_options.series[2].visible = music_visible;
hc_plays_by_day_options.series = getGraphVisibility(hc_plays_by_day_options.chart.renderTo, data.series);
var hc_plays_by_day = new Highcharts.Chart(hc_plays_by_day_options);
}
});
@@ -405,8 +402,7 @@
success: function(data) {
if (yaxis === 'duration') { dataSecondsToHours(data); }
hc_plays_by_dayofweek_options.xAxis.categories = data.categories;
hc_plays_by_dayofweek_options.series = data.series;
hc_plays_by_dayofweek_options.series[2].visible = music_visible;
hc_plays_by_dayofweek_options.series = getGraphVisibility(hc_plays_by_dayofweek_options.chart.renderTo, data.series);
var hc_plays_by_dayofweek = new Highcharts.Chart(hc_plays_by_dayofweek_options);
}
});
@@ -419,8 +415,7 @@
success: function(data) {
if (yaxis === 'duration') { dataSecondsToHours(data); }
hc_plays_by_hourofday_options.xAxis.categories = data.categories;
hc_plays_by_hourofday_options.series = data.series;
hc_plays_by_hourofday_options.series[2].visible = music_visible;
hc_plays_by_hourofday_options.series = getGraphVisibility(hc_plays_by_hourofday_options.chart.renderTo, data.series);
var hc_plays_by_hourofday = new Highcharts.Chart(hc_plays_by_hourofday_options);
}
});
@@ -433,8 +428,7 @@
success: function(data) {
if (yaxis === 'duration') { dataSecondsToHours(data); }
hc_plays_by_platform_options.xAxis.categories = data.categories;
hc_plays_by_platform_options.series = data.series;
hc_plays_by_platform_options.series[2].visible = music_visible;
hc_plays_by_platform_options.series = getGraphVisibility(hc_plays_by_platform_options.chart.renderTo, data.series);
var hc_plays_by_platform = new Highcharts.Chart(hc_plays_by_platform_options);
}
});
@@ -447,11 +441,12 @@
success: function(data) {
if (yaxis === 'duration') { dataSecondsToHours(data); }
hc_plays_by_user_options.xAxis.categories = data.categories;
hc_plays_by_user_options.series = data.series;
hc_plays_by_user_options.series[2].visible = music_visible;
hc_plays_by_user_options.series = getGraphVisibility(hc_plays_by_user_options.chart.renderTo, data.series);
var hc_plays_by_user = new Highcharts.Chart(hc_plays_by_user_options);
}
});
$('#graph-tabs a[href="#tabs-1"]').tab('show')
}
function loadGraphsTab2(time_range, yaxis) {
@@ -482,7 +477,7 @@
if (yaxis === 'duration') { dataSecondsToHours(data); }
hc_plays_by_stream_type_options.yAxis.min = 0;
hc_plays_by_stream_type_options.xAxis.categories = dateArray;
hc_plays_by_stream_type_options.series = data.series;
hc_plays_by_stream_type_options.series = getGraphVisibility(hc_plays_by_stream_type_options.chart.renderTo, data.series);
var hc_plays_by_stream_type = new Highcharts.Chart(hc_plays_by_stream_type_options);
}
});
@@ -495,7 +490,7 @@
success: function(data) {
if (yaxis === 'duration') { dataSecondsToHours(data); }
hc_plays_by_source_resolution_options.xAxis.categories = data.categories;
hc_plays_by_source_resolution_options.series = data.series;
hc_plays_by_source_resolution_options.series = getGraphVisibility(hc_plays_by_source_resolution_options.chart.renderTo, data.series);
var hc_plays_by_source_resolution = new Highcharts.Chart(hc_plays_by_source_resolution_options);
}
});
@@ -508,7 +503,7 @@
success: function(data) {
if (yaxis === 'duration') { dataSecondsToHours(data); }
hc_plays_by_stream_resolution_options.xAxis.categories = data.categories;
hc_plays_by_stream_resolution_options.series = data.series;
hc_plays_by_stream_resolution_options.series = getGraphVisibility(hc_plays_by_stream_resolution_options.chart.renderTo, data.series);
var hc_plays_by_stream_resolution = new Highcharts.Chart(hc_plays_by_stream_resolution_options);
}
});
@@ -521,7 +516,7 @@
success: function(data) {
if (yaxis === 'duration') { dataSecondsToHours(data); }
hc_plays_by_platform_by_stream_type_options.xAxis.categories = data.categories;
hc_plays_by_platform_by_stream_type_options.series = data.series;
hc_plays_by_platform_by_stream_type_options.series = getGraphVisibility(hc_plays_by_platform_by_stream_type_options.chart.renderTo, data.series);
var hc_plays_by_platform_by_stream_type = new Highcharts.Chart(hc_plays_by_platform_by_stream_type_options);
}
});
@@ -534,10 +529,12 @@
success: function(data) {
if (yaxis === 'duration') { dataSecondsToHours(data); }
hc_plays_by_user_by_stream_type_options.xAxis.categories = data.categories;
hc_plays_by_user_by_stream_type_options.series = data.series;
hc_plays_by_user_by_stream_type_options.series = getGraphVisibility(hc_plays_by_user_by_stream_type_options.chart.renderTo, data.series);
var hc_plays_by_user_by_stream_type = new Highcharts.Chart(hc_plays_by_user_by_stream_type_options);
}
});
$('#graph-tabs a[href="#tabs-2"]').tab('show')
}
function loadGraphsTab3(time_range, yaxis) {
@@ -555,51 +552,52 @@
if (yaxis === 'duration') { dataSecondsToHours(data); }
hc_plays_by_month_options.yAxis.min = 0;
hc_plays_by_month_options.xAxis.categories = data.categories;
hc_plays_by_month_options.series = data.series;
hc_plays_by_month_options.series[2].visible = music_visible;
hc_plays_by_month_options.series = getGraphVisibility(hc_plays_by_month_options.chart.renderTo, data.series);
var hc_plays_by_month = new Highcharts.Chart(hc_plays_by_month_options);
}
});
$('#graph-tabs a[href="#tabs-3"]').tab('show')
}
// Set initial state
if (current_tab == '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab == '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab == '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
if (current_tab === '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab === '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
// Tab1 opened
$('#graph-tabs a[href="#tabs-1"]').on('shown.bs.tab', function (e) {
e.preventDefault();
current_tab = $(this).attr('href');
setLocalStorage('graph_tab', current_tab.replace('#',''));
loadGraphsTab1(current_day_range, yaxis);
$.post('set_graph_config', { graph_tab: current_tab.replace('#','') });
})
});
// Tab2 opened
$('#graph-tabs a[href="#tabs-2"]').on('shown.bs.tab', function (e) {
e.preventDefault();
current_tab = $(this).attr('href');
setLocalStorage('graph_tab', current_tab.replace('#',''));
loadGraphsTab2(current_day_range, yaxis);
$.post('set_graph_config', { graph_tab: current_tab.replace('#','') });
})
});
// Tab3 opened
$('#graph-tabs a[href="#tabs-3"]').on('shown.bs.tab', function (e) {
e.preventDefault();
current_tab = $(this).attr('href');
setLocalStorage('graph_tab', current_tab.replace('#',''));
loadGraphsTab3(current_month_range, yaxis);
$.post('set_graph_config', { graph_tab: current_tab.replace('#','') });
})
});
// Date range changed
$('#graph-days').tooltip({ container: 'body', placement: 'top', html: true });
$('#graph-days').on('change', function() {
forceMinMax($(this));
current_day_range = $(this).val();
if (current_tab == '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab == '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
setLocalStorage('graph_days', current_day_range);
if (current_tab === '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
$('.days').html(current_day_range);
$.post('set_graph_config', { graph_days: current_day_range });
});
// Month range changed
@@ -607,26 +605,26 @@
$('#graph-months').on('change', function() {
forceMinMax($(this));
current_month_range = $(this).val();
if (current_tab == '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
setLocalStorage('graph_months', current_month_range);
if (current_tab === '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
$('.months').html(current_month_range);
$.post('set_graph_config', { graph_months: current_month_range });
});
// User changed
$('#graph-user').on('change', function() {
selected_user_id = $(this).val() || null;
if (current_tab == '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab == '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab == '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
if (current_tab === '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab === '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
});
// Y-axis changed
$('#yaxis-selection').on('change', function() {
yaxis = $('input[name=yaxis-options]:checked', '#yaxis-selection').val();
if (current_tab == '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab == '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab == '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
$.post('set_graph_config', { graph_type: yaxis });
setLocalStorage('graph_type', yaxis);
if (current_tab === '#tabs-1') { loadGraphsTab1(current_day_range, yaxis); }
if (current_tab === '#tabs-2') { loadGraphsTab2(current_day_range, yaxis); }
if (current_tab === '#tabs-3') { loadGraphsTab3(current_month_range, yaxis); }
});
function setGraphFormat(type) {

View File

@@ -32,17 +32,17 @@
</div>
% endif
<div class="btn-group" data-toggle="buttons" id="media_type-selection">
<label class="btn btn-dark active">
<input type="radio" name="media_type-filter" id="history-all" value="" autocomplete="off"> All
<label class="btn btn-dark">
<input type="radio" name="media_type-filter" id="history-all" value="all" autocomplete="off"> All
</label>
<label class="btn btn-dark">
<input type="radio" name="media_type-filter" id="history-movies" value="movie" autocomplete="off"> Movies
<input type="radio" name="media_type-filter" id="history-movie" value="movie" autocomplete="off"> Movies
</label>
<label class="btn btn-dark">
<input type="radio" name="media_type-filter" id="history-tv_shows" value="episode" autocomplete="off"> TV Shows
<input type="radio" name="media_type-filter" id="history-episode" value="episode" autocomplete="off"> TV Shows
</label>
<label class="btn btn-dark">
<input type="radio" name="media_type-filter" id="history-music" value="track" autocomplete="off"> Music
<input type="radio" name="media_type-filter" id="history-track" value="track" autocomplete="off"> Music
</label>
</div>
<div class="btn-group">
@@ -60,7 +60,8 @@
<th align="left" id="friendly_name">User</th>
<th align="left" id="ip_address">IP Address</th>
<th align="left" id="platform">Platform</th>
<th align="left" id="device">Player</th>
<th align="left" id="product">Product</th>
<th align="left" id="player">Player</th>
<th align="left" id="title">Title</th>
<th align="left" id="started">Started</th>
<th align="left" id="paused_counter">Paused</th>
@@ -143,7 +144,7 @@
var colvis = new $.fn.dataTable.ColVis(history_table, {
buttonText: '<i class="fa fa-columns"></i> Select columns',
buttonClass: 'btn btn-dark',
exclude: [0, 11]
exclude: [0, 12]
});
$(colvis.button()).appendTo('div.colvis-button-bar');
@@ -154,6 +155,7 @@
selected_filter = $('input[name=media_type-filter]:checked', '#media_type-selection');
$(selected_filter).closest('label').addClass('active');
media_type = $(selected_filter).val();
setLocalStorage('history_media_type', media_type);
history_table.draw();
});
@@ -163,8 +165,12 @@
});
}
var media_type = null;
var media_type = getLocalStorage('history_media_type', 'all');
var selected_user_id = "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}";
$('#history-' + media_type).prop('checked', true);
$('#history-' + media_type).closest('label').addClass('active');
loadHistoryTable(media_type, selected_user_id);
% if _session['user_group'] == 'admin':

View File

@@ -26,6 +26,7 @@
<th align="left" id="friendly_name">User</th>
<th align="left" id="ip_address">IP Address</th>
<th align="left" id="platform">Platform</th>
<th align="left" id="product">Product</th>
<th align="left" id="device">Player</th>
<th align="left" id="title">Title</th>
<th align="left" id="started">Started</th>
@@ -54,14 +55,14 @@
json_data: JSON.stringify(d),
user_id: "${data['user_id']}",
start_date: "${data['start_date']}",
media_type: "${data.get('media_type')}",
media_type: "${data.get('media_type') or 'all'}",
transcode_decision: "${data.get('transcode_decision')}"
};
}
};
history_table = $('#history_table_modal').DataTable(history_table_options);
history_table.columns([0, 3, 4, 8, 10, 11]).visible(false);
history_table.columns([0, 3, 4, 5, 9, 11, 12]).visible(false);
clearSearchButton('history_table_modal', history_table);

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -15,9 +15,9 @@
<h3><span id="sessions-xml">Activity</span> &nbsp;&nbsp;
<small>
<span id="currentActivityHeader" style="display: none;">
Streams: <span id="currentActivityHeader-streams"></span> |
Sessions: <span id="currentActivityHeader-streams"></span> |
Bandwidth: <span id="currentActivityHeader-bandwidth"></span>
<span id="currentActivityHeader-bandwidth-tooltip" data-toggle="tooltip" title="Streaming Brain Estimate (Required Bandwidth)"><i class="fa fa-info-circle"></i></span>
<span id="currentActivityHeader-bandwidth-tooltip" data-toggle="tooltip" title="Streaming Brain Estimate (Reserved Bandwidth)"><i class="fa fa-info-circle"></i></span>
</span>
</small>
</h3>
@@ -27,6 +27,8 @@
<div class="text-muted" id="dashboard-checking-activity"><i class="fa fa-refresh fa-spin"></i> Checking for activity...</div>
% elif config['pms_is_cloud']:
<div id="dashboard-no-activity" class="text-muted">Plex Cloud server is sleeping.</div>
% elif not config['first_run_complete']:
<div id="dashboard-no-activity" class="text-muted">The Tautulli setup wizard has not been completed. Please click <a href="welcome">here</a> to go to the setup wizard.</div>
% else:
<div id="dashboard-no-activity" class="text-muted">There was an error communicating with your Plex Server.
% if _session['user_group'] == 'admin':
@@ -44,25 +46,16 @@
<h3 class="pull-left">Watch Statistics</h3>
<div class="button-bar">
<div class="btn-group pull-left" data-toggle="buttons" id="watch-stats-toggles" style="margin-right: 3px">
% if config['home_stats_type'] == 0:
<label class="btn btn-dark active">
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-plays" value="0" autocomplete="off" checked> Play Count
<label class="btn btn-dark">
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-plays" value="plays" autocomplete="off"> Play Count
</label>
<label class="btn btn-dark">
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-duration" value="1" autocomplete="off"> Play Duration
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-duration" value="duration" autocomplete="off"> Play Duration
</label>
% else:
<label class="btn btn-dark">
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-plays" value="0" autocomplete="off"> Play Count
</label>
<label class="btn btn-dark active">
<input type="radio" class="watched-stats-toggle" name="watched-stats-type" id="watched-stats-duration" value="1" autocomplete="off" checked> Play Duration
</label>
% endif
</div>
<div class="input-group pull-left" style="width: 1px; margin-right: 3px" id="watched-stats-days-selection">
<span class="input-group-addon btn-dark inactive">Last</span>
<input type="number" class="form-control number-input" name="watched-stats-days" id="watched-stats-days" value="${config['home_stats_length']}" min="1" data-default="30" data-toggle="tooltip" title="Min: 1 day" />
<input type="number" class="form-control number-input" name="watched-stats-days" id="watched-stats-days" value="30" min="1" data-default="30" data-toggle="tooltip" title="Min: 1 day" />
<span class="input-group-addon btn-dark inactive">days</span>
</div>
</div>
@@ -111,8 +104,8 @@
</ul>
<div class="button-bar">
<div class="btn-group pull-left" data-toggle="buttons" id="recently-added-toggles" style="margin-right: 3px">
<label class="btn btn-dark active" id="recently-added-label-all">
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-all" value="" autocomplete="off"> All
<label class="btn btn-dark" id="recently-added-label-all">
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-all" value="all" autocomplete="off"> All
</label>
<label class="btn btn-dark" id="recently-added-label-movies">
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-movie" value="movie" autocomplete="off"> Movies
@@ -121,11 +114,14 @@
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-show" value="show" autocomplete="off"> TV Shows
</label>
<label class="btn btn-dark" id="recently-added-label-music">
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-music" value="artist" autocomplete="off"> Music
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-artist" value="artist" autocomplete="off"> Music
</label>
<label class="btn btn-dark" id="recently-added-label-other_video">
<input type="radio" name="recently-added-toggle" id="recently-added-toggle-other_video" value="other_video" autocomplete="off"> Videos
</label>
</div>
<div class="input-group pull-left" style="width: 1px;" id="recently-added-count-selection">
<input type="number" class="form-control number-input" name="recently-added-count" id="recently-added-count" value="${config['home_stats_recently_added_count']}" min="1" max="50" data-default="50" data-toggle="tooltip" title="Min: 1 item<br>Max: 50 items" />
<input type="number" class="form-control number-input" name="recently-added-count" id="recently-added-count" value="50" min="1" max="50" data-default="50" data-toggle="tooltip" title="Min: 1 item<br>Max: 50 items" />
<span class="input-group-addon btn-dark inactive">items</span>
</div>
</div>
@@ -359,8 +355,9 @@
var session_id = s.session_id;
var instance = $('#activity-instance-' + key);
// Create a new instance if it doesn't exist
if (!(instance.length)) {
// Create a new instance if it doesn't exist or recreate the entire instance
// if the rating key changed (for movies or episodes) with the same session key
if (!(instance.length) || (s.media_type !== 'track' && s.rating_key !== instance.data('rating_key').toString())) {
create_instances.push(key);
getActivityInstance(key);
return;
@@ -386,7 +383,7 @@
// Switching tracks can be under the same session key, so need to update the info.
if (s.media_type === 'track') {
// Update if artist changed
if (s.grandparent_rating_key !== instance.data('grandparent_rating_key')) {
if (s.grandparent_rating_key !== instance.data('grandparent_rating_key').toString()) {
$('#background-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.art + '&width=500&height=280&opacity=40&background=282828&blur=3&fallback=art&refresh=true)');
$('#metadata-grandparent_title-' + key)
.attr('href', 'info?rating_key=' + s.grandparent_rating_key)
@@ -394,7 +391,7 @@
.text(s.original_title || s.grandparent_title);
}
// 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').toString()) {
$('#poster-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&fallback=poster&refresh=true)');
$('#poster-' + key + '-bg').css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&opacity=60&background=282828&blur=3&fallback=poster&refresh=true)');
$('#poster-url-' + key)
@@ -406,7 +403,7 @@
.text(s.parent_title);
}
// Update cover if track changed
if (s.rating_key !== instance.data('rating_key')) {
if (s.rating_key !== instance.data('rating_key').toString()) {
$('#metadata-grandparent_title-' + key)
.attr('href', 'info?rating_key=' + s.grandparent_rating_key)
.attr('title', s.original_title || s.grandparent_title)
@@ -434,12 +431,14 @@
if (s.stream_container_decision === 'transcode') {
transcode_container = 'Transcode (' + s.container.toUpperCase() + ' <i class="fa fa-long-arrow-right"></i> ' + s.stream_container.toUpperCase() + ')';
} else {
transcode_container = 'Direct Play (' + s.container.toUpperCase() + ')';
transcode_container = 'Direct Play (' + s.stream_container.toUpperCase() + ')';
}
$('#transcode_container-' + key).html(transcode_container);
var video_decision = '';
if (['movie', 'episode', 'clip'].indexOf(s.media_type) > -1 && s.video_decision !== '') {
if (['movie', 'episode', 'clip'].indexOf(s.media_type) > -1 && s.stream_video_decision) {
var v_bd = (s.video_dynamic_range === 'HDR') ? ' ' + s.video_dynamic_range : '';
var sv_bd = (s.video_dynamic_range === 'HDR') ? ' ' + s.stream_video_dynamic_range : '';
var v_res= '';
switch (s.video_resolution.toLowerCase()) {
case 'sd':
@@ -449,7 +448,7 @@
v_res = '4k';
break;
default:
v_res = s.video_resolution + 'p'
v_res = s.video_full_resolution;
}
var sv_res = '';
switch (s.stream_video_resolution.toLowerCase()) {
@@ -460,16 +459,16 @@
sv_res = '4k';
break;
default:
sv_res = s.stream_video_resolution + 'p'
sv_res = s.stream_video_full_resolution;
}
if (s.stream_video_decision === 'transcode') {
var hw_d = (s.transcode_hw_decoding === 1) ? ' (HW)' : '';
var hw_e = (s.transcode_hw_encoding === 1) ? ' (HW)' : '';
video_decision = 'Transcode (' + s.video_codec.toUpperCase() + hw_d + ' ' + v_res + ' <i class="fa fa-long-arrow-right"></i> ' + s.stream_video_codec.toUpperCase() + hw_e + ' ' + sv_res + ')';
video_decision = 'Transcode (' + s.video_codec.toUpperCase() + hw_d + ' ' + v_res + v_bd + ' <i class="fa fa-long-arrow-right"></i> ' + s.stream_video_codec.toUpperCase() + hw_e + ' ' + sv_res + sv_bd + ')';
} else if (s.stream_video_decision === 'copy') {
video_decision = 'Direct Stream (' + s.stream_video_codec.toUpperCase() + ' ' + sv_res + ')';
video_decision = 'Direct Stream (' + s.stream_video_codec.toUpperCase() + ' ' + sv_res + sv_bd + ')';
} else {
video_decision = 'Direct Play (' + s.video_codec.toUpperCase() + ' ' + v_res + ')';
video_decision = 'Direct Play (' + s.stream_video_codec.toUpperCase() + ' ' + sv_res + sv_bd + ')';
}
} else if (s.media_type === 'photo') {
video_decision = 'Direct Play (' + s.width + 'x' + s.height + ')';
@@ -477,7 +476,7 @@
$('#video_decision-' + key).html(video_decision);
var audio_decision = '';
if (['movie', 'episode', 'clip', 'track'].indexOf(s.media_type) > -1 && s.audio_decision) {
if (['movie', 'episode', 'clip', 'track'].indexOf(s.media_type) > -1 && s.stream_audio_decision) {
var a_codec = (s.audio_codec === 'truehd') ? 'TrueHD' : s.audio_codec.toUpperCase();
var sa_codec = (s.stream_audio_codec === 'truehd') ? 'TrueHD' : s.stream_audio_codec.toUpperCase();
if (s.stream_audio_decision === 'transcode') {
@@ -485,7 +484,7 @@
} else if (s.stream_audio_decision === 'copy') {
audio_decision = 'Direct Stream (' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
} else {
audio_decision = 'Direct Play (' + a_codec + ' ' + capitalizeFirstLetter(s.audio_channel_layout.split('(')[0]) + ')';
audio_decision = 'Direct Play (' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
}
}
$('#audio_decision-' + key).html(audio_decision);
@@ -499,7 +498,7 @@
} else if (s.stream_subtitle_decision === 'burn') {
subtitle_decision = 'Burn (' + s.subtitle_codec.toUpperCase() + ')';
} else {
subtitle_decision = 'Direct Play (' + ((s.synced_version === '1') ? s.stream_subtitle_codec.toUpperCase() : s.subtitle_codec.toUpperCase()) + ')';
subtitle_decision = 'Direct Play (' + ((s.synced_version === '1') ? s.subtitle_codec.toUpperCase() : s.stream_subtitle_codec.toUpperCase()) + ')';
}
}
$('#subtitle_decision-' + key).html(subtitle_decision);
@@ -584,7 +583,14 @@
session_key: session_key
},
complete: function(xhr, status) {
$('#currentActivity').append(xhr.responseText);
var instance = $('#activity-instance-' + session_key);
if (instance.length) {
instance.replaceWith(xhr.responseText);
} else {
$('#currentActivity').append(xhr.responseText);
}
$('#activity-instance-' + session_key + ' .dashboard-activity-info-scroller').scrollbar();
$('#activity-instance-' + session_key + ' [data-toggle=tooltip]').tooltip({ container: 'body', placement: 'right', delay: 50 });
$('#terminate-button-' + session_key).tooltip('destroy').tooltip({ container: 'body', placement: 'left', delay: 50 });
@@ -724,20 +730,25 @@
});
}
var time_range = $('#watched-stats-days').val();
var stats_type = $('input[name=watched-stats-type]:checked', '#watch-stats-toggles').val();
var stats_type = getLocalStorage('home_stats_type', 'plays');
var time_range = getLocalStorage('home_stats_days', 30);
$('#watched-stats-' + stats_type).prop('checked', true);
$('#watched-stats-' + stats_type).closest('label').addClass('active');
$('#watched-stats-days').val(time_range);
getHomeStats(time_range, stats_type);
$('input[name=watched-stats-type]').change(function () {
stats_type = $(this).filter(':checked').val();
setLocalStorage('home_stats_type', stats_type);
getHomeStats(time_range, stats_type);
$.post('set_home_stats_config', { stats_type: stats_type });
});
$('#watched-stats-days').change(function () {
forceMinMax($(this));
time_range = $(this).val();
setLocalStorage('home_stats_days', time_range);
getHomeStats(time_range, stats_type);
$.post('set_home_stats_config', { time_range: time_range });
});
$('#watched-stats-days').tooltip({ container: 'body', placement: 'top', html: true });
@@ -771,7 +782,7 @@
async: true,
data: {
count: recently_added_count,
type: recently_added_type
media_type: recently_added_type
},
complete: function (xhr, status) {
$("#recentlyAdded").html(xhr.responseText);
@@ -780,8 +791,14 @@
}
});
}
var recently_added_count = $('#recently-added-count').val();
var recently_added_type = '';
var recently_added_count = getLocalStorage('home_stats_recently_added_count', 50);
var recently_added_type = getLocalStorage('home_stats_recently_added_type', 'all');;
$('#recently-added-toggle-' + recently_added_type).prop('checked', true);
$('#recently-added-toggle-' + recently_added_type).closest('label').addClass('active');
$('#recently-added-count').val(recently_added_count);
recentlyAdded(recently_added_count, recently_added_type);
function highlightAddedScrollerButton() {
@@ -835,6 +852,7 @@
$(selected_filter).closest('label').addClass('active');
recently_added_type = $(selected_filter).val();
resetScroller();
setLocalStorage('home_stats_recently_added_type', recently_added_type);
recentlyAdded(recently_added_count, recently_added_type);
});
@@ -842,8 +860,8 @@
forceMinMax($(this));
recently_added_count = $(this).val();
resetScroller();
setLocalStorage('home_stats_recently_added_count', recently_added_count);
recentlyAdded(recently_added_count, recently_added_type);
$.post('set_home_stats_config', { recently_added_count: recently_added_count });
});
$('#recently-added-count').tooltip({ container: 'body', placement: 'top', html: true });
@@ -865,7 +883,10 @@
async: true,
complete: function (xhr, status) {
$("#changelog-modal .modal-body").html(xhr.responseText);
$('#changelog-modal').modal();
$('#changelog-modal').modal({
backdrop: 'static',
keyboard: false
});
}
});
</script>

View File

@@ -405,11 +405,11 @@ DOCUMENTATION :: END
</a>
</div>
% endif
% if data.get('tvmaze_id') or data.get('themoviedb_id'):
% if data.get('tvmaze_id') or data.get('themoviedb_id') or data.get('musicbrainz_id'):
<div class="btn-group">
<button class="btn btn-danger btn-edit" data-toggle="modal" aria-pressed="false" autocomplete="off" id="delete-lookup-info"
data-id="${data['grandparent_rating_key'] if data['media_type'] in ('episode', 'track') else data['parent_rating_key'] if data['media_type'] in ('season', 'album') else data['rating_key']}"
data-title="${data['grandparent_title'] if data['media_type'] in ('episode', 'track') else data['parent_title'] if data['media_type'] in ('season', 'album') else data['title']}">
data-id="${data['grandparent_rating_key'] if data['media_type'] == 'episode' else data['parent_rating_key'] if data['media_type'] == 'season' else data['rating_key']}"
data-title="${data['grandparent_title'] if data['media_type'] == 'episode' else data['parent_title'] if data['media_type'] == 'season' else data['title']}">
<i class="fa fa-search"></i> Delete Lookup Info
</button>
</div>
@@ -451,6 +451,7 @@ DOCUMENTATION :: END
<th align="left" id="friendly_name">User</th>
<th align="left" id="ip_address">IP Address</th>
<th align="left" id="platform">Platform</th>
<th align="left" id="product">Product</th>
<th align="left" id="player">Player</th>
<th align="left" id="title">Title</th>
<th align="left" id="started">Started</th>
@@ -613,7 +614,7 @@ DOCUMENTATION :: END
$(document).ready(function () {
get_history();
history_table = $('#history_table-RK-${data["rating_key"]}').DataTable(history_table_options);
var colvis = new $.fn.dataTable.ColVis(history_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 11] });
var colvis = new $.fn.dataTable.ColVis(history_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 12] });
$(colvis.button()).appendTo('div.colvis-button-bar');
clearSearchButton('history_table-RK-${data["rating_key"]}', history_table);
@@ -750,7 +751,7 @@ DOCUMENTATION :: END
});
</script>
% endif
% if data.get('tvmaze_id') or data.get('themoviedb_id'):
% if data.get('tvmaze_id') or data.get('themoviedb_id') or data.get('musicbrainz_id'):
<script>
$('#delete-lookup-info').on('click', function () {
var msg = 'Are you sure you want to delete the 3rd party API lookup for <strong>' + $(this).data('title') + '</strong>?<br><br>' +

View File

@@ -190,12 +190,12 @@ DOCUMENTATION :: END
<li>
<a href="info?rating_key=${child['rating_key']}" id="${child['rating_key']}">
<div class="item-children-poster">
<div class="item-children-poster-face cover-item style="background-image: url(pms_image_proxy?img=${child['thumb']}&width=300&height=300&fallback=cover);"></div>
<div class="item-children-poster-face cover-item" style="background-image: url(pms_image_proxy?img=${child['thumb']}&width=300&height=300&fallback=cover);"></div>
% if _session['user_group'] == 'admin':
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
% endif
</div>
<div class="item-children-instance-text-wrapper album-item">
<div class="item-children-instance-text-wrapper cover-item">
<h3 title="${child['title']}">${child['title']}</h3>
</div>
</a>
@@ -219,7 +219,7 @@ DOCUMENTATION :: END
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
% endif
</div>
<div class="item-children-instance-text-wrapper album-item">
<div class="item-children-instance-text-wrapper cover-item">
<h3 title="${child['parent_title']}">${child['parent_title']}</h3>
<h3 title="${child['title']}">${child['title']}</h3>
</div>
@@ -246,11 +246,11 @@ DOCUMENTATION :: END
</div>
</div>
</div>
% if _session['user_group'] == 'admin':
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
% endif
</div>
% if _session['user_group'] == 'admin':
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
% endif
<div class="item-children-instance-text-wrapper album-item">
<div class="item-children-instance-text-wrapper cover-item">
<h3 title="${child['original_title'] or child['grandparent_title']}">${child['original_title'] or child['grandparent_title']}</h3>
<h3 title="${child['title']}">${child['title']}</h3>
<h3 title="${child['parent_title']}" class="text-muted">${child['parent_title']}</h3>

View File

@@ -2,7 +2,7 @@ var hc_plays_by_day_options = {
chart: {
type: 'line',
backgroundColor: 'rgba(0,0,0,0)',
renderTo: 'chart_div_plays_by_day'
renderTo: 'graph_plays_by_day'
},
title: {
text: ''
@@ -32,6 +32,11 @@ var hc_plays_by_day_options = {
selectHandler(this.category, this.series.name);
}
}
},
events: {
legendItemClick: function() {
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
}
}
}
},

View File

@@ -2,17 +2,11 @@ var hc_plays_by_dayofweek_options = {
chart: {
type: 'column',
backgroundColor: 'rgba(0,0,0,0)',
renderTo: 'chart_div_plays_by_dayofweek'
renderTo: 'graph_plays_by_dayofweek'
},
title: {
text: ''
},
plotOptions: {
column: {
pointPadding: 0.2,
borderWidth: 0
}
},
legend: {
enabled: true,
itemStyle: {
@@ -56,14 +50,21 @@ var hc_plays_by_dayofweek_options = {
},
plotOptions: {
column: {
borderWidth: 0,
stacking: 'normal',
borderWidth: '0',
dataLabels: {
enabled: false,
style: {
color: '#000'
}
}
},
series: {
events: {
legendItemClick: function () {
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
}
}
}
},
tooltip: {

View File

@@ -2,17 +2,11 @@ var hc_plays_by_hourofday_options = {
chart: {
type: 'column',
backgroundColor: 'rgba(0,0,0,0)',
renderTo: 'chart_div_plays_by_hourofday'
renderTo: 'graph_plays_by_hourofday'
},
title: {
text: ''
},
plotOptions: {
column: {
pointPadding: 0.2,
borderWidth: 0
}
},
legend: {
enabled: true,
itemStyle: {
@@ -56,14 +50,21 @@ var hc_plays_by_hourofday_options = {
},
plotOptions: {
column: {
borderWidth: 0,
stacking: 'normal',
borderWidth: '0',
dataLabels: {
enabled: false,
style: {
color: '#000'
}
}
},
series: {
events: {
legendItemClick: function () {
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
}
}
}
},
tooltip: {

View File

@@ -2,7 +2,7 @@ var hc_plays_by_month_options = {
chart: {
type: 'column',
backgroundColor: 'rgba(0,0,0,0)',
renderTo: 'chart_div_plays_by_month'
renderTo: 'graph_plays_by_month'
},
title: {
text: ''
@@ -50,14 +50,21 @@ var hc_plays_by_month_options = {
},
plotOptions: {
column: {
borderWidth: 0,
stacking: 'normal',
borderWidth: '0',
dataLabels: {
enabled: false,
style: {
color: '#000'
}
}
},
series: {
events: {
legendItemClick: function () {
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
}
}
}
},
tooltip: {

View File

@@ -2,17 +2,11 @@ var hc_plays_by_platform_options = {
chart: {
type: 'column',
backgroundColor: 'rgba(0,0,0,0)',
renderTo: 'chart_div_plays_by_platform'
renderTo: 'graph_plays_by_platform'
},
title: {
text: ''
},
plotOptions: {
column: {
pointPadding: 0.2,
borderWidth: 0
}
},
legend: {
enabled: true,
itemStyle: {
@@ -56,14 +50,21 @@ var hc_plays_by_platform_options = {
},
plotOptions: {
column: {
borderWidth: 0,
stacking: 'normal',
borderWidth: '0',
dataLabels: {
enabled: false,
style: {
color: '#000'
}
}
},
series: {
events: {
legendItemClick: function () {
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
}
}
}
},
tooltip: {

View File

@@ -2,17 +2,11 @@ var hc_plays_by_platform_by_stream_type_options = {
chart: {
type: 'column',
backgroundColor: 'rgba(0,0,0,0)',
renderTo: 'chart_div_plays_by_platform_by_stream_type'
renderTo: 'graph_plays_by_platform_by_stream_type'
},
title: {
text: ''
},
plotOptions: {
column: {
pointPadding: 0.2,
borderWidth: 0
}
},
legend: {
enabled: true,
itemStyle: {
@@ -56,14 +50,21 @@ var hc_plays_by_platform_by_stream_type_options = {
},
plotOptions: {
column: {
borderWidth: 0,
stacking: 'normal',
borderWidth: '0',
dataLabels: {
enabled: false,
style: {
color: '#000'
}
}
},
series: {
events: {
legendItemClick: function () {
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
}
}
}
},
tooltip: {

View File

@@ -2,17 +2,11 @@ var hc_plays_by_source_resolution_options = {
chart: {
type: 'column',
backgroundColor: 'rgba(0,0,0,0)',
renderTo: 'chart_div_plays_by_source_resolution'
renderTo: 'graph_plays_by_source_resolution'
},
title: {
text: ''
},
plotOptions: {
column: {
pointPadding: 0.2,
borderWidth: 0
}
},
legend: {
enabled: true,
itemStyle: {
@@ -56,14 +50,21 @@ var hc_plays_by_source_resolution_options = {
},
plotOptions: {
column: {
borderWidth: 0,
stacking: 'normal',
borderWidth: '0',
dataLabels: {
enabled: false,
style: {
color: '#000'
}
}
},
series: {
events: {
legendItemClick: function () {
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
}
}
}
},
tooltip: {

View File

@@ -2,17 +2,11 @@ var hc_plays_by_stream_resolution_options = {
chart: {
type: 'column',
backgroundColor: 'rgba(0,0,0,0)',
renderTo: 'chart_div_plays_by_stream_resolution'
renderTo: 'graph_plays_by_stream_resolution'
},
title: {
text: ''
},
plotOptions: {
column: {
pointPadding: 0.2,
borderWidth: 0
}
},
legend: {
enabled: true,
itemStyle: {
@@ -56,14 +50,21 @@ var hc_plays_by_stream_resolution_options = {
},
plotOptions: {
column: {
borderWidth: 0,
stacking: 'normal',
borderWidth: '0',
dataLabels: {
enabled: false,
style: {
color: '#000'
}
}
},
series: {
events: {
legendItemClick: function () {
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
}
}
}
},
tooltip: {

View File

@@ -2,7 +2,7 @@ var hc_plays_by_stream_type_options = {
chart: {
type: 'line',
backgroundColor: 'rgba(0,0,0,0)',
renderTo: 'chart_div_plays_by_stream_type'
renderTo: 'graph_plays_by_stream_type'
},
title: {
text: ''
@@ -32,6 +32,11 @@ var hc_plays_by_stream_type_options = {
selectHandler(this.category, this.series.name);
}
}
},
events: {
legendItemClick: function() {
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
}
}
}
},

View File

@@ -2,17 +2,11 @@ var hc_plays_by_user_options = {
chart: {
type: 'column',
backgroundColor: 'rgba(0,0,0,0)',
renderTo: 'chart_div_plays_by_user'
renderTo: 'graph_plays_by_user'
},
title: {
text: ''
},
plotOptions: {
column: {
pointPadding: 0.2,
borderWidth: 0
}
},
legend: {
enabled: true,
itemStyle: {
@@ -56,14 +50,21 @@ var hc_plays_by_user_options = {
},
plotOptions: {
column: {
borderWidth: 0,
stacking: 'normal',
borderWidth: '0',
dataLabels: {
enabled: false,
style: {
color: '#000'
}
}
},
series: {
events: {
legendItemClick: function () {
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
}
}
}
},
tooltip: {

View File

@@ -2,17 +2,11 @@ var hc_plays_by_user_by_stream_type_options = {
chart: {
type: 'column',
backgroundColor: 'rgba(0,0,0,0)',
renderTo: 'chart_div_plays_by_user_by_stream_type'
renderTo: 'graph_plays_by_user_by_stream_type'
},
title: {
text: ''
},
plotOptions: {
column: {
pointPadding: 0.2,
borderWidth: 0
}
},
legend: {
enabled: true,
itemStyle: {
@@ -56,14 +50,21 @@ var hc_plays_by_user_by_stream_type_options = {
},
plotOptions: {
column: {
borderWidth: 0,
stacking: 'normal',
borderWidth: '0',
dataLabels: {
enabled: false,
style: {
color: '#000'
}
}
},
series: {
events: {
legendItemClick: function () {
setGraphVisibility(this.chart.renderTo.id, this.chart.series, this.name);
}
}
}
},
tooltip: {

View File

@@ -1,3 +1,45 @@
var p = {
name: 'Unknown',
version: 'Unknown',
os: 'Unknown'
};
if (typeof platform !== 'undefined') {
p.name = platform.name;
p.version = platform.version;
p.os = platform.os.toString();
}
if (['IE', 'Microsoft Edge', 'IE Mobile'].indexOf(p.name) > -1) {
if (!getCookie('browserDismiss')) {
var $browser_warning = $('<div id="browser-warning">' +
'<i class="fa fa-exclamation-circle"></i>&nbsp;' +
'Tautulli does not support Internet Explorer or Microsoft Edge! ' +
'Please use a different browser such as Chrome or Firefox.' +
'<button type="button" class="close"><i class="fa fa-remove"></i></button>' +
'</div>');
$('body').prepend($browser_warning);
var offset = $browser_warning.height();
warningOffset(offset);
$browser_warning.one('click', 'button.close', function () {
$browser_warning.remove();
warningOffset(-offset);
setCookie('browserDismiss', 'true', 7);
});
function warningOffset(offset) {
var navbar = $('.navbar-fixed-top');
if (navbar.length) {
navbar.offset({top: navbar.offset().top + offset});
}
var container = $('.body-container');
if (container.length) {
container.offset({top: container.offset().top + offset});
}
}
}
}
function initConfigCheckbox(elem, toggleElem, reverse) {
toggleElem = (toggleElem === undefined) ? null : toggleElem;
reverse = (reverse === undefined) ? false : reverse;
@@ -141,7 +183,7 @@ function doAjaxCall(url, elem, reload, form, showMsg, callback) {
$.ajax({
url: url,
data: dataString,
type: 'post',
type: 'POST',
beforeSend: function (jqXHR, settings) {
// Start loader etc.
feedback.prepend(loader);
@@ -491,26 +533,48 @@ function PopupCenter(url, title, w, h) {
return newWindow;
}
if (!localStorage.getItem('Tautulli_ClientId')) {
localStorage.setItem('Tautulli_ClientId', uuidv4());
function setLocalStorage(key, value, path) {
if (path !== false) {
key = key + '_' + window.location.pathname;
}
localStorage.setItem(key, value);
}
function getLocalStorage(key, default_value, path) {
if (path !== false) {
key = key + '_' + window.location.pathname;
}
var value = localStorage.getItem(key);
if (value !== null) {
return value
} else if (default_value !== undefined) {
setLocalStorage(key, default_value, path);
return default_value
}
}
function uuidv4() {
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
)
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, function(c) {
var cryptoObj = window.crypto || window.msCrypto; // for IE 11
return (c ^ cryptoObj.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
});
}
var x_plex_headers = {
'Accept': 'application/json',
'X-Plex-Product': 'Tautulli',
'X-Plex-Version': 'Plex OAuth',
'X-Plex-Client-Identifier': localStorage.getItem('Tautulli_ClientId'),
'X-Plex-Platform': platform.name,
'X-Plex-Platform-Version': platform.version,
'X-Plex-Device': platform.os.toString(),
'X-Plex-Device-Name': platform.name
};
function getPlexHeaders() {
return {
'Accept': 'application/json',
'X-Plex-Product': 'Tautulli',
'X-Plex-Version': 'Plex OAuth',
'X-Plex-Client-Identifier': getLocalStorage('Tautulli_ClientID', uuidv4(), false),
'X-Plex-Platform': p.name,
'X-Plex-Platform-Version': p.version,
'X-Plex-Model': 'Plex OAuth',
'X-Plex-Device': p.os,
'X-Plex-Device-Name': p.name,
'X-Plex-Device-Screen-Resolution': window.screen.width + 'x' + window.screen.height,
'X-Plex-Language': 'en'
};
}
var plex_oauth_window = null;
const plex_oauth_loader = '<style>' +
@@ -561,6 +625,7 @@ function closePlexOAuthWindow() {
}
getPlexOAuthPin = function () {
var x_plex_headers = getPlexHeaders();
var deferred = $.Deferred();
$.ajax({
@@ -568,7 +633,6 @@ getPlexOAuthPin = function () {
type: 'POST',
headers: x_plex_headers,
success: function(data) {
plex_oauth_window.location = 'https://app.plex.tv/auth/#!?clientID=' + x_plex_headers['X-Plex-Client-Identifier'] + '&code=' + data.code;
deferred.resolve({pin: data.id, code: data.code});
},
error: function() {
@@ -585,48 +649,60 @@ function PlexOAuth(success, error, pre) {
if (typeof pre === "function") {
pre()
}
clearTimeout(polling);
closePlexOAuthWindow();
plex_oauth_window = PopupCenter('', 'Plex-OAuth', 600, 700);
$(plex_oauth_window.document.body).html(plex_oauth_loader);
getPlexOAuthPin().then(function (data) {
var x_plex_headers = getPlexHeaders();
const pin = data.pin;
const code = data.code;
var keep_polling = true;
var oauth_params = {
'clientID': x_plex_headers['X-Plex-Client-Identifier'],
'context[device][product]': x_plex_headers['X-Plex-Product'],
'context[device][version]': x_plex_headers['X-Plex-Version'],
'context[device][platform]': x_plex_headers['X-Plex-Platform'],
'context[device][platformVersion]': x_plex_headers['X-Plex-Platform-Version'],
'context[device][device]': x_plex_headers['X-Plex-Device'],
'context[device][deviceName]': x_plex_headers['X-Plex-Device-Name'],
'context[device][model]': x_plex_headers['X-Plex-Model'],
'context[device][screenResolution]': x_plex_headers['X-Plex-Device-Screen-Resolution'],
'context[device][layout]': 'desktop',
'code': code
}
plex_oauth_window.location = 'https://app.plex.tv/auth/#!?' + encodeData(oauth_params);
polling = pin;
(function poll() {
polling = setTimeout(function () {
$.ajax({
url: 'https://plex.tv/api/v2/pins/' + pin,
type: 'GET',
headers: x_plex_headers,
success: function (data) {
if (data.authToken){
keep_polling = false;
closePlexOAuthWindow();
if (typeof success === "function") {
success(data.authToken)
}
$.ajax({
url: 'https://plex.tv/api/v2/pins/' + pin,
type: 'GET',
headers: x_plex_headers,
success: function (data) {
if (data.authToken){
closePlexOAuthWindow();
if (typeof success === "function") {
success(data.authToken)
}
},
error: function () {
keep_polling = false;
}
},
error: function (jqXHR, textStatus, errorThrown) {
if (textStatus !== "timeout") {
closePlexOAuthWindow();
if (typeof error === "function") {
error()
}
},
complete: function () {
if (keep_polling){
poll();
} else {
clearTimeout(polling);
}
},
timeout: 1000
});
}, 1000);
}
},
complete: function () {
if (!plex_oauth_window.closed && polling === pin){
setTimeout(function() {poll()}, 1000);
}
},
timeout: 10000
});
})();
}, function () {
closePlexOAuthWindow();
@@ -634,4 +710,10 @@ function PlexOAuth(success, error, pre) {
error()
}
});
}
}
function encodeData(data) {
return Object.keys(data).map(function(key) {
return [key, data[key]].map(encodeURIComponent).join("=");
}).join("&");
}

View File

@@ -49,7 +49,7 @@ history_table_options = {
},
{
"targets": [1],
"data":"date",
"data": "date",
"createdCell": function (td, cellData, rowData, row, col) {
var date = moment(cellData, "X").format(date_format);
if (rowData['state'] !== null) {
@@ -77,7 +77,7 @@ history_table_options = {
},
{
"targets": [2],
"data":"friendly_name",
"data": "friendly_name",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
if (rowData['user_id']) {
@@ -112,7 +112,18 @@ history_table_options = {
},
{
"targets": [4],
"data":"platform",
"data": "platform",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(capitalizeFirstLetter(cellData));
}
},
"width": "10%",
"className": "no-wrap"
},
{
"targets": [5],
"data": "product",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
@@ -122,7 +133,7 @@ history_table_options = {
"className": "no-wrap"
},
{
"targets": [5],
"targets": [6],
"data": "player",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
@@ -137,12 +148,12 @@ history_table_options = {
$(td).html('<div><a href="#" data-target="#info-modal" data-toggle="modal"><div style="float: left;">' + transcode_dec + '&nbsp;' + cellData + '</div></a></div>');
}
},
"width": "12%",
"width": "10%",
"className": "no-wrap modal-control"
},
{
"targets": [6],
"data":"full_title",
"targets": [7],
"data": "full_title",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var parent_info = '';
@@ -155,7 +166,7 @@ history_table_options = {
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?' + source + 'rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'episode') {
if (rowData['parent_media_index'] && rowData['media_index']) { parent_info = ' (S' + rowData['parent_media_index'] + '&middot; E' + rowData['media_index'] + ')'; }
if (!isNaN(parseInt(rowData['parent_media_index'])) && !isNaN(parseInt(rowData['media_index']))) { parent_info = ' (S' + rowData['parent_media_index'] + ' &middot; E' + rowData['media_index'] + ')'; }
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Episode"><i class="fa fa-television fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?' + source + 'rating_key=' + rowData['rating_key'] + '"><div style="float: left;" >' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
@@ -171,12 +182,12 @@ history_table_options = {
}
}
},
"width": "33%",
"width": "25%",
"className": "datatable-wrap"
},
{
"targets": [7],
"data":"started",
"targets": [8],
"data": "started",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData === null) {
$(td).html('n/a');
@@ -189,8 +200,8 @@ history_table_options = {
"className": "no-wrap"
},
{
"targets": [8],
"data":"paused_counter",
"targets": [9],
"data": "paused_counter",
"render": function (data, type, full) {
if (data !== null) {
return Math.round(moment.duration(data, 'seconds').as('minutes')) + ' mins';
@@ -203,8 +214,8 @@ history_table_options = {
"className": "no-wrap"
},
{
"targets": [9],
"data":"stopped",
"targets": [10],
"data": "stopped",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData === null || (rowData['state'] != null && rowData['state'] != "stopped")) {
$(td).html('n/a');
@@ -217,8 +228,8 @@ history_table_options = {
"className": "no-wrap"
},
{
"targets": [10],
"data":"duration",
"targets": [11],
"data": "duration",
"render": function (data, type, full) {
if (data !== null) {
return Math.round(moment.duration(data, 'seconds').as('minutes')) + ' mins';
@@ -231,7 +242,7 @@ history_table_options = {
"className": "no-wrap"
},
{
"targets": [11],
"targets": [12],
"data": "watched_status",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData == 1) {
@@ -489,7 +500,8 @@ function childTableFormat(rowData) {
'<th align="left" id="friendly_name">User</th>' +
'<th align="left" id="ip_address">IP Address</th>' +
'<th align="left" id="platform">Platform</th>' +
'<th align="left" id="platform">Player</th>' +
'<th align="left" id="product">Product</th>' +
'<th align="left" id="player">Player</th>' +
'<th align="left" id="title">Title</th>' +
'<th align="left" id="started">Started</th>' +
'<th align="left" id="paused_counter">Paused</th>' +

View File

@@ -107,7 +107,7 @@ history_table_modal_options = {
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'episode') {
if (rowData['parent_media_index'] && rowData['media_index']) { parent_info = ' (S' + rowData['parent_media_index'] + '&middot; E' + rowData['media_index'] + ')'; }
if (!isNaN(parseInt(rowData['parent_media_index'])) && !isNaN(parseInt(rowData['media_index']))) { parent_info = ' (S' + rowData['parent_media_index'] + ' &middot; E' + rowData['media_index'] + ')'; }
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Episode"><i class="fa fa-television fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;" >' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');

View File

@@ -153,7 +153,7 @@ libraries_list_table_options = {
} else if (rowData['media_type'] === 'episode') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Episode"><i class="fa fa-television fa-fw"></i></span>';
if (rowData['rating_key']) {
if (rowData['parent_media_index'] && rowData['media_index']) { parent_info = ' (S' + rowData['parent_media_index'] + '&middot; E' + rowData['media_index'] + ')'; }
if (!isNaN(parseInt(rowData['parent_media_index'])) && !isNaN(parseInt(rowData['media_index']))) { parent_info = ' (S' + rowData['parent_media_index'] + ' &middot; E' + rowData['media_index'] + ')'; }
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;" >' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else {

View File

@@ -50,7 +50,7 @@ media_info_table_options = {
expand_details = '<span class="expand-media-info-tooltip" data-toggle="tooltip" title="Show Episodes"><i class="fa fa-plus-circle fa-fw"></i></span>';
$(td).html('<div><a href="#"><div style="float: left;">' + expand_details + '&nbsp;' + date + '</div></a></div>');
} else if (rowData['media_type'] === 'artist') {
expand_details = '<span class="expand-media-info-tooltip" data-toggle="tooltip" title="Show Albumns"><i class="fa fa-plus-circle fa-fw"></i></span>';
expand_details = '<span class="expand-media-info-tooltip" data-toggle="tooltip" title="Show Albums"><i class="fa fa-plus-circle fa-fw"></i></span>';
$(td).html('<div><a href="#"><div style="float: left;">' + expand_details + '&nbsp;' + date + '</div></a></div>');
} else if (rowData['media_type'] === 'album') {
expand_details = '<span class="expand-media-info-tooltip" data-toggle="tooltip" title="Show Tracks"><i class="fa fa-plus-circle fa-fw"></i></span>';

View File

@@ -91,7 +91,7 @@ user_ip_table_options = {
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'episode') {
if (rowData['parent_media_index'] && rowData['media_index']) { parent_info = ' (S' + rowData['parent_media_index'] + '&middot; E' + rowData['media_index'] + ')'; }
if (!isNaN(parseInt(rowData['parent_media_index'])) && !isNaN(parseInt(rowData['media_index']))) { parent_info = ' (S' + rowData['parent_media_index'] + ' &middot; E' + rowData['media_index'] + ')'; }
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Episode"><i class="fa fa-television fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;" >' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');

View File

@@ -166,7 +166,7 @@ users_list_table_options = {
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'episode') {
if (rowData['parent_media_index'] && rowData['media_index']) { parent_info = ' (S' + rowData['parent_media_index'] + '&middot; E' + rowData['media_index'] + ')'; }
if (!isNaN(parseInt(rowData['parent_media_index'])) && !isNaN(parseInt(rowData['media_index']))) { parent_info = ' (S' + rowData['parent_media_index'] + ' &middot; E' + rowData['media_index'] + ')'; }
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Episode"><i class="fa fa-television fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="pms_image_proxy?img=' + rowData['thumb'] + '&width=300&height=450&fallback=poster" data-height="120" data-width="80">' + cellData + parent_info + '</span>'
$(td).html('<div class="history-title"><a href="info?source=history&rating_key=' + rowData['rating_key'] + '"><div style="float: left;" >' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');

View File

@@ -205,6 +205,7 @@ DOCUMENTATION :: END
<th align="left" id="friendly_name">User</th>
<th align="left" id="ip_address">IP Address</th>
<th align="left" id="platform">Platform</th>
<th align="left" id="product">Product</th>
<th align="left" id="player">Player</th>
<th align="left" id="title">Title</th>
<th align="left" id="started">Started</th>
@@ -385,7 +386,7 @@ DOCUMENTATION :: END
};
history_table = $('#history_table-SID-${data["section_id"]}').DataTable(history_table_options);
var colvis = new $.fn.dataTable.ColVis(history_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 11] });
var colvis = new $.fn.dataTable.ColVis(history_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 12] });
$(colvis.button()).appendTo('#button-bar-history');
clearSearchButton('history_table-SID-${data["section_id"]}', history_table);

View File

@@ -12,7 +12,6 @@
<meta name="description" content="">
<meta name="author" content="">
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet">
<link href="${http_root}css/pnotify.custom.min.css" rel="stylesheet" />
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
<link href="${http_root}css/opensans.min.css" rel="stylesheet">
<link href="${http_root}css/font-awesome.all.min.css" rel="stylesheet">
@@ -151,6 +150,7 @@
token: token,
remember_me: remember_me
};
var x_plex_headers = getPlexHeaders();
data = $.extend(data, x_plex_headers);
$.ajax({

View File

@@ -20,6 +20,24 @@
</div>
<p class="help-block">Optional: Enter a friendly name for this device. Leave blank for default.</p>
</div>
<div class="form-group">
<label for="friendly_name">Device Token</label>
<div class="row">
<div class="col-md-8">
<input type="text" class="form-control" id="device_token" value="${device['device_token']}" size="30" readonly>
</div>
</div>
<p class="help-block">Your app device token.</p>
</div>
<div class="form-group">
<label for="friendly_name">OneSignal Device ID</label>
<div class="row">
<div class="col-md-8">
<input type="text" class="form-control" id="device_id" value="${device['device_id']}" size="30" readonly>
</div>
</div>
<p class="help-block">Your OneSignal device ID for notifications.</p>
</div>
</div>
</div>
</form>

View File

@@ -271,7 +271,7 @@
</div>
<p class="help-block">
Select an existing notification agent where the subject and body text will be sent.<br>
Note: Self-hosted newsletters must be enabled under <a data-tab-destination="tabs-notifications" data-dismiss="modal" data-target="#newsletter_self_hosted">Newsletters</a> to include a link to the newsletter.
Note: Self-hosted newsletters must be enabled under <a data-tab-destination="notifications" data-dismiss="modal" data-target="newsletter_self_hosted">Newsletters</a> to include a link to the newsletter.
</p>
</div>
<div id="newsletter-email-config">
@@ -584,6 +584,7 @@
var $email_selectors = $('#newsletter_email_to, #newsletter_email_cc, #newsletter_email_bcc').selectize({
plugins: ['remove_button'],
maxItems: null,
searchField: ['text', 'value'],
render: {
item: function(item, escape) {
return '<div>' +

View File

@@ -148,7 +148,7 @@
<div class="col-md-12">
<label>Notification Triggers</label>
<p class="help-block">
Select items that will trigger a notification for this ${notifier['agent_label']} notifiation agent.
Select items that will trigger a notification for this ${notifier['agent_label']} notification agent.
</p>
% for action in available_notification_actions:
<div class="checkbox">
@@ -243,6 +243,11 @@
</div>
<ul class="submenu">
<li>
<div class="form-group">
<label for="${action['name']}_subject">JSON Headers</label>
<textarea class="form-control" id="${action['name']}_subject" name="${action['name']}_subject" data-parsley-trigger="change" data-autoresize required>${notifier['notify_text'][action['name']]['subject']}</textarea>
<p class="help-block">Set custom JSON headers.</p>
</div>
<div class="form-group">
<label for="${action['name']}_body">JSON Data</label>
<textarea class="form-control" id="${action['name']}_body" name="${action['name']}_body" data-parsley-trigger="change" data-autoresize required>${notifier['notify_text'][action['name']]['body']}</textarea>
@@ -326,6 +331,15 @@
<p class="help-block">Set custom arguments passed to the script.</p>
</div>
% elif notifier['agent_name'] == 'webhook':
<div class="form-group">
<label for="test_subject">JSON Headers</label>
<div class="row">
<div class="col-md-12">
<textarea class="form-control" id="test_subject" name="test_subject" data-autoresize></textarea>
</div>
</div>
<p class="help-block">Set custom JSON headers sent to the webhook.</p>
</div>
<div class="form-group">
<label for="test_body">JSON Data</label>
<div class="row">
@@ -471,7 +485,7 @@
'<div class="form-group">' +
'<label>Warning</label>' +
'<p class="help-block" style="color: #eb8600;">Facebook requires HTTPS for authorization. ' +
'Please enable HTTPS for Tautulli under <a data-tab-destination="tabs-web_interface" data-dismiss="modal" data-target="#enable_https">Web Interface</a>.</p>' +
'Please enable HTTPS for Tautulli under <a data-tab-destination="web_interface" data-dismiss="modal" data-target="enable_https">Web Interface</a>.</p>' +
'</div>'
);
$('#facebook_redirect_uri').val('HTTPS not enabled');
@@ -566,6 +580,7 @@
var $email_selectors = $('#email_to, #email_cc, #email_bcc').selectize({
plugins: ['remove_button'],
maxItems: null,
searchField: ['text', 'value'],
render: {
item: function(item, escape) {
return '<div>' +
@@ -779,6 +794,7 @@
$.ajax({
url: 'get_notify_text_preview',
type: 'POST',
data: {
notify_action: action,
subject: subject,

View File

@@ -8,7 +8,12 @@
% if text:
% for item in text:
<div style="padding-bottom: 10px;">
<h4>${item['media_type'].capitalize()}</h4>
<h4>
${item['media_type'].capitalize()}
% if item['media_type'] != 'server':
<span class="inline-pre">&lt;${item['media_type']}&gt;&lt;/${item['media_type']}&gt;</span> tags
% endif
</h4>
% if agent != 'webhook':
<pre>${item['subject']}</pre>
% endif

View File

@@ -0,0 +1,67 @@
<%
import datetime
import plexpy
from plexpy import activity_handler, helpers
if queue == 'active sessions':
filter_key = 'session_key-'
title_format = '{2} / {1} ({0})'
title_key = title_format.format('Session Key', 'Title', 'User')
description = 'Queue to flush stuck active sessions to the database.'
else:
filter_key = 'rating_key-'
title_format = '{1} ({0})'
title_key = title_format.format('Rating Key', 'Title')
description = 'Queue to flush recently added items to the database and send notifications if enabled.'
scheduled_jobs = [j.id for j in activity_handler.ACTIVITY_SCHED.get_jobs() if j.id.startswith(filter_key)]
%>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
<h4 class="modal-title">${queue.title()} Queue</h4>
</div>
<div class="modal-body">
<p class="help-block">
${description}
</p>
<table class="activity-queue">
<thead>
<tr>
<th>
${title_key}
</th>
<th>
Next Flush In
</th>
<th>
Next Flush Time
</th>
</tr>
</thead>
<tbody>
% if scheduled_jobs:
% for job in scheduled_jobs:
<%
sched_job = activity_handler.ACTIVITY_SCHED.get_job(job)
now = datetime.datetime.now(sched_job.next_run_time.tzinfo)
%>
<tr>
<td><strong>${title_format.format(*sched_job.args)}</strong></td>
<td>${helpers.format_timedelta_Hms(sched_job.next_run_time - now)}</td>
<td>${sched_job.next_run_time.astimezone(plexpy.SYS_TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')}</td>
</tr>
% endfor
% else:
<tr>
<td colspan="3" style="text-align: center;"><i class="fa fa-check"></i>&nbsp; Nothing in the ${queue} queue</td>
</tr>
% endif
</tbody>
</table>
</div>
<div class="modal-footer">
</div>
</div>
</div>

View File

@@ -10,9 +10,9 @@ DOCUMENTATION :: END
</%doc>
<%!
import arrow
import datetime
import plexpy
from plexpy import common
from plexpy import common, helpers
scheduled_jobs = [j.id for j in plexpy.SCHED.get_jobs()]
%>
@@ -31,20 +31,25 @@ DOCUMENTATION :: END
% for job in common.SCHEDULER_LIST:
% if job in scheduled_jobs:
<%
sched_job = plexpy.SCHED.get_job(job)
run_interval = arrow.get(str(sched_job.trigger.interval), ['H:mm:ss', 'HH:mm:ss'])
next_run_interval = arrow.get(sched_job.next_run_time).timestamp - arrow.now().timestamp
sched_job = plexpy.SCHED.get_job(job)
now = datetime.datetime.now(sched_job.next_run_time.tzinfo)
%>
<tr>
<td>${sched_job.id}</td>
<td><i class="fa fa-sm fa-fw fa-check"></i> Active</td>
<td>${arrow.get(run_interval).format('HH:mm:ss')}</td>
<td>${arrow.get(next_run_interval).format('HH:mm:ss')}</td>
<td>${arrow.get(sched_job.next_run_time).format('YYYY-MM-DD HH:mm:ss')}</td>
<td>${helpers.format_timedelta_Hms(sched_job.trigger.interval)}</td>
<td>${helpers.format_timedelta_Hms(sched_job.next_run_time - now)}</td>
<td>${sched_job.next_run_time.astimezone(plexpy.SYS_TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')}</td>
</tr>
% elif job in ('Check for server response', 'Check for active sessions', 'Check for recently added items') and plexpy.WS_CONNECTED:
<tr>
% if job == 'Check for active sessions':
<td><a class="queue-modal-link" href="#" data-queue="active sessions">${job}</a></td>
% elif job == 'Check for recently added items':
<td><a class="queue-modal-link" href="#" data-queue="recently added">${job}</a></td>
% else:
<td>${job}</td>
% endif
<td><i class="fa fa-sm fa-fw fa-check"></i> Websocket</td>
<td>N/A</td>
<td>N/A</td>
@@ -61,4 +66,21 @@ DOCUMENTATION :: END
% endif
% endfor
</tbody>
</table>
</table>
<script>
$('.queue-modal-link').on('click', function (e) {
e.preventDefault();
$.ajax({
url: 'get_queue_modal',
data: {
queue: $(this).data('queue')
},
cache: false,
async: true,
complete: function(xhr, status) {
$("#queue-modal").html(xhr.responseText);
$('#queue-modal').modal();
}
});
});
</script>

View File

@@ -7,6 +7,9 @@
from plexpy import common, notifiers, newsletters
from plexpy.helpers import anon_url, checked
docker_setting = 'disabled' if plexpy.DOCKER else ''
docker_msg = '<span class="docker-setting small">(Controlled by Docker Container)</span>' if plexpy.DOCKER else ''
available_notification_agents = sorted(notifiers.available_notification_agents(), key=lambda k: k['label'].lower())
available_newsletter_agents = sorted(newsletters.available_newsletter_agents(), key=lambda k: k['label'].lower())
%>
@@ -53,6 +56,7 @@
<li role="presentation"><a href="#tabs-notifications" aria-controls="tabs-notifications" role="tab" data-toggle="tab">Notifications & Newsletters</a></li>
<li role="presentation"><a href="#tabs-notification_agents" aria-controls="tabs-notification_agents" role="tab" data-toggle="tab">Notification Agents</a></li>
<li role="presentation"><a href="#tabs-newsletter_agents" aria-controls="tabs-newsletter_agents" role="tab" data-toggle="tab">Newsletter Agents</a></li>
<li role="presentation"><a href="#tabs-3rd_party_apis" aria-controls="tabs-3rd_party_apis" role="tab" data-toggle="tab">3rd Party APIs</a></li>
<li role="presentation"><a href="#tabs-import_backups" aria-controls="tabs-import_backups" role="tab" data-toggle="tab">Import & Backups</a></li>
<li role="presentation"><a href="#tabs-android_app" aria-controls="tabs-android_app" role="tab" data-toggle="tab">Tautulli Remote Android App <sup><small>beta</small></sup></a></li>
</ul>
@@ -230,12 +234,12 @@
% if plexpy.INSTALL_TYPE == 'git':
<div class="form-group advanced-setting">
<label for="git_branch">Git Remote / Branch</label>
<label for="git_branch">Git Remote / Branch</label> ${docker_msg | n}
<div class="row">
<div class="col-md-6">
<div class="input-group git-group">
<input type="text" class="form-control" id="git_remote" name="git_remote" value="${config['git_remote']}" data-parsley-trigger="change">
<select class="form-control" id="git_branch" name="git_branch">
<input type="text" class="form-control" id="git_remote" name="git_remote" value="${config['git_remote']}" data-parsley-trigger="change" ${docker_setting}>
<select class="form-control" id="git_branch" name="git_branch" ${docker_setting}>
<% branches = ('master', 'beta', 'nightly') %>
% for branch in branches:
<option value="${branch}" ${'selected' if config['git_branch'] == branch else ''}>${branch}</option>
@@ -245,7 +249,7 @@
% endif
</select>
<span class="input-group-btn">
<button class="btn btn-form" type="button" id="switch_git_branch">Checkout Branch</button>
<button class="btn btn-form" type="button" id="switch_git_branch" ${docker_setting}>Checkout Branch</button>
</span>
</div>
</div>
@@ -253,10 +257,10 @@
<p class="help-block">The git tracking remote and branch (default "origin/master"). Select to switch the git branch (requires restart).</p>
</div>
<div class="form-group advanced-setting">
<label for="git_path">Git Path</label>
<label for="git_path">Git Path</label> ${docker_msg | n}
<div class="row">
<div class="col-md-4">
<input type="text" class="form-control" id="git_path" name="git_path" value="${config['git_path']}" size="30">
<input type="text" class="form-control" id="git_path" name="git_path" value="${config['git_path']}" size="30" ${docker_setting}>
</div>
</div>
<p class="help-block">Optional: The path to your git environment variable. Leave blank for default.</p>
@@ -281,7 +285,7 @@
</div>
<div id="home_refresh_interval_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
<p class="help-block">Set the interval (in seconds) to refresh the current activity on the homepage. Minimum 2.</p>
<p class="help-block">Set the interval (in seconds) to refresh the current activity on the homepage. Minimum 2, default 10.</p>
</div>
<div class="padded-header">
@@ -445,19 +449,19 @@
<p class="help-block">Launch browser pointed to Tautulli on startup.</p>
</div>
<div class="form-group advanced-setting">
<label for="http_host">HTTP Host</label>
<label for="http_host">HTTP Host</label> ${docker_msg | n}
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control http-settings" id="http_host" name="http_host" value="${config['http_host']}" data-parsley-trigger="change" required>
<input type="text" class="form-control http-settings" id="http_host" name="http_host" value="${config['http_host']}" data-parsley-trigger="change" required ${docker_setting}>
</div>
</div>
<p class="help-block">localhost or an IP address to bind the web server to. Default 0.0.0.0 to bind to all interfaces.</p>
</div>
<div class="form-group">
<label for="http_port">HTTP Port</label>
<label for="http_port">HTTP Port</label> ${docker_msg | n}
<div class="row">
<div class="col-md-2">
<input type="text" class="form-control http-settings" data-parsley-type="integer" id="http_port" name="http_port" value="${config['http_port']}" data-parsley-trigger="change" data-parsley-errors-container="#http_port_error" required>
<input type="text" class="form-control http-settings" data-parsley-type="integer" id="http_port" name="http_port" value="${config['http_port']}" data-parsley-trigger="change" data-parsley-errors-container="#http_port_error" required ${docker_setting}>
</div>
<div id="http_port_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
@@ -469,7 +473,7 @@
<div class="col-md-8">
<input type="text" class="form-control" id="http_base_url" name="http_base_url" value="${config['http_base_url']}" placeholder="http://mydomain.com" data-parsley-trigger="change" data-parsley-pattern="^https?:\/\/\S+$" data-parsley-errors-container="#http_base_url_error" data-parsley-error-message="Invalid URL">
</div>
<div id=http_base_url_error" class="alert alert-danger settings-alert" role="alert"></div>
<div id="http_base_url_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
<p class="help-block">
Set your public Tautulli domain for self-hosted notification images and newsletters. (e.g. http://mydomain.com)
@@ -811,6 +815,18 @@
</div>
</div>
</div>
<div class="form-group">
<label for="pms_update_check_interval">Update Check Interval</label>
<div class="row">
<div class="col-md-2">
<input type="text" class="form-control" data-parsley-type="integer" id="pms_update_check_interval" name="pms_update_check_interval" value="${config['pms_update_check_interval']}" size="3" data-parsley-min="1" data-parsley-trigger="change" data-parsley-errors-container="#pms_update_check_interval_error" required>
</div>
<div id="pms_update_check_interval_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
<p class="help-block">
The interval (in hours) Tautulli will check for a new Plex Media Server update. Minimum 1, default 24.
</p>
</div>
</div>
<div class="checkbox">
<label>
@@ -887,7 +903,6 @@
<h3>Current Activity Notifications</h3>
</div>
<p class="help-block">Note: Buffer warnings only work on certain Plex clients. Android and Plex Web do not report buffer events accurately or at all.</p>
<div class="form-group">
<label for="buffer_threshold">Buffer Threshold</label>
<div class="row">
@@ -896,7 +911,13 @@
</div>
<div id="buffer_threshold_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
<p class="help-block">How many buffer events should we wait before triggering the first warning. Buffer events increment on each monitor ping if play state is buffering. 0 to disable buffer warnings.</p>
<p class="help-block">
The number of buffer events required before triggering the first notification.
Buffer events increment on each incoming websocket message if the play state is buffering.
<br>
Note: Buffer warnings only work on certain Plex clients. Some clients can send excessive buffer messages or no messages at all.
This notification may be unreliable and not indicative of a real problem.
</p>
</div>
<div class="form-group advanced-setting">
<label for="buffer_wait">Buffer Wait</label>
@@ -988,7 +1009,7 @@
<p class="help-block" id="self_host_newsletter_message">
Note: The <span class="inline-pre">${http_root}newsletter</span> endpoint on your domain must be publicly accessible from the internet.
</p>
<p class="help-block settings-warning base-url-warning">Warning: Public Tautulli domain not set under <a data-tab-destination="tabs-web_interface" data-target="#http_base_url">Web Interface</a>.</p>
<p class="help-block settings-warning base-url-warning">Warning: Public Tautulli domain not set under <a data-tab-destination="web_interface" data-target="http_base_url">Web Interface</a>.</p>
</div>
<div class="form-group">
<label for="newsletter_auth">Newsletter Authentication</label>
@@ -1002,7 +1023,7 @@
</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>
<p class="help-block settings-warning newsletter-guest-access-warning">Warning: Guest Access is not enabled under <a data-tab-destination="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>
@@ -1034,116 +1055,15 @@
<p class="help-block">Optional: Enter the full path to your custom newsletter templates folder. Leave blank for default.</p>
</div>
<div class="form-group advanced-setting">
<label for="newsletter_dir">Newsletter Output Directory</label>
<label for="newsletter_dir">Newsletter Output Directory</label> ${docker_msg | n}
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="newsletter_dir" name="newsletter_dir" value="${config['newsletter_dir']}">
<input type="text" class="form-control" id="newsletter_dir" name="newsletter_dir" value="${config['newsletter_dir']}" ${docker_setting}>
</div>
</div>
<p class="help-block">Enter the full path to where newsletter files will be saved.</p>
</div>
<div class="padded-header">
<h3>3rd Party APIs</h3>
</div>
<div class="form-group">
<label for="notify_upload_posters">Image Hosting</label>
<div class="row">
<div class="col-md-6">
<div class="${'input-group' if config['notify_upload_posters'] in (1, 3) else ''}">
<select class="form-control" id="notify_upload_posters" name="notify_upload_posters">
<option value="0" ${'selected' if config['notify_upload_posters'] == 0 else ''}>Disabled</option>
<option value="1" ${'selected' if config['notify_upload_posters'] == 1 else ''}>Imgur</option>
<option value="3" ${'selected' if config['notify_upload_posters'] == 3 else ''}>Cloudinary</option>
<option value="2" ${'selected' if config['notify_upload_posters'] == 2 else ''}>Self-hosted on public domain</option>
</select>
% if config['notify_upload_posters'] in (1, 3):
<span class="input-group-btn" id="delete_all_uploads_container">
<button class="btn btn-form" type="button" id="delete_all_uploads">Delete All Uploads</button>
</span>
% endif
</div>
</div>
</div>
<p class="help-block">Select where to host Plex images for notifications and newsletters.</p>
</div>
<div id="imgur_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 1 else 'block'}">
<div class="form-group">
<p class="help-block" id="imgur_upload_message">
You can register a new Imgur application <a href="${anon_url('https://api.imgur.com/oauth2/addclient')}" target="_blank">here</a>.<br>
Warning: Imgur uploads are rate-limited and newsletters may exceed the limit. Please use Cloudinary for newsletters instead.
</p>
</div>
<div class="form-group">
<label for="imgur_client_id">Imgur Client ID</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="imgur_client_id" name="imgur_client_id" value="${config['imgur_client_id']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">Enter your Imgur API Client ID.</p>
</div>
</div>
<div id="self_host_image_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 2 else 'block'}">
<div class="form-group">
<p class="help-block" id="self_host_image_message">Note: The <span class="inline-pre">${http_root}image</span> endpoint on your domain must be publicly accessible from the internet.</p>
<p class="help-block settings-warning base-url-warning">Warning: Public Tautulli domain not set under <a data-tab-destination="tabs-web_interface" data-target="#http_base_url">Web Interface</a>.</p>
</div>
</div>
<div id="cloudinary_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 3 else 'block'}">
<div class="form-group">
<p class="help-block" id="imgur_upload_message">
You can sign up for Cloudinary <a href="${anon_url('https://cloudinary.com')}" target="_blank">here</a>.<br>
</p>
</div>
<div class="form-group">
<label for="cloudinary_cloud_name">Cloudinary Cloud Name</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="cloudinary_cloud_name" name="cloudinary_cloud_name" value="${config['cloudinary_cloud_name']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your Cloudinary Cloud Name.
</p>
</div>
<div class="form-group">
<label for="cloudinary_api_key">Cloudinary API Key</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="cloudinary_api_key" name="cloudinary_api_key" value="${config['cloudinary_api_key']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your Cloudinary API Key.
</p>
</div>
<div class="form-group">
<label for="cloudinary_api_secret">Cloudinary API Secret</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="cloudinary_api_secret" name="cloudinary_api_secret" value="${config['cloudinary_api_secret']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your Cloudinary API Secret.
</p>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" name="themoviedb_lookup" id="themoviedb_lookup" value="1" ${config['themoviedb_lookup']}> Lookup TheMovieDB Links
</label>
<p class="help-block">Enable to lookup links to TheMovieDB (and IMDb if needed) for movies and TV shows when available.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" name="tvmaze_lookup" id="tvmaze_lookup" value="1" ${config['tvmaze_lookup']}> Lookup TVmaze Links
</label>
<p class="help-block">Enable to lookup links to TVmaze (and IMDb if needed) for TV shows when available.</p>
</div>
<p><input type="button" class="btn btn-bright save-button" value="Save" data-success="Changes saved successfully"></p>
</div>
@@ -1178,7 +1098,7 @@
Add a new newsletter agent, or configure an existing newsletter agent by clicking the settings icon on the right.
</p>
<p class="help-block settings-warning" id="newsletter_upload_warning">
Warning: The <a data-tab-destination="tabs-notifications" data-target="#notify_upload_posters">Image Hosting</a> setting must be enabled for images to display on the newsletter.</span>
Warning: The <a data-tab-destination="3rd_party_apis" data-target="notify_upload_posters">Image Hosting</a> setting must be enabled for images to display on the newsletter.</span>
</p>
<br/>
<div id="plexpy-newsletters-table">
@@ -1188,6 +1108,176 @@
</div>
<div role="tabpanel" class="tab-pane" id="tabs-3rd_party_apis">
<div class="padded-header">
<h3>Image Hosting</h3>
</div>
<p class="help-block">Image hosting is used to provide posters and artwork for some notification agents and newsletters.</p>
<div class="form-group">
<label for="notify_upload_posters">Image Host</label>
<div class="row">
<div class="col-md-6">
<div class="${'input-group' if config['notify_upload_posters'] in (1, 3) else ''}">
<select class="form-control" id="notify_upload_posters" name="notify_upload_posters">
<option value="0" ${'selected' if config['notify_upload_posters'] == 0 else ''}>Disabled</option>
<option value="1" ${'selected' if config['notify_upload_posters'] == 1 else ''}>Imgur</option>
<option value="3" ${'selected' if config['notify_upload_posters'] == 3 else ''}>Cloudinary</option>
<option value="2" ${'selected' if config['notify_upload_posters'] == 2 else ''}>Self-hosted on public domain</option>
</select>
% if config['notify_upload_posters'] in (1, 3):
<span class="input-group-btn" id="delete_all_uploads_container">
<button class="btn btn-form" type="button" id="delete_all_uploads">Delete All Uploads</button>
</span>
% endif
</div>
</div>
</div>
<p class="help-block">Select where to host Plex images for notifications and newsletters.</p>
</div>
<div id="imgur_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 1 else 'block'}">
<div class="form-group">
<p class="help-block">
Please see the <a target='_blank' href='${anon_url('https://github.com/%s/%s-Wiki/wiki/3rd-Party-APIs-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}'>3rd Party APIs Guide</a> for instructions on setting up Imgur.<br>
Warning: Imgur uploads are rate-limited and newsletters may exceed the limit. Please use Cloudinary for newsletters instead.
</p>
</div>
<div class="form-group">
<label for="imgur_client_id">Imgur Client ID</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="imgur_client_id" name="imgur_client_id" value="${config['imgur_client_id']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">Enter your Imgur API Client ID.</p>
</div>
</div>
<div id="self_host_image_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 2 else 'block'}">
<div class="form-group">
<p class="help-block" id="self_host_image_message">Note: The <span class="inline-pre">${http_root}image</span> endpoint on your domain must be publicly accessible from the internet.</p>
<p class="help-block settings-warning base-url-warning">Warning: Public Tautulli domain not set under <a data-tab-destination="web_interface" data-target="http_base_url">Web Interface</a>.</p>
</div>
</div>
<div id="cloudinary_upload_options" style="overlfow: hidden; display: ${'none' if config['notify_upload_posters'] != 3 else 'block'}">
<div class="form-group">
<p class="help-block">
Please see the <a target='_blank' href='${anon_url('https://github.com/%s/%s-Wiki/wiki/3rd-Party-APIs-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}'>3rd Party APIs Guide</a> for instructions on setting up Cloudinary.
</p>
</div>
<div class="form-group">
<label for="cloudinary_cloud_name">Cloudinary Cloud Name</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="cloudinary_cloud_name" name="cloudinary_cloud_name" value="${config['cloudinary_cloud_name']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your Cloudinary Cloud Name.
</p>
</div>
<div class="form-group">
<label for="cloudinary_api_key">Cloudinary API Key</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="cloudinary_api_key" name="cloudinary_api_key" value="${config['cloudinary_api_key']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your Cloudinary API Key.
</p>
</div>
<div class="form-group">
<label for="cloudinary_api_secret">Cloudinary API Secret</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="cloudinary_api_secret" name="cloudinary_api_secret" value="${config['cloudinary_api_secret']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your Cloudinary API Secret.
</p>
</div>
</div>
<div class="padded-header">
<h3>Metadata Lookups</h3>
</div>
<p class="help-block">Metadata lookups are used to provide additional links for notifications when available.</p>
<div class="checkbox">
<label>
<input type="checkbox" name="themoviedb_lookup" id="themoviedb_lookup" value="1" ${config['themoviedb_lookup']}> Lookup TheMovieDB Links
</label>
<p class="help-block">Enable to lookup links to TheMovieDB (and IMDb if needed) for movies and TV shows when available.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" name="tvmaze_lookup" id="tvmaze_lookup" value="1" ${config['tvmaze_lookup']}> Lookup TVmaze Links
</label>
<p class="help-block">Enable to lookup links to TVmaze (and IMDb if needed) for TV shows when available.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" name="musicbrainz_lookup" id="musicbrainz_lookup" value="1" ${config['musicbrainz_lookup']}> Lookup MusicBrainz Links
</label>
<p class="help-block">Enable to lookup links to MusicBrainz for music when available.</p>
</div>
<div class="padded-header">
<h3>Geolocation Database</h3>
</div>
<p class="help-block">The GeoLite2 database is used to geolocate IP addresses.</p>
<p class="help-block">
Please see the <a target='_blank' href='${anon_url('https://github.com/%s/%s-Wiki/wiki/3rd-Party-APIs-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}'>3rd Party APIs Guide</a> for instructions on setting up MaxMind.<br>
</p>
<div class="form-group">
<label for="maxmind_license_key">MaxMind License Key</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="maxmind_license_key" name="maxmind_license_key" value="${config['maxmind_license_key']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your MaxMind License Key to install the GeoLite2 database.
</p>
</div>
<div class="form-group">
<label for="geoip_db">GeoLite2 Database File</label> ${docker_msg | n}
<div class="row">
<div class="col-md-9">
<div class="input-group">
<input type="text" class="form-control" id="geoip_db" name="geoip_db" value="${config['geoip_db']}" ${docker_setting} data-parsley-trigger="change" data-parsley-pattern=".+\.mmdb$" data-parsley-errors-container="#geoip_db_error" data-parsley-error-message="Must end with '.mmdb'">
<span class="input-group-btn">
<button class="btn btn-form" type="button" id="install_geoip_db">${'Update' if config["geoip_db_installed"] else 'Install'}</button>
<button class="btn btn-form" type="button" id="uninstall_geoip_db" ${'disabled' if not config['geoip_db_installed'] else ''}>Uninstall</button>
</span>
</div>
</div>
<div id="geoip_db_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
<p class="help-block">
Leave blank to install in the default location. GeoLite2 database last updated <strong><span id="geoip_db_updated">never</span></strong>.
</p>
</div>
<div class="form-group advanced-setting">
<label for="geoip_db_update_days">GeoLite2 Database Update Interval</label>
<div class="row">
<div class="col-md-2">
<input type="text" class="form-control" data-parsley-type="integer" id="geoip_db_update_days" name="geoip_db_update_days" value="${config['geoip_db_update_days']}" size="5" data-parsley-range="[7, 30]" data-parsley-trigger="change" data-parsley-errors-container="#geoip_db_update_days_error" required>
</div>
<div id="geoip_db_update_days_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
<p class="help-block">The interval (in days) Tautulli will automatically update the GeoLite2 database. Minimum 7, maximum 30, default 30.</p>
</div>
<p><input type="button" class="btn btn-bright save-button" value="Save" data-success="Changes saved successfully"></p>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-import_backups">
<div class="padded-header">
@@ -1233,10 +1323,10 @@
</div>
<div class="form-group">
<label for="log_dir">Log Directory</label>
<label for="log_dir">Log Directory</label> ${docker_msg | n}
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control directory-settings" id="log_dir" name="log_dir" value="${config['log_dir']}">
<input type="text" class="form-control directory-settings" id="log_dir" name="log_dir" value="${config['log_dir']}" ${docker_setting}>
<div class="btn-group">
<button class="btn btn-form" type="button" id="clear_logs">Clear Logs</button>
</div>
@@ -1244,10 +1334,10 @@
</div>
</div>
<div class="form-group">
<label for="backup_dir">Backup Directory</label>
<label for="backup_dir">Backup Directory</label> ${docker_msg | n}
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control directory-settings" id="backup_dir" name="backup_dir" value="${config['backup_dir']}">
<input type="text" class="form-control directory-settings" id="backup_dir" name="backup_dir" value="${config['backup_dir']}" ${docker_setting}>
<div class="btn-group">
<button class="btn btn-form" type="button" id="backup_config">Backup Config</button>
<button class="btn btn-form" type="button" id="backup_database">Backup Database</button>
@@ -1256,10 +1346,10 @@
</div>
</div>
<div class="form-group">
<label for="cache_dir">Cache Directory</label>
<label for="cache_dir">Cache Directory</label> ${docker_msg | n}
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control directory-settings" id="cache_dir" name="cache_dir" value="${config['cache_dir']}">
<input type="text" class="form-control directory-settings" id="cache_dir" name="cache_dir" value="${config['cache_dir']}" ${docker_setting}>
<div class="btn-group">
<button class="btn btn-form" type="button" id="clear_cache">Clear All Cache</button>
<button class="btn btn-form" type="button" id="clear_image_cache">Clear Image Cache</button>
@@ -1290,7 +1380,7 @@
<div class="form-group">
<label>Registered Devices</label>
<p class="help-block">Register a new device using a QR code, or configure an existing device by clicking the settings icon on the right.</p>
<p id="app_api_msg" style="color: #eb8600;">The API must be enabled under <a data-tab-destination="tabs-web_interface" data-target="#api_enabled">Web Interface</a> to use the app.</p>
<p id="app_api_msg" style="color: #eb8600;">Warning: The API must be enabled under <a data-tab-destination="web_interface" data-target="api_enabled">Web Interface</a> to use the app.</p>
<div class="row">
<div id="plexpy-mobile-devices-table" class="col-md-12">
<div class='text-muted'><i class="fa fa-refresh fa-spin"></i> Loading registered devices...</div>
@@ -1308,6 +1398,7 @@
</%def>
<%def name="modalIncludes()">
<div id="queue-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="queue-modal"></div>
<div id="guidelines-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="guidelines-modal">
<div class="modal-dialog" role="document">
<div class="modal-content">
@@ -1523,7 +1614,7 @@
<div style="padding-bottom: 10px;">
<p class="help-block">
All text inside <span class="inline-pre">&lt;show&gt;&lt;/show&gt;</span>/<span class="inline-pre">&lt;season&gt;&lt;/season&gt;</span>/<span class="inline-pre">&lt;episode&gt;&lt;/episode&gt;</span>
tags will only be sent when the media type is show/season/episode.
tags will only be sent when the media type is show, season, or episode, respectively.
</p>
<p><strong style="color: #fff;">Example:</strong></p>
<pre>{show_name}&lt;season&gt; - Season {season_num}&lt;/season&gt;&lt;episode&gt; - S{season_num}E{episode_num} - {episode_name}&lt;/episode&gt; was recently added to Plex.</pre>
@@ -1534,7 +1625,7 @@
<div>
<p class="help-block">
All text inside <span class="inline-pre">&lt;artist&gt;&lt;/artist&gt;</span>/<span class="inline-pre">&lt;album&gt;&lt;/album&gt;</span>/<span class="inline-pre">&lt;track&gt;&lt;/track&gt;</span>
tags will only be sent when the media type is artist/album/track.
tags will only be sent when the media type is artist, album, or track, respectively.
</p>
<p><strong style="color: #fff;">Example:</strong></p>
<pre>{artist_name}&lt;album&gt; - {album_name}&lt;/album&gt;&lt;track&gt; - {album_name} - {track_name}&lt;/track&gt; was recently added to Plex.</pre>
@@ -1579,7 +1670,7 @@
<div>
<h4>List Slicing</h4>
</div>
<div>
<div style="padding-bottom: 10px;">
<p class="help-block">
Notification parameters which have a list of items can be sliced with a slice formatter <span class="inline-pre">:[X:Y]</span> to limit the number of items.
Note: the first item in the list is numbered <span class="inline-pre">0</span>.
@@ -1590,6 +1681,41 @@
{actors:[2:]} --> Only the 3rd to last actors (Actors: 2, 3, 4, ...)
{actors:[1:5]} --> Only the 2nd to 5th actors (Actors: 1, 2, 3, 4)</pre>
</div>
<div>
<h4>Prefix and Suffix</h4>
</div>
<div style="padding-bottom: 10px;">
<p class="help-block">
A prefix or a suffix can be added to the notification parameters using <span class="inline-pre">Prefix&lt;</span> and <span class="inline-pre">&gt;Suffix</span>.
If the notification parameter is unavailable, the prefix or suffix will not be displayed.
</p>
<p><strong style="color: #fff;">Example:</strong></p>
<pre>{rating} --> 8.9
{Rating: &lt;rating} --> Rating: 8.9
{rating&gt;/10} --> 8.9/10
{Rating: &lt;rating&gt;/10} --> Rating: 8.9/10</pre>
<p><strong style="color: #fff;">Example with unavailable parameter:</strong></p>
<pre>{rating} -->
Rating: {rating}/10 --> Rating: /10
{Rating: &lt;rating&gt;/10} --> </pre>
</div>
<div>
<h4>Combined</h4>
</div>
<div>
<p class="help-block">
If combining multiple notification text modifiers, the order of the modifiers must be:
</p>
<ol class="help-block">
<li>Prefix</li>
<li>Parameter</li>
<li>Case Modifier</li>
<li>List Slicing</li>
<li>Suffix</li>
</ol>
<p><strong style="color: #fff;">Example:</strong></p>
<pre>{Starring &lt;actors!c:[0]&gt; as the main character.} --> Starring Arnold Schwarzenegger as the main character.</pre>
</div>
</div>
</div>
<div class="modal-footer">
@@ -1611,7 +1737,7 @@
<div class="modal-body">
<div>
<p class="help-block">
If the value for a selected parameter cannot be provided, it will display as blank.
If the value for a selected parameter is unavailable, it will display as blank.
</p>
% for category in common.NEWSLETTER_PARAMETERS:
<table class="notification-params">
@@ -1734,11 +1860,6 @@
async: true,
complete: function(xhr, status) {
$("#plexpy-configuration-table").html(xhr.responseText);
if ("${kwargs.get('install_geoip')}" == 'true') {
$('#install_geoip_db').removeClass('no-highlight').css('color','#e9a049');
} else if ("${kwargs.get('reinstall_geoip')}" == 'true') {
$('#reinstall_geoip_db').removeClass('no-highlight').css('color','#e9a049');
}
}
});
}
@@ -1859,12 +1980,10 @@ $(document).ready(function() {
}
function preSaveChecks(_callback) {
if (serverChanged) {
verifyServer();
}
verifyPMSWebURL();
if (_callback) {
if (serverChanged) {
verifyServer(_callback);
} else if (typeof _callback === "function") {
_callback();
}
}
@@ -1889,12 +2008,13 @@ $(document).ready(function() {
settingsChanged = true;
});
function saveSettings() {
function saveSettings(showMsg, _callback) {
if (configForm.parsley().validate()) {
doAjaxCall('configUpdate', $(this), 'tabs', true, true, postSaveChecks);
return false;
doAjaxCall('configUpdate', $(this), 'tabs', true, showMsg, _callback);
return true;
} else {
showMsg('<i class="fa fa-exclamation-circle"></i> Please verify your settings.', false, true, 5000, true)
showMsg('<i class="fa fa-exclamation-circle"></i> Please verify your settings.', false, true, 5000, true);
return false;
}
}
@@ -1908,7 +2028,7 @@ $(document).ready(function() {
}
$('.save-button').click(function() {
preSaveChecks(function () { saveSettings() });
preSaveChecks(function () { saveSettings(true, postSaveChecks) });
});
initConfigCheckbox('#api_enabled');
@@ -2084,7 +2204,7 @@ $(document).ready(function() {
if (!item.label) {
$.extend(item,
$(this.revertSettings.$children)
.filter('[value=' + item.value + ']').data()
.filter('[value="' + item.value + '"]').data()
);
}
var label = item.label || item.value;
@@ -2217,6 +2337,7 @@ $(document).ready(function() {
if (($("#pms_ip").val() !== '') || ($("#pms_port").val() !== '')) {
$("#pms_verify").html('<i class="fa fa-refresh fa-spin"></i>').fadeIn('fast');
showMsg('Verifying Plex server...', true, true, 10000, false);
$.ajax({
url: 'get_server_id',
data: {
@@ -2254,10 +2375,11 @@ $(document).ready(function() {
} else {
$("#pms_verify").html('<i class="fa fa-check"></i>').fadeIn('fast');
$("#pms_ip_group").removeClass("has-error");
showMsg('<i class="fa fa-check"></i> Server verified.', false, true, 5000);
serverChanged = false;
}
if (_callback) {
if (typeof _callback === "function") {
_callback();
}
} else {
@@ -2292,6 +2414,7 @@ $(document).ready(function() {
$("#token_verify").html('<i class="fa fa-refresh fa-spin"></i>').fadeIn('fast');
}
function OAuthSuccessCallback(authToken) {
var x_plex_headers = getPlexHeaders();
$("#pms_token").val(authToken);
$("#pms_uuid").val(x_plex_headers['X-Plex-Client-Identifier']);
$("#token_verify").html('<i class="fa fa-check"></i>').fadeIn('fast');
@@ -2521,8 +2644,10 @@ $(document).ready(function() {
.prop('selected', selected));
}
var download_url = 'https://plex.tv/api/downloads/' + (plex_update_channel === 'plexpass' ? '5' : '1') + '.json?channel=' + plex_update_channel;
$.ajax({
url: 'https://plex.tv/api/downloads/1.json?channel=' + plex_update_channel,
url: download_url,
type: 'GET',
dataType: 'json',
beforeSend: function (xhr) {
@@ -2708,25 +2833,81 @@ $(document).ready(function() {
$('#allow_guest_access').click(function () {
newsletterPasswordEnabled();
})
});
function gotoSetting(tab, setting){
$("a[href=#tabs-" + tab + "]").click();
if (setting) {
_setting = '#' + setting;
if ($(_setting).closest('.advanced-setting').length && !$('#menu_link_show_advanced_settings').hasClass('active')) {
$('#menu_link_show_advanced_settings').click()
}
var body_container = $('.body-container');
var scroll_pos = setting ? body_container.scrollTop() + $(_setting).offset().top - 100 : 0;
body_container.animate({scrollTop: scroll_pos});
$(_setting).closest('.form-group, .checkbox').delay(500).fadeOut().fadeIn('slow').fadeOut().fadeIn('slow');
}
}
$('body').on('click', 'a[data-tab-destination]', function () {
var tab = $(this).data('tab-destination');
$("a[href=#" + tab + "]").click();
var scroll_destination = $(this).data('target');
if (scroll_destination) {
if ($(scroll_destination).closest('.advanced-setting').length && !$('#menu_link_show_advanced_settings').hasClass('active')) {
$('#menu_link_show_advanced_settings').click()
}
var body_container = $('.body-container')
var scroll_pos = scroll_destination ? body_container.scrollTop() + $(scroll_destination).offset().top - 100 : 0;
body_container.animate({scrollTop: scroll_pos});
}
var setting = $(this).data('target');
gotoSetting(tab, setting)
});
$('#resources-xml').on('tripleclick', function () {
openPlexXML('/api/resources', true, {includeHttps: 1});
});
if ("${kwargs.get('install_geoip')}" === 'true') {
gotoSetting('3rd_party_apis', 'geoip_db')
}
if ("${config['geoip_db_installed']}" > "0") {
$("#geoip_db_updated").text(moment("${config['geoip_db_installed']}", "X").fromNow());
}
$("#install_geoip_db").click(function () {
var maxmind_license_key = $("#maxmind_license_key");
maxmind_license_key.val($.trim(maxmind_license_key.val()));
if (maxmind_license_key.val() === "") {
maxmind_license_key.focus();
showMsg('<i class="fa fa-exclamation-circle"></i> Maxmind License Key is required.', false, true, 5000, true);
return false;
} else if (!(saveSettings())) {
return false;
}
var msg = 'Are you sure you want to install the GeoLite2 database?<br /><br />' +
'The database is used to lookup IP address geolocation info.<br />' +
'The database will be downloaded from <a href="${anon_url("https://dev.maxmind.com/geoip/geoip2/geolite2/")}" target="_blank">MaxMind</a>, <br />' +
'and requires <strong>100MB</strong> of free space to install.<br />';
var url = 'install_geoip_db';
if ($(this).text() === 'Update') {
url += '?update=true';
}
confirmAjaxCall(url, msg, null, 'Installing GeoLite2 database.', function (result) {
if (result.result === "success") {
$('#install_geoip_db').text('Update');
$('#uninstall_geoip_db').prop('disabled', false);
$('#geoip_db_updated').text(moment(result.updated, "X").fromNow());
}
getSchedulerTable();
});
});
$("#uninstall_geoip_db").click(function () {
var msg = 'Are you sure you want to uninstall the GeoLite2 database?<br /><br />' +
'You will not be able to lookup IP address geolocation info.';
var url = 'uninstall_geoip_db';
confirmAjaxCall(url, msg, null, 'Uninstalling GeoLite2 database.', function (result) {
if (result.result === "success") {
$('#install_geoip_db').text('Install');
$('#uninstall_geoip_db').prop('disabled', true);
$('#geoip_db_updated').text('never');
}
getSchedulerTable();
});
});
});
</script>
</%def>

View File

@@ -96,8 +96,8 @@ DOCUMENTATION :: END
% if data['media_type'] != 'track':
<tr>
<td>Resolution</td>
<td>${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])}</td>
<td>${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])}</td>
<td>${data['stream_video_full_resolution']}</td>
<td>${data['video_full_resolution']}</td>
</tr>
% endif
<tr>
@@ -178,6 +178,11 @@ DOCUMENTATION :: END
<td>${data['stream_video_framerate']}</td>
<td>${data['video_framerate']}</td>
</tr>
<tr>
<td>Dynamic Range</td>
<td>${data['stream_video_dynamic_range']}</td>
<td>${data['video_dynamic_range']}</td>
</tr>
<tr>
<td>Aspect Ratio</td>
<td>-</td>
@@ -230,7 +235,7 @@ DOCUMENTATION :: END
<tbody>
<tr>
<td>Codec</td>
<td>${data['stream_subtitle_codec'].upper()}</td>
<td>${data['stream_subtitle_codec'].upper() or '-'}</td>
<td>${data['subtitle_codec'].upper()}</td>
</tr>
</tbody>

View File

@@ -183,6 +183,7 @@ DOCUMENTATION :: END
async: true,
data: {
query: query_string,
limit: 30,
media_type: '${query["media_type"]}',
season_index: '${query["parent_media_index"]}'
},

View File

@@ -156,17 +156,17 @@ DOCUMENTATION :: END
</div>
% endif
<div class="btn-group" data-toggle="buttons" id="media_type-selection">
<label class="btn btn-dark active">
<input type="radio" name="media_type-filter" id="history-all" value="" autocomplete="off"> All
<label class="btn btn-dark">
<input type="radio" name="media_type-filter" id="history-all" value="all" autocomplete="off"> All
</label>
<label class="btn btn-dark">
<input type="radio" name="media_type-filter" id="history-movies" value="movie" autocomplete="off"> Movies
<input type="radio" name="media_type-filter" id="history-movie" value="movie" autocomplete="off"> Movies
</label>
<label class="btn btn-dark">
<input type="radio" name="media_type-filter" id="history-tv_shows" value="episode" autocomplete="off"> TV Shows
<input type="radio" name="media_type-filter" id="history-episode" value="episode" autocomplete="off"> TV Shows
</label>
<label class="btn btn-dark">
<input type="radio" name="media_type-filter" id="history-music" value="track" autocomplete="off"> Music
<input type="radio" name="media_type-filter" id="history-track" value="track" autocomplete="off"> Music
</label>
</div>
<div class="btn-group">
@@ -184,6 +184,7 @@ DOCUMENTATION :: END
<th align="left" id="friendly_name">User</th>
<th align="left" id="ip_address">IP Address</th>
<th align="left" id="platform">Platform</th>
<th align="left" id="product">Product</th>
<th align="left" id="player">Player</th>
<th align="left" id="title">Title</th>
<th align="left" id="started">Started</th>
@@ -274,12 +275,12 @@ DOCUMENTATION :: END
<table class="display user_ip_table" id="user_ip_table-UID-${data['user_id']}" width="100%">
<thead>
<tr>
<th align="left">Last Seen</th>
<th align="left">IP Address</th>
<th align="left">Last Platform</th>
<th align="left">Last Player</th>
<th align="left">Last Played</th>
<th align="left">Play Count</th>
<th align="left" id="last_seen">Last Seen</th>
<th align="left" id="ip_address">IP Address</th>
<th align="left" id="platform">Last Platform</th>
<th align="left" id="player">Last Player</th>
<th align="left" id="last_played">Last Played</th>
<th align="left" id="play_count">Play Count</th>
</tr>
</thead>
</table>
@@ -425,7 +426,7 @@ DOCUMENTATION :: END
history_table = $('#history_table-UID-${data["user_id"]}').DataTable(history_table_options);
history_table.column(2).visible(false);
var colvis = new $.fn.dataTable.ColVis(history_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 11] });
var colvis = new $.fn.dataTable.ColVis(history_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 12] });
$(colvis.button()).appendTo('#button-bar-history');
clearSearchButton('history_table-UID-${data["user_id"]}', history_table);
@@ -435,6 +436,7 @@ DOCUMENTATION :: END
selected_filter = $('input[name=media_type-filter]:checked', '#media_type-selection');
$(selected_filter).closest('label').addClass('active');
media_type = $(selected_filter).val();
setLocalStorage('user_' + user_id + '-history_media_type', media_type);
history_table.draw();
});
}
@@ -494,7 +496,9 @@ DOCUMENTATION :: END
$('a[href="#tabs-history"]').on('shown.bs.tab', function() {
if (typeof(history_table) === 'undefined') {
var media_type = null;
var media_type = getLocalStorage('user_' + user_id + '-history_media_type', 'all');
$('#history-' + media_type).prop('checked', true);
$('#history-' + media_type).closest('label').addClass('active');
loadHistoryTable(media_type);
}
});

View File

@@ -21,28 +21,21 @@
<link href="${http_root}css/font-awesome.v4-shims.min.css" rel="stylesheet">
<!-- Favicons -->
<link rel="icon" type="image/png" sizes="32x32" href="${http_root}images/favicon/favicon-32x32.png?v=2.0.0">
<link rel="icon" type="image/png" sizes="16x16" href="${http_root}images/favicon/favicon-16x16.png?v=2.0.0">
<link rel="shortcut icon" href="${http_root}images/favicon/favicon.ico?v=2.0.0">
<link rel="icon" type="image/png" sizes="32x32" href="${http_root}images/favicon/favicon-32x32.png?v=2.0.5">
<link rel="icon" type="image/png" sizes="16x16" href="${http_root}images/favicon/favicon-16x16.png?v=2.0.5">
<link rel="shortcut icon" href="${http_root}images/favicon/favicon.ico?v=2.0.5">
<!-- ICONS -->
<!-- Android >M39 icon -->
<link rel="icon" type="image/png" sizes="192x192" href="${http_root}images/favicon/android-chrome-192x192.png?v=2.0.0">
<link rel="manifest" href="${http_root}json/Android-manifest.json?v=2.0.0">
<meta name="theme-color" content="#1f1f1f">
<!-- Android -->
<link rel="manifest" href="${http_root}images/favicon/manifest.json?v=2.0.5">
<meta name="theme-color" content="#282a2d">
<!-- Apple -->
<link rel="apple-touch-icon" sizes="180x180" href="${http_root}images/favicon/apple-touch-icon.png?v=2.0.0">
<link rel="mask-icon" href="${http_root}images/favicon/safari-pinned-tab.svg?v=2.0.0" color="#1f1f1f">
<link rel="apple-touch-icon" sizes="180x180" href="${http_root}images/favicon/apple-touch-icon.png?v=2.0.5">
<link rel="mask-icon" href="${http_root}images/favicon/safari-pinned-tab.svg?v=2.0.5" color="#282a2d">
<meta name="apple-mobile-web-app-title" content="Tautulli">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="viewport" content="initial-scale=1">
<meta name="format-detection" content="telephone=no">
<!-- IE10 icon -->
<!-- Microsoft -->
<meta name="application-name" content="Tautulli">
<meta name="msapplication-TileColor" content="#1f1f1f">
<meta name="msapplication-TileImage" content="${http_root}images/favicon/mstile-144x144.png?v=2.0.0">
<meta name="msapplication-config" content="${http_root}xml/IEconfig.xml?v=2.0.0" />
<meta name="msapplication-config" content="${http_root}images/favicon/browserconfig.xml?v=2.0.5">
</head>
<body>
@@ -67,8 +60,38 @@
</p>
</div>
</div>
<div class="wizard-card" data-cardname="card2">
<h3>Plex Authentication</h3>
<h3>Authentication</h3>
<div class="wizard-input-section">
<p class="help-block">
Please setup an admin username and password for Tautulli.
</p>
</div>
<div class="wizard-input-section">
<label for="http_username">HTTP Username</label>
<div class="row">
<div class="col-xs-8">
<input type="text" class="form-control auth-settings" id="http_username" name="http_username" value="" size="30">
</div>
</div>
</div>
<div class="wizard-input-section">
<label for="http_password">HTTP Password</label>
<div class="row">
<div class="col-xs-8">
<input type="password" class="form-control auth-settings" id="http_password" name="http_password" value="" size="30" autocomplete="new-password">
</div>
</div>
</div>
<input type="hidden" class="form-control" name="http_hash_password" id="http_hash_password" value="1">
<input type="hidden" class="form-control" name="http_plex_admin" id="http_plex_admin" value="1">
<input type="hidden" id="authentication_valid" data-validate="validateAuthentication" value="">
<span style="display: none;" id="authentication-status"></span>
</div>
<div class="wizard-card" data-cardname="card3">
<h3>Plex Account</h3>
<div class="wizard-input-section">
<p class="help-block">
Tautulli requires a Plex.tv account. Click the button below to sign in on Plex.tv. You may need to allow popups in your browser.
@@ -78,7 +101,8 @@
<a class="btn btn-dark" id="sign-in-plex" href="#" role="button">Sign In with Plex</a>
<span style="margin-left: 10px; display: none;" id="pms-token-status"></span>
</div>
<div class="wizard-card" data-cardname="card3">
<div class="wizard-card" data-cardname="card4">
<h3>Plex Media Server</h3>
<div class="wizard-input-section">
<p class="help-block">
@@ -90,6 +114,7 @@
<div class="row">
<div class="col-xs-12">
<select class="form-control pms-settings selectize-pms-ip" id="pms_ip_selectize">
% if config['pms_identifier']:
<option value="${config['pms_ip']}:${config['pms_port']}"
data-identifier="${config['pms_identifier']}"
data-ip="${config['pms_ip']}"
@@ -99,6 +124,7 @@
data-is_cloud="${config['pms_is_cloud']}"
data-label="${config['pms_name'] or 'Local'}"
selected>${config['pms_ip']}</option>
% endif
</select>
</div>
</div>
@@ -135,7 +161,7 @@
<span style="margin-left: 10px; display: none;" id="pms-verify-status"></span>
</div>
<div class="wizard-card" data-cardname="card4">
<div class="wizard-card" data-cardname="card5">
<h3>Activity Logging</h3>
<div class="wizard-input-section">
<p class="help-block">
@@ -160,7 +186,7 @@
</div>
</div>
<div class="wizard-card" data-cardname="card4">
<div class="wizard-card" data-cardname="card6">
<h3>Notifications</h3>
<div class="wizard-input-section">
<p class="help-block">
@@ -173,7 +199,7 @@
</div>
</div>
<div class="wizard-card" data-cardname="card5">
<div class="wizard-card" data-cardname="card7">
<h3>Database Import</h3>
<div class="wizard-input-section">
<p class="help-block">
@@ -225,11 +251,29 @@
<script src="${http_root}js/script.js${cache_param}"></script>
<script src="${http_root}js/bootstrap-wizard.min.js"></script>
<script>
function validateAuthentication(el) {
var http_username = $("#http_username").val();
var http_password = $("#http_password").val();
var valid_authentication = el.val();
var retValue = {};
if (http_username === "" || http_password === "") {
retValue.status = false;
retValue.msg = "Please enter a username and password.";
$("#authentication-status").html('<i class="fa fa-exclamation-circle"></i> Please enter a username and password.');
$('#authentication-status').fadeIn('fast').delay(2000).fadeOut('fast');
} else {
retValue.status = true;
}
return retValue;
}
function validatePMSip(el) {
var valid_pms_ip = el.val();
var retValue = {};
if (valid_pms_ip == "") {
if (valid_pms_ip === "") {
retValue.status = false;
retValue.msg = "Please verify your server.";
$("#pms-verify-status").html('<i class="fa fa-exclamation-circle"></i> Please verify your server.');
@@ -245,7 +289,7 @@
var valid_pms_token = el.val();
var retValue = {};
if (valid_pms_token == "") {
if (valid_pms_token === "") {
retValue.status = false;
retValue.msg = "Please authenticate.";
$("#pms-token-status").html('<i class="fa fa-exclamation-circle"></i> Please authenticate.');
@@ -282,7 +326,7 @@ $(document).ready(function() {
$.fn.wizard.logging = false;
var options = {
keyboard : false,
contentHeight : 400,
contentHeight : 450,
contentWidth : 700,
backdrop: 'static',
buttons: {submitText: 'Finish'},
@@ -336,7 +380,7 @@ $(document).ready(function() {
if (!item.label) {
$.extend(item,
$(this.revertSettings.$children)
.filter('[value=' + item.value + ']').data()
.filter('[value="' + item.value + '"]').data()
);
}
var label = item.label || item.value;

View File

@@ -368,6 +368,7 @@
line-height: 1.2rem;
font-size: 0.9rem;
padding: 5px;
max-width: 320px;
}
.card-info-title a {
text-decoration: none;
@@ -693,7 +694,7 @@
<div class="sub-header-count" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;">
<span class="count" style="color: #E5A00D;">${len(recently_added['show'])}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">show${'s' if len(recently_added['show']) > 1 else ''}</span>&nbsp;/&nbsp;
<% total_episodes = sum(season['episode_count'] for show in recently_added['show'] for season in show['season']) %>
<span class="count" style="color: #E5A00D;">${total_episodes}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">episode${'s' if total > 1 else ''}</span>
<span class="count" style="color: #E5A00D;">${total_episodes}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">episode${'s' if total_episodes > 1 else ''}</span>
</div>
</td>
</tr>
@@ -846,7 +847,7 @@
<div class="sub-header-count" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;">
<span class="count" style="color: #E5A00D;">${len(recently_added['artist'])}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">artist${'s' if len(recently_added['artist']) > 1 else ''}</span> /
<% total_albums = sum(artist['album_count'] for artist in recently_added['artist']) %>
<span class="count" style="color: #E5A00D;">${total_albums}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">album${'s' if total > 1 else ''}</span>
<span class="count" style="color: #E5A00D;">${total_albums}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">album${'s' if total_albums > 1 else ''}</span>
</div>
</td>
</tr>
@@ -952,6 +953,124 @@
</td>
</tr>
% endif
% if recently_added.get('other_video'):
<tr>
<td class="wrapper" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;box-sizing: border-box;padding: 5px;overflow: auto;">
<div class="sub-header-bar" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;width: 200px;border-top: 1px solid #E5A00D;margin-top: 15px;margin-bottom: 25px;"></div>
<div class="sub-header-title" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;font-weight: lighter;">
<img src="${(base_url_image + 'images/libraries/video.png') if base_url_image else 'https://tautulli.com/images/libraries/video.png'}" class="sub-header-icon" width="30" height="30" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;height: 30px;width: 30px;vertical-align: middle;margin-right: 5px;margin-bottom: 5px;"> Recently Added Videos
</div>
<div class="sub-header-count" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;">
<span class="count" style="color: #E5A00D;">${len(recently_added['other_video'])}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">video${'s' if len(recently_added['other_video']) > 1 else ''}</span>
</div>
</td>
</tr>
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
% for video_a, video_b in grouper(recently_added['other_video'], 2):
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
% for video in (video_a, video_b):
% if video:
% if not video_b:
<td align="center" valign="top" class="card-instance pad" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 0 !important;width: 251px !important;min-width: 251px !important;max-width: 251px !important;"></td>
% endif
<td align="center" valign="top" class="card-instance movie" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 3px;width: 502px;min-width: 502px;max-width: 502px;height: 233px;">
<table border="0" cellpadding="0" cellspacing="3" width="100%" class="card-background" style="background-image: url(${(base_url_image + video['art_hash']) if base_url_image else video['art_url']});border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background-color: #282828;background-position: center;background-size: cover;background-repeat: no-repeat;background-clip: padding-box;border: 1px solid rgba(255,255,255,.1);">
<tr>
<td class="card-poster-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;width: 152px;min-width: 152px;height: 227px;">
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + video['thumb_hash']) if base_url_image else video['thumb_url']});border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;background-color: #3F4245;background-position: center;background-size: cover;background-repeat: no-repeat;background-clip: padding-box;border: 1px solid rgba(255,255,255,.1);">
<tr>
<td style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['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 'https://tautulli.com/images/newsletter/view-on-plex-poster.png'}" width="150" height="225" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;display: block;">
</a>
</td>
</tr>
</table>
</td>
<td class="card-info-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;padding-left: 4px;text-align: left;height: 227px;">
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;height: 100%;">
<tr>
<td class="card-info-title nowrap" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.9rem;vertical-align: top;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;border-bottom: 1px solid rgba(255, 255, 255, .1);line-height: 1.2rem;padding: 5px;max-width: 320px;">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank" style="text-decoration: none;color: #ffffff;">${video['title']}</a>
</td>
</tr>
<tr>
<td class="card-info-body" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.75rem;vertical-align: top;padding: 5px;height: 100%;">
% if video['tagline']:
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;color: #ffffff;">
<em>${video['tagline']}</em>
</p>
% endif
<p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;color: #ffffff;">
${video['summary'][:450] + (video['summary'][450:] and '...')}
</p>
</td>
</tr>
<tr>
<td class="card-info-footer nowrap" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.6rem;vertical-align: top;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;padding-top: 0px;padding-right: 5px;padding-bottom: 5px;padding-left: 5px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
<td class="badge-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;max-width: 260px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
% if video['year']:
<td class="badge" title="${video['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;">${video['year']}</td>
% endif
% if video['duration']:
<% duration = int(int(video['duration'])/60000) %>
<td class="badge" title="${duration} mins" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 11px;vertical-align: middle;display: inline-block;min-width: 10px;margin-right: 4px;padding: 3px 7px;line-height: 1;text-align: center;white-space: nowrap;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${duration} mins</td>
% endif
% if video['genres']:
% for genre in video['genres'][:]:
<td class="badge" title="${genre}" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 11px;vertical-align: middle;display: inline-block;min-width: 10px;margin-right: 4px;padding: 3px 7px;line-height: 1;text-align: center;white-space: nowrap;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${genre}</td>
% endfor
% endif
</tr>
</table>
</td>
% if video['rating']:
<% rating = int(round(float(video['rating']) / 2)) %>
<td class="star-rating-container" title="${int(float(video['rating'])/0.1)}%" align="right" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;width: 65px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate;mso-table-lspace: 0pt;mso-table-rspace: 0pt;width: 100%;">
<tr>
% for _ in range(rating):
<td class="star-rating full" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.8rem;vertical-align: bottom;margin-left: 4px;line-height: 1rem;width: 0.5rem;display: inline-block;color: #E5A00D;">&#9733;</td>
% endfor
% for _ in range(5-rating):
<td class="star-rating empty" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.8rem;vertical-align: bottom;margin-left: 4px;line-height: 1rem;width: 0.5rem;display: inline-block;color: #aaaaaa;">&#9734;</td>
% endfor
</tr>
</table>
</td>
% endif
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
% if not video_b:
<td align="center" valign="top" class="card-instance pad" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;overflow: hidden;padding: 0 !important;width: 251px !important;min-width: 251px !important;max-width: 251px !important;"></td>
% endif
% endif
% endfor
</tr>
</table>
</td>
</tr>
% endfor
</table>
</td>
</tr>
% endif
<tr>
<td class="footer" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 12px;vertical-align: top;clear: both;margin-top: 10px;text-align: center;width: 100%;">
<div class="footer-bar" style="margin-left: auto;margin-right: auto;width: 200px;border-top: 1px solid #E5A00D;margin-top: 25px;"></div>

View File

@@ -694,7 +694,7 @@
<div class="sub-header-count">
<span class="count">${len(recently_added['show'])}</span> <span class="count-units">show${'s' if len(recently_added['show']) > 1 else ''}</span>&nbsp;/&nbsp;
<% total_episodes = sum(season['episode_count'] for show in recently_added['show'] for season in show['season']) %>
<span class="count">${total_episodes}</span> <span class="count-units">episode${'s' if total > 1 else ''}</span>
<span class="count">${total_episodes}</span> <span class="count-units">episode${'s' if total_episodes > 1 else ''}</span>
</div>
</td>
</tr>
@@ -847,7 +847,7 @@
<div class="sub-header-count">
<span class="count">${len(recently_added['artist'])}</span> <span class="count-units">artist${'s' if len(recently_added['artist']) > 1 else ''}</span> /
<% total_albums = sum(artist['album_count'] for artist in recently_added['artist']) %>
<span class="count">${total_albums}</span> <span class="count-units">album${'s' if total > 1 else ''}</span>
<span class="count">${total_albums}</span> <span class="count-units">album${'s' if total_albums > 1 else ''}</span>
</div>
</td>
</tr>
@@ -953,6 +953,124 @@
</td>
</tr>
% endif
% if recently_added.get('other_video'):
<tr>
<td class="wrapper">
<div class="sub-header-bar"></div>
<div class="sub-header-title">
<img src="${(base_url_image + 'images/libraries/video.png') if base_url_image else 'https://tautulli.com/images/libraries/video.png'}" class="sub-header-icon" width="30" height="30"/> Recently Added Videos
</div>
<div class="sub-header-count">
<span class="count">${len(recently_added['other_video'])}</span> <span class="count-units">video${'s' if len(recently_added['other_video']) > 1 else ''}</span>
</div>
</td>
</tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0">
% for video_a, video_b in grouper(recently_added['other_video'], 2):
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
% for video in (video_a, video_b):
% if video:
% if not video_b:
<td align="center" valign="top" class="card-instance pad"></td>
% endif
<td align="center" valign="top" class="card-instance movie">
<table border="0" cellpadding="0" cellspacing="3" width="100%" class="card-background" style="background-image: url(${(base_url_image + video['art_hash']) if base_url_image else video['art_url']});">
<tr>
<td class="card-poster-container">
<table border="0" cellpadding="0" cellspacing="0" class="card-poster" style="background-image: url(${(base_url_image + video['thumb_hash']) if base_url_image else video['thumb_url']})">
<tr>
<td>
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank">
<img class="card-poster-overlay" src="${base_url_image + 'images/newsletter/view-on-plex-poster.png' if base_url_image else 'https://tautulli.com/images/newsletter/view-on-plex-poster.png'}" width="150" height="225">
</a>
</td>
</tr>
</table>
</td>
<td class="card-info-container">
<table border="0" cellpadding="0" cellspacing="0" class="card-info-container-table">
<tr>
<td class="card-info-title nowrap">
<a href="${parameters['pms_web_url']}#!/server/${parameters['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${video['rating_key']}" title="${video['title']}" target="_blank">${video['title']}</a>
</td>
</tr>
<tr>
<td class="card-info-body">
% if video['tagline']:
<p class="nowrap mb5">
<em>${video['tagline']}</em>
</p>
% endif
<p>
${video['summary'][:450] + (video['summary'][450:] and '...')}
</p>
</td>
</tr>
<tr>
<td class="card-info-footer nowrap">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="badge-container">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
% if video['year']:
<td class="badge" title="${video['year']}">${video['year']}</td>
% endif
% if video['duration']:
<% duration = int(int(video['duration'])/60000) %>
<td class="badge" title="${duration} mins">${duration} mins</td>
% endif
% if video['genres']:
% for genre in video['genres'][:]:
<td class="badge" title="${genre}">${genre}</td>
% endfor
% endif
</tr>
</table>
</td>
% if video['rating']:
<% rating = int(round(float(video['rating']) / 2)) %>
<td class="star-rating-container" title="${int(float(video['rating'])/0.1)}%" align="right">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
% for _ in range(rating):
<td class="star-rating full">&#9733;</td>
% endfor
% for _ in range(5-rating):
<td class="star-rating empty">&#9734;</td>
% endfor
</tr>
</table>
</td>
% endif
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
% if not video_b:
<td align="center" valign="top" class="card-instance pad"></td>
% endif
% endif
% endfor
</tr>
</table>
</td>
</tr>
% endfor
</table>
</td>
</tr>
% endif
<tr>
<td class="footer">
<div class="footer-bar"></div>

0
init-scripts/init.fedora.centos.service Normal file → Executable file
View File

View File

@@ -24,9 +24,12 @@
# - The example settings in this file assume that Tautulli is installed to: /opt/Tautulli
#
# - To create this user and give it ownership of the Tautulli directory:
# sudo adduser --system --no-create-home tautulli
# sudo chown tautulli:nogroup -R /opt/Tautulli
#
# 1. Create the user:
# Ubuntu/Debian: sudo addgroup tautulli && sudo adduser --system --no-create-home tautulli --ingroup tautulli
# CentOS/Fedora: sudo adduser --system --no-create-home tautulli
# 2. Give the user ownership of the Tautulli directory:
# sudo chown tautulli:tautulli -R /opt/Tautulli
#
# - Adjust ExecStart= to point to:
# 1. Your Tautulli executable
# - Default: /opt/Tautulli/Tautulli.py
@@ -51,7 +54,11 @@ ExecStart=/opt/Tautulli/Tautulli.py --config /opt/Tautulli/config.ini --datadir
GuessMainPID=no
Type=forking
User=tautulli
Group=nogroup
Group=tautulli
Restart=on-abnormal
RestartSec=5
StartLimitInterval=90
StartLimitBurst=3
[Install]
WantedBy=multi-user.target

View File

@@ -1,3 +1,3 @@
from .core import where, old_where
from .core import where
__version__ = "2017.11.05"
__version__ = "2019.11.28"

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@@ -8,30 +7,9 @@ certifi.py
This module returns the installation location of cacert.pem.
"""
import os
import warnings
class DeprecatedBundleWarning(DeprecationWarning):
"""
The weak security bundle is being deprecated. Please bother your service
provider to get them to stop using cross-signed roots.
"""
def where():
f = os.path.dirname(__file__)
return os.path.join(f, 'cacert.pem')
def old_where():
warnings.warn(
"The weak security bundle is being deprecated. It will be removed in "
"2018.",
DeprecatedBundleWarning
)
f = os.path.dirname(__file__)
return os.path.join(f, 'weak.pem')
if __name__ == '__main__':
print(where())

View File

@@ -1,414 +0,0 @@
# Issuer: CN=Entrust.net Secure Server Certification Authority O=Entrust.net OU=www.entrust.net/CPS incorp. by ref. (limits liab.)/(c) 1999 Entrust.net Limited
# Subject: CN=Entrust.net Secure Server Certification Authority O=Entrust.net OU=www.entrust.net/CPS incorp. by ref. (limits liab.)/(c) 1999 Entrust.net Limited
# Label: "Entrust.net Secure Server CA"
# Serial: 927650371
# MD5 Fingerprint: df:f2:80:73:cc:f1:e6:61:73:fc:f5:42:e9:c5:7c:ee
# SHA1 Fingerprint: 99:a6:9b:e6:1a:fe:88:6b:4d:2b:82:00:7c:b8:54:fc:31:7e:15:39
# SHA256 Fingerprint: 62:f2:40:27:8c:56:4c:4d:d8:bf:7d:9d:4f:6f:36:6e:a8:94:d2:2f:5f:34:d9:89:a9:83:ac:ec:2f:ff:ed:50
-----BEGIN CERTIFICATE-----
MIIE2DCCBEGgAwIBAgIEN0rSQzANBgkqhkiG9w0BAQUFADCBwzELMAkGA1UEBhMC
VVMxFDASBgNVBAoTC0VudHJ1c3QubmV0MTswOQYDVQQLEzJ3d3cuZW50cnVzdC5u
ZXQvQ1BTIGluY29ycC4gYnkgcmVmLiAobGltaXRzIGxpYWIuKTElMCMGA1UECxMc
KGMpIDE5OTkgRW50cnVzdC5uZXQgTGltaXRlZDE6MDgGA1UEAxMxRW50cnVzdC5u
ZXQgU2VjdXJlIFNlcnZlciBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw05OTA1
MjUxNjA5NDBaFw0xOTA1MjUxNjM5NDBaMIHDMQswCQYDVQQGEwJVUzEUMBIGA1UE
ChMLRW50cnVzdC5uZXQxOzA5BgNVBAsTMnd3dy5lbnRydXN0Lm5ldC9DUFMgaW5j
b3JwLiBieSByZWYuIChsaW1pdHMgbGlhYi4pMSUwIwYDVQQLExwoYykgMTk5OSBF
bnRydXN0Lm5ldCBMaW1pdGVkMTowOAYDVQQDEzFFbnRydXN0Lm5ldCBTZWN1cmUg
U2VydmVyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGdMA0GCSqGSIb3DQEBAQUA
A4GLADCBhwKBgQDNKIM0VBuJ8w+vN5Ex/68xYMmo6LIQaO2f55M28Qpku0f1BBc/
I0dNxScZgSYMVHINiC3ZH5oSn7yzcdOAGT9HZnuMNSjSuQrfJNqc1lB5gXpa0zf3
wkrYKZImZNHkmGw6AIr1NJtl+O3jEP/9uElY3KDegjlrgbEWGWG5VLbmQwIBA6OC
AdcwggHTMBEGCWCGSAGG+EIBAQQEAwIABzCCARkGA1UdHwSCARAwggEMMIHeoIHb
oIHYpIHVMIHSMQswCQYDVQQGEwJVUzEUMBIGA1UEChMLRW50cnVzdC5uZXQxOzA5
BgNVBAsTMnd3dy5lbnRydXN0Lm5ldC9DUFMgaW5jb3JwLiBieSByZWYuIChsaW1p
dHMgbGlhYi4pMSUwIwYDVQQLExwoYykgMTk5OSBFbnRydXN0Lm5ldCBMaW1pdGVk
MTowOAYDVQQDEzFFbnRydXN0Lm5ldCBTZWN1cmUgU2VydmVyIENlcnRpZmljYXRp
b24gQXV0aG9yaXR5MQ0wCwYDVQQDEwRDUkwxMCmgJ6AlhiNodHRwOi8vd3d3LmVu
dHJ1c3QubmV0L0NSTC9uZXQxLmNybDArBgNVHRAEJDAigA8xOTk5MDUyNTE2MDk0
MFqBDzIwMTkwNTI1MTYwOTQwWjALBgNVHQ8EBAMCAQYwHwYDVR0jBBgwFoAU8Bdi
E1U9s/8KAGv7UISX8+1i0BowHQYDVR0OBBYEFPAXYhNVPbP/CgBr+1CEl/PtYtAa
MAwGA1UdEwQFMAMBAf8wGQYJKoZIhvZ9B0EABAwwChsEVjQuMAMCBJAwDQYJKoZI
hvcNAQEFBQADgYEAkNwwAvpkdMKnCqV8IY00F6j7Rw7/JXyNEwr75Ji174z4xRAN
95K+8cPV1ZVqBLssziY2ZcgxxufuP+NXdYR6Ee9GTxj005i7qIcyunL2POI9n9cd
2cNgQ4xYDiKWL2KjLB+6rQXvqzJ4h6BUcxm1XAX5Uj5tLUUL9wqT6u0G+bI=
-----END CERTIFICATE-----
# Issuer: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 2 Policy Validation Authority
# Subject: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 2 Policy Validation Authority
# Label: "ValiCert Class 2 VA"
# Serial: 1
# MD5 Fingerprint: a9:23:75:9b:ba:49:36:6e:31:c2:db:f2:e7:66:ba:87
# SHA1 Fingerprint: 31:7a:2a:d0:7f:2b:33:5e:f5:a1:c3:4e:4b:57:e8:b7:d8:f1:fc:a6
# SHA256 Fingerprint: 58:d0:17:27:9c:d4:dc:63:ab:dd:b1:96:a6:c9:90:6c:30:c4:e0:87:83:ea:e8:c1:60:99:54:d6:93:55:59:6b
-----BEGIN CERTIFICATE-----
MIIC5zCCAlACAQEwDQYJKoZIhvcNAQEFBQAwgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0
IFZhbGlkYXRpb24gTmV0d29yazEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAz
BgNVBAsTLFZhbGlDZXJ0IENsYXNzIDIgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9y
aXR5MSEwHwYDVQQDExhodHRwOi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG
9w0BCQEWEWluZm9AdmFsaWNlcnQuY29tMB4XDTk5MDYyNjAwMTk1NFoXDTE5MDYy
NjAwMTk1NFowgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0IFZhbGlkYXRpb24gTmV0d29y
azEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAzBgNVBAsTLFZhbGlDZXJ0IENs
YXNzIDIgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9yaXR5MSEwHwYDVQQDExhodHRw
Oi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG9w0BCQEWEWluZm9AdmFsaWNl
cnQuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDOOnHK5avIWZJV16vY
dA757tn2VUdZZUcOBVXc65g2PFxTXdMwzzjsvUGJ7SVCCSRrCl6zfN1SLUzm1NZ9
WlmpZdRJEy0kTRxQb7XBhVQ7/nHk01xC+YDgkRoKWzk2Z/M/VXwbP7RfZHM047QS
v4dk+NoS/zcnwbNDu+97bi5p9wIDAQABMA0GCSqGSIb3DQEBBQUAA4GBADt/UG9v
UJSZSWI4OB9L+KXIPqeCgfYrx+jFzug6EILLGACOTb2oWH+heQC1u+mNr0HZDzTu
IYEZoDJJKPTEjlbVUjP9UNV+mWwD5MlM/Mtsq2azSiGM5bUMMj4QssxsodyamEwC
W/POuZ6lcg5Ktz885hZo+L7tdEy8W9ViH0Pd
-----END CERTIFICATE-----
# Issuer: CN=NetLock Expressz (Class C) Tanusitvanykiado O=NetLock Halozatbiztonsagi Kft. OU=Tanusitvanykiadok
# Subject: CN=NetLock Expressz (Class C) Tanusitvanykiado O=NetLock Halozatbiztonsagi Kft. OU=Tanusitvanykiadok
# Label: "NetLock Express (Class C) Root"
# Serial: 104
# MD5 Fingerprint: 4f:eb:f1:f0:70:c2:80:63:5d:58:9f:da:12:3c:a9:c4
# SHA1 Fingerprint: e3:92:51:2f:0a:cf:f5:05:df:f6:de:06:7f:75:37:e1:65:ea:57:4b
# SHA256 Fingerprint: 0b:5e:ed:4e:84:64:03:cf:55:e0:65:84:84:40:ed:2a:82:75:8b:f5:b9:aa:1f:25:3d:46:13:cf:a0:80:ff:3f
-----BEGIN CERTIFICATE-----
MIIFTzCCBLigAwIBAgIBaDANBgkqhkiG9w0BAQQFADCBmzELMAkGA1UEBhMCSFUx
ETAPBgNVBAcTCEJ1ZGFwZXN0MScwJQYDVQQKEx5OZXRMb2NrIEhhbG96YXRiaXp0
b25zYWdpIEtmdC4xGjAYBgNVBAsTEVRhbnVzaXR2YW55a2lhZG9rMTQwMgYDVQQD
EytOZXRMb2NrIEV4cHJlc3N6IChDbGFzcyBDKSBUYW51c2l0dmFueWtpYWRvMB4X
DTk5MDIyNTE0MDgxMVoXDTE5MDIyMDE0MDgxMVowgZsxCzAJBgNVBAYTAkhVMREw
DwYDVQQHEwhCdWRhcGVzdDEnMCUGA1UEChMeTmV0TG9jayBIYWxvemF0Yml6dG9u
c2FnaSBLZnQuMRowGAYDVQQLExFUYW51c2l0dmFueWtpYWRvazE0MDIGA1UEAxMr
TmV0TG9jayBFeHByZXNzeiAoQ2xhc3MgQykgVGFudXNpdHZhbnlraWFkbzCBnzAN
BgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA6+ywbGGKIyWvYCDj2Z/8kwvbXY2wobNA
OoLO/XXgeDIDhlqGlZHtU/qdQPzm6N3ZW3oDvV3zOwzDUXmbrVWg6dADEK8KuhRC
2VImESLH0iDMgqSaqf64gXadarfSNnU+sYYJ9m5tfk63euyucYT2BDMIJTLrdKwW
RMbkQJMdf60CAwEAAaOCAp8wggKbMBIGA1UdEwEB/wQIMAYBAf8CAQQwDgYDVR0P
AQH/BAQDAgAGMBEGCWCGSAGG+EIBAQQEAwIABzCCAmAGCWCGSAGG+EIBDQSCAlEW
ggJNRklHWUVMRU0hIEV6ZW4gdGFudXNpdHZhbnkgYSBOZXRMb2NrIEtmdC4gQWx0
YWxhbm9zIFN6b2xnYWx0YXRhc2kgRmVsdGV0ZWxlaWJlbiBsZWlydCBlbGphcmFz
b2sgYWxhcGphbiBrZXN6dWx0LiBBIGhpdGVsZXNpdGVzIGZvbHlhbWF0YXQgYSBO
ZXRMb2NrIEtmdC4gdGVybWVrZmVsZWxvc3NlZy1iaXp0b3NpdGFzYSB2ZWRpLiBB
IGRpZ2l0YWxpcyBhbGFpcmFzIGVsZm9nYWRhc2FuYWsgZmVsdGV0ZWxlIGF6IGVs
b2lydCBlbGxlbm9yemVzaSBlbGphcmFzIG1lZ3RldGVsZS4gQXogZWxqYXJhcyBs
ZWlyYXNhIG1lZ3RhbGFsaGF0byBhIE5ldExvY2sgS2Z0LiBJbnRlcm5ldCBob25s
YXBqYW4gYSBodHRwczovL3d3dy5uZXRsb2NrLm5ldC9kb2NzIGNpbWVuIHZhZ3kg
a2VyaGV0byBheiBlbGxlbm9yemVzQG5ldGxvY2submV0IGUtbWFpbCBjaW1lbi4g
SU1QT1JUQU5UISBUaGUgaXNzdWFuY2UgYW5kIHRoZSB1c2Ugb2YgdGhpcyBjZXJ0
aWZpY2F0ZSBpcyBzdWJqZWN0IHRvIHRoZSBOZXRMb2NrIENQUyBhdmFpbGFibGUg
YXQgaHR0cHM6Ly93d3cubmV0bG9jay5uZXQvZG9jcyBvciBieSBlLW1haWwgYXQg
Y3BzQG5ldGxvY2submV0LjANBgkqhkiG9w0BAQQFAAOBgQAQrX/XDDKACtiG8XmY
ta3UzbM2xJZIwVzNmtkFLp++UOv0JhQQLdRmF/iewSf98e3ke0ugbLWrmldwpu2g
pO0u9f38vf5NNwgMvOOWgyL1SRt/Syu0VMGAfJlOHdCM7tCs5ZL6dVb+ZKATj7i4
Fp1hBWeAyNDYpQcCNJgEjTME1A==
-----END CERTIFICATE-----
# Issuer: CN=NetLock Uzleti (Class B) Tanusitvanykiado O=NetLock Halozatbiztonsagi Kft. OU=Tanusitvanykiadok
# Subject: CN=NetLock Uzleti (Class B) Tanusitvanykiado O=NetLock Halozatbiztonsagi Kft. OU=Tanusitvanykiadok
# Label: "NetLock Business (Class B) Root"
# Serial: 105
# MD5 Fingerprint: 39:16:aa:b9:6a:41:e1:14:69:df:9e:6c:3b:72:dc:b6
# SHA1 Fingerprint: 87:9f:4b:ee:05:df:98:58:3b:e3:60:d6:33:e7:0d:3f:fe:98:71:af
# SHA256 Fingerprint: 39:df:7b:68:2b:7b:93:8f:84:71:54:81:cc:de:8d:60:d8:f2:2e:c5:98:87:7d:0a:aa:c1:2b:59:18:2b:03:12
-----BEGIN CERTIFICATE-----
MIIFSzCCBLSgAwIBAgIBaTANBgkqhkiG9w0BAQQFADCBmTELMAkGA1UEBhMCSFUx
ETAPBgNVBAcTCEJ1ZGFwZXN0MScwJQYDVQQKEx5OZXRMb2NrIEhhbG96YXRiaXp0
b25zYWdpIEtmdC4xGjAYBgNVBAsTEVRhbnVzaXR2YW55a2lhZG9rMTIwMAYDVQQD
EylOZXRMb2NrIFV6bGV0aSAoQ2xhc3MgQikgVGFudXNpdHZhbnlraWFkbzAeFw05
OTAyMjUxNDEwMjJaFw0xOTAyMjAxNDEwMjJaMIGZMQswCQYDVQQGEwJIVTERMA8G
A1UEBxMIQnVkYXBlc3QxJzAlBgNVBAoTHk5ldExvY2sgSGFsb3phdGJpenRvbnNh
Z2kgS2Z0LjEaMBgGA1UECxMRVGFudXNpdHZhbnlraWFkb2sxMjAwBgNVBAMTKU5l
dExvY2sgVXpsZXRpIChDbGFzcyBCKSBUYW51c2l0dmFueWtpYWRvMIGfMA0GCSqG
SIb3DQEBAQUAA4GNADCBiQKBgQCx6gTsIKAjwo84YM/HRrPVG/77uZmeBNwcf4xK
gZjupNTKihe5In+DCnVMm8Bp2GQ5o+2So/1bXHQawEfKOml2mrriRBf8TKPV/riX
iK+IA4kfpPIEPsgHC+b5sy96YhQJRhTKZPWLgLViqNhr1nGTLbO/CVRY7QbrqHvc
Q7GhaQIDAQABo4ICnzCCApswEgYDVR0TAQH/BAgwBgEB/wIBBDAOBgNVHQ8BAf8E
BAMCAAYwEQYJYIZIAYb4QgEBBAQDAgAHMIICYAYJYIZIAYb4QgENBIICURaCAk1G
SUdZRUxFTSEgRXplbiB0YW51c2l0dmFueSBhIE5ldExvY2sgS2Z0LiBBbHRhbGFu
b3MgU3pvbGdhbHRhdGFzaSBGZWx0ZXRlbGVpYmVuIGxlaXJ0IGVsamFyYXNvayBh
bGFwamFuIGtlc3p1bHQuIEEgaGl0ZWxlc2l0ZXMgZm9seWFtYXRhdCBhIE5ldExv
Y2sgS2Z0LiB0ZXJtZWtmZWxlbG9zc2VnLWJpenRvc2l0YXNhIHZlZGkuIEEgZGln
aXRhbGlzIGFsYWlyYXMgZWxmb2dhZGFzYW5hayBmZWx0ZXRlbGUgYXogZWxvaXJ0
IGVsbGVub3J6ZXNpIGVsamFyYXMgbWVndGV0ZWxlLiBBeiBlbGphcmFzIGxlaXJh
c2EgbWVndGFsYWxoYXRvIGEgTmV0TG9jayBLZnQuIEludGVybmV0IGhvbmxhcGph
biBhIGh0dHBzOi8vd3d3Lm5ldGxvY2submV0L2RvY3MgY2ltZW4gdmFneSBrZXJo
ZXRvIGF6IGVsbGVub3J6ZXNAbmV0bG9jay5uZXQgZS1tYWlsIGNpbWVuLiBJTVBP
UlRBTlQhIFRoZSBpc3N1YW5jZSBhbmQgdGhlIHVzZSBvZiB0aGlzIGNlcnRpZmlj
YXRlIGlzIHN1YmplY3QgdG8gdGhlIE5ldExvY2sgQ1BTIGF2YWlsYWJsZSBhdCBo
dHRwczovL3d3dy5uZXRsb2NrLm5ldC9kb2NzIG9yIGJ5IGUtbWFpbCBhdCBjcHNA
bmV0bG9jay5uZXQuMA0GCSqGSIb3DQEBBAUAA4GBAATbrowXr/gOkDFOzT4JwG06
sPgzTEdM43WIEJessDgVkcYplswhwG08pXTP2IKlOcNl40JwuyKQ433bNXbhoLXa
n3BukxowOR0w2y7jfLKRstE3Kfq51hdcR0/jHTjrn9V7lagonhVK0dHQKwCXoOKS
NitjrFgBazMpUIaD8QFI
-----END CERTIFICATE-----
# Issuer: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 3 Policy Validation Authority
# Subject: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 3 Policy Validation Authority
# Label: "RSA Root Certificate 1"
# Serial: 1
# MD5 Fingerprint: a2:6f:53:b7:ee:40:db:4a:68:e7:fa:18:d9:10:4b:72
# SHA1 Fingerprint: 69:bd:8c:f4:9c:d3:00:fb:59:2e:17:93:ca:55:6a:f3:ec:aa:35:fb
# SHA256 Fingerprint: bc:23:f9:8a:31:3c:b9:2d:e3:bb:fc:3a:5a:9f:44:61:ac:39:49:4c:4a:e1:5a:9e:9d:f1:31:e9:9b:73:01:9a
-----BEGIN CERTIFICATE-----
MIIC5zCCAlACAQEwDQYJKoZIhvcNAQEFBQAwgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0
IFZhbGlkYXRpb24gTmV0d29yazEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAz
BgNVBAsTLFZhbGlDZXJ0IENsYXNzIDMgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9y
aXR5MSEwHwYDVQQDExhodHRwOi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG
9w0BCQEWEWluZm9AdmFsaWNlcnQuY29tMB4XDTk5MDYyNjAwMjIzM1oXDTE5MDYy
NjAwMjIzM1owgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0IFZhbGlkYXRpb24gTmV0d29y
azEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAzBgNVBAsTLFZhbGlDZXJ0IENs
YXNzIDMgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9yaXR5MSEwHwYDVQQDExhodHRw
Oi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG9w0BCQEWEWluZm9AdmFsaWNl
cnQuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDjmFGWHOjVsQaBalfD
cnWTq8+epvzzFlLWLU2fNUSoLgRNB0mKOCn1dzfnt6td3zZxFJmP3MKS8edgkpfs
2Ejcv8ECIMYkpChMMFp2bbFc893enhBxoYjHW5tBbcqwuI4V7q0zK89HBFx1cQqY
JJgpp0lZpd34t0NiYfPT4tBVPwIDAQABMA0GCSqGSIb3DQEBBQUAA4GBAFa7AliE
Zwgs3x/be0kz9dNnnfS0ChCzycUs4pJqcXgn8nCDQtM+z6lU9PHYkhaM0QTLS6vJ
n0WuPIqpsHEzXcjFV9+vqDWzf4mH6eglkrh/hXqu1rweN1gqZ8mRzyqBPu3GOd/A
PhmcGcwTTYJBtYze4D1gCCAPRX5ron+jjBXu
-----END CERTIFICATE-----
# Issuer: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 1 Policy Validation Authority
# Subject: CN=http://www.valicert.com/ O=ValiCert, Inc. OU=ValiCert Class 1 Policy Validation Authority
# Label: "ValiCert Class 1 VA"
# Serial: 1
# MD5 Fingerprint: 65:58:ab:15:ad:57:6c:1e:a8:a7:b5:69:ac:bf:ff:eb
# SHA1 Fingerprint: e5:df:74:3c:b6:01:c4:9b:98:43:dc:ab:8c:e8:6a:81:10:9f:e4:8e
# SHA256 Fingerprint: f4:c1:49:55:1a:30:13:a3:5b:c7:bf:fe:17:a7:f3:44:9b:c1:ab:5b:5a:0a:e7:4b:06:c2:3b:90:00:4c:01:04
-----BEGIN CERTIFICATE-----
MIIC5zCCAlACAQEwDQYJKoZIhvcNAQEFBQAwgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0
IFZhbGlkYXRpb24gTmV0d29yazEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAz
BgNVBAsTLFZhbGlDZXJ0IENsYXNzIDEgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9y
aXR5MSEwHwYDVQQDExhodHRwOi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG
9w0BCQEWEWluZm9AdmFsaWNlcnQuY29tMB4XDTk5MDYyNTIyMjM0OFoXDTE5MDYy
NTIyMjM0OFowgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0IFZhbGlkYXRpb24gTmV0d29y
azEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAzBgNVBAsTLFZhbGlDZXJ0IENs
YXNzIDEgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9yaXR5MSEwHwYDVQQDExhodHRw
Oi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG9w0BCQEWEWluZm9AdmFsaWNl
cnQuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDYWYJ6ibiWuqYvaG9Y
LqdUHAZu9OqNSLwxlBfw8068srg1knaw0KWlAdcAAxIiGQj4/xEjm84H9b9pGib+
TunRf50sQB1ZaG6m+FiwnRqP0z/x3BkGgagO4DrdyFNFCQbmD3DD+kCmDuJWBQ8Y
TfwggtFzVXSNdnKgHZ0dwN0/cQIDAQABMA0GCSqGSIb3DQEBBQUAA4GBAFBoPUn0
LBwGlN+VYH+Wexf+T3GtZMjdd9LvWVXoP+iOBSoh8gfStadS/pyxtuJbdxdA6nLW
I8sogTLDAHkY7FkXicnGah5xyf23dKUlRWnFSKsZ4UWKJWsZ7uW7EvV/96aNUcPw
nXS3qT6gpf+2SQMT2iLM7XGCK5nPOrf1LXLI
-----END CERTIFICATE-----
# Issuer: CN=Equifax Secure eBusiness CA-1 O=Equifax Secure Inc.
# Subject: CN=Equifax Secure eBusiness CA-1 O=Equifax Secure Inc.
# Label: "Equifax Secure eBusiness CA 1"
# Serial: 4
# MD5 Fingerprint: 64:9c:ef:2e:44:fc:c6:8f:52:07:d0:51:73:8f:cb:3d
# SHA1 Fingerprint: da:40:18:8b:91:89:a3:ed:ee:ae:da:97:fe:2f:9d:f5:b7:d1:8a:41
# SHA256 Fingerprint: cf:56:ff:46:a4:a1:86:10:9d:d9:65:84:b5:ee:b5:8a:51:0c:42:75:b0:e5:f9:4f:40:bb:ae:86:5e:19:f6:73
-----BEGIN CERTIFICATE-----
MIICgjCCAeugAwIBAgIBBDANBgkqhkiG9w0BAQQFADBTMQswCQYDVQQGEwJVUzEc
MBoGA1UEChMTRXF1aWZheCBTZWN1cmUgSW5jLjEmMCQGA1UEAxMdRXF1aWZheCBT
ZWN1cmUgZUJ1c2luZXNzIENBLTEwHhcNOTkwNjIxMDQwMDAwWhcNMjAwNjIxMDQw
MDAwWjBTMQswCQYDVQQGEwJVUzEcMBoGA1UEChMTRXF1aWZheCBTZWN1cmUgSW5j
LjEmMCQGA1UEAxMdRXF1aWZheCBTZWN1cmUgZUJ1c2luZXNzIENBLTEwgZ8wDQYJ
KoZIhvcNAQEBBQADgY0AMIGJAoGBAM4vGbwXt3fek6lfWg0XTzQaDJj0ItlZ1MRo
RvC0NcWFAyDGr0WlIVFFQesWWDYyb+JQYmT5/VGcqiTZ9J2DKocKIdMSODRsjQBu
WqDZQu4aIZX5UkxVWsUPOE9G+m34LjXWHXzr4vCwdYDIqROsvojvOm6rXyo4YgKw
Env+j6YDAgMBAAGjZjBkMBEGCWCGSAGG+EIBAQQEAwIABzAPBgNVHRMBAf8EBTAD
AQH/MB8GA1UdIwQYMBaAFEp4MlIR21kWNl7fwRQ2QGpHfEyhMB0GA1UdDgQWBBRK
eDJSEdtZFjZe38EUNkBqR3xMoTANBgkqhkiG9w0BAQQFAAOBgQB1W6ibAxHm6VZM
zfmpTMANmvPMZWnmJXbMWbfWVMMdzZmsGd20hdXgPfxiIKeES1hl8eL5lSE/9dR+
WB5Hh1Q+WKG1tfgq73HnvMP2sUlG4tega+VWeponmHxGYhTnyfxuAxJ5gDgdSIKN
/Bf+KpYrtWKmpj29f5JZzVoqgrI3eQ==
-----END CERTIFICATE-----
# Issuer: CN=Equifax Secure Global eBusiness CA-1 O=Equifax Secure Inc.
# Subject: CN=Equifax Secure Global eBusiness CA-1 O=Equifax Secure Inc.
# Label: "Equifax Secure Global eBusiness CA"
# Serial: 1
# MD5 Fingerprint: 8f:5d:77:06:27:c4:98:3c:5b:93:78:e7:d7:7d:9b:cc
# SHA1 Fingerprint: 7e:78:4a:10:1c:82:65:cc:2d:e1:f1:6d:47:b4:40:ca:d9:0a:19:45
# SHA256 Fingerprint: 5f:0b:62:ea:b5:e3:53:ea:65:21:65:16:58:fb:b6:53:59:f4:43:28:0a:4a:fb:d1:04:d7:7d:10:f9:f0:4c:07
-----BEGIN CERTIFICATE-----
MIICkDCCAfmgAwIBAgIBATANBgkqhkiG9w0BAQQFADBaMQswCQYDVQQGEwJVUzEc
MBoGA1UEChMTRXF1aWZheCBTZWN1cmUgSW5jLjEtMCsGA1UEAxMkRXF1aWZheCBT
ZWN1cmUgR2xvYmFsIGVCdXNpbmVzcyBDQS0xMB4XDTk5MDYyMTA0MDAwMFoXDTIw
MDYyMTA0MDAwMFowWjELMAkGA1UEBhMCVVMxHDAaBgNVBAoTE0VxdWlmYXggU2Vj
dXJlIEluYy4xLTArBgNVBAMTJEVxdWlmYXggU2VjdXJlIEdsb2JhbCBlQnVzaW5l
c3MgQ0EtMTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAuucXkAJlsTRVPEnC
UdXfp9E3j9HngXNBUmCbnaEXJnitx7HoJpQytd4zjTov2/KaelpzmKNc6fuKcxtc
58O/gGzNqfTWK8D3+ZmqY6KxRwIP1ORROhI8bIpaVIRw28HFkM9yRcuoWcDNM50/
o5brhTMhHD4ePmBudpxnhcXIw2ECAwEAAaNmMGQwEQYJYIZIAYb4QgEBBAQDAgAH
MA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUvqigdHJQa0S3ySPY+6j/s1dr
aGwwHQYDVR0OBBYEFL6ooHRyUGtEt8kj2Puo/7NXa2hsMA0GCSqGSIb3DQEBBAUA
A4GBADDiAVGqx+pf2rnQZQ8w1j7aDRRJbpGTJxQx78T3LUX47Me/okENI7SS+RkA
Z70Br83gcfxaz2TE4JaY0KNA4gGK7ycH8WUBikQtBmV1UsCGECAhX2xrD2yuCRyv
8qIYNMR1pHMc8Y3c7635s3a0kr/clRAevsvIO1qEYBlWlKlV
-----END CERTIFICATE-----
# Issuer: CN=Thawte Premium Server CA O=Thawte Consulting cc OU=Certification Services Division
# Subject: CN=Thawte Premium Server CA O=Thawte Consulting cc OU=Certification Services Division
# Label: "Thawte Premium Server CA"
# Serial: 1
# MD5 Fingerprint: 06:9f:69:79:16:66:90:02:1b:8c:8c:a2:c3:07:6f:3a
# SHA1 Fingerprint: 62:7f:8d:78:27:65:63:99:d2:7d:7f:90:44:c9:fe:b3:f3:3e:fa:9a
# SHA256 Fingerprint: ab:70:36:36:5c:71:54:aa:29:c2:c2:9f:5d:41:91:16:3b:16:2a:22:25:01:13:57:d5:6d:07:ff:a7:bc:1f:72
-----BEGIN CERTIFICATE-----
MIIDJzCCApCgAwIBAgIBATANBgkqhkiG9w0BAQQFADCBzjELMAkGA1UEBhMCWkEx
FTATBgNVBAgTDFdlc3Rlcm4gQ2FwZTESMBAGA1UEBxMJQ2FwZSBUb3duMR0wGwYD
VQQKExRUaGF3dGUgQ29uc3VsdGluZyBjYzEoMCYGA1UECxMfQ2VydGlmaWNhdGlv
biBTZXJ2aWNlcyBEaXZpc2lvbjEhMB8GA1UEAxMYVGhhd3RlIFByZW1pdW0gU2Vy
dmVyIENBMSgwJgYJKoZIhvcNAQkBFhlwcmVtaXVtLXNlcnZlckB0aGF3dGUuY29t
MB4XDTk2MDgwMTAwMDAwMFoXDTIwMTIzMTIzNTk1OVowgc4xCzAJBgNVBAYTAlpB
MRUwEwYDVQQIEwxXZXN0ZXJuIENhcGUxEjAQBgNVBAcTCUNhcGUgVG93bjEdMBsG
A1UEChMUVGhhd3RlIENvbnN1bHRpbmcgY2MxKDAmBgNVBAsTH0NlcnRpZmljYXRp
b24gU2VydmljZXMgRGl2aXNpb24xITAfBgNVBAMTGFRoYXd0ZSBQcmVtaXVtIFNl
cnZlciBDQTEoMCYGCSqGSIb3DQEJARYZcHJlbWl1bS1zZXJ2ZXJAdGhhd3RlLmNv
bTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA0jY2aovXwlue2oFBYo847kkE
VdbQ7xwblRZH7xhINTpS9CtqBo87L+pW46+GjZ4X9560ZXUCTe/LCaIhUdib0GfQ
ug2SBhRz1JPLlyoAnFxODLz6FVL88kRu2hFKbgifLy3j+ao6hnO2RlNYyIkFvYMR
uHM/qgeN9EJN50CdHDcCAwEAAaMTMBEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG
9w0BAQQFAAOBgQAmSCwWwlj66BZ0DKqqX1Q/8tfJeGBeXm43YyJ3Nn6yF8Q0ufUI
hfzJATj/Tb7yFkJD57taRvvBxhEf8UqwKEbJw8RCfbz6q1lu1bdRiBHjpIUZa4JM
pAwSremkrj/xw0llmozFyD4lt5SZu5IycQfwhl7tUCemDaYj+bvLpgcUQg==
-----END CERTIFICATE-----
# Issuer: CN=Thawte Server CA O=Thawte Consulting cc OU=Certification Services Division
# Subject: CN=Thawte Server CA O=Thawte Consulting cc OU=Certification Services Division
# Label: "Thawte Server CA"
# Serial: 1
# MD5 Fingerprint: c5:70:c4:a2:ed:53:78:0c:c8:10:53:81:64:cb:d0:1d
# SHA1 Fingerprint: 23:e5:94:94:51:95:f2:41:48:03:b4:d5:64:d2:a3:a3:f5:d8:8b:8c
# SHA256 Fingerprint: b4:41:0b:73:e2:e6:ea:ca:47:fb:c4:2f:8f:a4:01:8a:f4:38:1d:c5:4c:fa:a8:44:50:46:1e:ed:09:45:4d:e9
-----BEGIN CERTIFICATE-----
MIIDEzCCAnygAwIBAgIBATANBgkqhkiG9w0BAQQFADCBxDELMAkGA1UEBhMCWkEx
FTATBgNVBAgTDFdlc3Rlcm4gQ2FwZTESMBAGA1UEBxMJQ2FwZSBUb3duMR0wGwYD
VQQKExRUaGF3dGUgQ29uc3VsdGluZyBjYzEoMCYGA1UECxMfQ2VydGlmaWNhdGlv
biBTZXJ2aWNlcyBEaXZpc2lvbjEZMBcGA1UEAxMQVGhhd3RlIFNlcnZlciBDQTEm
MCQGCSqGSIb3DQEJARYXc2VydmVyLWNlcnRzQHRoYXd0ZS5jb20wHhcNOTYwODAx
MDAwMDAwWhcNMjAxMjMxMjM1OTU5WjCBxDELMAkGA1UEBhMCWkExFTATBgNVBAgT
DFdlc3Rlcm4gQ2FwZTESMBAGA1UEBxMJQ2FwZSBUb3duMR0wGwYDVQQKExRUaGF3
dGUgQ29uc3VsdGluZyBjYzEoMCYGA1UECxMfQ2VydGlmaWNhdGlvbiBTZXJ2aWNl
cyBEaXZpc2lvbjEZMBcGA1UEAxMQVGhhd3RlIFNlcnZlciBDQTEmMCQGCSqGSIb3
DQEJARYXc2VydmVyLWNlcnRzQHRoYXd0ZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQAD
gY0AMIGJAoGBANOkUG7I/1Zr5s9dtuoMaHVHoqrC2oQl/Kj0R1HahbUgdJSGHg91
yekIYfUGbTBuFRkC6VLAYttNmZ7iagxEOM3+vuNkCXDF/rFrKbYvScg71CcEJRCX
L+eQbcAoQpnXTEPew/UhbVSfXcNY4cDk2VuwuNy0e982OsK1ZiIS1ocNAgMBAAGj
EzARMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAB/pMaVz7lcxG
7oWDTSEwjsrZqG9JGubaUeNgcGyEYRGhGshIPllDfU+VPaGLtwtimHp1it2ITk6e
QNuozDJ0uW8NxuOzRAvZim+aKZuZGCg70eNAKJpaPNW15yAbi8qkq43pUdniTCxZ
qdq5snUb9kLy78fyGPmJvKP/iiMucEc=
-----END CERTIFICATE-----
# Issuer: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority
# Subject: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority
# Label: "Verisign Class 3 Public Primary Certification Authority"
# Serial: 149843929435818692848040365716851702463
# MD5 Fingerprint: 10:fc:63:5d:f6:26:3e:0d:f3:25:be:5f:79:cd:67:67
# SHA1 Fingerprint: 74:2c:31:92:e6:07:e4:24:eb:45:49:54:2b:e1:bb:c5:3e:61:74:e2
# SHA256 Fingerprint: e7:68:56:34:ef:ac:f6:9a:ce:93:9a:6b:25:5b:7b:4f:ab:ef:42:93:5b:50:a2:65:ac:b5:cb:60:27:e4:4e:70
-----BEGIN CERTIFICATE-----
MIICPDCCAaUCEHC65B0Q2Sk0tjjKewPMur8wDQYJKoZIhvcNAQECBQAwXzELMAkG
A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFz
cyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTk2
MDEyOTAwMDAwMFoXDTI4MDgwMTIzNTk1OVowXzELMAkGA1UEBhMCVVMxFzAVBgNV
BAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFzcyAzIFB1YmxpYyBQcmlt
YXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GN
ADCBiQKBgQDJXFme8huKARS0EN8EQNvjV69qRUCPhAwL0TPZ2RHP7gJYHyX3KqhE
BarsAx94f56TuZoAqiN91qyFomNFx3InzPRMxnVx0jnvT0Lwdd8KkMaOIG+YD/is
I19wKTakyYbnsZogy1Olhec9vn2a/iRFM9x2Fe0PonFkTGUugWhFpwIDAQABMA0G
CSqGSIb3DQEBAgUAA4GBALtMEivPLCYATxQT3ab7/AoRhIzzKBxnki98tsX63/Do
lbwdj2wsqFHMc9ikwFPwTtYmwHYBV4GSXiHx0bH/59AhWM1pF+NEHJwZRDmJXNyc
AA9WjQKZ7aKQRUzkuxCkPfAyAw7xzvjoyVGM5mKf5p/AfbdynMk2OmufTqj/ZA1k
-----END CERTIFICATE-----
# Issuer: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority
# Subject: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority
# Label: "Verisign Class 3 Public Primary Certification Authority"
# Serial: 80507572722862485515306429940691309246
# MD5 Fingerprint: ef:5a:f1:33:ef:f1:cd:bb:51:02:ee:12:14:4b:96:c4
# SHA1 Fingerprint: a1:db:63:93:91:6f:17:e4:18:55:09:40:04:15:c7:02:40:b0:ae:6b
# SHA256 Fingerprint: a4:b6:b3:99:6f:c2:f3:06:b3:fd:86:81:bd:63:41:3d:8c:50:09:cc:4f:a3:29:c2:cc:f0:e2:fa:1b:14:03:05
-----BEGIN CERTIFICATE-----
MIICPDCCAaUCEDyRMcsf9tAbDpq40ES/Er4wDQYJKoZIhvcNAQEFBQAwXzELMAkG
A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFz
cyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTk2
MDEyOTAwMDAwMFoXDTI4MDgwMjIzNTk1OVowXzELMAkGA1UEBhMCVVMxFzAVBgNV
BAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFzcyAzIFB1YmxpYyBQcmlt
YXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GN
ADCBiQKBgQDJXFme8huKARS0EN8EQNvjV69qRUCPhAwL0TPZ2RHP7gJYHyX3KqhE
BarsAx94f56TuZoAqiN91qyFomNFx3InzPRMxnVx0jnvT0Lwdd8KkMaOIG+YD/is
I19wKTakyYbnsZogy1Olhec9vn2a/iRFM9x2Fe0PonFkTGUugWhFpwIDAQABMA0G
CSqGSIb3DQEBBQUAA4GBABByUqkFFBkyCEHwxWsKzH4PIRnN5GfcX6kb5sroc50i
2JhucwNhkcV8sEVAbkSdjbCxlnRhLQ2pRdKkkirWmnWXbj9T/UWZYB2oK0z5XqcJ
2HUw19JlYD1n1khVdWk/kfVIC0dpImmClr7JyDiGSnoscxlIaU5rfGW/D/xwzoiQ
-----END CERTIFICATE-----
# Issuer: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority - G2/(c) 1998 VeriSign, Inc. - For authorized use only/VeriSign Trust Network
# Subject: O=VeriSign, Inc. OU=Class 3 Public Primary Certification Authority - G2/(c) 1998 VeriSign, Inc. - For authorized use only/VeriSign Trust Network
# Label: "Verisign Class 3 Public Primary Certification Authority - G2"
# Serial: 167285380242319648451154478808036881606
# MD5 Fingerprint: a2:33:9b:4c:74:78:73:d4:6c:e7:c1:f3:8d:cb:5c:e9
# SHA1 Fingerprint: 85:37:1c:a6:e5:50:14:3d:ce:28:03:47:1b:de:3a:09:e8:f8:77:0f
# SHA256 Fingerprint: 83:ce:3c:12:29:68:8a:59:3d:48:5f:81:97:3c:0f:91:95:43:1e:da:37:cc:5e:36:43:0e:79:c7:a8:88:63:8b
-----BEGIN CERTIFICATE-----
MIIDAjCCAmsCEH3Z/gfPqB63EHln+6eJNMYwDQYJKoZIhvcNAQEFBQAwgcExCzAJ
BgNVBAYTAlVTMRcwFQYDVQQKEw5WZXJpU2lnbiwgSW5jLjE8MDoGA1UECxMzQ2xh
c3MgMyBQdWJsaWMgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcy
MTowOAYDVQQLEzEoYykgMTk5OCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3Jp
emVkIHVzZSBvbmx5MR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMB4X
DTk4MDUxODAwMDAwMFoXDTI4MDgwMTIzNTk1OVowgcExCzAJBgNVBAYTAlVTMRcw
FQYDVQQKEw5WZXJpU2lnbiwgSW5jLjE8MDoGA1UECxMzQ2xhc3MgMyBQdWJsaWMg
UHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcyMTowOAYDVQQLEzEo
YykgMTk5OCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5
MR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMIGfMA0GCSqGSIb3DQEB
AQUAA4GNADCBiQKBgQDMXtERXVxp0KvTuWpMmR9ZmDCOFoUgRm1HP9SFIIThbbP4
pO0M8RcPO/mn+SXXwc+EY/J8Y8+iR/LGWzOOZEAEaMGAuWQcRXfH2G71lSk8UOg0
13gfqLptQ5GVj0VXXn7F+8qkBOvqlzdUMG+7AUcyM83cV5tkaWH4mx0ciU9cZwID
AQABMA0GCSqGSIb3DQEBBQUAA4GBAFFNzb5cy5gZnBWyATl4Lk0PZ3BwmcYQWpSk
U01UbSuvDV1Ai2TT1+7eVmGSX6bEHRBhNtMsJzzoKQm5EWR0zLVznxxIqbxhAe7i
F6YM40AIOw7n60RzKprxaZLvcRTDOaxxp5EJb+RxBrO6WVcmeQD2+A2iMzAo1KpY
oJ2daZH9
-----END CERTIFICATE-----
# Issuer: CN=GTE CyberTrust Global Root O=GTE Corporation OU=GTE CyberTrust Solutions, Inc.
# Subject: CN=GTE CyberTrust Global Root O=GTE Corporation OU=GTE CyberTrust Solutions, Inc.
# Label: "GTE CyberTrust Global Root"
# Serial: 421
# MD5 Fingerprint: ca:3d:d3:68:f1:03:5c:d0:32:fa:b8:2b:59:e8:5a:db
# SHA1 Fingerprint: 97:81:79:50:d8:1c:96:70:cc:34:d8:09:cf:79:44:31:36:7e:f4:74
# SHA256 Fingerprint: a5:31:25:18:8d:21:10:aa:96:4b:02:c7:b7:c6:da:32:03:17:08:94:e5:fb:71:ff:fb:66:67:d5:e6:81:0a:36
-----BEGIN CERTIFICATE-----
MIICWjCCAcMCAgGlMA0GCSqGSIb3DQEBBAUAMHUxCzAJBgNVBAYTAlVTMRgwFgYD
VQQKEw9HVEUgQ29ycG9yYXRpb24xJzAlBgNVBAsTHkdURSBDeWJlclRydXN0IFNv
bHV0aW9ucywgSW5jLjEjMCEGA1UEAxMaR1RFIEN5YmVyVHJ1c3QgR2xvYmFsIFJv
b3QwHhcNOTgwODEzMDAyOTAwWhcNMTgwODEzMjM1OTAwWjB1MQswCQYDVQQGEwJV
UzEYMBYGA1UEChMPR1RFIENvcnBvcmF0aW9uMScwJQYDVQQLEx5HVEUgQ3liZXJU
cnVzdCBTb2x1dGlvbnMsIEluYy4xIzAhBgNVBAMTGkdURSBDeWJlclRydXN0IEds
b2JhbCBSb290MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCVD6C28FCc6HrH
iM3dFw4usJTQGz0O9pTAipTHBsiQl8i4ZBp6fmw8U+E3KHNgf7KXUwefU/ltWJTS
r41tiGeA5u2ylc9yMcqlHHK6XALnZELn+aks1joNrI1CqiQBOeacPwGFVw1Yh0X4
04Wqk2kmhXBIgD8SFcd5tB8FLztimQIDAQABMA0GCSqGSIb3DQEBBAUAA4GBAG3r
GwnpXtlR22ciYaQqPEh346B8pt5zohQDhT37qw4wxYMWM4ETCJ57NE7fQMh017l9
3PR2VX2bY1QY6fDq81yx2YtCHrnAlU66+tXifPVoYb+O7AWXX1uw16OFNMQkpw0P
lZPvy5TYnh+dXIVtx6quTx8itc2VrbqnzPmrC3p/
-----END CERTIFICATE-----
# Issuer: C=US, O=Equifax, OU=Equifax Secure Certificate Authority
# Subject: C=US, O=Equifax, OU=Equifax Secure Certificate Authority
# Label: "Equifax Secure Certificate Authority"
# Serial: 903804111
# MD5 Fingerprint: 67:cb:9d:c0:13:24:8a:82:9b:b2:17:1e:d1:1b:ec:d4
# SHA1 Fingerprint: d2:32:09:ad:23:d3:14:23:21:74:e4:0d:7f:9d:62:13:97:86:63:3a
# SHA256 Fingerprint: 08:29:7a:40:47:db:a2:36:80:c7:31:db:6e:31:76:53:ca:78:48:e1:be:bd:3a:0b:01:79:a7:07:f9:2c:f1:78
-----BEGIN CERTIFICATE-----
MIIDIDCCAomgAwIBAgIENd70zzANBgkqhkiG9w0BAQUFADBOMQswCQYDVQQGEwJV
UzEQMA4GA1UEChMHRXF1aWZheDEtMCsGA1UECxMkRXF1aWZheCBTZWN1cmUgQ2Vy
dGlmaWNhdGUgQXV0aG9yaXR5MB4XDTk4MDgyMjE2NDE1MVoXDTE4MDgyMjE2NDE1
MVowTjELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0VxdWlmYXgxLTArBgNVBAsTJEVx
dWlmYXggU2VjdXJlIENlcnRpZmljYXRlIEF1dGhvcml0eTCBnzANBgkqhkiG9w0B
AQEFAAOBjQAwgYkCgYEAwV2xWGcIYu6gmi0fCG2RFGiYCh7+2gRvE4RiIcPRfM6f
BeC4AfBONOziipUEZKzxa1NfBbPLZ4C/QgKO/t0BCezhABRP/PvwDN1Dulsr4R+A
cJkVV5MW8Q+XarfCaCMczE1ZMKxRHjuvK9buY0V7xdlfUNLjUA86iOe/FP3gx7kC
AwEAAaOCAQkwggEFMHAGA1UdHwRpMGcwZaBjoGGkXzBdMQswCQYDVQQGEwJVUzEQ
MA4GA1UEChMHRXF1aWZheDEtMCsGA1UECxMkRXF1aWZheCBTZWN1cmUgQ2VydGlm
aWNhdGUgQXV0aG9yaXR5MQ0wCwYDVQQDEwRDUkwxMBoGA1UdEAQTMBGBDzIwMTgw
ODIyMTY0MTUxWjALBgNVHQ8EBAMCAQYwHwYDVR0jBBgwFoAUSOZo+SvSspXXR9gj
IBBPM5iQn9QwHQYDVR0OBBYEFEjmaPkr0rKV10fYIyAQTzOYkJ/UMAwGA1UdEwQF
MAMBAf8wGgYJKoZIhvZ9B0EABA0wCxsFVjMuMGMDAgbAMA0GCSqGSIb3DQEBBQUA
A4GBAFjOKer89961zgK5F7WF0bnj4JXMJTENAKaSbn+2kmOeUJXRmm/kEd5jhW6Y
7qj/WsjTVbJmcVfewCHrPSqnI0kBBIZCe/zuf6IWUrVnZ9NA2zsmWLIodz2uFHdh
1voqZiegDfqnc1zqcPGUIWVEX/r87yloqaKHee9570+sB3c4
-----END CERTIFICATE-----

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
from musicbrainzngs.musicbrainz import *
from musicbrainzngs.caa import *

187
lib/musicbrainzngs/caa.py Normal file
View File

@@ -0,0 +1,187 @@
# This file is part of the musicbrainzngs library
# Copyright (C) Alastair Porter, Wieland Hoffmann, and others
# This file is distributed under a BSD-2-Clause type license.
# See the COPYING file for more information.
__all__ = [
'set_caa_hostname', 'get_image_list', 'get_release_group_image_list',
'get_release_group_image_front', 'get_image_front', 'get_image_back',
'get_image'
]
import json
from musicbrainzngs import compat
from musicbrainzngs import musicbrainz
from musicbrainzngs.util import _unicode
hostname = "coverartarchive.org"
https = True
def set_caa_hostname(new_hostname, use_https=False):
"""Set the base hostname for Cover Art Archive requests.
Defaults to 'coverartarchive.org', accessing over https.
For backwards compatibility, `use_https` is False by default.
:param str new_hostname: The hostname (and port) of the CAA server to connect to
:param bool use_https: `True` if the host should be accessed using https. Default is `False`
"""
global hostname
global https
hostname = new_hostname
https = use_https
def _caa_request(mbid, imageid=None, size=None, entitytype="release"):
""" Make a CAA request.
:param imageid: ``front``, ``back`` or a number from the listing obtained
with :meth:`get_image_list`.
:type imageid: str
:param size: "250", "500", "1200"
:type size: str or None
:param entitytype: ``release`` or ``release-group``
:type entitytype: str
"""
# Construct the full URL for the request, including hostname and
# query string.
path = [entitytype, mbid]
if imageid and size:
path.append("%s-%s" % (imageid, size))
elif imageid:
path.append(imageid)
url = compat.urlunparse((
'https' if https else 'http',
hostname,
'/%s' % '/'.join(path),
'',
'',
''
))
musicbrainz._log.debug("GET request for %s" % (url, ))
# Set up HTTP request handler and URL opener.
httpHandler = compat.HTTPHandler(debuglevel=0)
handlers = [httpHandler]
opener = compat.build_opener(*handlers)
# Make request.
req = musicbrainz._MusicbrainzHttpRequest("GET", url, None)
# Useragent isn't needed for CAA, but we'll add it if it exists
if musicbrainz._useragent != "":
req.add_header('User-Agent', musicbrainz._useragent)
musicbrainz._log.debug("requesting with UA %s" % musicbrainz._useragent)
resp = musicbrainz._safe_read(opener, req, None)
# TODO: The content type declared by the CAA for JSON files is
# 'applicaiton/octet-stream'. This is not useful to detect whether the
# content is JSON, so default to decoding JSON if no imageid was supplied.
# http://tickets.musicbrainz.org/browse/CAA-75
if imageid:
# If we asked for an image, return the image
return resp
else:
# Otherwise it's json
data = _unicode(resp)
return json.loads(data)
def get_image_list(releaseid):
"""Get the list of cover art associated with a release.
The return value is the deserialized response of the `JSON listing
<http://musicbrainz.org/doc/Cover_Art_Archive/API#.2Frelease.2F.7Bmbid.7D.2F>`_
returned by the Cover Art Archive API.
If an error occurs then a :class:`~musicbrainzngs.ResponseError` will
be raised with one of the following HTTP codes:
* 400: `Releaseid` is not a valid UUID
* 404: No release exists with an MBID of `releaseid`
* 503: Ratelimit exceeded
"""
return _caa_request(releaseid)
def get_release_group_image_list(releasegroupid):
"""Get the list of cover art associated with a release group.
The return value is the deserialized response of the `JSON listing
<http://musicbrainz.org/doc/Cover_Art_Archive/API#.2Frelease-group.2F.7Bmbid.7D.2F>`_
returned by the Cover Art Archive API.
If an error occurs then a :class:`~musicbrainzngs.ResponseError` will
be raised with one of the following HTTP codes:
* 400: `Releaseid` is not a valid UUID
* 404: No release exists with an MBID of `releaseid`
* 503: Ratelimit exceeded
"""
return _caa_request(releasegroupid, entitytype="release-group")
def get_release_group_image_front(releasegroupid, size=None):
"""Download the front cover art for a release group.
The `size` argument and the possible error conditions are the same as for
:meth:`get_image`.
"""
return get_image(releasegroupid, "front", size=size,
entitytype="release-group")
def get_image_front(releaseid, size=None):
"""Download the front cover art for a release.
The `size` argument and the possible error conditions are the same as for
:meth:`get_image`.
"""
return get_image(releaseid, "front", size=size)
def get_image_back(releaseid, size=None):
"""Download the back cover art for a release.
The `size` argument and the possible error conditions are the same as for
:meth:`get_image`.
"""
return get_image(releaseid, "back", size=size)
def get_image(mbid, coverid, size=None, entitytype="release"):
"""Download cover art for a release. The coverart file to download
is specified by the `coverid` argument.
If `size` is not specified, download the largest copy present, which can be
very large.
If an error occurs then a :class:`~musicbrainzngs.ResponseError`
will be raised with one of the following HTTP codes:
* 400: `Releaseid` is not a valid UUID or `coverid` is invalid
* 404: No release exists with an MBID of `releaseid`
* 503: Ratelimit exceeded
:param coverid: ``front``, ``back`` or a number from the listing obtained with
:meth:`get_image_list`
:type coverid: int or str
:param size: "250", "500", "1200" or None. If it is None, the largest
available picture will be downloaded. If the image originally
uploaded to the Cover Art Archive was smaller than the
requested size, only the original image will be returned.
:type size: str or None
:param entitytype: The type of entity for which to download the cover art.
This is either ``release`` or ``release-group``.
:type entitytype: str
:return: The binary image data
:type: str
"""
if isinstance(coverid, int):
coverid = "%d" % (coverid, )
if isinstance(size, int):
size = "%d" % (size, )
return _caa_request(mbid, coverid, size=size, entitytype=entitytype)

View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2012 Kenneth Reitz.
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
"""
pythoncompat
"""
import sys
# -------
# Pythons
# -------
# Syntax sugar.
_ver = sys.version_info
#: Python 2.x?
is_py2 = (_ver[0] == 2)
#: Python 3.x?
is_py3 = (_ver[0] == 3)
# ---------
# Specifics
# ---------
if is_py2:
from StringIO import StringIO
from urllib2 import HTTPPasswordMgr, HTTPDigestAuthHandler, Request,\
HTTPHandler, build_opener, HTTPError, URLError
from httplib import BadStatusLine, HTTPException
from urlparse import urlunparse
from urllib import urlencode, quote_plus
bytes = str
unicode = unicode
basestring = basestring
elif is_py3:
from io import StringIO
from urllib.request import HTTPPasswordMgr, HTTPDigestAuthHandler, Request,\
HTTPHandler, build_opener
from urllib.error import HTTPError, URLError
from http.client import HTTPException, BadStatusLine
from urllib.parse import urlunparse, urlencode, quote_plus
unicode = str
bytes = bytes
basestring = (str,bytes)

817
lib/musicbrainzngs/mbxml.py Normal file
View File

@@ -0,0 +1,817 @@
# This file is part of the musicbrainzngs library
# Copyright (C) Alastair Porter, Adrian Sampson, and others
# This file is distributed under a BSD-2-Clause type license.
# See the COPYING file for more information.
import re
import xml.etree.ElementTree as ET
import logging
from . import util
def fixtag(tag, namespaces):
# given a decorated tag (of the form {uri}tag), return prefixed
# tag and namespace declaration, if any
if isinstance(tag, ET.QName):
tag = tag.text
namespace_uri, tag = tag[1:].split("}", 1)
prefix = namespaces.get(namespace_uri)
if prefix is None:
prefix = "ns%d" % len(namespaces)
namespaces[namespace_uri] = prefix
if prefix == "xml":
xmlns = None
else:
xmlns = ("xmlns:%s" % prefix, namespace_uri)
else:
xmlns = None
return "%s:%s" % (prefix, tag), xmlns
NS_MAP = {"http://musicbrainz.org/ns/mmd-2.0#": "ws2",
"http://musicbrainz.org/ns/ext#-2.0": "ext"}
_log = logging.getLogger("musicbrainzngs")
def get_error_message(error):
""" Given an error XML message from the webservice containing
<error><text>x</text><text>y</text></error>, return a list
of [x, y]"""
try:
tree = util.bytes_to_elementtree(error)
root = tree.getroot()
errors = []
if root.tag == "error":
for ch in root:
if ch.tag == "text":
errors.append(ch.text)
return errors
except ET.ParseError:
return None
def make_artist_credit(artists):
names = []
for artist in artists:
if isinstance(artist, dict):
if "name" in artist:
names.append(artist.get("name", ""))
else:
names.append(artist.get("artist", {}).get("name", ""))
else:
names.append(artist)
return "".join(names)
def parse_elements(valid_els, inner_els, element):
""" Extract single level subelements from an element.
For example, given the element:
<element>
<subelement>Text</subelement>
</element>
and a list valid_els that contains "subelement",
return a dict {'subelement': 'Text'}
Delegate the parsing of multi-level subelements to another function.
For example, given the element:
<element>
<subelement>
<a>Foo</a><b>Bar</b>
</subelement>
</element>
and a dictionary {'subelement': parse_subelement},
call parse_subelement(<subelement>) and
return a dict {'subelement': <result>}
if parse_subelement returns a tuple of the form
(True, {'subelement-key': <result>})
then merge the second element of the tuple into the
result (which may have a key other than 'subelement' or
more than 1 key)
"""
result = {}
for sub in element:
t = fixtag(sub.tag, NS_MAP)[0]
if ":" in t:
t = t.split(":")[1]
if t in valid_els:
result[t] = sub.text or ""
elif t in inner_els.keys():
inner_result = inner_els[t](sub)
if isinstance(inner_result, tuple) and inner_result[0]:
result.update(inner_result[1])
else:
result[t] = inner_result
# add counts for lists when available
m = re.match(r'([a-z0-9-]+)-list', t)
if m and "count" in sub.attrib:
result["%s-count" % m.group(1)] = int(sub.attrib["count"])
else:
_log.info("in <%s>, uncaught <%s>",
fixtag(element.tag, NS_MAP)[0], t)
return result
def parse_attributes(attributes, element):
""" Extract attributes from an element.
For example, given the element:
<element type="Group" />
and a list attributes that contains "type",
return a dict {'type': 'Group'}
"""
result = {}
for attr in element.attrib:
if "{" in attr:
a = fixtag(attr, NS_MAP)[0]
else:
a = attr
if a in attributes:
result[a] = element.attrib[attr]
else:
_log.info("in <%s>, uncaught attribute %s", fixtag(element.tag, NS_MAP)[0], attr)
return result
def parse_message(message):
tree = util.bytes_to_elementtree(message)
root = tree.getroot()
result = {}
valid_elements = {"area": parse_area,
"artist": parse_artist,
"instrument": parse_instrument,
"label": parse_label,
"place": parse_place,
"event": parse_event,
"release": parse_release,
"release-group": parse_release_group,
"series": parse_series,
"recording": parse_recording,
"work": parse_work,
"url": parse_url,
"disc": parse_disc,
"cdstub": parse_cdstub,
"isrc": parse_isrc,
"annotation-list": parse_annotation_list,
"area-list": parse_area_list,
"artist-list": parse_artist_list,
"label-list": parse_label_list,
"place-list": parse_place_list,
"event-list": parse_event_list,
"instrument-list": parse_instrument_list,
"release-list": parse_release_list,
"release-group-list": parse_release_group_list,
"series-list": parse_series_list,
"recording-list": parse_recording_list,
"work-list": parse_work_list,
"url-list": parse_url_list,
"collection-list": parse_collection_list,
"collection": parse_collection,
"message": parse_response_message
}
result.update(parse_elements([], valid_elements, root))
return result
def parse_response_message(message):
return parse_elements(["text"], {}, message)
def parse_collection_list(cl):
return [parse_collection(c) for c in cl]
def parse_collection(collection):
result = {}
attribs = ["id", "type", "entity-type"]
elements = ["name", "editor"]
inner_els = {"release-list": parse_release_list,
"artist-list": parse_artist_list,
"event-list": parse_event_list,
"place-list": parse_place_list,
"recording-list": parse_recording_list,
"work-list": parse_work_list}
result.update(parse_attributes(attribs, collection))
result.update(parse_elements(elements, inner_els, collection))
return result
def parse_annotation_list(al):
return [parse_annotation(a) for a in al]
def parse_annotation(annotation):
result = {}
attribs = ["type", "ext:score"]
elements = ["entity", "name", "text"]
result.update(parse_attributes(attribs, annotation))
result.update(parse_elements(elements, {}, annotation))
return result
def parse_lifespan(lifespan):
parts = parse_elements(["begin", "end", "ended"], {}, lifespan)
return parts
def parse_area_list(al):
return [parse_area(a) for a in al]
def parse_area(area):
result = {}
attribs = ["id", "type", "ext:score"]
elements = ["name", "sort-name", "disambiguation"]
inner_els = {"life-span": parse_lifespan,
"alias-list": parse_alias_list,
"relation-list": parse_relation_list,
"annotation": parse_annotation,
"iso-3166-1-code-list": parse_element_list,
"iso-3166-2-code-list": parse_element_list,
"iso-3166-3-code-list": parse_element_list}
result.update(parse_attributes(attribs, area))
result.update(parse_elements(elements, inner_els, area))
return result
def parse_artist_list(al):
return [parse_artist(a) for a in al]
def parse_artist(artist):
result = {}
attribs = ["id", "type", "ext:score"]
elements = ["name", "sort-name", "country", "user-rating",
"disambiguation", "gender", "ipi"]
inner_els = {"area": parse_area,
"begin-area": parse_area,
"end-area": parse_area,
"life-span": parse_lifespan,
"recording-list": parse_recording_list,
"relation-list": parse_relation_list,
"release-list": parse_release_list,
"release-group-list": parse_release_group_list,
"work-list": parse_work_list,
"tag-list": parse_tag_list,
"user-tag-list": parse_tag_list,
"rating": parse_rating,
"ipi-list": parse_element_list,
"isni-list": parse_element_list,
"alias-list": parse_alias_list,
"annotation": parse_annotation}
result.update(parse_attributes(attribs, artist))
result.update(parse_elements(elements, inner_els, artist))
return result
def parse_coordinates(c):
return parse_elements(['latitude', 'longitude'], {}, c)
def parse_place_list(pl):
return [parse_place(p) for p in pl]
def parse_place(place):
result = {}
attribs = ["id", "type", "ext:score"]
elements = ["name", "address",
"ipi", "disambiguation"]
inner_els = {"area": parse_area,
"coordinates": parse_coordinates,
"life-span": parse_lifespan,
"tag-list": parse_tag_list,
"user-tag-list": parse_tag_list,
"alias-list": parse_alias_list,
"relation-list": parse_relation_list,
"annotation": parse_annotation}
result.update(parse_attributes(attribs, place))
result.update(parse_elements(elements, inner_els, place))
return result
def parse_event_list(el):
return [parse_event(e) for e in el]
def parse_event(event):
result = {}
attribs = ["id", "type", "ext:score"]
elements = ["name", "time", "setlist", "cancelled", "disambiguation", "user-rating"]
inner_els = {"life-span": parse_lifespan,
"relation-list": parse_relation_list,
"alias-list": parse_alias_list,
"tag-list": parse_tag_list,
"user-tag-list": parse_tag_list,
"rating": parse_rating}
result.update(parse_attributes(attribs, event))
result.update(parse_elements(elements, inner_els, event))
return result
def parse_instrument(instrument):
result = {}
attribs = ["id", "type", "ext:score"]
elements = ["name", "description", "disambiguation"]
inner_els = {"relation-list": parse_relation_list,
"tag-list": parse_tag_list,
"alias-list": parse_alias_list,
"annotation": parse_annotation}
result.update(parse_attributes(attribs, instrument))
result.update(parse_elements(elements, inner_els, instrument))
return result
def parse_label_list(ll):
return [parse_label(l) for l in ll]
def parse_label(label):
result = {}
attribs = ["id", "type", "ext:score"]
elements = ["name", "sort-name", "country", "label-code", "user-rating",
"ipi", "disambiguation"]
inner_els = {"area": parse_area,
"life-span": parse_lifespan,
"release-list": parse_release_list,
"tag-list": parse_tag_list,
"user-tag-list": parse_tag_list,
"rating": parse_rating,
"ipi-list": parse_element_list,
"alias-list": parse_alias_list,
"relation-list": parse_relation_list,
"annotation": parse_annotation}
result.update(parse_attributes(attribs, label))
result.update(parse_elements(elements, inner_els, label))
return result
def parse_relation_target(tgt):
attributes = parse_attributes(['id'], tgt)
if 'id' in attributes:
return (True, {'target-id': attributes['id']})
else:
return (True, {'target-id': tgt.text})
def parse_relation_list(rl):
attribs = ["target-type"]
ttype = parse_attributes(attribs, rl)
key = "%s-relation-list" % ttype["target-type"]
return (True, {key: [parse_relation(r) for r in rl]})
def parse_relation(relation):
result = {}
attribs = ["type", "type-id"]
elements = ["target", "direction", "begin", "end", "ended", "ordering-key"]
inner_els = {"area": parse_area,
"artist": parse_artist,
"instrument": parse_instrument,
"label": parse_label,
"place": parse_place,
"event": parse_event,
"recording": parse_recording,
"release": parse_release,
"release-group": parse_release_group,
"series": parse_series,
"attribute-list": parse_element_list,
"work": parse_work,
"target": parse_relation_target
}
result.update(parse_attributes(attribs, relation))
result.update(parse_elements(elements, inner_els, relation))
# We parse attribute-list again to get attributes that have both
# text and attribute values
result.update(parse_elements(['target-credit'], {"attribute-list": parse_relation_attribute_list}, relation))
return result
def parse_relation_attribute_list(attributelist):
ret = []
for attribute in attributelist:
ret.append(parse_relation_attribute_element(attribute))
return (True, {"attributes": ret})
def parse_relation_attribute_element(element):
# Parses an attribute into a dictionary containing an element
# {"attribute": <text value>} and also an additional element
# containing any xml attributes.
# e.g <attribute value="BuxWV 1">number</attribute>
# -> {"attribute": "number", "value": "BuxWV 1"}
result = {}
for attr in element.attrib:
if "{" in attr:
a = fixtag(attr, NS_MAP)[0]
else:
a = attr
result[a] = element.attrib[attr]
result["attribute"] = element.text
return result
def parse_release(release):
result = {}
attribs = ["id", "ext:score"]
elements = ["title", "status", "disambiguation", "quality", "country",
"barcode", "date", "packaging", "asin"]
inner_els = {"text-representation": parse_text_representation,
"artist-credit": parse_artist_credit,
"label-info-list": parse_label_info_list,
"medium-list": parse_medium_list,
"release-group": parse_release_group,
"tag-list": parse_tag_list,
"user-tag-list": parse_tag_list,
"relation-list": parse_relation_list,
"annotation": parse_annotation,
"cover-art-archive": parse_caa,
"release-event-list": parse_release_event_list}
result.update(parse_attributes(attribs, release))
result.update(parse_elements(elements, inner_els, release))
if "artist-credit" in result:
result["artist-credit-phrase"] = make_artist_credit(
result["artist-credit"])
return result
def parse_medium_list(ml):
"""medium-list results from search have an additional
<track-count> element containing the number of tracks
over all mediums. Optionally add this"""
medium_list = []
track_count = None
for m in ml:
tag = fixtag(m.tag, NS_MAP)[0]
if tag == "ws2:medium":
medium_list.append(parse_medium(m))
elif tag == "ws2:track-count":
track_count = int(m.text)
ret = {"medium-list": medium_list}
if track_count is not None:
ret["medium-track-count"] = track_count
return (True, ret)
def parse_release_event_list(rel):
return [parse_release_event(re) for re in rel]
def parse_release_event(event):
result = {}
elements = ["date"]
inner_els = {"area": parse_area}
result.update(parse_elements(elements, inner_els, event))
return result
def parse_medium(medium):
result = {}
elements = ["position", "format", "title"]
inner_els = {"disc-list": parse_disc_list,
"pregap": parse_track,
"track-list": parse_track_list,
"data-track-list": parse_track_list}
result.update(parse_elements(elements, inner_els, medium))
return result
def parse_disc_list(dl):
return [parse_disc(d) for d in dl]
def parse_text_representation(textr):
return parse_elements(["language", "script"], {}, textr)
def parse_release_group(rg):
result = {}
attribs = ["id", "type", "ext:score"]
elements = ["title", "user-rating", "first-release-date", "primary-type",
"disambiguation"]
inner_els = {"artist-credit": parse_artist_credit,
"release-list": parse_release_list,
"tag-list": parse_tag_list,
"user-tag-list": parse_tag_list,
"secondary-type-list": parse_element_list,
"relation-list": parse_relation_list,
"rating": parse_rating,
"annotation": parse_annotation}
result.update(parse_attributes(attribs, rg))
result.update(parse_elements(elements, inner_els, rg))
if "artist-credit" in result:
result["artist-credit-phrase"] = make_artist_credit(result["artist-credit"])
return result
def parse_recording(recording):
result = {}
attribs = ["id", "ext:score"]
elements = ["title", "length", "user-rating", "disambiguation", "video"]
inner_els = {"artist-credit": parse_artist_credit,
"release-list": parse_release_list,
"tag-list": parse_tag_list,
"user-tag-list": parse_tag_list,
"rating": parse_rating,
"isrc-list": parse_external_id_list,
"relation-list": parse_relation_list,
"annotation": parse_annotation}
result.update(parse_attributes(attribs, recording))
result.update(parse_elements(elements, inner_els, recording))
if "artist-credit" in result:
result["artist-credit-phrase"] = make_artist_credit(result["artist-credit"])
return result
def parse_series_list(sl):
return [parse_series(s) for s in sl]
def parse_series(series):
result = {}
attribs = ["id", "type", "ext:score"]
elements = ["name", "disambiguation"]
inner_els = {"alias-list": parse_alias_list,
"relation-list": parse_relation_list,
"annotation": parse_annotation}
result.update(parse_attributes(attribs, series))
result.update(parse_elements(elements, inner_els, series))
return result
def parse_external_id_list(pl):
return [parse_attributes(["id"], p)["id"] for p in pl]
def parse_element_list(el):
return [e.text for e in el]
def parse_work_list(wl):
return [parse_work(w) for w in wl]
def parse_work(work):
result = {}
attribs = ["id", "ext:score", "type"]
elements = ["title", "user-rating", "language", "iswc", "disambiguation"]
inner_els = {"tag-list": parse_tag_list,
"user-tag-list": parse_tag_list,
"rating": parse_rating,
"alias-list": parse_alias_list,
"iswc-list": parse_element_list,
"relation-list": parse_relation_list,
"annotation": parse_response_message,
"attribute-list": parse_work_attribute_list
}
result.update(parse_attributes(attribs, work))
result.update(parse_elements(elements, inner_els, work))
return result
def parse_work_attribute_list(wal):
return [parse_work_attribute(wa) for wa in wal]
def parse_work_attribute(wa):
attribs = ["type"]
typeinfo = parse_attributes(attribs, wa)
result = {}
if typeinfo:
result = {"attribute": typeinfo["type"],
"value": wa.text}
return result
def parse_url_list(ul):
return [parse_url(u) for u in ul]
def parse_url(url):
result = {}
attribs = ["id"]
elements = ["resource"]
inner_els = {"relation-list": parse_relation_list}
result.update(parse_attributes(attribs, url))
result.update(parse_elements(elements, inner_els, url))
return result
def parse_disc(disc):
result = {}
attribs = ["id"]
elements = ["sectors"]
inner_els = {"release-list": parse_release_list,
"offset-list": parse_offset_list
}
result.update(parse_attributes(attribs, disc))
result.update(parse_elements(elements, inner_els, disc))
return result
def parse_cdstub(cdstub):
result = {}
attribs = ["id"]
elements = ["title", "artist", "barcode"]
inner_els = {"track-list": parse_track_list}
result.update(parse_attributes(attribs, cdstub))
result.update(parse_elements(elements, inner_els, cdstub))
return result
def parse_offset_list(ol):
return [int(o.text) for o in ol]
def parse_instrument_list(rl):
result = []
for r in rl:
result.append(parse_instrument(r))
return result
def parse_release_list(rl):
result = []
for r in rl:
result.append(parse_release(r))
return result
def parse_release_group_list(rgl):
result = []
for rg in rgl:
result.append(parse_release_group(rg))
return result
def parse_isrc(isrc):
result = {}
attribs = ["id"]
inner_els = {"recording-list": parse_recording_list}
result.update(parse_attributes(attribs, isrc))
result.update(parse_elements([], inner_els, isrc))
return result
def parse_recording_list(recs):
result = []
for r in recs:
result.append(parse_recording(r))
return result
def parse_artist_credit(ac):
result = []
for namecredit in ac:
result.append(parse_name_credit(namecredit))
join = parse_attributes(["joinphrase"], namecredit)
if "joinphrase" in join:
result.append(join["joinphrase"])
return result
def parse_name_credit(nc):
result = {}
elements = ["name"]
inner_els = {"artist": parse_artist}
result.update(parse_elements(elements, inner_els, nc))
return result
def parse_label_info_list(lil):
result = []
for li in lil:
result.append(parse_label_info(li))
return result
def parse_label_info(li):
result = {}
elements = ["catalog-number"]
inner_els = {"label": parse_label}
result.update(parse_elements(elements, inner_els, li))
return result
def parse_track_list(tl):
result = []
for t in tl:
result.append(parse_track(t))
return result
def parse_track(track):
result = {}
attribs = ["id"]
elements = ["number", "position", "title", "length"]
inner_els = {"recording": parse_recording,
"artist-credit": parse_artist_credit}
result.update(parse_attributes(attribs, track))
result.update(parse_elements(elements, inner_els, track))
if "artist-credit" in result.get("recording", {}) and "artist-credit" not in result:
result["artist-credit"] = result["recording"]["artist-credit"]
if "artist-credit" in result:
result["artist-credit-phrase"] = make_artist_credit(result["artist-credit"])
# Make a length field that contains track length or recording length
track_or_recording = None
if "length" in result:
track_or_recording = result["length"]
elif result.get("recording", {}).get("length"):
track_or_recording = result.get("recording", {}).get("length")
if track_or_recording:
result["track_or_recording_length"] = track_or_recording
return result
def parse_tag_list(tl):
return [parse_tag(t) for t in tl]
def parse_tag(tag):
result = {}
attribs = ["count"]
elements = ["name"]
result.update(parse_attributes(attribs, tag))
result.update(parse_elements(elements, {}, tag))
return result
def parse_rating(rating):
result = {}
attribs = ["votes-count"]
result.update(parse_attributes(attribs, rating))
result["rating"] = rating.text
return result
def parse_alias_list(al):
return [parse_alias(a) for a in al]
def parse_alias(alias):
result = {}
attribs = ["locale", "sort-name", "type", "primary",
"begin-date", "end-date"]
result.update(parse_attributes(attribs, alias))
result["alias"] = alias.text
return result
def parse_caa(caa_element):
result = {}
elements = ["artwork", "count", "front", "back", "darkened"]
result.update(parse_elements(elements, {}, caa_element))
return result
###
def make_barcode_request(release2barcode):
NS = "http://musicbrainz.org/ns/mmd-2.0#"
root = ET.Element("{%s}metadata" % NS)
rel_list = ET.SubElement(root, "{%s}release-list" % NS)
for release, barcode in release2barcode.items():
rel_xml = ET.SubElement(rel_list, "{%s}release" % NS)
bar_xml = ET.SubElement(rel_xml, "{%s}barcode" % NS)
rel_xml.set("{%s}id" % NS, release)
bar_xml.text = barcode
return ET.tostring(root, "utf-8")
def make_tag_request(**kwargs):
NS = "http://musicbrainz.org/ns/mmd-2.0#"
root = ET.Element("{%s}metadata" % NS)
for entity_type in ['artist', 'label', 'place', 'recording', 'release', 'release_group', 'work']:
entity_tags = kwargs.pop(entity_type + '_tags', None)
if entity_tags is not None:
e_list = ET.SubElement(root, "{%s}%s-list" % (NS, entity_type.replace('_', '-')))
for e, tags in entity_tags.items():
e_xml = ET.SubElement(e_list, "{%s}%s" % (NS, entity_type.replace('_', '-')))
e_xml.set("{%s}id" % NS, e)
taglist = ET.SubElement(e_xml, "{%s}user-tag-list" % NS)
for tag in tags:
usertag_xml = ET.SubElement(taglist, "{%s}user-tag" % NS)
name_xml = ET.SubElement(usertag_xml, "{%s}name" % NS)
name_xml.text = tag
if kwargs.keys():
raise TypeError("make_tag_request() got an unexpected keyword argument '%s'" % kwargs.popitem()[0])
return ET.tostring(root, "utf-8")
def make_rating_request(**kwargs):
NS = "http://musicbrainz.org/ns/mmd-2.0#"
root = ET.Element("{%s}metadata" % NS)
for entity_type in ['artist', 'label', 'recording', 'release_group', 'work']:
entity_ratings = kwargs.pop(entity_type + '_ratings', None)
if entity_ratings is not None:
e_list = ET.SubElement(root, "{%s}%s-list" % (NS, entity_type.replace('_', '-')))
for e, rating in entity_ratings.items():
e_xml = ET.SubElement(e_list, "{%s}%s" % (NS, entity_type.replace('_', '-')))
e_xml.set("{%s}id" % NS, e)
rating_xml = ET.SubElement(e_xml, "{%s}user-rating" % NS)
rating_xml.text = str(rating)
if kwargs.keys():
raise TypeError("make_rating_request() got an unexpected keyword argument '%s'" % kwargs.popitem()[0])
return ET.tostring(root, "utf-8")
def make_isrc_request(recording2isrcs):
NS = "http://musicbrainz.org/ns/mmd-2.0#"
root = ET.Element("{%s}metadata" % NS)
rec_list = ET.SubElement(root, "{%s}recording-list" % NS)
for rec, isrcs in recording2isrcs.items():
if len(isrcs) > 0:
rec_xml = ET.SubElement(rec_list, "{%s}recording" % NS)
rec_xml.set("{%s}id" % NS, rec)
isrc_list_xml = ET.SubElement(rec_xml, "{%s}isrc-list" % NS)
isrc_list_xml.set("{%s}count" % NS, str(len(isrcs)))
for isrc in isrcs:
isrc_xml = ET.SubElement(isrc_list_xml, "{%s}isrc" % NS)
isrc_xml.set("{%s}id" % NS, isrc)
return ET.tostring(root, "utf-8")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
# This file is part of the musicbrainzngs library
# Copyright (C) Alastair Porter, Adrian Sampson, and others
# This file is distributed under a BSD-2-Clause type license.
# See the COPYING file for more information.
import sys
import locale
import xml.etree.ElementTree as ET
from . import compat
def _unicode(string, encoding=None):
"""Try to decode byte strings to unicode.
This can only be a guess, but this might be better than failing.
It is safe to use this on numbers or strings that are already unicode.
"""
if isinstance(string, compat.unicode):
unicode_string = string
elif isinstance(string, compat.bytes):
# use given encoding, stdin, preferred until something != None is found
if encoding is None:
encoding = sys.stdin.encoding
if encoding is None:
encoding = locale.getpreferredencoding()
unicode_string = string.decode(encoding, "ignore")
else:
unicode_string = compat.unicode(string)
return unicode_string.replace('\x00', '').strip()
def bytes_to_elementtree(bytes_or_file):
"""Given a bytestring or a file-like object that will produce them,
parse and return an ElementTree.
"""
if isinstance(bytes_or_file, compat.basestring):
s = bytes_or_file
else:
s = bytes_or_file.read()
if compat.is_py3:
s = _unicode(s, "utf-8")
f = compat.StringIO(s)
tree = ET.ElementTree(file=f)
return tree

View File

@@ -1,19 +0,0 @@
Copyright (c) 2003-2009 Stuart Bishop <stuart@stuartbishop.net>
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

View File

@@ -1,575 +0,0 @@
pytz - World Timezone Definitions for Python
============================================
:Author: Stuart Bishop <stuart@stuartbishop.net>
Introduction
~~~~~~~~~~~~
pytz brings the Olson tz database into Python. This library allows
accurate and cross platform timezone calculations using Python 2.4
or higher. It also solves the issue of ambiguous times at the end
of daylight saving time, which you can read more about in the Python
Library Reference (``datetime.tzinfo``).
Almost all of the Olson timezones are supported.
.. note::
This library differs from the documented Python API for
tzinfo implementations; if you want to create local wallclock
times you need to use the ``localize()`` method documented in this
document. In addition, if you perform date arithmetic on local
times that cross DST boundaries, the result may be in an incorrect
timezone (ie. subtract 1 minute from 2002-10-27 1:00 EST and you get
2002-10-27 0:59 EST instead of the correct 2002-10-27 1:59 EDT). A
``normalize()`` method is provided to correct this. Unfortunately these
issues cannot be resolved without modifying the Python datetime
implementation (see PEP-431).
Installation
~~~~~~~~~~~~
This package can either be installed from a .egg file using setuptools,
or from the tarball using the standard Python distutils.
If you are installing from a tarball, run the following command as an
administrative user::
python setup.py install
If you are installing using setuptools, you don't even need to download
anything as the latest version will be downloaded for you
from the Python package index::
easy_install --upgrade pytz
If you already have the .egg file, you can use that too::
easy_install pytz-2008g-py2.6.egg
Example & Usage
~~~~~~~~~~~~~~~
Localized times and date arithmetic
-----------------------------------
>>> from datetime import datetime, timedelta
>>> from pytz import timezone
>>> import pytz
>>> utc = pytz.utc
>>> utc.zone
'UTC'
>>> eastern = timezone('US/Eastern')
>>> eastern.zone
'US/Eastern'
>>> amsterdam = timezone('Europe/Amsterdam')
>>> fmt = '%Y-%m-%d %H:%M:%S %Z%z'
This library only supports two ways of building a localized time. The
first is to use the ``localize()`` method provided by the pytz library.
This is used to localize a naive datetime (datetime with no timezone
information):
>>> loc_dt = eastern.localize(datetime(2002, 10, 27, 6, 0, 0))
>>> print(loc_dt.strftime(fmt))
2002-10-27 06:00:00 EST-0500
The second way of building a localized time is by converting an existing
localized time using the standard ``astimezone()`` method:
>>> ams_dt = loc_dt.astimezone(amsterdam)
>>> ams_dt.strftime(fmt)
'2002-10-27 12:00:00 CET+0100'
Unfortunately using the tzinfo argument of the standard datetime
constructors ''does not work'' with pytz for many timezones.
>>> datetime(2002, 10, 27, 12, 0, 0, tzinfo=amsterdam).strftime(fmt)
'2002-10-27 12:00:00 LMT+0020'
It is safe for timezones without daylight saving transitions though, such
as UTC:
>>> datetime(2002, 10, 27, 12, 0, 0, tzinfo=pytz.utc).strftime(fmt)
'2002-10-27 12:00:00 UTC+0000'
The preferred way of dealing with times is to always work in UTC,
converting to localtime only when generating output to be read
by humans.
>>> utc_dt = datetime(2002, 10, 27, 6, 0, 0, tzinfo=utc)
>>> loc_dt = utc_dt.astimezone(eastern)
>>> loc_dt.strftime(fmt)
'2002-10-27 01:00:00 EST-0500'
This library also allows you to do date arithmetic using local
times, although it is more complicated than working in UTC as you
need to use the ``normalize()`` method to handle daylight saving time
and other timezone transitions. In this example, ``loc_dt`` is set
to the instant when daylight saving time ends in the US/Eastern
timezone.
>>> before = loc_dt - timedelta(minutes=10)
>>> before.strftime(fmt)
'2002-10-27 00:50:00 EST-0500'
>>> eastern.normalize(before).strftime(fmt)
'2002-10-27 01:50:00 EDT-0400'
>>> after = eastern.normalize(before + timedelta(minutes=20))
>>> after.strftime(fmt)
'2002-10-27 01:10:00 EST-0500'
Creating local times is also tricky, and the reason why working with
local times is not recommended. Unfortunately, you cannot just pass
a ``tzinfo`` argument when constructing a datetime (see the next
section for more details)
>>> dt = datetime(2002, 10, 27, 1, 30, 0)
>>> dt1 = eastern.localize(dt, is_dst=True)
>>> dt1.strftime(fmt)
'2002-10-27 01:30:00 EDT-0400'
>>> dt2 = eastern.localize(dt, is_dst=False)
>>> dt2.strftime(fmt)
'2002-10-27 01:30:00 EST-0500'
Converting between timezones also needs special attention. We also need
to use the ``normalize()`` method to ensure the conversion is correct.
>>> utc_dt = utc.localize(datetime.utcfromtimestamp(1143408899))
>>> utc_dt.strftime(fmt)
'2006-03-26 21:34:59 UTC+0000'
>>> au_tz = timezone('Australia/Sydney')
>>> au_dt = au_tz.normalize(utc_dt.astimezone(au_tz))
>>> au_dt.strftime(fmt)
'2006-03-27 08:34:59 AEDT+1100'
>>> utc_dt2 = utc.normalize(au_dt.astimezone(utc))
>>> utc_dt2.strftime(fmt)
'2006-03-26 21:34:59 UTC+0000'
You can take shortcuts when dealing with the UTC side of timezone
conversions. ``normalize()`` and ``localize()`` are not really
necessary when there are no daylight saving time transitions to
deal with.
>>> utc_dt = datetime.utcfromtimestamp(1143408899).replace(tzinfo=utc)
>>> utc_dt.strftime(fmt)
'2006-03-26 21:34:59 UTC+0000'
>>> au_tz = timezone('Australia/Sydney')
>>> au_dt = au_tz.normalize(utc_dt.astimezone(au_tz))
>>> au_dt.strftime(fmt)
'2006-03-27 08:34:59 AEDT+1100'
>>> utc_dt2 = au_dt.astimezone(utc)
>>> utc_dt2.strftime(fmt)
'2006-03-26 21:34:59 UTC+0000'
``tzinfo`` API
--------------
The ``tzinfo`` instances returned by the ``timezone()`` function have
been extended to cope with ambiguous times by adding an ``is_dst``
parameter to the ``utcoffset()``, ``dst()`` && ``tzname()`` methods.
>>> tz = timezone('America/St_Johns')
>>> normal = datetime(2009, 9, 1)
>>> ambiguous = datetime(2009, 10, 31, 23, 30)
The ``is_dst`` parameter is ignored for most timestamps. It is only used
during DST transition ambiguous periods to resulve that ambiguity.
>>> tz.utcoffset(normal, is_dst=True)
datetime.timedelta(-1, 77400)
>>> tz.dst(normal, is_dst=True)
datetime.timedelta(0, 3600)
>>> tz.tzname(normal, is_dst=True)
'NDT'
>>> tz.utcoffset(ambiguous, is_dst=True)
datetime.timedelta(-1, 77400)
>>> tz.dst(ambiguous, is_dst=True)
datetime.timedelta(0, 3600)
>>> tz.tzname(ambiguous, is_dst=True)
'NDT'
>>> tz.utcoffset(normal, is_dst=False)
datetime.timedelta(-1, 77400)
>>> tz.dst(normal, is_dst=False)
datetime.timedelta(0, 3600)
>>> tz.tzname(normal, is_dst=False)
'NDT'
>>> tz.utcoffset(ambiguous, is_dst=False)
datetime.timedelta(-1, 73800)
>>> tz.dst(ambiguous, is_dst=False)
datetime.timedelta(0)
>>> tz.tzname(ambiguous, is_dst=False)
'NST'
If ``is_dst`` is not specified, ambiguous timestamps will raise
an ``pytz.exceptions.AmbiguousTimeError`` exception.
>>> tz.utcoffset(normal)
datetime.timedelta(-1, 77400)
>>> tz.dst(normal)
datetime.timedelta(0, 3600)
>>> tz.tzname(normal)
'NDT'
>>> import pytz.exceptions
>>> try:
... tz.utcoffset(ambiguous)
... except pytz.exceptions.AmbiguousTimeError:
... print('pytz.exceptions.AmbiguousTimeError: %s' % ambiguous)
pytz.exceptions.AmbiguousTimeError: 2009-10-31 23:30:00
>>> try:
... tz.dst(ambiguous)
... except pytz.exceptions.AmbiguousTimeError:
... print('pytz.exceptions.AmbiguousTimeError: %s' % ambiguous)
pytz.exceptions.AmbiguousTimeError: 2009-10-31 23:30:00
>>> try:
... tz.tzname(ambiguous)
... except pytz.exceptions.AmbiguousTimeError:
... print('pytz.exceptions.AmbiguousTimeError: %s' % ambiguous)
pytz.exceptions.AmbiguousTimeError: 2009-10-31 23:30:00
Problems with Localtime
~~~~~~~~~~~~~~~~~~~~~~~
The major problem we have to deal with is that certain datetimes
may occur twice in a year. For example, in the US/Eastern timezone
on the last Sunday morning in October, the following sequence
happens:
- 01:00 EDT occurs
- 1 hour later, instead of 2:00am the clock is turned back 1 hour
and 01:00 happens again (this time 01:00 EST)
In fact, every instant between 01:00 and 02:00 occurs twice. This means
that if you try and create a time in the 'US/Eastern' timezone
the standard datetime syntax, there is no way to specify if you meant
before of after the end-of-daylight-saving-time transition. Using the
pytz custom syntax, the best you can do is make an educated guess:
>>> loc_dt = eastern.localize(datetime(2002, 10, 27, 1, 30, 00))
>>> loc_dt.strftime(fmt)
'2002-10-27 01:30:00 EST-0500'
As you can see, the system has chosen one for you and there is a 50%
chance of it being out by one hour. For some applications, this does
not matter. However, if you are trying to schedule meetings with people
in different timezones or analyze log files it is not acceptable.
The best and simplest solution is to stick with using UTC. The pytz
package encourages using UTC for internal timezone representation by
including a special UTC implementation based on the standard Python
reference implementation in the Python documentation.
The UTC timezone unpickles to be the same instance, and pickles to a
smaller size than other pytz tzinfo instances. The UTC implementation
can be obtained as pytz.utc, pytz.UTC, or pytz.timezone('UTC').
>>> import pickle, pytz
>>> dt = datetime(2005, 3, 1, 14, 13, 21, tzinfo=utc)
>>> naive = dt.replace(tzinfo=None)
>>> p = pickle.dumps(dt, 1)
>>> naive_p = pickle.dumps(naive, 1)
>>> len(p) - len(naive_p)
17
>>> new = pickle.loads(p)
>>> new == dt
True
>>> new is dt
False
>>> new.tzinfo is dt.tzinfo
True
>>> pytz.utc is pytz.UTC is pytz.timezone('UTC')
True
Note that some other timezones are commonly thought of as the same (GMT,
Greenwich, Universal, etc.). The definition of UTC is distinct from these
other timezones, and they are not equivalent. For this reason, they will
not compare the same in Python.
>>> utc == pytz.timezone('GMT')
False
See the section `What is UTC`_, below.
If you insist on working with local times, this library provides a
facility for constructing them unambiguously:
>>> loc_dt = datetime(2002, 10, 27, 1, 30, 00)
>>> est_dt = eastern.localize(loc_dt, is_dst=True)
>>> edt_dt = eastern.localize(loc_dt, is_dst=False)
>>> print(est_dt.strftime(fmt) + ' / ' + edt_dt.strftime(fmt))
2002-10-27 01:30:00 EDT-0400 / 2002-10-27 01:30:00 EST-0500
If you pass None as the is_dst flag to localize(), pytz will refuse to
guess and raise exceptions if you try to build ambiguous or non-existent
times.
For example, 1:30am on 27th Oct 2002 happened twice in the US/Eastern
timezone when the clocks where put back at the end of Daylight Saving
Time:
>>> dt = datetime(2002, 10, 27, 1, 30, 00)
>>> try:
... eastern.localize(dt, is_dst=None)
... except pytz.exceptions.AmbiguousTimeError:
... print('pytz.exceptions.AmbiguousTimeError: %s' % dt)
pytz.exceptions.AmbiguousTimeError: 2002-10-27 01:30:00
Similarly, 2:30am on 7th April 2002 never happened at all in the
US/Eastern timezone, as the clocks where put forward at 2:00am skipping
the entire hour:
>>> dt = datetime(2002, 4, 7, 2, 30, 00)
>>> try:
... eastern.localize(dt, is_dst=None)
... except pytz.exceptions.NonExistentTimeError:
... print('pytz.exceptions.NonExistentTimeError: %s' % dt)
pytz.exceptions.NonExistentTimeError: 2002-04-07 02:30:00
Both of these exceptions share a common base class to make error handling
easier:
>>> isinstance(pytz.AmbiguousTimeError(), pytz.InvalidTimeError)
True
>>> isinstance(pytz.NonExistentTimeError(), pytz.InvalidTimeError)
True
A special case is where countries change their timezone definitions
with no daylight savings time switch. For example, in 1915 Warsaw
switched from Warsaw time to Central European time with no daylight savings
transition. So at the stroke of midnight on August 5th 1915 the clocks
were wound back 24 minutes creating an ambiguous time period that cannot
be specified without referring to the timezone abbreviation or the
actual UTC offset. In this case midnight happened twice, neither time
during a daylight saving time period. pytz handles this transition by
treating the ambiguous period before the switch as daylight savings
time, and the ambiguous period after as standard time.
>>> warsaw = pytz.timezone('Europe/Warsaw')
>>> amb_dt1 = warsaw.localize(datetime(1915, 8, 4, 23, 59, 59), is_dst=True)
>>> amb_dt1.strftime(fmt)
'1915-08-04 23:59:59 WMT+0124'
>>> amb_dt2 = warsaw.localize(datetime(1915, 8, 4, 23, 59, 59), is_dst=False)
>>> amb_dt2.strftime(fmt)
'1915-08-04 23:59:59 CET+0100'
>>> switch_dt = warsaw.localize(datetime(1915, 8, 5, 00, 00, 00), is_dst=False)
>>> switch_dt.strftime(fmt)
'1915-08-05 00:00:00 CET+0100'
>>> str(switch_dt - amb_dt1)
'0:24:01'
>>> str(switch_dt - amb_dt2)
'0:00:01'
The best way of creating a time during an ambiguous time period is
by converting from another timezone such as UTC:
>>> utc_dt = datetime(1915, 8, 4, 22, 36, tzinfo=pytz.utc)
>>> utc_dt.astimezone(warsaw).strftime(fmt)
'1915-08-04 23:36:00 CET+0100'
The standard Python way of handling all these ambiguities is not to
handle them, such as demonstrated in this example using the US/Eastern
timezone definition from the Python documentation (Note that this
implementation only works for dates between 1987 and 2006 - it is
included for tests only!):
>>> from pytz.reference import Eastern # pytz.reference only for tests
>>> dt = datetime(2002, 10, 27, 0, 30, tzinfo=Eastern)
>>> str(dt)
'2002-10-27 00:30:00-04:00'
>>> str(dt + timedelta(hours=1))
'2002-10-27 01:30:00-05:00'
>>> str(dt + timedelta(hours=2))
'2002-10-27 02:30:00-05:00'
>>> str(dt + timedelta(hours=3))
'2002-10-27 03:30:00-05:00'
Notice the first two results? At first glance you might think they are
correct, but taking the UTC offset into account you find that they are
actually two hours appart instead of the 1 hour we asked for.
>>> from pytz.reference import UTC # pytz.reference only for tests
>>> str(dt.astimezone(UTC))
'2002-10-27 04:30:00+00:00'
>>> str((dt + timedelta(hours=1)).astimezone(UTC))
'2002-10-27 06:30:00+00:00'
Country Information
~~~~~~~~~~~~~~~~~~~
A mechanism is provided to access the timezones commonly in use
for a particular country, looked up using the ISO 3166 country code.
It returns a list of strings that can be used to retrieve the relevant
tzinfo instance using ``pytz.timezone()``:
>>> print(' '.join(pytz.country_timezones['nz']))
Pacific/Auckland Pacific/Chatham
The Olson database comes with a ISO 3166 country code to English country
name mapping that pytz exposes as a dictionary:
>>> print(pytz.country_names['nz'])
New Zealand
What is UTC
~~~~~~~~~~~
'UTC' is `Coordinated Universal Time`_. It is a successor to, but distinct
from, Greenwich Mean Time (GMT) and the various definitions of Universal
Time. UTC is now the worldwide standard for regulating clocks and time
measurement.
All other timezones are defined relative to UTC, and include offsets like
UTC+0800 - hours to add or subtract from UTC to derive the local time. No
daylight saving time occurs in UTC, making it a useful timezone to perform
date arithmetic without worrying about the confusion and ambiguities caused
by daylight saving time transitions, your country changing its timezone, or
mobile computers that roam through multiple timezones.
.. _Coordinated Universal Time: https://en.wikipedia.org/wiki/Coordinated_Universal_Time
Helpers
~~~~~~~
There are two lists of timezones provided.
``all_timezones`` is the exhaustive list of the timezone names that can
be used.
>>> from pytz import all_timezones
>>> len(all_timezones) >= 500
True
>>> 'Etc/Greenwich' in all_timezones
True
``common_timezones`` is a list of useful, current timezones. It doesn't
contain deprecated zones or historical zones, except for a few I've
deemed in common usage, such as US/Eastern (open a bug report if you
think other timezones are deserving of being included here). It is also
a sequence of strings.
>>> from pytz import common_timezones
>>> len(common_timezones) < len(all_timezones)
True
>>> 'Etc/Greenwich' in common_timezones
False
>>> 'Australia/Melbourne' in common_timezones
True
>>> 'US/Eastern' in common_timezones
True
>>> 'Canada/Eastern' in common_timezones
True
>>> 'US/Pacific-New' in all_timezones
True
>>> 'US/Pacific-New' in common_timezones
False
Both ``common_timezones`` and ``all_timezones`` are alphabetically
sorted:
>>> common_timezones_dupe = common_timezones[:]
>>> common_timezones_dupe.sort()
>>> common_timezones == common_timezones_dupe
True
>>> all_timezones_dupe = all_timezones[:]
>>> all_timezones_dupe.sort()
>>> all_timezones == all_timezones_dupe
True
``all_timezones`` and ``common_timezones`` are also available as sets.
>>> from pytz import all_timezones_set, common_timezones_set
>>> 'US/Eastern' in all_timezones_set
True
>>> 'US/Eastern' in common_timezones_set
True
>>> 'Australia/Victoria' in common_timezones_set
False
You can also retrieve lists of timezones used by particular countries
using the ``country_timezones()`` function. It requires an ISO-3166
two letter country code.
>>> from pytz import country_timezones
>>> print(' '.join(country_timezones('ch')))
Europe/Zurich
>>> print(' '.join(country_timezones('CH')))
Europe/Zurich
License
~~~~~~~
MIT license.
This code is also available as part of Zope 3 under the Zope Public
License, Version 2.1 (ZPL).
I'm happy to relicense this code if necessary for inclusion in other
open source projects.
Latest Versions
~~~~~~~~~~~~~~~
This package will be updated after releases of the Olson timezone
database. The latest version can be downloaded from the `Python Package
Index <http://pypi.python.org/pypi/pytz/>`_. The code that is used
to generate this distribution is hosted on launchpad.net and available
using the `Bazaar version control system <http://bazaar-vcs.org>`_
using::
bzr branch lp:pytz
Announcements of new releases are made on
`Launchpad <https://launchpad.net/pytz>`_, and the
`Atom feed <http://feeds.launchpad.net/pytz/announcements.atom>`_
hosted there.
Bugs, Feature Requests & Patches
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Bugs can be reported using `Launchpad <https://bugs.launchpad.net/pytz>`_.
Issues & Limitations
~~~~~~~~~~~~~~~~~~~~
- Offsets from UTC are rounded to the nearest whole minute, so timezones
such as Europe/Amsterdam pre 1937 will be up to 30 seconds out. This
is a limitation of the Python datetime library.
- If you think a timezone definition is incorrect, I probably can't fix
it. pytz is a direct translation of the Olson timezone database, and
changes to the timezone definitions need to be made to this source.
If you find errors they should be reported to the time zone mailing
list, linked from http://www.iana.org/time-zones.
Further Reading
~~~~~~~~~~~~~~~
More info than you want to know about timezones:
http://www.twinsun.com/tz/tz-link.htm
Contact
~~~~~~~
Stuart Bishop <stuart@stuartbishop.net>

View File

@@ -8,12 +8,25 @@ See the datetime section of the Python Library Reference for information
on how to use these modules.
'''
import sys
import datetime
import os.path
from pytz.exceptions import AmbiguousTimeError
from pytz.exceptions import InvalidTimeError
from pytz.exceptions import NonExistentTimeError
from pytz.exceptions import UnknownTimeZoneError
from pytz.lazy import LazyDict, LazyList, LazySet # noqa
from pytz.tzinfo import unpickler, BaseTzInfo
from pytz.tzfile import build_tzinfo
# The IANA (nee Olson) database is updated several times a year.
OLSON_VERSION = '2016f'
VERSION = '2016.6.1' # Switching to pip compatible version numbering.
OLSON_VERSION = '2019c'
VERSION = '2019.3' # pip compatible version number.
__version__ = VERSION
OLSEN_VERSION = OLSON_VERSION # Old releases had this misspelling
OLSEN_VERSION = OLSON_VERSION # Old releases had this misspelling
__all__ = [
'timezone', 'utc', 'country_timezones', 'country_names',
@@ -21,23 +34,11 @@ __all__ = [
'NonExistentTimeError', 'UnknownTimeZoneError',
'all_timezones', 'all_timezones_set',
'common_timezones', 'common_timezones_set',
]
import sys, datetime, os.path, gettext
from pytz.exceptions import AmbiguousTimeError
from pytz.exceptions import InvalidTimeError
from pytz.exceptions import NonExistentTimeError
from pytz.exceptions import UnknownTimeZoneError
from pytz.lazy import LazyDict, LazyList, LazySet
from pytz.tzinfo import unpickler
from pytz.tzfile import build_tzinfo, _byte_string
'BaseTzInfo',
]
try:
unicode
except NameError: # Python 3.x
if sys.version_info[0] > 2: # Python 3.x
# Python 3.x doesn't have unicode(), making writing code
# for Python 2.3 and Python 3.x a pain.
@@ -52,10 +53,13 @@ except NameError: # Python 3.x
...
UnicodeEncodeError: ...
"""
s.encode('ASCII') # Raise an exception if not ASCII
return s # But return the original string - not a byte string.
if type(s) == bytes:
s = s.decode('ASCII')
else:
s.encode('ASCII') # Raise an exception if not ASCII
return s # But the string - not a byte string.
else: # Python 2.x
else: # Python 2.x
def ascii(s):
r"""
@@ -76,24 +80,31 @@ def open_resource(name):
Uses the pkg_resources module if available and no standard file
found at the calculated location.
It is possible to specify different location for zoneinfo
subdir by using the PYTZ_TZDATADIR environment variable.
"""
name_parts = name.lstrip('/').split('/')
for part in name_parts:
if part == os.path.pardir or os.path.sep in part:
raise ValueError('Bad path segment: %r' % part)
filename = os.path.join(os.path.dirname(__file__),
'zoneinfo', *name_parts)
if not os.path.exists(filename):
# http://bugs.launchpad.net/bugs/383171 - we avoid using this
# unless absolutely necessary to help when a broken version of
# pkg_resources is installed.
try:
from pkg_resources import resource_stream
except ImportError:
resource_stream = None
zoneinfo_dir = os.environ.get('PYTZ_TZDATADIR', None)
if zoneinfo_dir is not None:
filename = os.path.join(zoneinfo_dir, *name_parts)
else:
filename = os.path.join(os.path.dirname(__file__),
'zoneinfo', *name_parts)
if not os.path.exists(filename):
# http://bugs.launchpad.net/bugs/383171 - we avoid using this
# unless absolutely necessary to help when a broken version of
# pkg_resources is installed.
try:
from pkg_resources import resource_stream
except ImportError:
resource_stream = None
if resource_stream is not None:
return resource_stream(__name__, 'zoneinfo/' + name)
if resource_stream is not None:
return resource_stream(__name__, 'zoneinfo/' + name)
return open(filename, 'rb')
@@ -106,23 +117,9 @@ def resource_exists(name):
return False
# Enable this when we get some translations?
# We want an i18n API that is useful to programs using Python's gettext
# module, as well as the Zope3 i18n package. Perhaps we should just provide
# the POT file and translations, and leave it up to callers to make use
# of them.
#
# t = gettext.translation(
# 'pytz', os.path.join(os.path.dirname(__file__), 'locales'),
# fallback=True
# )
# def _(timezone_name):
# """Translate a timezone name using the current locale, returning Unicode"""
# return t.ugettext(timezone_name)
_tzinfo_cache = {}
def timezone(zone):
r''' Return a datetime.tzinfo implementation for the given timezone
@@ -160,6 +157,9 @@ def timezone(zone):
Unknown
'''
if zone is None:
raise UnknownTimeZoneError(None)
if zone.upper() == 'UTC':
return utc
@@ -169,9 +169,9 @@ def timezone(zone):
# All valid timezones are ASCII
raise UnknownTimeZoneError(zone)
zone = _unmunge_zone(zone)
zone = _case_insensitive_zone_lookup(_unmunge_zone(zone))
if zone not in _tzinfo_cache:
if zone in all_timezones_set:
if zone in all_timezones_set: # noqa
fp = open_resource(zone)
try:
_tzinfo_cache[zone] = build_tzinfo(zone, fp)
@@ -188,11 +188,22 @@ def _unmunge_zone(zone):
return zone.replace('_plus_', '+').replace('_minus_', '-')
_all_timezones_lower_to_standard = None
def _case_insensitive_zone_lookup(zone):
"""case-insensitively matching timezone, else return zone unchanged"""
global _all_timezones_lower_to_standard
if _all_timezones_lower_to_standard is None:
_all_timezones_lower_to_standard = dict((tz.lower(), tz) for tz in all_timezones) # noqa
return _all_timezones_lower_to_standard.get(zone.lower()) or zone # noqa
ZERO = datetime.timedelta(0)
HOUR = datetime.timedelta(hours=1)
class UTC(datetime.tzinfo):
class UTC(BaseTzInfo):
"""UTC
Optimized UTC implementation. It unpickles using the single module global
@@ -275,6 +286,8 @@ def _UTC():
False
"""
return utc
_UTC.__safe_for_unpickling__ = True
@@ -285,9 +298,10 @@ def _p(*args):
by shortening the path.
"""
return unpickler(*args)
_p.__safe_for_unpickling__ = True
_p.__safe_for_unpickling__ = True
class _CountryTimezoneDict(LazyDict):
"""Map ISO 3166 country code to a list of timezone names commonly used
@@ -334,7 +348,7 @@ class _CountryTimezoneDict(LazyDict):
if line.startswith('#'):
continue
code, coordinates, zone = line.split(None, 4)[:3]
if zone not in all_timezones_set:
if zone not in all_timezones_set: # noqa
continue
try:
data[code].append(zone)
@@ -344,6 +358,7 @@ class _CountryTimezoneDict(LazyDict):
finally:
zone_tab.close()
country_timezones = _CountryTimezoneDict()
@@ -367,6 +382,7 @@ class _CountryNameDict(LazyDict):
finally:
zone_tab.close()
country_names = _CountryNameDict()
@@ -374,7 +390,7 @@ country_names = _CountryNameDict()
class _FixedOffset(datetime.tzinfo):
zone = None # to match the standard pytz API
zone = None # to match the standard pytz API
def __init__(self, minutes):
if abs(minutes) >= 1440:
@@ -412,24 +428,24 @@ class _FixedOffset(datetime.tzinfo):
return dt.astimezone(self)
def FixedOffset(offset, _tzinfos = {}):
def FixedOffset(offset, _tzinfos={}):
"""return a fixed-offset timezone based off a number of minutes.
>>> one = FixedOffset(-330)
>>> one
pytz.FixedOffset(-330)
>>> one.utcoffset(datetime.datetime.now())
datetime.timedelta(-1, 66600)
>>> one.dst(datetime.datetime.now())
datetime.timedelta(0)
>>> str(one.utcoffset(datetime.datetime.now()))
'-1 day, 18:30:00'
>>> str(one.dst(datetime.datetime.now()))
'0:00:00'
>>> two = FixedOffset(1380)
>>> two
pytz.FixedOffset(1380)
>>> two.utcoffset(datetime.datetime.now())
datetime.timedelta(0, 82800)
>>> two.dst(datetime.datetime.now())
datetime.timedelta(0)
>>> str(two.utcoffset(datetime.datetime.now()))
'23:00:00'
>>> str(two.dst(datetime.datetime.now()))
'0:00:00'
The datetime.timedelta must be between the range of -1 and 1 day,
non-inclusive.
@@ -478,18 +494,19 @@ def FixedOffset(offset, _tzinfos = {}):
return info
FixedOffset.__safe_for_unpickling__ = True
def _test():
import doctest, os, sys
import doctest
sys.path.insert(0, os.pardir)
import pytz
return doctest.testmod(pytz)
if __name__ == '__main__':
_test()
all_timezones = \
['Africa/Abidjan',
'Africa/Accra',
@@ -676,6 +693,7 @@ all_timezones = \
'America/Porto_Acre',
'America/Porto_Velho',
'America/Puerto_Rico',
'America/Punta_Arenas',
'America/Rainy_River',
'America/Rankin_Inlet',
'America/Recife',
@@ -731,6 +749,7 @@ all_timezones = \
'Asia/Aqtobe',
'Asia/Ashgabat',
'Asia/Ashkhabad',
'Asia/Atyrau',
'Asia/Baghdad',
'Asia/Bahrain',
'Asia/Baku',
@@ -751,6 +770,7 @@ all_timezones = \
'Asia/Dili',
'Asia/Dubai',
'Asia/Dushanbe',
'Asia/Famagusta',
'Asia/Gaza',
'Asia/Harbin',
'Asia/Hebron',
@@ -789,6 +809,7 @@ all_timezones = \
'Asia/Pontianak',
'Asia/Pyongyang',
'Asia/Qatar',
'Asia/Qostanay',
'Asia/Qyzylorda',
'Asia/Rangoon',
'Asia/Riyadh',
@@ -816,6 +837,7 @@ all_timezones = \
'Asia/Vientiane',
'Asia/Vladivostok',
'Asia/Yakutsk',
'Asia/Yangon',
'Asia/Yekaterinburg',
'Asia/Yerevan',
'Atlantic/Azores',
@@ -861,7 +883,6 @@ all_timezones = \
'CST6CDT',
'Canada/Atlantic',
'Canada/Central',
'Canada/East-Saskatchewan',
'Canada/Eastern',
'Canada/Mountain',
'Canada/Newfoundland',
@@ -955,6 +976,7 @@ all_timezones = \
'Europe/Samara',
'Europe/San_Marino',
'Europe/Sarajevo',
'Europe/Saratov',
'Europe/Simferopol',
'Europe/Skopje',
'Europe/Sofia',
@@ -1072,7 +1094,6 @@ all_timezones = \
'US/Michigan',
'US/Mountain',
'US/Pacific',
'US/Pacific-New',
'US/Samoa',
'UTC',
'Universal',
@@ -1252,6 +1273,7 @@ common_timezones = \
'America/Port_of_Spain',
'America/Porto_Velho',
'America/Puerto_Rico',
'America/Punta_Arenas',
'America/Rainy_River',
'America/Rankin_Inlet',
'America/Recife',
@@ -1301,6 +1323,7 @@ common_timezones = \
'Asia/Aqtau',
'Asia/Aqtobe',
'Asia/Ashgabat',
'Asia/Atyrau',
'Asia/Baghdad',
'Asia/Bahrain',
'Asia/Baku',
@@ -1317,6 +1340,7 @@ common_timezones = \
'Asia/Dili',
'Asia/Dubai',
'Asia/Dushanbe',
'Asia/Famagusta',
'Asia/Gaza',
'Asia/Hebron',
'Asia/Ho_Chi_Minh',
@@ -1350,8 +1374,8 @@ common_timezones = \
'Asia/Pontianak',
'Asia/Pyongyang',
'Asia/Qatar',
'Asia/Qostanay',
'Asia/Qyzylorda',
'Asia/Rangoon',
'Asia/Riyadh',
'Asia/Sakhalin',
'Asia/Samarkand',
@@ -1372,6 +1396,7 @@ common_timezones = \
'Asia/Vientiane',
'Asia/Vladivostok',
'Asia/Yakutsk',
'Asia/Yangon',
'Asia/Yekaterinburg',
'Asia/Yerevan',
'Atlantic/Azores',
@@ -1444,6 +1469,7 @@ common_timezones = \
'Europe/Samara',
'Europe/San_Marino',
'Europe/Sarajevo',
'Europe/Saratov',
'Europe/Simferopol',
'Europe/Skopje',
'Europe/Sofia',
@@ -1489,7 +1515,6 @@ common_timezones = \
'Pacific/Guadalcanal',
'Pacific/Guam',
'Pacific/Honolulu',
'Pacific/Johnston',
'Pacific/Kiritimati',
'Pacific/Kosrae',
'Pacific/Kwajalein',

View File

@@ -5,7 +5,7 @@ Custom exceptions raised by pytz.
__all__ = [
'UnknownTimeZoneError', 'InvalidTimeError', 'AmbiguousTimeError',
'NonExistentTimeError',
]
]
class UnknownTimeZoneError(KeyError):

View File

@@ -1,8 +1,11 @@
from threading import RLock
try:
from UserDict import DictMixin
except ImportError:
from collections import Mapping as DictMixin
from collections.abc import Mapping as DictMixin
except ImportError: # Python < 3.3
try:
from UserDict import DictMixin # Python 2
except ImportError: # Python 3.0-3.3
from collections import Mapping as DictMixin
# With lazy loading, we might end up with multiple threads triggering
@@ -13,6 +16,7 @@ _fill_lock = RLock()
class LazyDict(DictMixin):
"""Dictionary populated on first use."""
data = None
def __getitem__(self, key):
if self.data is None:
_fill_lock.acquire()

View File

@@ -5,17 +5,28 @@ Used for testing against as they are only correct for the years
'''
from datetime import tzinfo, timedelta, datetime
from pytz import utc, UTC, HOUR, ZERO
from pytz import HOUR, ZERO, UTC
__all__ = [
'FixedOffset',
'LocalTimezone',
'USTimeZone',
'Eastern',
'Central',
'Mountain',
'Pacific',
'UTC'
]
# A class building tzinfo objects for fixed-offset time zones.
# Note that FixedOffset(0, "UTC") is a different way to build a
# UTC tzinfo object.
class FixedOffset(tzinfo):
"""Fixed offset in minutes east from UTC."""
def __init__(self, offset, name):
self.__offset = timedelta(minutes = offset)
self.__offset = timedelta(minutes=offset)
self.__name = name
def utcoffset(self, dt):
@@ -27,18 +38,19 @@ class FixedOffset(tzinfo):
def dst(self, dt):
return ZERO
# A class capturing the platform's idea of local time.
import time as _time
STDOFFSET = timedelta(seconds = -_time.timezone)
STDOFFSET = timedelta(seconds=-_time.timezone)
if _time.daylight:
DSTOFFSET = timedelta(seconds = -_time.altzone)
DSTOFFSET = timedelta(seconds=-_time.altzone)
else:
DSTOFFSET = STDOFFSET
DSTDIFF = DSTOFFSET - STDOFFSET
# A class capturing the platform's idea of local time.
class LocalTimezone(tzinfo):
def utcoffset(self, dt):
@@ -66,7 +78,6 @@ class LocalTimezone(tzinfo):
Local = LocalTimezone()
# A complete implementation of current DST rules for major US time zones.
def first_sunday_on_or_after(dt):
days_to_go = 6 - dt.weekday()
@@ -74,12 +85,15 @@ def first_sunday_on_or_after(dt):
dt += timedelta(days_to_go)
return dt
# In the US, DST starts at 2am (standard time) on the first Sunday in April.
DSTSTART = datetime(1, 4, 1, 2)
# and ends at 2am (DST time; 1am standard time) on the last Sunday of Oct.
# which is the first Sunday on or after Oct 25.
DSTEND = datetime(1, 10, 25, 1)
# A complete implementation of current DST rules for major US time zones.
class USTimeZone(tzinfo):
def __init__(self, hours, reprname, stdname, dstname):
@@ -120,8 +134,7 @@ class USTimeZone(tzinfo):
else:
return ZERO
Eastern = USTimeZone(-5, "Eastern", "EST", "EDT")
Central = USTimeZone(-6, "Central", "CST", "CDT")
Eastern = USTimeZone(-5, "Eastern", "EST", "EDT")
Central = USTimeZone(-6, "Central", "CST", "CDT")
Mountain = USTimeZone(-7, "Mountain", "MST", "MDT")
Pacific = USTimeZone(-8, "Pacific", "PST", "PDT")
Pacific = USTimeZone(-8, "Pacific", "PST", "PDT")

View File

@@ -0,0 +1,34 @@
# -*- coding: ascii -*-
from doctest import DocFileSuite
import unittest
import os.path
import sys
THIS_DIR = os.path.dirname(__file__)
README = os.path.join(THIS_DIR, os.pardir, os.pardir, 'README.txt')
class DocumentationTestCase(unittest.TestCase):
def test_readme_encoding(self):
'''Confirm the README.txt is pure ASCII.'''
f = open(README, 'rb')
try:
f.read().decode('ASCII')
finally:
f.close()
def test_suite():
"For the Z3 test runner"
return unittest.TestSuite((
DocumentationTestCase('test_readme_encoding'),
DocFileSuite(os.path.join(os.pardir, os.pardir, 'README.txt'))))
if __name__ == '__main__':
sys.path.insert(
0, os.path.abspath(os.path.join(THIS_DIR, os.pardir, os.pardir))
)
unittest.main(defaultTest='test_suite')

315
lib/pytz/tests/test_lazy.py Normal file
View File

@@ -0,0 +1,315 @@
from operator import (
eq, ge, gt, le, lt, ne, add, concat, not_, sub, and_, or_, xor
)
import os.path
import sys
import unittest
import warnings
if __name__ == '__main__':
# Only munge path if invoked as a script. Testrunners should have setup
# the paths already
sys.path.insert(0, os.path.abspath(os.path.join(os.pardir, os.pardir)))
from pytz.lazy import LazyList, LazySet
class LazyListTestCase(unittest.TestCase):
initial_data = [3, 2, 1]
def setUp(self):
self.base = [3, 2, 1]
self.lesser = [2, 1, 0]
self.greater = [4, 3, 2]
self.lazy = LazyList(iter(list(self.base)))
def test_unary_ops(self):
unary_ops = [str, repr, len, bool, not_]
try:
unary_ops.append(unicode)
except NameError:
pass # unicode no longer exists in Python 3.
for op in unary_ops:
self.assertEqual(
op(self.lazy),
op(self.base), str(op))
def test_binary_ops(self):
binary_ops = [eq, ge, gt, le, lt, ne, add, concat]
try:
binary_ops.append(cmp)
except NameError:
pass # cmp no longer exists in Python 3.
for op in binary_ops:
self.assertEqual(
op(self.lazy, self.lazy),
op(self.base, self.base), str(op))
for other in [self.base, self.lesser, self.greater]:
self.assertEqual(
op(self.lazy, other),
op(self.base, other), '%s %s' % (op, other))
self.assertEqual(
op(other, self.lazy),
op(other, self.base), '%s %s' % (op, other))
# Multiplication
self.assertEqual(self.lazy * 3, self.base * 3)
self.assertEqual(3 * self.lazy, 3 * self.base)
# Contains
self.assertTrue(2 in self.lazy)
self.assertFalse(42 in self.lazy)
def test_iadd(self):
self.lazy += [1]
self.base += [1]
self.assertEqual(self.lazy, self.base)
def test_bool(self):
self.assertTrue(bool(self.lazy))
self.assertFalse(bool(LazyList()))
self.assertFalse(bool(LazyList(iter([]))))
def test_hash(self):
self.assertRaises(TypeError, hash, self.lazy)
def test_isinstance(self):
self.assertTrue(isinstance(self.lazy, list))
self.assertFalse(isinstance(self.lazy, tuple))
def test_callable(self):
try:
callable
except NameError:
return # No longer exists with Python 3.
self.assertFalse(callable(self.lazy))
def test_append(self):
self.base.append('extra')
self.lazy.append('extra')
self.assertEqual(self.lazy, self.base)
def test_count(self):
self.assertEqual(self.lazy.count(2), 1)
def test_index(self):
self.assertEqual(self.lazy.index(2), 1)
def test_extend(self):
self.base.extend([6, 7])
self.lazy.extend([6, 7])
self.assertEqual(self.lazy, self.base)
def test_insert(self):
self.base.insert(0, 'ping')
self.lazy.insert(0, 'ping')
self.assertEqual(self.lazy, self.base)
def test_pop(self):
self.assertEqual(self.lazy.pop(), self.base.pop())
self.assertEqual(self.lazy, self.base)
def test_remove(self):
self.base.remove(2)
self.lazy.remove(2)
self.assertEqual(self.lazy, self.base)
def test_reverse(self):
self.base.reverse()
self.lazy.reverse()
self.assertEqual(self.lazy, self.base)
def test_reversed(self):
self.assertEqual(list(reversed(self.lazy)), list(reversed(self.base)))
def test_sort(self):
self.base.sort()
self.assertNotEqual(self.lazy, self.base, 'Test data already sorted')
self.lazy.sort()
self.assertEqual(self.lazy, self.base)
def test_sorted(self):
self.assertEqual(sorted(self.lazy), sorted(self.base))
def test_getitem(self):
for idx in range(-len(self.base), len(self.base)):
self.assertEqual(self.lazy[idx], self.base[idx])
def test_setitem(self):
for idx in range(-len(self.base), len(self.base)):
self.base[idx] = idx + 1000
self.assertNotEqual(self.lazy, self.base)
self.lazy[idx] = idx + 1000
self.assertEqual(self.lazy, self.base)
def test_delitem(self):
del self.base[0]
self.assertNotEqual(self.lazy, self.base)
del self.lazy[0]
self.assertEqual(self.lazy, self.base)
del self.base[-2]
self.assertNotEqual(self.lazy, self.base)
del self.lazy[-2]
self.assertEqual(self.lazy, self.base)
def test_iter(self):
self.assertEqual(list(iter(self.lazy)), list(iter(self.base)))
def test_getslice(self):
for i in range(-len(self.base), len(self.base)):
for j in range(-len(self.base), len(self.base)):
for step in [-1, 1]:
self.assertEqual(self.lazy[i:j:step], self.base[i:j:step])
def test_setslice(self):
for i in range(-len(self.base), len(self.base)):
for j in range(-len(self.base), len(self.base)):
for step in [-1, 1]:
replacement = range(0, len(self.base[i:j:step]))
self.base[i:j:step] = replacement
self.lazy[i:j:step] = replacement
self.assertEqual(self.lazy, self.base)
def test_delslice(self):
del self.base[0:1]
del self.lazy[0:1]
self.assertEqual(self.lazy, self.base)
del self.base[-1:1:-1]
del self.lazy[-1:1:-1]
self.assertEqual(self.lazy, self.base)
class LazySetTestCase(unittest.TestCase):
initial_data = set([3, 2, 1])
def setUp(self):
self.base = set([3, 2, 1])
self.lazy = LazySet(iter(set(self.base)))
def test_unary_ops(self):
# These ops just need to work.
unary_ops = [str, repr]
try:
unary_ops.append(unicode)
except NameError:
pass # unicode no longer exists in Python 3.
for op in unary_ops:
op(self.lazy) # These ops just need to work.
# These ops should return identical values as a real set.
unary_ops = [len, bool, not_]
for op in unary_ops:
self.assertEqual(
op(self.lazy),
op(self.base), '%s(lazy) == %r' % (op, op(self.lazy)))
def test_binary_ops(self):
binary_ops = [eq, ge, gt, le, lt, ne, sub, and_, or_, xor]
try:
binary_ops.append(cmp)
except NameError:
pass # cmp no longer exists in Python 3.
for op in binary_ops:
self.assertEqual(
op(self.lazy, self.lazy),
op(self.base, self.base), str(op))
self.assertEqual(
op(self.lazy, self.base),
op(self.base, self.base), str(op))
self.assertEqual(
op(self.base, self.lazy),
op(self.base, self.base), str(op))
# Contains
self.assertTrue(2 in self.lazy)
self.assertFalse(42 in self.lazy)
def test_iops(self):
try:
iops = [isub, iand, ior, ixor]
except NameError:
return # Don't exist in older Python versions.
for op in iops:
# Mutating operators, so make fresh copies.
lazy = LazySet(self.base)
base = self.base.copy()
op(lazy, set([1]))
op(base, set([1]))
self.assertEqual(lazy, base, str(op))
def test_bool(self):
self.assertTrue(bool(self.lazy))
self.assertFalse(bool(LazySet()))
self.assertFalse(bool(LazySet(iter([]))))
def test_hash(self):
self.assertRaises(TypeError, hash, self.lazy)
def test_isinstance(self):
self.assertTrue(isinstance(self.lazy, set))
def test_callable(self):
try:
callable
except NameError:
return # No longer exists with Python 3.
self.assertFalse(callable(self.lazy))
def test_add(self):
self.base.add('extra')
self.lazy.add('extra')
self.assertEqual(self.lazy, self.base)
def test_copy(self):
self.assertEqual(self.lazy.copy(), self.base)
def test_method_ops(self):
ops = [
'difference', 'intersection', 'isdisjoint',
'issubset', 'issuperset', 'symmetric_difference', 'union',
'difference_update', 'intersection_update',
'symmetric_difference_update', 'update']
for op in ops:
if not hasattr(set, op):
continue # Not in this version of Python.
# Make a copy, as some of the ops are mutating.
lazy = LazySet(set(self.base))
base = set(self.base)
self.assertEqual(
getattr(lazy, op)(set([1])),
getattr(base, op)(set([1])), op)
self.assertEqual(lazy, base, op)
def test_discard(self):
self.base.discard(1)
self.assertNotEqual(self.lazy, self.base)
self.lazy.discard(1)
self.assertEqual(self.lazy, self.base)
def test_pop(self):
self.assertEqual(self.lazy.pop(), self.base.pop())
self.assertEqual(self.lazy, self.base)
def test_remove(self):
self.base.remove(2)
self.lazy.remove(2)
self.assertEqual(self.lazy, self.base)
def test_clear(self):
self.lazy.clear()
self.assertEqual(self.lazy, set())
if __name__ == '__main__':
warnings.simplefilter("error") # Warnings should be fatal in tests.
unittest.main()

View File

@@ -0,0 +1,869 @@
# -*- coding: ascii -*-
import doctest
import sys
import os
import os.path
import unittest
try:
import cPickle as pickle
except ImportError:
import pickle
from datetime import (
datetime,
timedelta
)
import warnings
if __name__ == '__main__':
# Only munge path if invoked as a script. Testrunners should have setup
# the paths already
sys.path.insert(0, os.path.abspath(os.path.join(os.pardir, os.pardir)))
import pytz # noqa
from pytz import reference # noqa
from pytz.tzfile import _byte_string # noqa
from pytz.tzinfo import DstTzInfo, StaticTzInfo # noqa
# I test for expected version to ensure the correct version of pytz is
# actually being tested.
EXPECTED_VERSION = '2019.3'
EXPECTED_OLSON_VERSION = '2019c'
fmt = '%Y-%m-%d %H:%M:%S %Z%z'
NOTIME = timedelta(0)
# GMT is a tzinfo.StaticTzInfo--the class we primarily want to test--while
# UTC is reference implementation. They both have the same timezone meaning.
UTC = pytz.timezone('UTC')
GMT = pytz.timezone('GMT')
assert isinstance(GMT, StaticTzInfo), 'GMT is no longer a StaticTzInfo'
def prettydt(dt):
"""datetime as a string using a known format.
We don't use strftime as it doesn't handle years earlier than 1900
per http://bugs.python.org/issue1777412
"""
if dt.utcoffset() >= timedelta(0):
offset = '+%s' % (dt.utcoffset(),)
else:
offset = '-%s' % (-1 * dt.utcoffset(),)
return '%04d-%02d-%02d %02d:%02d:%02d %s %s' % (
dt.year, dt.month, dt.day,
dt.hour, dt.minute, dt.second,
dt.tzname(), offset)
if sys.version_info[0] > 2:
# Python 3.x doesn't have unicode(), making writing code
# for Python 2.3 and Python 3.x a pain.
unicode = str
class BasicTest(unittest.TestCase):
def testVersion(self):
# Ensuring the correct version of pytz has been loaded
self.assertEqual(
EXPECTED_VERSION, pytz.__version__,
'Incorrect pytz version loaded. Import path is stuffed '
'or this test needs updating. (Wanted %s, got %s)'
% (EXPECTED_VERSION, pytz.__version__)
)
self.assertEqual(
EXPECTED_OLSON_VERSION, pytz.OLSON_VERSION,
'Incorrect pytz version loaded. Import path is stuffed '
'or this test needs updating. (Wanted %s, got %s)'
% (EXPECTED_OLSON_VERSION, pytz.OLSON_VERSION)
)
def testGMT(self):
now = datetime.now(tz=GMT)
self.assertTrue(now.utcoffset() == NOTIME)
self.assertTrue(now.dst() == NOTIME)
self.assertTrue(now.timetuple() == now.utctimetuple())
self.assertTrue(now == now.replace(tzinfo=UTC))
def testReferenceUTC(self):
now = datetime.now(tz=UTC)
self.assertTrue(now.utcoffset() == NOTIME)
self.assertTrue(now.dst() == NOTIME)
self.assertTrue(now.timetuple() == now.utctimetuple())
def testUnknownOffsets(self):
# This tzinfo behavior is required to make
# datetime.time.{utcoffset, dst, tzname} work as documented.
dst_tz = pytz.timezone('US/Eastern')
# This information is not known when we don't have a date,
# so return None per API.
self.assertTrue(dst_tz.utcoffset(None) is None)
self.assertTrue(dst_tz.dst(None) is None)
# We don't know the abbreviation, but this is still a valid
# tzname per the Python documentation.
self.assertEqual(dst_tz.tzname(None), 'US/Eastern')
def clearCache(self):
pytz._tzinfo_cache.clear()
def testUnicodeTimezone(self):
# We need to ensure that cold lookups work for both Unicode
# and traditional strings, and that the desired singleton is
# returned.
self.clearCache()
eastern = pytz.timezone(unicode('US/Eastern'))
self.assertTrue(eastern is pytz.timezone('US/Eastern'))
self.clearCache()
eastern = pytz.timezone('US/Eastern')
self.assertTrue(eastern is pytz.timezone(unicode('US/Eastern')))
def testStaticTzInfo(self):
# Ensure that static timezones are correctly detected,
# per lp:1602807
static = pytz.timezone('Etc/GMT-4')
self.assertTrue(isinstance(static, StaticTzInfo))
class PicklingTest(unittest.TestCase):
def _roundtrip_tzinfo(self, tz):
p = pickle.dumps(tz)
unpickled_tz = pickle.loads(p)
self.assertTrue(tz is unpickled_tz, '%s did not roundtrip' % tz.zone)
def _roundtrip_datetime(self, dt):
# Ensure that the tzinfo attached to a datetime instance
# is identical to the one returned. This is important for
# DST timezones, as some state is stored in the tzinfo.
tz = dt.tzinfo
p = pickle.dumps(dt)
unpickled_dt = pickle.loads(p)
unpickled_tz = unpickled_dt.tzinfo
self.assertTrue(tz is unpickled_tz, '%s did not roundtrip' % tz.zone)
def testDst(self):
tz = pytz.timezone('Europe/Amsterdam')
dt = datetime(2004, 2, 1, 0, 0, 0)
for localized_tz in tz._tzinfos.values():
self._roundtrip_tzinfo(localized_tz)
self._roundtrip_datetime(dt.replace(tzinfo=localized_tz))
def testRoundtrip(self):
for zone in pytz.all_timezones:
tz = pytz.timezone(zone)
self._roundtrip_tzinfo(tz)
def testDatabaseFixes(self):
# Hack the pickle to make it refer to a timezone abbreviation
# that does not match anything. The unpickler should be able
# to repair this case
tz = pytz.timezone('Australia/Melbourne')
p = pickle.dumps(tz)
tzname = tz._tzname
hacked_p = p.replace(
_byte_string(tzname),
_byte_string('?' * len(tzname))
)
self.assertNotEqual(p, hacked_p)
unpickled_tz = pickle.loads(hacked_p)
self.assertTrue(tz is unpickled_tz)
# Simulate a database correction. In this case, the incorrect
# data will continue to be used.
p = pickle.dumps(tz)
new_utcoffset = tz._utcoffset.seconds + 42
# Python 3 introduced a new pickle protocol where numbers are stored in
# hexadecimal representation. Here we extract the pickle
# representation of the number for the current Python version.
#
# Test protocol 3 on Python 3 and protocol 0 on Python 2.
if sys.version_info >= (3,):
protocol = 3
else:
protocol = 0
old_pickle_pattern = pickle.dumps(tz._utcoffset.seconds, protocol)[3:-1]
new_pickle_pattern = pickle.dumps(new_utcoffset, protocol)[3:-1]
hacked_p = p.replace(old_pickle_pattern, new_pickle_pattern)
self.assertNotEqual(p, hacked_p)
unpickled_tz = pickle.loads(hacked_p)
self.assertEqual(unpickled_tz._utcoffset.seconds, new_utcoffset)
self.assertTrue(tz is not unpickled_tz)
def testOldPickles(self):
# Ensure that applications serializing pytz instances as pickles
# have no troubles upgrading to a new pytz release. These pickles
# where created with pytz2006j
east1 = pickle.loads(
_byte_string(
"cpytz\n_p\np1\n(S'US/Eastern'\np2\nI-18000\n"
"I0\nS'EST'\np3\ntRp4\n."
)
)
east2 = pytz.timezone('US/Eastern').localize(
datetime(2006, 1, 1)).tzinfo
self.assertTrue(east1 is east2)
# Confirm changes in name munging between 2006j and 2007c cause
# no problems.
pap1 = pickle.loads(_byte_string(
"cpytz\n_p\np1\n(S'America/Port_minus_au_minus_Prince'"
"\np2\nI-17340\nI0\nS'PPMT'\np3\ntRp4\n."))
pap2 = pytz.timezone('America/Port-au-Prince').localize(
datetime(1910, 1, 1)).tzinfo
self.assertTrue(pap1 is pap2)
gmt1 = pickle.loads(_byte_string(
"cpytz\n_p\np1\n(S'Etc/GMT_plus_10'\np2\ntRp3\n."))
gmt2 = pytz.timezone('Etc/GMT+10')
self.assertTrue(gmt1 is gmt2)
class USEasternDSTStartTestCase(unittest.TestCase):
tzinfo = pytz.timezone('US/Eastern')
# 24 hours before DST changeover
transition_time = datetime(2002, 4, 7, 7, 0, 0, tzinfo=UTC)
# Increase for 'flexible' DST transitions due to 1 minute granularity
# of Python's datetime library
instant = timedelta(seconds=1)
# before transition
before = {
'tzname': 'EST',
'utcoffset': timedelta(hours=-5),
'dst': timedelta(hours=0),
}
# after transition
after = {
'tzname': 'EDT',
'utcoffset': timedelta(hours=-4),
'dst': timedelta(hours=1),
}
def _test_tzname(self, utc_dt, wanted):
tzname = wanted['tzname']
dt = utc_dt.astimezone(self.tzinfo)
self.assertEqual(
dt.tzname(), tzname,
'Expected %s as tzname for %s. Got %s' % (
tzname, str(utc_dt), dt.tzname()
)
)
def _test_utcoffset(self, utc_dt, wanted):
utcoffset = wanted['utcoffset']
dt = utc_dt.astimezone(self.tzinfo)
self.assertEqual(
dt.utcoffset(), wanted['utcoffset'],
'Expected %s as utcoffset for %s. Got %s' % (
utcoffset, utc_dt, dt.utcoffset()
)
)
def _test_dst(self, utc_dt, wanted):
dst = wanted['dst']
dt = utc_dt.astimezone(self.tzinfo)
self.assertEqual(
dt.dst(), dst,
'Expected %s as dst for %s. Got %s' % (dst, utc_dt, dt.dst())
)
def test_arithmetic(self):
utc_dt = self.transition_time
for days in range(-420, 720, 20):
delta = timedelta(days=days)
# Make sure we can get back where we started
dt = utc_dt.astimezone(self.tzinfo)
dt2 = dt + delta
dt2 = dt2 - delta
self.assertEqual(dt, dt2)
# Make sure arithmetic crossing DST boundaries ends
# up in the correct timezone after normalization
utc_plus_delta = (utc_dt + delta).astimezone(self.tzinfo)
local_plus_delta = self.tzinfo.normalize(dt + delta)
self.assertEqual(
prettydt(utc_plus_delta), prettydt(local_plus_delta),
'Incorrect result for delta==%d days. Wanted %r. Got %r' % (
days, prettydt(utc_plus_delta), prettydt(local_plus_delta),
)
)
def _test_all(self, utc_dt, wanted):
self._test_utcoffset(utc_dt, wanted)
self._test_tzname(utc_dt, wanted)
self._test_dst(utc_dt, wanted)
def testDayBefore(self):
self._test_all(
self.transition_time - timedelta(days=1), self.before
)
def testTwoHoursBefore(self):
self._test_all(
self.transition_time - timedelta(hours=2), self.before
)
def testHourBefore(self):
self._test_all(
self.transition_time - timedelta(hours=1), self.before
)
def testInstantBefore(self):
self._test_all(
self.transition_time - self.instant, self.before
)
def testTransition(self):
self._test_all(
self.transition_time, self.after
)
def testInstantAfter(self):
self._test_all(
self.transition_time + self.instant, self.after
)
def testHourAfter(self):
self._test_all(
self.transition_time + timedelta(hours=1), self.after
)
def testTwoHoursAfter(self):
self._test_all(
self.transition_time + timedelta(hours=1), self.after
)
def testDayAfter(self):
self._test_all(
self.transition_time + timedelta(days=1), self.after
)
class USEasternDSTEndTestCase(USEasternDSTStartTestCase):
tzinfo = pytz.timezone('US/Eastern')
transition_time = datetime(2002, 10, 27, 6, 0, 0, tzinfo=UTC)
before = {
'tzname': 'EDT',
'utcoffset': timedelta(hours=-4),
'dst': timedelta(hours=1),
}
after = {
'tzname': 'EST',
'utcoffset': timedelta(hours=-5),
'dst': timedelta(hours=0),
}
class USEasternEPTStartTestCase(USEasternDSTStartTestCase):
transition_time = datetime(1945, 8, 14, 23, 0, 0, tzinfo=UTC)
before = {
'tzname': 'EWT',
'utcoffset': timedelta(hours=-4),
'dst': timedelta(hours=1),
}
after = {
'tzname': 'EPT',
'utcoffset': timedelta(hours=-4),
'dst': timedelta(hours=1),
}
class USEasternEPTEndTestCase(USEasternDSTStartTestCase):
transition_time = datetime(1945, 9, 30, 6, 0, 0, tzinfo=UTC)
before = {
'tzname': 'EPT',
'utcoffset': timedelta(hours=-4),
'dst': timedelta(hours=1),
}
after = {
'tzname': 'EST',
'utcoffset': timedelta(hours=-5),
'dst': timedelta(hours=0),
}
class WarsawWMTEndTestCase(USEasternDSTStartTestCase):
# In 1915, Warsaw changed from Warsaw to Central European time.
# This involved the clocks being set backwards, causing a end-of-DST
# like situation without DST being involved.
tzinfo = pytz.timezone('Europe/Warsaw')
transition_time = datetime(1915, 8, 4, 22, 36, 0, tzinfo=UTC)
before = {
'tzname': 'WMT',
'utcoffset': timedelta(hours=1, minutes=24),
'dst': timedelta(0),
}
after = {
'tzname': 'CET',
'utcoffset': timedelta(hours=1),
'dst': timedelta(0),
}
class VilniusWMTEndTestCase(USEasternDSTStartTestCase):
# At the end of 1916, Vilnius changed timezones putting its clock
# forward by 11 minutes 35 seconds. Neither timezone was in DST mode.
tzinfo = pytz.timezone('Europe/Vilnius')
instant = timedelta(seconds=31)
transition_time = datetime(1916, 12, 31, 22, 36, 00, tzinfo=UTC)
before = {
'tzname': 'WMT',
'utcoffset': timedelta(hours=1, minutes=24),
'dst': timedelta(0),
}
after = {
'tzname': 'KMT',
'utcoffset': timedelta(hours=1, minutes=36), # Really 1:35:36
'dst': timedelta(0),
}
class VilniusCESTStartTestCase(USEasternDSTStartTestCase):
# In 1941, Vilnius changed from MSG to CEST, switching to summer
# time while simultaneously reducing its UTC offset by two hours,
# causing the clocks to go backwards for this summer time
# switchover.
tzinfo = pytz.timezone('Europe/Vilnius')
transition_time = datetime(1941, 6, 23, 21, 00, 00, tzinfo=UTC)
before = {
'tzname': 'MSK',
'utcoffset': timedelta(hours=3),
'dst': timedelta(0),
}
after = {
'tzname': 'CEST',
'utcoffset': timedelta(hours=2),
'dst': timedelta(hours=1),
}
class LondonHistoryStartTestCase(USEasternDSTStartTestCase):
# The first known timezone transition in London was in 1847 when
# clocks where synchronized to GMT. However, we currently only
# understand v1 format tzfile(5) files which does handle years
# this far in the past, so our earliest known transition is in
# 1916.
tzinfo = pytz.timezone('Europe/London')
# transition_time = datetime(1847, 12, 1, 1, 15, 00, tzinfo=UTC)
# before = {
# 'tzname': 'LMT',
# 'utcoffset': timedelta(minutes=-75),
# 'dst': timedelta(0),
# }
# after = {
# 'tzname': 'GMT',
# 'utcoffset': timedelta(0),
# 'dst': timedelta(0),
# }
transition_time = datetime(1916, 5, 21, 2, 00, 00, tzinfo=UTC)
before = {
'tzname': 'GMT',
'utcoffset': timedelta(0),
'dst': timedelta(0),
}
after = {
'tzname': 'BST',
'utcoffset': timedelta(hours=1),
'dst': timedelta(hours=1),
}
class LondonHistoryEndTestCase(USEasternDSTStartTestCase):
# Timezone switchovers are projected into the future, even
# though no official statements exist or could be believed even
# if they did exist. We currently only check the last known
# transition in 2037, as we are still using v1 format tzfile(5)
# files.
tzinfo = pytz.timezone('Europe/London')
# transition_time = datetime(2499, 10, 25, 1, 0, 0, tzinfo=UTC)
transition_time = datetime(2037, 10, 25, 1, 0, 0, tzinfo=UTC)
before = {
'tzname': 'BST',
'utcoffset': timedelta(hours=1),
'dst': timedelta(hours=1),
}
after = {
'tzname': 'GMT',
'utcoffset': timedelta(0),
'dst': timedelta(0),
}
class NoumeaHistoryStartTestCase(USEasternDSTStartTestCase):
# Noumea adopted a whole hour offset in 1912. Previously
# it was 11 hours, 5 minutes and 48 seconds off UTC. However,
# due to limitations of the Python datetime library, we need
# to round that to 11 hours 6 minutes.
tzinfo = pytz.timezone('Pacific/Noumea')
transition_time = datetime(1912, 1, 12, 12, 54, 12, tzinfo=UTC)
before = {
'tzname': 'LMT',
'utcoffset': timedelta(hours=11, minutes=6),
'dst': timedelta(0),
}
after = {
'tzname': '+11', # pre-2017a, NCT
'utcoffset': timedelta(hours=11),
'dst': timedelta(0),
}
class NoumeaDSTEndTestCase(USEasternDSTStartTestCase):
# Noumea dropped DST in 1997.
tzinfo = pytz.timezone('Pacific/Noumea')
transition_time = datetime(1997, 3, 1, 15, 00, 00, tzinfo=UTC)
before = {
'tzname': '+12', # pre-2017a, NCST
'utcoffset': timedelta(hours=12),
'dst': timedelta(hours=1),
}
after = {
'tzname': '+11', # pre-2017a, NCT
'utcoffset': timedelta(hours=11),
'dst': timedelta(0),
}
class NoumeaNoMoreDSTTestCase(NoumeaDSTEndTestCase):
# Noumea dropped DST in 1997. Here we test that it stops occuring.
transition_time = (
NoumeaDSTEndTestCase.transition_time + timedelta(days=365 * 10))
before = NoumeaDSTEndTestCase.after
after = NoumeaDSTEndTestCase.after
class TahitiTestCase(USEasternDSTStartTestCase):
# Tahiti has had a single transition in its history.
tzinfo = pytz.timezone('Pacific/Tahiti')
transition_time = datetime(1912, 10, 1, 9, 58, 16, tzinfo=UTC)
before = {
'tzname': 'LMT',
'utcoffset': timedelta(hours=-9, minutes=-58),
'dst': timedelta(0),
}
after = {
'tzname': '-10', # pre-2017a, TAHT
'utcoffset': timedelta(hours=-10),
'dst': timedelta(0),
}
class SamoaInternationalDateLineChange(USEasternDSTStartTestCase):
# At the end of 2011, Samoa will switch from being east of the
# international dateline to the west. There will be no Dec 30th
# 2011 and it will switch from UTC-10 to UTC+14.
tzinfo = pytz.timezone('Pacific/Apia')
transition_time = datetime(2011, 12, 30, 10, 0, 0, tzinfo=UTC)
before = {
'tzname': '-10', # pre-2017a, SDT
'utcoffset': timedelta(hours=-10),
'dst': timedelta(hours=1),
}
after = {
'tzname': '+14', # pre-2017a, WSDT
'utcoffset': timedelta(hours=14),
'dst': timedelta(hours=1),
}
class ReferenceUSEasternDSTStartTestCase(USEasternDSTStartTestCase):
tzinfo = reference.Eastern
def test_arithmetic(self):
# Reference implementation cannot handle this
pass
class ReferenceUSEasternDSTEndTestCase(USEasternDSTEndTestCase):
tzinfo = reference.Eastern
def testHourBefore(self):
# Python's datetime library has a bug, where the hour before
# a daylight saving transition is one hour out. For example,
# at the end of US/Eastern daylight saving time, 01:00 EST
# occurs twice (once at 05:00 UTC and once at 06:00 UTC),
# whereas the first should actually be 01:00 EDT.
# Note that this bug is by design - by accepting this ambiguity
# for one hour one hour per year, an is_dst flag on datetime.time
# became unnecessary.
self._test_all(self.transition_time - timedelta(hours=1), self.after)
def testInstantBefore(self):
self._test_all(self.transition_time - timedelta(seconds=1), self.after)
def test_arithmetic(self):
# Reference implementation cannot handle this
pass
class LocalTestCase(unittest.TestCase):
def testLocalize(self):
loc_tz = pytz.timezone('Europe/Amsterdam')
loc_time = loc_tz.localize(datetime(1930, 5, 10, 0, 0, 0))
# Actually +00:19:32, but Python datetime rounds this
self.assertEqual(loc_time.strftime('%Z%z'), 'AMT+0020')
loc_time = loc_tz.localize(datetime(1930, 5, 20, 0, 0, 0))
# Actually +00:19:32, but Python datetime rounds this
self.assertEqual(loc_time.strftime('%Z%z'), 'NST+0120')
loc_time = loc_tz.localize(datetime(1940, 5, 10, 0, 0, 0))
# pre-2017a, abbreviation was NCT
self.assertEqual(loc_time.strftime('%Z%z'), '+0020+0020')
loc_time = loc_tz.localize(datetime(1940, 5, 20, 0, 0, 0))
self.assertEqual(loc_time.strftime('%Z%z'), 'CEST+0200')
loc_time = loc_tz.localize(datetime(2004, 2, 1, 0, 0, 0))
self.assertEqual(loc_time.strftime('%Z%z'), 'CET+0100')
loc_time = loc_tz.localize(datetime(2004, 4, 1, 0, 0, 0))
self.assertEqual(loc_time.strftime('%Z%z'), 'CEST+0200')
loc_time = loc_tz.localize(datetime(1943, 3, 29, 1, 59, 59))
self.assertEqual(loc_time.strftime('%Z%z'), 'CET+0100')
# Switch to US
loc_tz = pytz.timezone('US/Eastern')
# End of DST ambiguity check
loc_time = loc_tz.localize(datetime(1918, 10, 27, 1, 59, 59), is_dst=1)
self.assertEqual(loc_time.strftime('%Z%z'), 'EDT-0400')
loc_time = loc_tz.localize(datetime(1918, 10, 27, 1, 59, 59), is_dst=0)
self.assertEqual(loc_time.strftime('%Z%z'), 'EST-0500')
self.assertRaises(
pytz.AmbiguousTimeError,
loc_tz.localize, datetime(1918, 10, 27, 1, 59, 59), is_dst=None
)
# Start of DST non-existent times
loc_time = loc_tz.localize(datetime(1918, 3, 31, 2, 0, 0), is_dst=0)
self.assertEqual(loc_time.strftime('%Z%z'), 'EST-0500')
loc_time = loc_tz.localize(datetime(1918, 3, 31, 2, 0, 0), is_dst=1)
self.assertEqual(loc_time.strftime('%Z%z'), 'EDT-0400')
self.assertRaises(
pytz.NonExistentTimeError,
loc_tz.localize, datetime(1918, 3, 31, 2, 0, 0), is_dst=None
)
# Weird changes - war time and peace time both is_dst==True
loc_time = loc_tz.localize(datetime(1942, 2, 9, 3, 0, 0))
self.assertEqual(loc_time.strftime('%Z%z'), 'EWT-0400')
loc_time = loc_tz.localize(datetime(1945, 8, 14, 19, 0, 0))
self.assertEqual(loc_time.strftime('%Z%z'), 'EPT-0400')
loc_time = loc_tz.localize(datetime(1945, 9, 30, 1, 0, 0), is_dst=1)
self.assertEqual(loc_time.strftime('%Z%z'), 'EPT-0400')
loc_time = loc_tz.localize(datetime(1945, 9, 30, 1, 0, 0), is_dst=0)
self.assertEqual(loc_time.strftime('%Z%z'), 'EST-0500')
# Weird changes - ambiguous time (end-of-DST like) but is_dst==False
for zonename, ambiguous_naive, expected in [
('Europe/Warsaw', datetime(1915, 8, 4, 23, 59, 59),
['1915-08-04 23:59:59 WMT+0124',
'1915-08-04 23:59:59 CET+0100']),
('Europe/Moscow', datetime(2014, 10, 26, 1, 30),
['2014-10-26 01:30:00 MSK+0400',
'2014-10-26 01:30:00 MSK+0300'])]:
loc_tz = pytz.timezone(zonename)
self.assertRaises(
pytz.AmbiguousTimeError,
loc_tz.localize, ambiguous_naive, is_dst=None
)
# Also test non-boolean is_dst in the weird case
for dst in [True, timedelta(1), False, timedelta(0)]:
loc_time = loc_tz.localize(ambiguous_naive, is_dst=dst)
self.assertEqual(loc_time.strftime(fmt), expected[not dst])
def testNormalize(self):
tz = pytz.timezone('US/Eastern')
dt = datetime(2004, 4, 4, 7, 0, 0, tzinfo=UTC).astimezone(tz)
dt2 = dt - timedelta(minutes=10)
self.assertEqual(
dt2.strftime('%Y-%m-%d %H:%M:%S %Z%z'),
'2004-04-04 02:50:00 EDT-0400'
)
dt2 = tz.normalize(dt2)
self.assertEqual(
dt2.strftime('%Y-%m-%d %H:%M:%S %Z%z'),
'2004-04-04 01:50:00 EST-0500'
)
def testPartialMinuteOffsets(self):
# utcoffset in Amsterdam was not a whole minute until 1937
# However, we fudge this by rounding them, as the Python
# datetime library
tz = pytz.timezone('Europe/Amsterdam')
utc_dt = datetime(1914, 1, 1, 13, 40, 28, tzinfo=UTC) # correct
utc_dt = utc_dt.replace(second=0) # But we need to fudge it
loc_dt = utc_dt.astimezone(tz)
self.assertEqual(
loc_dt.strftime('%Y-%m-%d %H:%M:%S %Z%z'),
'1914-01-01 14:00:00 AMT+0020'
)
# And get back...
utc_dt = loc_dt.astimezone(UTC)
self.assertEqual(
utc_dt.strftime('%Y-%m-%d %H:%M:%S %Z%z'),
'1914-01-01 13:40:00 UTC+0000'
)
def no_testCreateLocaltime(self):
# It would be nice if this worked, but it doesn't.
tz = pytz.timezone('Europe/Amsterdam')
dt = datetime(2004, 10, 31, 2, 0, 0, tzinfo=tz)
self.assertEqual(
dt.strftime(fmt),
'2004-10-31 02:00:00 CET+0100'
)
class CommonTimezonesTestCase(unittest.TestCase):
def test_bratislava(self):
# Bratislava is the default timezone for Slovakia, but our
# heuristics where not adding it to common_timezones. Ideally,
# common_timezones should be populated from zone.tab at runtime,
# but I'm hesitant to pay the startup cost as loading the list
# on demand whilst remaining backwards compatible seems
# difficult.
self.assertTrue('Europe/Bratislava' in pytz.common_timezones)
self.assertTrue('Europe/Bratislava' in pytz.common_timezones_set)
def test_us_eastern(self):
self.assertTrue('US/Eastern' in pytz.common_timezones)
self.assertTrue('US/Eastern' in pytz.common_timezones_set)
def test_belfast(self):
# Belfast uses London time.
self.assertTrue('Europe/Belfast' in pytz.all_timezones_set)
self.assertFalse('Europe/Belfast' in pytz.common_timezones)
self.assertFalse('Europe/Belfast' in pytz.common_timezones_set)
class ZoneCaseInsensitivityTestCase(unittest.TestCase):
def test_lower_case_timezone_constructor_arg(self):
for tz in pytz.all_timezones_set:
from_lower = pytz.timezone(tz.lower())
from_passed = pytz.timezone(tz)
self.assertEqual(from_lower,
from_passed,
"arg '%s' and arg '%s' produce different "
"timezone objects" % (
from_lower, from_passed))
class BaseTzInfoTestCase:
'''Ensure UTC, StaticTzInfo and DstTzInfo work consistently.
These tests are run for each type of tzinfo.
'''
tz = None # override
tz_class = None # override
def test_expectedclass(self):
self.assertTrue(isinstance(self.tz, self.tz_class))
def test_fromutc(self):
# naive datetime.
dt1 = datetime(2011, 10, 31)
# localized datetime, same timezone.
dt2 = self.tz.localize(dt1)
# Both should give the same results. Note that the standard
# Python tzinfo.fromutc() only supports the second.
for dt in [dt1, dt2]:
loc_dt = self.tz.fromutc(dt)
loc_dt2 = pytz.utc.localize(dt1).astimezone(self.tz)
self.assertEqual(loc_dt, loc_dt2)
# localized datetime, different timezone.
new_tz = pytz.timezone('Europe/Paris')
self.assertTrue(self.tz is not new_tz)
dt3 = new_tz.localize(dt1)
self.assertRaises(ValueError, self.tz.fromutc, dt3)
def test_normalize(self):
other_tz = pytz.timezone('Europe/Paris')
self.assertTrue(self.tz is not other_tz)
dt = datetime(2012, 3, 26, 12, 0)
other_dt = other_tz.localize(dt)
local_dt = self.tz.normalize(other_dt)
self.assertTrue(local_dt.tzinfo is not other_dt.tzinfo)
self.assertNotEqual(
local_dt.replace(tzinfo=None), other_dt.replace(tzinfo=None))
def test_astimezone(self):
other_tz = pytz.timezone('Europe/Paris')
self.assertTrue(self.tz is not other_tz)
dt = datetime(2012, 3, 26, 12, 0)
other_dt = other_tz.localize(dt)
local_dt = other_dt.astimezone(self.tz)
self.assertTrue(local_dt.tzinfo is not other_dt.tzinfo)
self.assertNotEqual(
local_dt.replace(tzinfo=None), other_dt.replace(tzinfo=None))
class OptimizedUTCTestCase(unittest.TestCase, BaseTzInfoTestCase):
tz = pytz.utc
tz_class = tz.__class__
class LegacyUTCTestCase(unittest.TestCase, BaseTzInfoTestCase):
# Deprecated timezone, but useful for comparison tests.
tz = pytz.timezone('Etc/UTC')
tz_class = StaticTzInfo
class StaticTzInfoTestCase(unittest.TestCase, BaseTzInfoTestCase):
tz = pytz.timezone('GMT')
tz_class = StaticTzInfo
class DstTzInfoTestCase(unittest.TestCase, BaseTzInfoTestCase):
tz = pytz.timezone('Australia/Melbourne')
tz_class = DstTzInfo
def test_suite():
suite = unittest.TestSuite()
suite.addTest(doctest.DocTestSuite('pytz'))
suite.addTest(doctest.DocTestSuite('pytz.tzinfo'))
import test_tzinfo
suite.addTest(unittest.defaultTestLoader.loadTestsFromModule(test_tzinfo))
return suite
if __name__ == '__main__':
warnings.simplefilter("error") # Warnings should be fatal in tests.
unittest.main(defaultTest='test_suite')

View File

@@ -3,38 +3,37 @@
$Id: tzfile.py,v 1.8 2004/06/03 00:15:24 zenzen Exp $
'''
try:
from cStringIO import StringIO
except ImportError:
from io import StringIO
from datetime import datetime, timedelta
from datetime import datetime
from struct import unpack, calcsize
from pytz.tzinfo import StaticTzInfo, DstTzInfo, memorized_ttinfo
from pytz.tzinfo import memorized_datetime, memorized_timedelta
def _byte_string(s):
"""Cast a string or byte string to an ASCII byte string."""
return s.encode('ASCII')
_NULL = _byte_string('\0')
def _std_string(s):
"""Cast a string or byte string to an ASCII string."""
return str(s.decode('ASCII'))
def build_tzinfo(zone, fp):
head_fmt = '>4s c 15x 6l'
head_size = calcsize(head_fmt)
(magic, format, ttisgmtcnt, ttisstdcnt,leapcnt, timecnt,
typecnt, charcnt) = unpack(head_fmt, fp.read(head_size))
(magic, format, ttisgmtcnt, ttisstdcnt, leapcnt, timecnt,
typecnt, charcnt) = unpack(head_fmt, fp.read(head_size))
# Make sure it is a tzfile(5) file
assert magic == _byte_string('TZif'), 'Got magic %s' % repr(magic)
# Read out the transition times, localtime indices and ttinfo structures.
data_fmt = '>%(timecnt)dl %(timecnt)dB %(ttinfo)s %(charcnt)ds' % dict(
timecnt=timecnt, ttinfo='lBB'*typecnt, charcnt=charcnt)
timecnt=timecnt, ttinfo='lBB' * typecnt, charcnt=charcnt)
data_size = calcsize(data_fmt)
data = unpack(data_fmt, fp.read(data_size))
@@ -53,7 +52,7 @@ def build_tzinfo(zone, fp):
i = 0
while i < len(ttinfo_raw):
# have we looked up this timezone name yet?
tzname_offset = ttinfo_raw[i+2]
tzname_offset = ttinfo_raw[i + 2]
if tzname_offset not in tznames:
nul = tznames_raw.find(_NULL, tzname_offset)
if nul < 0:
@@ -61,12 +60,12 @@ def build_tzinfo(zone, fp):
tznames[tzname_offset] = _std_string(
tznames_raw[tzname_offset:nul])
ttinfo.append((ttinfo_raw[i],
bool(ttinfo_raw[i+1]),
bool(ttinfo_raw[i + 1]),
tznames[tzname_offset]))
i += 3
# Now build the timezone object
if len(ttinfo) ==1 or len(transitions) == 0:
if len(ttinfo) == 1 or len(transitions) == 0:
ttinfo[0][0], ttinfo[0][2]
cls = type(zone, (StaticTzInfo,), dict(
zone=zone,
@@ -91,21 +90,21 @@ def build_tzinfo(zone, fp):
if not inf[1]:
dst = 0
else:
for j in range(i-1, -1, -1):
for j in range(i - 1, -1, -1):
prev_inf = ttinfo[lindexes[j]]
if not prev_inf[1]:
break
dst = inf[0] - prev_inf[0] # dst offset
dst = inf[0] - prev_inf[0] # dst offset
# Bad dst? Look further. DST > 24 hours happens when
# a timzone has moved across the international dateline.
if dst <= 0 or dst > 3600*3:
for j in range(i+1, len(transitions)):
if dst <= 0 or dst > 3600 * 3:
for j in range(i + 1, len(transitions)):
stdinf = ttinfo[lindexes[j]]
if not stdinf[1]:
dst = inf[0] - stdinf[0]
if dst > 0:
break # Found a useful std time.
break # Found a useful std time.
tzname = inf[2]
@@ -129,9 +128,7 @@ if __name__ == '__main__':
from pprint import pprint
base = os.path.join(os.path.dirname(__file__), 'zoneinfo')
tz = build_tzinfo('Australia/Melbourne',
open(os.path.join(base,'Australia','Melbourne'), 'rb'))
open(os.path.join(base, 'Australia', 'Melbourne'), 'rb'))
tz = build_tzinfo('US/Eastern',
open(os.path.join(base,'US','Eastern'), 'rb'))
open(os.path.join(base, 'US', 'Eastern'), 'rb'))
pprint(tz._utc_transition_times)
#print tz.asPython(4)
#print tz.transitions_mapping

View File

@@ -13,6 +13,8 @@ from pytz.exceptions import AmbiguousTimeError, NonExistentTimeError
__all__ = []
_timedelta_cache = {}
def memorized_timedelta(seconds):
'''Create only one instance of each distinct timedelta'''
try:
@@ -24,6 +26,8 @@ def memorized_timedelta(seconds):
_epoch = datetime.utcfromtimestamp(0)
_datetime_cache = {0: _epoch}
def memorized_datetime(seconds):
'''Create only one instance of each distinct datetime'''
try:
@@ -36,21 +40,24 @@ def memorized_datetime(seconds):
return dt
_ttinfo_cache = {}
def memorized_ttinfo(*args):
'''Create only one instance of each distinct tuple'''
try:
return _ttinfo_cache[args]
except KeyError:
ttinfo = (
memorized_timedelta(args[0]),
memorized_timedelta(args[1]),
args[2]
)
memorized_timedelta(args[0]),
memorized_timedelta(args[1]),
args[2]
)
_ttinfo_cache[args] = ttinfo
return ttinfo
_notime = memorized_timedelta(0)
def _to_seconds(td):
'''Convert a timedelta to seconds'''
return td.seconds + td.days * 24 * 60 * 60
@@ -154,14 +161,20 @@ class DstTzInfo(BaseTzInfo):
timezone definition.
'''
# Overridden in subclass
_utc_transition_times = None # Sorted list of DST transition times in UTC
_transition_info = None # [(utcoffset, dstoffset, tzname)] corresponding
# to _utc_transition_times entries
# Sorted list of DST transition times, UTC
_utc_transition_times = None
# [(utcoffset, dstoffset, tzname)] corresponding to
# _utc_transition_times entries
_transition_info = None
zone = None
# Set in __init__
_tzinfos = None
_dst = None # DST offset
_dst = None # DST offset
def __init__(self, _inf=None, _tzinfos=None):
if _inf:
@@ -170,7 +183,8 @@ class DstTzInfo(BaseTzInfo):
else:
_tzinfos = {}
self._tzinfos = _tzinfos
self._utcoffset, self._dst, self._tzname = self._transition_info[0]
self._utcoffset, self._dst, self._tzname = (
self._transition_info[0])
_tzinfos[self._transition_info[0]] = self
for inf in self._transition_info[1:]:
if inf not in _tzinfos:
@@ -178,8 +192,8 @@ class DstTzInfo(BaseTzInfo):
def fromutc(self, dt):
'''See datetime.tzinfo.fromutc'''
if (dt.tzinfo is not None
and getattr(dt.tzinfo, '_tzinfos', None) is not self._tzinfos):
if (dt.tzinfo is not None and
getattr(dt.tzinfo, '_tzinfos', None) is not self._tzinfos):
raise ValueError('fromutc: dt.tzinfo is not self')
dt = dt.replace(tzinfo=None)
idx = max(0, bisect_right(self._utc_transition_times, dt) - 1)
@@ -337,8 +351,8 @@ class DstTzInfo(BaseTzInfo):
# obtain the correct timezone by winding the clock back.
else:
return self.localize(
dt - timedelta(hours=6), is_dst=False) + timedelta(hours=6)
dt - timedelta(hours=6),
is_dst=False) + timedelta(hours=6)
# If we get this far, we have multiple possible timezones - this
# is an ambiguous case occuring during the end-of-DST transition.
@@ -351,9 +365,8 @@ class DstTzInfo(BaseTzInfo):
# Filter out the possiblilities that don't match the requested
# is_dst
filtered_possible_loc_dt = [
p for p in possible_loc_dt
if bool(p.tzinfo._dst) == is_dst
]
p for p in possible_loc_dt if bool(p.tzinfo._dst) == is_dst
]
# Hopefully we only have one possibility left. Return it.
if len(filtered_possible_loc_dt) == 1:
@@ -372,9 +385,10 @@ class DstTzInfo(BaseTzInfo):
# Choose the earliest (by UTC) applicable timezone if is_dst=True
# Choose the latest (by UTC) applicable timezone if is_dst=False
# i.e., behave like end-of-DST transition
dates = {} # utc -> local
dates = {} # utc -> local
for local_dt in filtered_possible_loc_dt:
utc_time = local_dt.replace(tzinfo=None) - local_dt.tzinfo._utcoffset
utc_time = (
local_dt.replace(tzinfo=None) - local_dt.tzinfo._utcoffset)
assert utc_time not in dates
dates[utc_time] = local_dt
return dates[[min, max][not is_dst](dates)]
@@ -389,11 +403,11 @@ class DstTzInfo(BaseTzInfo):
>>> tz = timezone('America/St_Johns')
>>> ambiguous = datetime(2009, 10, 31, 23, 30)
>>> tz.utcoffset(ambiguous, is_dst=False)
datetime.timedelta(-1, 73800)
>>> str(tz.utcoffset(ambiguous, is_dst=False))
'-1 day, 20:30:00'
>>> tz.utcoffset(ambiguous, is_dst=True)
datetime.timedelta(-1, 77400)
>>> str(tz.utcoffset(ambiguous, is_dst=True))
'-1 day, 21:30:00'
>>> try:
... tz.utcoffset(ambiguous)
@@ -421,19 +435,19 @@ class DstTzInfo(BaseTzInfo):
>>> normal = datetime(2009, 9, 1)
>>> tz.dst(normal)
datetime.timedelta(0, 3600)
>>> tz.dst(normal, is_dst=False)
datetime.timedelta(0, 3600)
>>> tz.dst(normal, is_dst=True)
datetime.timedelta(0, 3600)
>>> str(tz.dst(normal))
'1:00:00'
>>> str(tz.dst(normal, is_dst=False))
'1:00:00'
>>> str(tz.dst(normal, is_dst=True))
'1:00:00'
>>> ambiguous = datetime(2009, 10, 31, 23, 30)
>>> tz.dst(ambiguous, is_dst=False)
datetime.timedelta(0)
>>> tz.dst(ambiguous, is_dst=True)
datetime.timedelta(0, 3600)
>>> str(tz.dst(ambiguous, is_dst=False))
'0:00:00'
>>> str(tz.dst(ambiguous, is_dst=True))
'1:00:00'
>>> try:
... tz.dst(ambiguous)
... except AmbiguousTimeError:
@@ -494,23 +508,22 @@ class DstTzInfo(BaseTzInfo):
dst = 'STD'
if self._utcoffset > _notime:
return '<DstTzInfo %r %s+%s %s>' % (
self.zone, self._tzname, self._utcoffset, dst
)
self.zone, self._tzname, self._utcoffset, dst
)
else:
return '<DstTzInfo %r %s%s %s>' % (
self.zone, self._tzname, self._utcoffset, dst
)
self.zone, self._tzname, self._utcoffset, dst
)
def __reduce__(self):
# Special pickle to zone remains a singleton and to cope with
# database changes.
return pytz._p, (
self.zone,
_to_seconds(self._utcoffset),
_to_seconds(self._dst),
self._tzname
)
self.zone,
_to_seconds(self._utcoffset),
_to_seconds(self._dst),
self._tzname
)
def unpickler(zone, utcoffset=None, dstoffset=None, tzname=None):
@@ -549,8 +562,8 @@ def unpickler(zone, utcoffset=None, dstoffset=None, tzname=None):
# get changed from the initial guess by the database maintainers to
# match reality when this information is discovered.
for localized_tz in tz._tzinfos.values():
if (localized_tz._utcoffset == utcoffset
and localized_tz._dst == dstoffset):
if (localized_tz._utcoffset == utcoffset and
localized_tz._dst == dstoffset):
return localized_tz
# This (utcoffset, dstoffset) information has been removed from the

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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